mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-09-01 14:52:19 +08:00
Enable apply_patch bridging for all non-GPT Codex models
This commit is contained in:
@@ -55,11 +55,11 @@ After saving, CCR formats those descriptions as “Configured CCR gateway models
|
||||
|
||||
### Codex
|
||||
|
||||
The built-in Codex route adapts Codex's `apply_patch` file-editing tool for third-party or non-GPT models. The goal is for those models to edit files through the patch tool instead of generating commands or scripts such as `cat >`, `sed -i`, `python`, or `node`.
|
||||
CCR automatically adapts Codex's `apply_patch` file-editing tool for third-party or non-GPT models. The goal is for those models to edit files through the patch tool instead of generating commands or scripts such as `cat >`, `sed -i`, `python`, or `node`.
|
||||
|
||||
Technically, this is a tool protocol bridge. Native Codex `apply_patch` is a custom/freeform tool whose input is raw patch text, while many OpenAI-compatible third-party models handle ordinary function tools more reliably. CCR rewrites `apply_patch` into an upstream-visible `virtual_apply_patch` function tool and injects the full `apply_patch.lark` grammar into the tool description, requiring the model to put the patch in the `patch` field.
|
||||
|
||||
When the model returns `virtual_apply_patch`, CCR rewrites it back to Codex's expected shape: `custom_tool_call` with `name = apply_patch` and `input = raw patch text`. CCR does not edit files directly; Codex still executes the resulting patch. This adaptation follows the built-in **Codex** route and has no separate switch. GPT-named models keep using Codex's native freeform `apply_patch` path.
|
||||
When the model returns `virtual_apply_patch`, CCR rewrites it back to Codex's expected shape: `custom_tool_call` with `name = apply_patch` and `input = raw patch text`. CCR does not edit files directly; Codex still executes the resulting patch. This adaptation is enabled automatically for non-GPT models and is independent of the built-in **Codex** routing switch. GPT-named models, including Fusion models whose resolved base model is GPT, keep using Codex's native freeform `apply_patch` path.
|
||||
|
||||
## Custom Routing
|
||||
|
||||
|
||||
@@ -55,11 +55,11 @@ Description 建议写成任务导向,而不是只写模型厂商名。例如
|
||||
|
||||
### Codex
|
||||
|
||||
Codex 内置路由会为第三方或非 GPT 模型适配 Codex 的 `apply_patch` 文件编辑工具。目标是让这些模型通过 patch 工具完成文件修改,而不是生成 `cat >`、`sed -i`、`python`、`node` 等命令或脚本来编辑文件。
|
||||
CCR 会自动为第三方或非 GPT 模型适配 Codex 的 `apply_patch` 文件编辑工具。目标是让这些模型通过 patch 工具完成文件修改,而不是生成 `cat >`、`sed -i`、`python`、`node` 等命令或脚本来编辑文件。
|
||||
|
||||
技术原理是做一次工具协议桥接:Codex 原生的 `apply_patch` 是 custom/freeform 工具,入参是原始 patch 文本;很多 OpenAI-compatible 三方模型更擅长普通 function tool。CCR 会在上游请求中把 `apply_patch` 转成 `virtual_apply_patch` function tool,并在工具说明里注入完整的 `apply_patch.lark` 语法,要求模型把 patch 写入 `patch` 字段。
|
||||
|
||||
模型返回 `virtual_apply_patch` 后,CCR 会把它转换回 Codex 期望的 `custom_tool_call`:`name = apply_patch`,`input = 原始 patch 文本`。CCR 不直接修改文件,真正执行 patch 的仍然是 Codex 客户端。这个适配跟随 **Codex** 内置路由启用或关闭,没有单独开关;GPT 命名模型继续使用 Codex 原生 freeform `apply_patch` 路径。
|
||||
模型返回 `virtual_apply_patch` 后,CCR 会把它转换回 Codex 期望的 `custom_tool_call`:`name = apply_patch`,`input = 原始 patch 文本`。CCR 不直接修改文件,真正执行 patch 的仍然是 Codex 客户端。这个适配会对非 GPT 模型自动启用,不受 **Codex** 内置路由开关影响;GPT 命名模型以及实际基模为 GPT 的 Fusion 模型继续使用 Codex 原生 freeform `apply_patch` 路径。
|
||||
|
||||
## 自定义路由
|
||||
|
||||
|
||||
@@ -182,7 +182,6 @@ function codexModelCapabilityProfile(
|
||||
const catalogEntry = findModelCatalogEntry(model);
|
||||
const capabilities = catalogEntry?.capabilities ?? {};
|
||||
const providerProtocol = provider ? codexProviderProtocol(provider) : undefined;
|
||||
const providerSupportsResponses = provider ? codexProviderSupportsResponses(provider) : false;
|
||||
const supportsFusionVision = codexVirtualModelSupportsFusionVision(model, config);
|
||||
const supportsFusionWebSearch = codexVirtualModelSupportsFusionWebSearch(model, config);
|
||||
const metadataReasoningLevels = normalizeProviderReasoningLevels(providerModelMetadata?.supportedReasoningLevels);
|
||||
@@ -195,9 +194,9 @@ function codexModelCapabilityProfile(
|
||||
?? (metadataReasoningLevels !== undefined || readCatalogCapability(capabilities, "reasoning"));
|
||||
const supportsImageInput = supportsFusionVision || catalogEntrySupportsImageInput(catalogEntry);
|
||||
const supportsParallelToolCalls = readCatalogCapability(capabilities, "parallelFunctionCalling");
|
||||
const applyPatchToolType = providerSupportsResponses || catalogModelLooksLikeGpt(model, catalogEntry) || codexPatchBridgeApplies(model, catalogEntry, config)
|
||||
? "freeform"
|
||||
: null;
|
||||
// Codex must emit apply_patch for both native GPT models and non-GPT models
|
||||
// that the gateway converts through the compatibility bridge.
|
||||
const applyPatchToolType = "freeform";
|
||||
const supportsSearchTool =
|
||||
supportsFusionWebSearch ||
|
||||
(
|
||||
@@ -489,13 +488,6 @@ function codexProviderProtocol(provider: GatewayProviderConfig): GatewayProvider
|
||||
return normalizeProviderProtocol(provider.type) ?? normalizeProviderProtocol(provider.provider) ?? inferProviderProtocol(provider);
|
||||
}
|
||||
|
||||
function codexProviderSupportsResponses(provider: GatewayProviderConfig): boolean {
|
||||
return uniqueProviderProtocols((provider.capabilities ?? []).map((capability) => normalizeProviderProtocol(capability.type))).includes("openai_responses") ||
|
||||
normalizeProviderProtocol(provider.type) === "openai_responses" ||
|
||||
normalizeProviderProtocol(provider.provider) === "openai_responses" ||
|
||||
providerEndpointLooksLikeResponses(provider);
|
||||
}
|
||||
|
||||
function inferProviderProtocol(provider: GatewayProviderConfig): GatewayProviderProtocol {
|
||||
const url = (provider.baseUrl || provider.baseurl || provider.api_base_url || "").toLowerCase();
|
||||
const transformer = JSON.stringify(provider.transformer ?? "").toLowerCase();
|
||||
@@ -519,30 +511,6 @@ function providerEndpointLooksLikeResponses(provider: GatewayProviderConfig): bo
|
||||
return url.endsWith("/responses") || url.includes("/responses?");
|
||||
}
|
||||
|
||||
function catalogModelLooksLikeGpt(model: string, entry: ModelCatalogEntry | undefined): boolean {
|
||||
return [
|
||||
model,
|
||||
entry?.id,
|
||||
entry?.model
|
||||
].some((value) => typeof value === "string" && value.toLowerCase().includes("gpt"));
|
||||
}
|
||||
|
||||
function codexPatchBridgeApplies(
|
||||
model: string,
|
||||
entry: ModelCatalogEntry | undefined,
|
||||
config?: Partial<Pick<AppConfig, "Router">>
|
||||
): boolean {
|
||||
const codexRule = config?.Router?.builtInRules?.codex;
|
||||
if (!codexRule || codexRule.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
return !catalogModelLooksLikeGpt(modelNameForPatchBridge(model), entry);
|
||||
}
|
||||
|
||||
function modelNameForPatchBridge(model: string): string {
|
||||
return parseModelSelector(model)?.model ?? model;
|
||||
}
|
||||
|
||||
function normalizeProviderProtocol(value: unknown): GatewayProviderProtocol | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { readHeader } from "@ccr/core/gateway/http/io";
|
||||
import { codexPatchBridgeInstructionText, codexPatchBridgeShellToolGuidance, virtualApplyPatchLarkGrammar, virtualApplyPatchToolName } from "@ccr/core/gateway/internal/shared";
|
||||
import { parseJsonObjectSafe, serializeJsonBody } from "@ccr/core/gateway/http/body";
|
||||
import { requestProtocolForPath } from "@ccr/core/routing/protocol-endpoints";
|
||||
import { resolveUsageModelAttribution } from "@ccr/core/usage/model-attribution";
|
||||
|
||||
|
||||
export function prepareCodexApplyPatchBridgeRequest(input: {
|
||||
@@ -20,7 +21,7 @@ export function prepareCodexApplyPatchBridgeRequest(input: {
|
||||
path: string;
|
||||
routedModel?: string;
|
||||
}): { body: Buffer; diagnostic: string } | undefined {
|
||||
if (!codexApplyPatchBridgeEnabled(input.config, input.headers, input.method, input.path)) {
|
||||
if (!codexApplyPatchBridgeEnabled(input.headers, input.method, input.path)) {
|
||||
return undefined;
|
||||
}
|
||||
const parsedBody = parseJsonObjectSafe(input.body);
|
||||
@@ -28,7 +29,7 @@ export function prepareCodexApplyPatchBridgeRequest(input: {
|
||||
return undefined;
|
||||
}
|
||||
const model = input.routedModel || stringValue(parsedBody.model);
|
||||
if (!codexPatchBridgeModelEligible(model)) {
|
||||
if (!codexPatchBridgeModelEligible(model, input.config)) {
|
||||
return undefined;
|
||||
}
|
||||
const transformed = transformCodexApplyPatchBridgeRequestBody(parsedBody);
|
||||
@@ -255,12 +256,10 @@ function virtualApplyPatchToolSpec(): Record<string, unknown> {
|
||||
}
|
||||
|
||||
|
||||
function codexApplyPatchBridgeEnabled(config: AppConfig, headers: IncomingHttpHeaders, method: string, path: string): boolean {
|
||||
const codexRule = config.Router.builtInRules?.codex;
|
||||
function codexApplyPatchBridgeEnabled(headers: IncomingHttpHeaders, method: string, path: string): boolean {
|
||||
return (method || "GET").toUpperCase() === "POST" &&
|
||||
requestProtocolForPath(path) === "openai_responses" &&
|
||||
isCodexUserAgent(headers) &&
|
||||
codexRule?.enabled !== false;
|
||||
isCodexUserAgent(headers);
|
||||
}
|
||||
|
||||
|
||||
@@ -269,9 +268,13 @@ function isCodexUserAgent(headers: IncomingHttpHeaders): boolean {
|
||||
}
|
||||
|
||||
|
||||
function codexPatchBridgeModelEligible(model: string | undefined): boolean {
|
||||
function codexPatchBridgeModelEligible(model: string | undefined, config: AppConfig): boolean {
|
||||
const modelName = modelNameForPatchBridge(model);
|
||||
return Boolean(modelName) && !modelName.toLowerCase().includes("gpt");
|
||||
if (!modelName || modelName.toLowerCase().includes("gpt")) {
|
||||
return false;
|
||||
}
|
||||
const baseModelName = modelNameForPatchBridge(resolveUsageModelAttribution(config, model).model);
|
||||
return !baseModelName.toLowerCase().includes("gpt");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ test("codex catalog removes duplicate model IDs case-insensitively without reord
|
||||
assert.deepEqual(ids, ["provider/model-a", "Provider/MODEL-B"]);
|
||||
});
|
||||
|
||||
test("codex catalog treats unknown models as text-only without advanced tools", () => {
|
||||
test("codex catalog treats unknown models as text-only while enabling apply_patch", () => {
|
||||
const model = catalogModelFor({
|
||||
Providers: [
|
||||
{ name: "Custom", type: "openai_chat_completions", models: ["unknown-model"] }
|
||||
@@ -36,7 +36,7 @@ test("codex catalog treats unknown models as text-only without advanced tools",
|
||||
assert.equal(model.supports_image_detail_original, false);
|
||||
assert.deepEqual(model.supported_reasoning_levels, []);
|
||||
assert.equal(model.default_reasoning_level, null);
|
||||
assert.equal(model.apply_patch_tool_type, null);
|
||||
assert.equal(model.apply_patch_tool_type, "freeform");
|
||||
});
|
||||
|
||||
test("codex catalog uses model catalog capabilities for known text models", () => {
|
||||
@@ -53,7 +53,7 @@ test("codex catalog uses model catalog capabilities for known text models", () =
|
||||
assert.equal(model.supports_image_detail_original, false);
|
||||
assert.deepEqual(model.supported_reasoning_levels, []);
|
||||
assert.equal(model.default_reasoning_level, null);
|
||||
assert.equal(model.apply_patch_tool_type, null);
|
||||
assert.equal(model.apply_patch_tool_type, "freeform");
|
||||
});
|
||||
|
||||
test("codex catalog enables multimodal reasoning and search when provider protocol supports it", () => {
|
||||
@@ -342,7 +342,7 @@ test("codex catalog enables native search for Gemini Interactions providers", ()
|
||||
assert.equal(model.supports_search_tool, true);
|
||||
});
|
||||
|
||||
test("codex catalog does not expose native search through chat-completions-only providers", () => {
|
||||
test("codex catalog omits native search but enables apply_patch for non-GPT chat-completions models", () => {
|
||||
const model = catalogModelFor({
|
||||
Providers: [
|
||||
{ name: "openrouter", type: "openai_chat_completions", models: ["google/gemini-2.5-pro"] }
|
||||
@@ -354,7 +354,7 @@ test("codex catalog does not expose native search through chat-completions-only
|
||||
assert.equal(model.supports_reasoning_summaries, true);
|
||||
assert.equal(model.supports_search_tool, false);
|
||||
assert.equal(model.web_search_tool_type, "text");
|
||||
assert.equal(model.apply_patch_tool_type, null);
|
||||
assert.equal(model.apply_patch_tool_type, "freeform");
|
||||
});
|
||||
|
||||
test("codex catalog keeps freeform apply_patch for GPT-named chat-compatible models", () => {
|
||||
@@ -403,7 +403,7 @@ test("codex catalog enables apply_patch bridge for non-GPT models when Codex bui
|
||||
assert.equal(model.apply_patch_tool_type, "freeform");
|
||||
});
|
||||
|
||||
test("codex catalog disables apply_patch bridge for non-GPT models when the Codex built-in route is off", () => {
|
||||
test("codex catalog automatically enables apply_patch bridge for non-GPT models when the Codex built-in route is off", () => {
|
||||
const model = catalogModelFor({
|
||||
Providers: [
|
||||
{ name: "openrouter", type: "openai_chat_completions", models: ["google/gemini-2.5-pro"] }
|
||||
@@ -418,7 +418,7 @@ test("codex catalog disables apply_patch bridge for non-GPT models when the Code
|
||||
}
|
||||
}, "openrouter/google/gemini-2.5-pro");
|
||||
|
||||
assert.equal(model.apply_patch_tool_type, null);
|
||||
assert.equal(model.apply_patch_tool_type, "freeform");
|
||||
});
|
||||
|
||||
test("codex catalog marks Fusion aliases with builtin web search as searchable", () => {
|
||||
@@ -451,7 +451,7 @@ test("codex catalog marks Fusion aliases with builtin web search as searchable",
|
||||
assert.deepEqual(model.input_modalities, ["text"]);
|
||||
assert.equal(model.supports_search_tool, true);
|
||||
assert.equal(model.web_search_tool_type, "text");
|
||||
assert.equal(model.apply_patch_tool_type, null);
|
||||
assert.equal(model.apply_patch_tool_type, "freeform");
|
||||
});
|
||||
|
||||
test("codex catalog marks image-recognition Fusion aliases as image capable", () => {
|
||||
@@ -514,5 +514,5 @@ test("codex catalog marks prefixed Fusion virtual models with legacy web search
|
||||
assert.deepEqual(model.input_modalities, ["text"]);
|
||||
assert.equal(model.supports_search_tool, true);
|
||||
assert.equal(model.web_search_tool_type, "text");
|
||||
assert.equal(model.apply_patch_tool_type, null);
|
||||
assert.equal(model.apply_patch_tool_type, "freeform");
|
||||
});
|
||||
|
||||
@@ -62,6 +62,89 @@ test("Codex patch bridge leaves GPT models untouched", () => {
|
||||
assert.equal(result, undefined);
|
||||
});
|
||||
|
||||
test("Codex patch bridge automatically rewrites non-GPT models when the built-in route is disabled", () => {
|
||||
const result = prepareCodexApplyPatchBridgeRequest({
|
||||
body: Buffer.from(JSON.stringify({
|
||||
model: "openrouter/google/gemini-2.5-pro",
|
||||
tools: [{ type: "custom", name: "apply_patch" }]
|
||||
})),
|
||||
config: {
|
||||
...config,
|
||||
Router: {
|
||||
...config.Router,
|
||||
builtInRules: {
|
||||
...config.Router.builtInRules,
|
||||
codex: { enabled: false }
|
||||
}
|
||||
}
|
||||
},
|
||||
headers: { "user-agent": "codex-test" },
|
||||
method: "POST",
|
||||
path: "/v1/responses"
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
const body = JSON.parse(result.body.toString("utf8"));
|
||||
assert.equal(body.tools[0].type, "function");
|
||||
assert.equal(body.tools[0].name, "virtual_apply_patch");
|
||||
});
|
||||
|
||||
test("Codex patch bridge leaves GPT-backed Fusion models untouched", () => {
|
||||
const result = prepareCodexApplyPatchBridgeRequest({
|
||||
body: Buffer.from(JSON.stringify({
|
||||
model: "Fusion/research",
|
||||
tools: [{ type: "custom", name: "apply_patch" }]
|
||||
})),
|
||||
config: {
|
||||
...config,
|
||||
Providers: [
|
||||
{ name: "openai", type: "openai_chat_completions", models: ["gpt-5-codex"] }
|
||||
],
|
||||
virtualModelProfiles: [
|
||||
{
|
||||
baseModel: { fixedModel: "openai/gpt-5-codex", mode: "fixed" },
|
||||
enabled: true,
|
||||
match: { exactAliases: ["research"], prefixes: [], suffixes: [] }
|
||||
}
|
||||
]
|
||||
},
|
||||
headers: { "user-agent": "codex-test" },
|
||||
method: "POST",
|
||||
path: "/v1/responses"
|
||||
});
|
||||
|
||||
assert.equal(result, undefined);
|
||||
});
|
||||
|
||||
test("Codex patch bridge rewrites non-GPT-backed Fusion models", () => {
|
||||
const result = prepareCodexApplyPatchBridgeRequest({
|
||||
body: Buffer.from(JSON.stringify({
|
||||
model: "Fusion/research",
|
||||
tools: [{ type: "custom", name: "apply_patch" }]
|
||||
})),
|
||||
config: {
|
||||
...config,
|
||||
Providers: [
|
||||
{ name: "anthropic", type: "anthropic_messages", models: ["claude-sonnet-4"] }
|
||||
],
|
||||
virtualModelProfiles: [
|
||||
{
|
||||
baseModel: { fixedModel: "anthropic/claude-sonnet-4", mode: "fixed" },
|
||||
enabled: true,
|
||||
match: { exactAliases: ["research"], prefixes: [], suffixes: [] }
|
||||
}
|
||||
]
|
||||
},
|
||||
headers: { "user-agent": "codex-test" },
|
||||
method: "POST",
|
||||
path: "/v1/responses"
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
const body = JSON.parse(result.body.toString("utf8"));
|
||||
assert.equal(body.tools[0].name, "virtual_apply_patch");
|
||||
});
|
||||
|
||||
test("Codex patch bridge discourages shell-based file edits", () => {
|
||||
const result = prepareCodexApplyPatchBridgeRequest({
|
||||
body: Buffer.from(JSON.stringify({
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { gatewayService } from "@ccr/core/gateway/application/gateway-service.ts";
|
||||
import { ClaudeCodeRouterPlugin } from "@ccr/core/gateway/claude-code-router-plugin.ts";
|
||||
import { buildRouteScriptInput } from "@ccr/core/routing/route-script-context.ts";
|
||||
import { RouteScriptRuntime } from "@ccr/core/routing/route-script-runtime.ts";
|
||||
@@ -18,8 +19,8 @@ const routeScriptDirectory = mkdtempSync(path.join(os.tmpdir(), "ccr-route-scrip
|
||||
let routeScriptFileIndex = 0;
|
||||
test.after(() => rmSync(routeScriptDirectory, { force: true, recursive: true }));
|
||||
|
||||
function routeScript(source, overrides = {}) {
|
||||
const file = path.join(routeScriptDirectory, `route-${++routeScriptFileIndex}.js`);
|
||||
function routeScript(source, overrides = {}, extension = "js") {
|
||||
const file = path.join(routeScriptDirectory, `route-${++routeScriptFileIndex}.${extension}`);
|
||||
writeFileSync(file, source, "utf8");
|
||||
return {
|
||||
apiVersion: 1,
|
||||
@@ -30,6 +31,31 @@ function routeScript(source, overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function scriptRule(id, script, overrides = {}) {
|
||||
return {
|
||||
enabled: true,
|
||||
id,
|
||||
name: id,
|
||||
script,
|
||||
type: "script",
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function routingConfig(rules = []) {
|
||||
return {
|
||||
CUSTOM_ROUTER_PATH: "",
|
||||
Providers: [{ models: ["alpha", "beta", "fallback"], name: "Provider", type: "anthropic_messages" }],
|
||||
Router: {
|
||||
builtInRules: { "claude-code": { enabled: false }, codex: { enabled: false } },
|
||||
fallback: { mode: "off", models: [], retryCount: 0 },
|
||||
rules
|
||||
},
|
||||
profile: { enabled: false, profiles: [] },
|
||||
virtualModelProfiles: []
|
||||
};
|
||||
}
|
||||
|
||||
function scriptInput(script, body = {}) {
|
||||
return buildRouteScriptInput({
|
||||
body,
|
||||
@@ -60,15 +86,112 @@ test("route script input exposes the complete request body and headers", () => {
|
||||
assert.equal(input.headers["x-visible"], "yes");
|
||||
});
|
||||
|
||||
test("route script input derives every documented routing summary field", () => {
|
||||
const body = {
|
||||
messages: [
|
||||
{ content: "ignore assistant", role: "assistant" },
|
||||
{
|
||||
content: [
|
||||
{ text: "route this request", type: "text" },
|
||||
{ source: { media_type: "image/png" }, type: "image" }
|
||||
],
|
||||
role: "user"
|
||||
}
|
||||
],
|
||||
model: "Provider/alpha",
|
||||
system: [{ text: "system policy", type: "text" }],
|
||||
tools: [{ name: "direct_tool" }, { function: { name: "function_tool" } }]
|
||||
};
|
||||
const input = buildRouteScriptInput({
|
||||
body,
|
||||
builtInSubagentModel: "Provider/beta",
|
||||
headers: { "X-Auth-Api-Key-Id": "key-id", "x-tags": ["one", "two"] },
|
||||
log: console,
|
||||
method: "POST",
|
||||
sessionId: "session-1",
|
||||
tokenCount: 321,
|
||||
url: "/v1/messages?beta=true"
|
||||
});
|
||||
|
||||
assert.notEqual(input.body, body);
|
||||
assert.deepEqual(input, {
|
||||
apiKeyId: "key-id",
|
||||
body,
|
||||
builtInSubagentModel: "Provider/beta",
|
||||
headers: { "X-Auth-Api-Key-Id": "key-id", "x-tags": ["one", "two"] },
|
||||
method: "POST",
|
||||
model: "Provider/alpha",
|
||||
sessionId: "session-1",
|
||||
summary: {
|
||||
hasImage: true,
|
||||
lastUserText: "route this request",
|
||||
messageCount: 2,
|
||||
systemText: "system policy",
|
||||
toolNames: ["direct_tool", "function_tool"]
|
||||
},
|
||||
tokenCount: 321,
|
||||
url: "/v1/messages?beta=true"
|
||||
});
|
||||
});
|
||||
|
||||
test("route scripts receive frozen per-request input and a stable hash helper", async () => {
|
||||
const runtime = new RouteScriptRuntime({ workerCount: 1, workerFile });
|
||||
const script = routeScript(`
|
||||
const blocked = [];
|
||||
try { input.body.model = "Provider/beta"; } catch { blocked.push("body"); }
|
||||
try { input.headers["x-visible"] = "changed"; } catch { blocked.push("headers"); }
|
||||
try { api.fs.readText = undefined; } catch { blocked.push("api"); }
|
||||
const leaked = globalThis.routeScriptLeak === true;
|
||||
globalThis.routeScriptLeak = true;
|
||||
return {
|
||||
blocked,
|
||||
hash: api.hash(input.sessionId),
|
||||
leaked,
|
||||
model: input.body.model,
|
||||
visibleHeader: input.headers["x-visible"]
|
||||
};
|
||||
`);
|
||||
const input = buildRouteScriptInput({
|
||||
body: { model: "Provider/alpha" },
|
||||
headers: { "x-visible": "yes" },
|
||||
log: console,
|
||||
method: "POST",
|
||||
sessionId: "stable-session",
|
||||
url: "/v1/messages"
|
||||
});
|
||||
try {
|
||||
const first = await runtime.execute("frozen-input-1", script, input);
|
||||
const second = await runtime.execute("frozen-input-2", script, input);
|
||||
|
||||
assert.equal(first.status, "ok");
|
||||
assert.equal(second.status, "ok");
|
||||
assert.deepEqual(first.value.blocked, ["body", "headers", "api"]);
|
||||
assert.equal(first.value.model, "Provider/alpha");
|
||||
assert.equal(first.value.visibleHeader, "yes");
|
||||
assert.equal(first.value.leaked, false);
|
||||
assert.equal(second.value.leaked, false);
|
||||
assert.equal(first.value.hash, second.value.hash);
|
||||
assert.ok(Number.isInteger(first.value.hash));
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("route scripts load local files and pick up file changes", async () => {
|
||||
const runtime = new RouteScriptRuntime({ workerCount: 1, workerFile });
|
||||
const script = routeScript("return 'first';");
|
||||
try {
|
||||
assert.deepEqual(await runtime.validate(script), { diagnostics: [], ok: true });
|
||||
const first = await runtime.execute("file-reload", script, scriptInput(script));
|
||||
assert.equal(first.status, "ok");
|
||||
assert.equal(first.value, "first");
|
||||
|
||||
writeFileSync(script.file, "return {;", "utf8");
|
||||
const invalid = await runtime.validate(script);
|
||||
assert.equal(invalid.ok, false);
|
||||
|
||||
writeFileSync(script.file, "return 'second';", "utf8");
|
||||
assert.deepEqual(await runtime.validate(script), { diagnostics: [], ok: true });
|
||||
const second = await runtime.execute("file-reload", script, scriptInput(script));
|
||||
assert.equal(second.status, "ok");
|
||||
assert.equal(second.value, "second");
|
||||
@@ -77,6 +200,60 @@ test("route scripts load local files and pick up file changes", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("route scripts support every documented file extension and legacy inline source", async () => {
|
||||
const runtime = new RouteScriptRuntime({ workerCount: 1, workerFile });
|
||||
try {
|
||||
for (const extension of ["js", "mjs", "cjs"]) {
|
||||
const script = routeScript(`return "${extension}";`, {}, extension);
|
||||
assert.deepEqual(await runtime.validate(script), { diagnostics: [], ok: true });
|
||||
const result = await runtime.execute(`extension-${extension}`, script, scriptInput(script));
|
||||
assert.equal(result.status, "ok");
|
||||
assert.equal(result.value, extension);
|
||||
}
|
||||
|
||||
const inline = {
|
||||
apiVersion: 1,
|
||||
language: "javascript",
|
||||
source: "await Promise.resolve(); return 'inline';",
|
||||
timeoutMs: 500
|
||||
};
|
||||
assert.deepEqual(await runtime.validate(inline), { diagnostics: [], ok: true });
|
||||
const result = await runtime.execute("legacy-inline", inline, scriptInput(inline));
|
||||
assert.equal(result.status, "ok");
|
||||
assert.equal(result.value, "inline");
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("route script validation rejects invalid metadata, paths, content, and limits", async () => {
|
||||
const runtime = new RouteScriptRuntime({ workerCount: 1, workerFile });
|
||||
const valid = routeScript("return true;");
|
||||
const directoryFile = path.join(routeScriptDirectory, `directory-${++routeScriptFileIndex}.js`);
|
||||
mkdirSync(directoryFile);
|
||||
const cases = [
|
||||
[{ ...valid, apiVersion: 2 }, /unsupported route script api or language/i],
|
||||
[{ ...valid, language: "typescript" }, /unsupported route script api or language/i],
|
||||
[{ ...valid, file: undefined, source: " " }, /between 1 and 65536 bytes/i],
|
||||
[{ ...valid, file: `${valid.file}.txt` }, /\.js, \.mjs, or \.cjs extension/i],
|
||||
[{ ...valid, timeoutMs: 9 }, /between 10 and 30000 ms/i],
|
||||
[{ ...valid, timeoutMs: 30001 }, /between 10 and 30000 ms/i],
|
||||
[{ ...valid, file: path.join(routeScriptDirectory, "missing.js") }, /unable to read route script file/i],
|
||||
[{ ...valid, file: directoryFile }, /is not a file/i],
|
||||
[routeScript("x".repeat(64 * 1024 + 1)), /exceeds 65536 bytes/i]
|
||||
];
|
||||
try {
|
||||
for (const [script, expectedMessage] of cases) {
|
||||
const result = await runtime.validate(script);
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.diagnostics[0].code, "script-source-invalid");
|
||||
assert.match(result.diagnostics[0].message, expectedMessage);
|
||||
}
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("route scripts validate and execute async Node.js source", async () => {
|
||||
process.env.CCR_ROUTE_SCRIPT_ENV_TEST = "available";
|
||||
const runtime = new RouteScriptRuntime({ workerCount: 1, workerFile });
|
||||
@@ -131,24 +308,8 @@ test("Node.js script rules participate in ordered routing and return dynamic dec
|
||||
rewrites: [{ key: "request.header.x-script-route", operation: "set", value: "matched" }]
|
||||
};
|
||||
`);
|
||||
const rule = {
|
||||
enabled: true,
|
||||
id: "dynamic-script",
|
||||
name: "Dynamic script",
|
||||
script,
|
||||
type: "script"
|
||||
};
|
||||
const config = {
|
||||
CUSTOM_ROUTER_PATH: "",
|
||||
Providers: [{ models: ["alpha", "beta"], name: "Provider", type: "anthropic_messages" }],
|
||||
Router: {
|
||||
builtInRules: { "claude-code": { enabled: false }, codex: { enabled: false } },
|
||||
fallback: { mode: "off", models: [], retryCount: 0 },
|
||||
rules: [rule]
|
||||
},
|
||||
profile: { enabled: false, profiles: [] },
|
||||
virtualModelProfiles: []
|
||||
};
|
||||
const rule = scriptRule("dynamic-script", script, { name: "Dynamic script" });
|
||||
const config = routingConfig([rule]);
|
||||
try {
|
||||
const validationErrors = await runtime.prepare([rule]);
|
||||
const plugin = new ClaudeCodeRouterPlugin(config, {
|
||||
@@ -173,6 +334,178 @@ test("Node.js script rules participate in ordered routing and return dynamic dec
|
||||
}
|
||||
});
|
||||
|
||||
test("Node.js script rules fail open and continue to the first valid dynamic decision", async () => {
|
||||
const runtime = new RouteScriptRuntime({ workerCount: 1, workerFile });
|
||||
const rules = [
|
||||
scriptRule("null-result", routeScript("return null;")),
|
||||
scriptRule("runtime-error", routeScript("throw new Error('policy unavailable');")),
|
||||
scriptRule("invalid-result", routeScript("return 'Provider/beta';")),
|
||||
scriptRule("unconfigured-model", routeScript('return { model: "Provider/missing" };')),
|
||||
scriptRule("invalid-fallback", routeScript(`
|
||||
return { fallback: { mode: "model-chain", models: ["Provider/missing"], retryCount: 0 } };
|
||||
`)),
|
||||
scriptRule("protected-rewrite", routeScript(`
|
||||
return {
|
||||
rewrites: [{ key: "request.header.authorization", operation: "set", value: "replaced" }]
|
||||
};
|
||||
`)),
|
||||
scriptRule("explicit-no-match", routeScript(`
|
||||
return { match: false, model: "Provider/alpha" };
|
||||
`)),
|
||||
scriptRule("valid-result", routeScript(`
|
||||
return {
|
||||
model: "Provider/beta",
|
||||
rewrites: [
|
||||
{ key: "request.body.temperature", operation: "set", value: 0.25 },
|
||||
{ key: "request.body.tags", operation: "array-append", value: "script" },
|
||||
{ key: "request.header.x-route-policy", operation: "set", value: "dynamic" }
|
||||
],
|
||||
fallback: {
|
||||
mode: "model-chain",
|
||||
models: ["Provider/fallback"],
|
||||
retryCount: 0
|
||||
}
|
||||
};
|
||||
`))
|
||||
];
|
||||
const config = routingConfig(rules);
|
||||
try {
|
||||
const validationErrors = await runtime.prepare(rules);
|
||||
assert.deepEqual([...validationErrors], []);
|
||||
const plugin = new ClaudeCodeRouterPlugin(config, {
|
||||
scriptRuntime: runtime,
|
||||
scriptValidationErrors: validationErrors
|
||||
});
|
||||
const headers = {};
|
||||
const result = await plugin.routeRequest({
|
||||
body: { messages: [], model: "Provider/alpha", tags: ["base"] },
|
||||
headers,
|
||||
method: "POST",
|
||||
url: "/v1/messages"
|
||||
});
|
||||
|
||||
assert.equal(result.body.model, "Provider/beta");
|
||||
assert.equal(result.body.temperature, 0.25);
|
||||
assert.deepEqual(result.body.tags, ["base", "script"]);
|
||||
assert.equal(headers["x-route-policy"], "dynamic");
|
||||
assert.equal(result.decision.reason, "script:valid-result");
|
||||
assert.deepEqual(result.decision.fallback, {
|
||||
mode: "model-chain",
|
||||
models: ["Provider/fallback"],
|
||||
retryCount: 0
|
||||
});
|
||||
assert.deepEqual(
|
||||
result.decision.diagnostics.map((diagnostic) => diagnostic.code),
|
||||
[
|
||||
"script-runtime-error",
|
||||
"script-invalid-result",
|
||||
"script-model-not-configured",
|
||||
"script-invalid-result",
|
||||
"script-invalid-result"
|
||||
]
|
||||
);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("startup validation disables an invalid script without blocking later rules", async () => {
|
||||
const runtime = new RouteScriptRuntime({ workerCount: 1, workerFile });
|
||||
const invalidRule = scriptRule("invalid-syntax", routeScript("return {;"));
|
||||
const validRule = scriptRule("valid-after-invalid", routeScript('return { model: "Provider/beta" };'));
|
||||
const rules = [invalidRule, validRule];
|
||||
const config = routingConfig(rules);
|
||||
try {
|
||||
const validationErrors = await runtime.prepare(rules);
|
||||
assert.equal(validationErrors.size, 1);
|
||||
assert.match(validationErrors.get("invalid-syntax"), /syntax|unexpected/i);
|
||||
|
||||
const plugin = new ClaudeCodeRouterPlugin(config, {
|
||||
scriptRuntime: runtime,
|
||||
scriptValidationErrors: validationErrors
|
||||
});
|
||||
const result = await plugin.routeRequest({
|
||||
body: { messages: [], model: "Provider/alpha" },
|
||||
headers: {},
|
||||
method: "POST",
|
||||
url: "/v1/messages"
|
||||
});
|
||||
|
||||
assert.equal(result.body.model, "Provider/beta");
|
||||
assert.equal(result.decision.reason, "script:valid-after-invalid");
|
||||
assert.ok(result.decision.diagnostics.some((diagnostic) =>
|
||||
diagnostic.code === "script-source-invalid" && diagnostic.ruleId === "invalid-syntax"
|
||||
));
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a true script result applies the rule's static target, rewrites, and fallback", async () => {
|
||||
const runtime = new RouteScriptRuntime({ workerCount: 1, workerFile });
|
||||
const rule = scriptRule("static-script-decision", routeScript("return true;"), {
|
||||
fallback: { mode: "retry", models: [], retryCount: 2 },
|
||||
rewrites: [
|
||||
{ key: "request.body.model", operation: "set", value: "Provider/beta" },
|
||||
{ key: "request.body.metadata.route", operation: "set", value: "static" }
|
||||
]
|
||||
});
|
||||
const config = routingConfig([rule]);
|
||||
try {
|
||||
const validationErrors = await runtime.prepare([rule]);
|
||||
const plugin = new ClaudeCodeRouterPlugin(config, {
|
||||
scriptRuntime: runtime,
|
||||
scriptValidationErrors: validationErrors
|
||||
});
|
||||
const result = await plugin.routeRequest({
|
||||
body: { messages: [], model: "Provider/alpha" },
|
||||
headers: {},
|
||||
method: "POST",
|
||||
url: "/v1/messages"
|
||||
});
|
||||
|
||||
assert.equal(result.body.model, "Provider/beta");
|
||||
assert.deepEqual(result.body.metadata, { route: "static" });
|
||||
assert.deepEqual(result.decision.fallback, { mode: "retry", models: [], retryCount: 2 });
|
||||
assert.equal(result.decision.reason, "script:static-script-decision");
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("the route-script test service validates, executes, and previews a custom decision", async () => {
|
||||
const script = routeScript(`
|
||||
return {
|
||||
model: input.headers["x-use-beta"] === "yes" ? "Provider/beta" : "Provider/alpha",
|
||||
rewrites: [{ key: "request.body.tested", operation: "set", value: true }]
|
||||
};
|
||||
`);
|
||||
try {
|
||||
const result = await gatewayService.testRouteScript(routingConfig(), {
|
||||
request: {
|
||||
body: { messages: [], model: "Provider/alpha" },
|
||||
headers: { "x-use-beta": "yes" },
|
||||
method: "POST",
|
||||
sessionId: "test-session",
|
||||
tokenCount: 42,
|
||||
url: "/v1/messages"
|
||||
},
|
||||
script
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.matched, true);
|
||||
assert.deepEqual(result.diagnostics, []);
|
||||
assert.deepEqual(result.output, {
|
||||
model: "Provider/beta",
|
||||
rewrites: [{ key: "request.body.tested", operation: "set", value: true }]
|
||||
});
|
||||
assert.ok(result.durationMs >= 0);
|
||||
} finally {
|
||||
await gatewayService.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("dynamic script model deletion overrides an earlier static model rewrite", async () => {
|
||||
const runtime = new RouteScriptRuntime({ workerCount: 1, workerFile });
|
||||
const script = routeScript(`
|
||||
@@ -180,25 +513,11 @@ test("dynamic script model deletion overrides an earlier static model rewrite",
|
||||
rewrites: [{ key: "request.body.model", operation: "delete" }]
|
||||
};
|
||||
`);
|
||||
const rule = {
|
||||
enabled: true,
|
||||
id: "dynamic-model-delete",
|
||||
const rule = scriptRule("dynamic-model-delete", script, {
|
||||
name: "Dynamic model delete",
|
||||
rewrites: [{ key: "request.body.model", operation: "set", value: "Provider/alpha" }],
|
||||
script,
|
||||
type: "script"
|
||||
};
|
||||
const config = {
|
||||
CUSTOM_ROUTER_PATH: "",
|
||||
Providers: [{ models: ["alpha", "beta"], name: "Provider", type: "anthropic_messages" }],
|
||||
Router: {
|
||||
builtInRules: { "claude-code": { enabled: false }, codex: { enabled: false } },
|
||||
fallback: { mode: "off", models: [], retryCount: 0 },
|
||||
rules: [rule]
|
||||
},
|
||||
profile: { enabled: false, profiles: [] },
|
||||
virtualModelProfiles: []
|
||||
};
|
||||
});
|
||||
const config = routingConfig([rule]);
|
||||
try {
|
||||
const validationErrors = await runtime.prepare([rule]);
|
||||
const plugin = new ClaudeCodeRouterPlugin(config, {
|
||||
@@ -219,32 +538,72 @@ test("dynamic script model deletion overrides an earlier static model rewrite",
|
||||
}
|
||||
});
|
||||
|
||||
test("route scripts can read and write arbitrary filesystem paths", async () => {
|
||||
test("route scripts expose the complete documented filesystem API", async () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "ccr-route-script-"));
|
||||
const inputFile = path.join(directory, "input.txt");
|
||||
const inputFile = path.join(directory, "input.json");
|
||||
const outputFile = path.join(directory, "output.txt");
|
||||
writeFileSync(inputFile, "allowed", "utf8");
|
||||
const outputJsonFile = path.join(directory, "output.json");
|
||||
const missingFile = path.join(directory, "missing.txt");
|
||||
writeFileSync(inputFile, JSON.stringify({ route: "allowed" }), "utf8");
|
||||
const runtime = new RouteScriptRuntime({ workerCount: 1, workerFile });
|
||||
const script = routeScript(`
|
||||
const value = await api.fs.readText(input.body.inputFile);
|
||||
await api.fs.writeText(input.body.outputFile, value.toUpperCase());
|
||||
return value;
|
||||
const text = await api.fs.readText(input.body.inputFile);
|
||||
const json = await api.fs.readJson(input.body.inputFile);
|
||||
await api.fs.writeText(input.body.outputFile, json.route.toUpperCase());
|
||||
await api.fs.writeJson(input.body.outputJsonFile, { copied: json.route });
|
||||
return {
|
||||
directory: await api.fs.stat(input.body.directory),
|
||||
entries: await api.fs.list(input.body.directory),
|
||||
file: await api.fs.stat(input.body.inputFile),
|
||||
inputExists: await api.fs.exists(input.body.inputFile),
|
||||
json,
|
||||
missingExists: await api.fs.exists(input.body.missingFile),
|
||||
text
|
||||
};
|
||||
`);
|
||||
try {
|
||||
const result = await runtime.execute("filesystem", script, scriptInput(script, { inputFile, outputFile }));
|
||||
const result = await runtime.execute("filesystem", script, scriptInput(script, {
|
||||
directory,
|
||||
inputFile,
|
||||
missingFile,
|
||||
outputFile,
|
||||
outputJsonFile
|
||||
}));
|
||||
assert.equal(result.status, "ok");
|
||||
assert.equal(result.value, "allowed");
|
||||
assert.equal(result.value.inputExists, true);
|
||||
assert.equal(result.value.missingExists, false);
|
||||
assert.equal(result.value.text, JSON.stringify({ route: "allowed" }));
|
||||
assert.deepEqual(result.value.json, { route: "allowed" });
|
||||
assert.equal(result.value.directory.isDirectory, true);
|
||||
assert.equal(result.value.file.isFile, true);
|
||||
assert.ok(result.value.file.size > 0);
|
||||
assert.match(result.value.file.modifiedAt, /^\d{4}-\d{2}-\d{2}T/);
|
||||
assert.deepEqual(
|
||||
result.value.entries.map((entry) => entry.name).sort(),
|
||||
["input.json", "output.json", "output.txt"]
|
||||
);
|
||||
assert.equal(readFileSync(outputFile, "utf8"), "ALLOWED");
|
||||
|
||||
assert.equal(readFileSync(outputJsonFile, "utf8"), '{\n "copied": "allowed"\n}\n');
|
||||
} finally {
|
||||
await runtime.close();
|
||||
rmSync(directory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("route scripts can access arbitrary HTTP endpoints", async () => {
|
||||
const server = createServer((_request, response) => {
|
||||
test("route scripts expose HTTP request options and response metadata", async () => {
|
||||
let received;
|
||||
const server = createServer(async (request, response) => {
|
||||
const chunks = [];
|
||||
for await (const chunk of request) chunks.push(chunk);
|
||||
received = {
|
||||
body: Buffer.concat(chunks).toString("utf8"),
|
||||
header: request.headers["x-policy-request"],
|
||||
method: request.method,
|
||||
url: request.url
|
||||
};
|
||||
response.statusCode = 201;
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.setHeader("x-policy-response", "available");
|
||||
response.end(JSON.stringify({ route: "beta" }));
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
@@ -253,13 +612,34 @@ test("route scripts can access arbitrary HTTP endpoints", async () => {
|
||||
const endpoint = `http://127.0.0.1:${address.port}/route`;
|
||||
const runtime = new RouteScriptRuntime({ workerCount: 1, workerFile });
|
||||
const script = routeScript(`
|
||||
const response = await api.fetch(input.body.endpoint);
|
||||
return JSON.parse(response.body);
|
||||
const response = await api.fetch(input.body.endpoint, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "x-policy-request": "check" },
|
||||
body: JSON.stringify({ tenant: "acme" })
|
||||
});
|
||||
return { ...response, body: JSON.parse(response.body) };
|
||||
`);
|
||||
try {
|
||||
const result = await runtime.execute("network", script, scriptInput(script, { endpoint }));
|
||||
assert.equal(result.status, "ok");
|
||||
assert.deepEqual(result.value, { route: "beta" });
|
||||
assert.equal(result.value.ok, true);
|
||||
assert.equal(result.value.status, 201);
|
||||
assert.equal(result.value.statusText, "Created");
|
||||
assert.equal(result.value.redirected, false);
|
||||
assert.equal(result.value.url, endpoint);
|
||||
assert.equal(result.value.headers["x-policy-response"], "available");
|
||||
assert.deepEqual(result.value.body, { route: "beta" });
|
||||
assert.deepEqual(received, {
|
||||
body: JSON.stringify({ tenant: "acme" }),
|
||||
header: "check",
|
||||
method: "POST",
|
||||
url: "/route"
|
||||
});
|
||||
|
||||
const invalidUrl = routeScript('await api.fetch("file:///tmp/policy.json");');
|
||||
const invalidResult = await runtime.execute("network-invalid-url", invalidUrl, scriptInput(invalidUrl));
|
||||
assert.equal(invalidResult.status, "error");
|
||||
assert.match(invalidResult.error, /only http\(s\) urls/i);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
@@ -282,6 +662,46 @@ test("route scripts report syntax failures and stop synchronous infinite loops",
|
||||
}
|
||||
});
|
||||
|
||||
test("route scripts stop unresolved async work and keep the worker reusable", async () => {
|
||||
const runtime = new RouteScriptRuntime({ workerCount: 1, workerFile });
|
||||
const stalled = routeScript("await new Promise(() => {});", { timeoutMs: 30 });
|
||||
const healthy = routeScript("return 'recovered';");
|
||||
try {
|
||||
const timeout = await runtime.execute("async-timeout", stalled, scriptInput(stalled));
|
||||
assert.equal(timeout.status, "timeout");
|
||||
assert.match(timeout.error, /timed out/i);
|
||||
|
||||
const recovered = await runtime.execute("after-async-timeout", healthy, scriptInput(healthy));
|
||||
assert.equal(recovered.status, "ok");
|
||||
assert.equal(recovered.value, "recovered");
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("route scripts reject non-JSON and oversized results without poisoning later executions", async () => {
|
||||
const runtime = new RouteScriptRuntime({ workerCount: 1, workerFile });
|
||||
const cases = [
|
||||
["bigint", routeScript("return 1n;"), /json serializable|bigint/i],
|
||||
["circular", routeScript("const value = {}; value.self = value; return value;"), /circular/i],
|
||||
["oversized", routeScript('return { value: "x".repeat(70 * 1024) };'), /exceeds 65536 bytes/i]
|
||||
];
|
||||
try {
|
||||
for (const [ruleId, script, expectedMessage] of cases) {
|
||||
const result = await runtime.execute(ruleId, script, scriptInput(script));
|
||||
assert.equal(result.status, "error");
|
||||
assert.match(result.error, expectedMessage);
|
||||
}
|
||||
|
||||
const healthy = routeScript("return { match: true };");
|
||||
const recovered = await runtime.execute("after-invalid-results", healthy, scriptInput(healthy));
|
||||
assert.equal(recovered.status, "ok");
|
||||
assert.deepEqual(recovered.value, { match: true });
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("route script workers distribute concurrent executions across available slots", async () => {
|
||||
const runtime = new RouteScriptRuntime({ workerCount: 2, workerFile });
|
||||
const script = routeScript(`
|
||||
|
||||
Reference in New Issue
Block a user