mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-18 21:13:23 +00:00
Reuse healthy external gateways and decode Claude App routes
This commit is contained in:
parent
e8db9406a7
commit
551fc0e8ef
11 changed files with 704 additions and 36 deletions
|
|
@ -1809,6 +1809,7 @@ export type GatewayStatus = {
|
|||
coreEndpoint: string;
|
||||
coreManagedExternally?: boolean;
|
||||
endpoint: string;
|
||||
gatewayManagedExternally?: boolean;
|
||||
lastError?: string;
|
||||
lastStartedAt?: string;
|
||||
networkEndpoints: GatewayNetworkEndpoint[];
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { pluginService } from "@ccr/core/plugins/service";
|
|||
import { proxyService } from "@ccr/core/proxy/service";
|
||||
import { ClaudeCodeRouterPlugin } from "@ccr/core/gateway/claude-code-router-plugin";
|
||||
import { compileCoreGatewayConfig } from "@ccr/core/gateway/core-runtime/config-compiler";
|
||||
import { isAddressInUseMessage, probeExistingCcrGateway } from "@ccr/core/gateway/existing-gateway-probe";
|
||||
import { closeServer, formatError } from "@ccr/core/gateway/http/io";
|
||||
import { RawTraceSynchronizer } from "@ccr/core/observability/raw-trace-sync";
|
||||
import { GatewayBillingSynchronizer } from "@ccr/core/usage/billing-sync";
|
||||
|
|
@ -210,6 +211,7 @@ class GatewayService {
|
|||
this.status = {
|
||||
...this.status,
|
||||
coreManagedExternally: this.status.coreManagedExternally,
|
||||
gatewayManagedExternally: undefined,
|
||||
lastStartedAt: new Date().toISOString(),
|
||||
pid: this.child?.pid,
|
||||
state: "running"
|
||||
|
|
@ -226,6 +228,37 @@ class GatewayService {
|
|||
}
|
||||
}
|
||||
|
||||
async ensureStarted(config: AppConfig): Promise<GatewayStatus> {
|
||||
const desiredEndpoint = endpoint(config.gateway.host, config.gateway.port);
|
||||
const currentStatus = this.getStatus();
|
||||
if (currentStatus.state === "running" && currentStatus.endpoint === desiredEndpoint) {
|
||||
if (currentStatus.gatewayManagedExternally) {
|
||||
const existingGateway = await probeExistingCcrGateway(config);
|
||||
if (existingGateway.state === "usable") {
|
||||
this.markExternalGatewayRunning(config, existingGateway.endpoint);
|
||||
return this.getStatus();
|
||||
}
|
||||
}
|
||||
if (!currentStatus.gatewayManagedExternally) {
|
||||
await this.updateConfig(config);
|
||||
return this.getStatus();
|
||||
}
|
||||
}
|
||||
|
||||
const status = await this.start(config);
|
||||
if (status.state !== "error" || !isAddressInUseMessage(status.lastError)) {
|
||||
return status;
|
||||
}
|
||||
|
||||
const existingGateway = await probeExistingCcrGateway(config);
|
||||
if (existingGateway.state !== "usable") {
|
||||
return status;
|
||||
}
|
||||
|
||||
this.markExternalGatewayRunning(config, existingGateway.endpoint);
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
async stop(options: GatewayStopOptions = {}): Promise<GatewayStatus> {
|
||||
const child = this.child;
|
||||
this.child = undefined;
|
||||
|
|
@ -259,6 +292,7 @@ class GatewayService {
|
|||
this.status = {
|
||||
...this.status,
|
||||
coreManagedExternally: undefined,
|
||||
gatewayManagedExternally: undefined,
|
||||
pid: undefined,
|
||||
state: "stopped"
|
||||
};
|
||||
|
|
@ -276,6 +310,11 @@ class GatewayService {
|
|||
|
||||
async updateConfig(config: AppConfig): Promise<void> {
|
||||
assertLoopbackCoreHost(config.gateway.coreHost);
|
||||
if (this.status.gatewayManagedExternally) {
|
||||
this.markExternalGatewayRunning(config, endpoint(config.gateway.host, config.gateway.port));
|
||||
return;
|
||||
}
|
||||
|
||||
const scriptValidationErrors = await this.routeScriptRuntime.prepare(config.Router.rules);
|
||||
const nextPlugin = new ClaudeCodeRouterPlugin(config, {
|
||||
scriptRuntime: this.routeScriptRuntime,
|
||||
|
|
@ -293,6 +332,7 @@ class GatewayService {
|
|||
...this.status,
|
||||
coreEndpoint: endpoint(config.gateway.coreHost, config.gateway.corePort),
|
||||
endpoint: endpoint(config.gateway.host, config.gateway.port),
|
||||
gatewayManagedExternally: undefined,
|
||||
networkEndpoints: gatewayNetworkEndpoints(config.gateway.host, config.gateway.port)
|
||||
};
|
||||
}
|
||||
|
|
@ -412,6 +452,20 @@ class GatewayService {
|
|||
return this.requestPipeline.proxyRequest(request, response, path, apiKey);
|
||||
}
|
||||
|
||||
private markExternalGatewayRunning(config: AppConfig, externalEndpoint: string): void {
|
||||
this.config = config;
|
||||
this.child = undefined;
|
||||
this.server = undefined;
|
||||
this.coreAuthToken = "";
|
||||
this.status = {
|
||||
coreEndpoint: endpoint(config.gateway.coreHost, config.gateway.corePort),
|
||||
endpoint: externalEndpoint,
|
||||
gatewayManagedExternally: true,
|
||||
networkEndpoints: gatewayNetworkEndpoints(config.gateway.host, config.gateway.port),
|
||||
state: "running"
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function assertManagedGatewayStartupContinues(child: ChildProcess, startupFailure: Error | undefined): void {
|
||||
|
|
|
|||
188
packages/core/src/gateway/existing-gateway-probe.ts
Normal file
188
packages/core/src/gateway/existing-gateway-probe.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import type { ApiKeyConfig, AppConfig } from "@ccr/core/contracts/app";
|
||||
|
||||
export type ExistingCcrGatewayProbe =
|
||||
| { endpoint: string; reason?: string; state: "unavailable" }
|
||||
| { endpoint: string; status?: number; state: "not-ccr" }
|
||||
| { endpoint: string; message?: string; status: number; state: "unauthorized" }
|
||||
| { endpoint: string; status: number; state: "unusable" }
|
||||
| { apiKey?: string; endpoint: string; state: "usable" };
|
||||
|
||||
type ExistingGatewayHttpProbe = {
|
||||
payload?: unknown;
|
||||
reason?: string;
|
||||
status?: number;
|
||||
};
|
||||
|
||||
const existingGatewayFetchAttempts = 3;
|
||||
|
||||
export async function probeExistingCcrGateway(
|
||||
config: Pick<AppConfig, "APIKEY" | "APIKEYS" | "gateway">
|
||||
): Promise<ExistingCcrGatewayProbe> {
|
||||
const endpoint = publicGatewayEndpoint(config);
|
||||
const health = await fetchExistingGateway(endpoint, "/health");
|
||||
let ccrGateway = isCcrGatewayHealth(health.payload);
|
||||
let root: ExistingGatewayHttpProbe | undefined;
|
||||
|
||||
if (!ccrGateway) {
|
||||
root = await fetchExistingGateway(endpoint, "/");
|
||||
ccrGateway = isCcrGatewayRoot(root.payload);
|
||||
}
|
||||
if (!ccrGateway) {
|
||||
if (health.status === undefined && root?.status === undefined) {
|
||||
return { endpoint, reason: health.reason || root?.reason, state: "unavailable" };
|
||||
}
|
||||
return { endpoint, status: health.status ?? root?.status, state: "not-ccr" };
|
||||
}
|
||||
|
||||
const candidates = existingGatewayApiKeyCandidates(config);
|
||||
if (candidates.length === 0) {
|
||||
return { endpoint, message: "No configured CCR API key is available.", status: 401, state: "unauthorized" };
|
||||
}
|
||||
|
||||
let lastUnauthorized: ExistingGatewayHttpProbe | undefined;
|
||||
for (const apiKey of candidates) {
|
||||
const models = await fetchExistingGateway(endpoint, "/v1/models", {
|
||||
headers: {
|
||||
authorization: `Bearer ${apiKey.key}`,
|
||||
"user-agent": "Claude Code"
|
||||
}
|
||||
});
|
||||
if (models.status === 200) {
|
||||
return { apiKey: apiKey.key, endpoint, state: "usable" };
|
||||
}
|
||||
if (models.status === 401 || models.status === 403) {
|
||||
lastUnauthorized = models;
|
||||
continue;
|
||||
}
|
||||
return { endpoint, status: models.status ?? 0, state: "unusable" };
|
||||
}
|
||||
|
||||
if (lastUnauthorized?.status === 401 || lastUnauthorized?.status === 403) {
|
||||
return {
|
||||
endpoint,
|
||||
message: readGatewayErrorMessage(lastUnauthorized.payload),
|
||||
status: lastUnauthorized.status,
|
||||
state: "unauthorized"
|
||||
};
|
||||
}
|
||||
|
||||
return { endpoint, status: 0, state: "unusable" };
|
||||
}
|
||||
|
||||
export function publicGatewayEndpoint(config: Pick<AppConfig, "gateway">): string {
|
||||
const host = probeGatewayHost(config.gateway.host);
|
||||
return `http://${formatEndpointHost(host)}:${config.gateway.port}/`;
|
||||
}
|
||||
|
||||
export function isAddressInUseMessage(message: string | undefined): boolean {
|
||||
return /\bEADDRINUSE\b/i.test(message || "");
|
||||
}
|
||||
|
||||
async function fetchExistingGateway(
|
||||
endpoint: string,
|
||||
pathname: string,
|
||||
init: RequestInit = {}
|
||||
): Promise<ExistingGatewayHttpProbe> {
|
||||
let reason: string | undefined;
|
||||
for (let attempt = 0; attempt < existingGatewayFetchAttempts; attempt += 1) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 1200);
|
||||
try {
|
||||
const response = await fetch(new URL(pathname, endpoint).toString(), {
|
||||
...init,
|
||||
signal: controller.signal
|
||||
});
|
||||
return {
|
||||
payload: await readResponseJson(response),
|
||||
status: response.status
|
||||
};
|
||||
} catch (error) {
|
||||
reason = formatError(error);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
return { reason };
|
||||
}
|
||||
|
||||
function existingGatewayApiKeyCandidates(config: Pick<AppConfig, "APIKEY" | "APIKEYS">): ApiKeyConfig[] {
|
||||
const candidates = [
|
||||
...(Array.isArray(config.APIKEYS) ? config.APIKEYS : []),
|
||||
...(config.APIKEY ? [{ createdAt: "", id: "legacy", key: config.APIKEY, name: "Legacy API Key" }] : [])
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
const result: ApiKeyConfig[] = [];
|
||||
for (const candidate of candidates) {
|
||||
const key = candidate.key?.trim();
|
||||
if (!key || seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
result.push({ ...candidate, key });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isCcrGatewayHealth(value: unknown): boolean {
|
||||
return isRecord(value) &&
|
||||
typeof value.status === "string" &&
|
||||
typeof value.core === "string";
|
||||
}
|
||||
|
||||
function isCcrGatewayRoot(value: unknown): boolean {
|
||||
return isRecord(value) &&
|
||||
(value.name === "claude-code-router" ||
|
||||
value.plugin === "claude-code-router" ||
|
||||
(value.core === "next-ai-gateway" && Array.isArray(value.endpoints)));
|
||||
}
|
||||
|
||||
function readGatewayErrorMessage(value: unknown): string | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const error = value.error;
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
if (isRecord(error) && typeof error.message === "string") {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof value.message === "string") {
|
||||
return value.message;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function readResponseJson(response: Response): Promise<unknown> {
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function probeGatewayHost(host: string): string {
|
||||
if (!host || host === "0.0.0.0") {
|
||||
return "127.0.0.1";
|
||||
}
|
||||
if (host === "::") {
|
||||
return "::1";
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
function formatEndpointHost(host: string): string {
|
||||
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { mkdirSync } from "node:fs";
|
|||
import { dirname } from "node:path";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import { REQUEST_LOGS_DB_FILE } from "@ccr/core/config/constants";
|
||||
import { decodeClaudeAppGatewayRouteId } from "@ccr/core/agents/claude-app/gateway-routes";
|
||||
import {
|
||||
estimateUsageCostUsd,
|
||||
estimateUsageCostUsdFromLoadedCatalog,
|
||||
|
|
@ -518,9 +519,12 @@ export class RequestLogStore {
|
|||
providerProtocol: input.providerProtocol,
|
||||
usageHint: bodyUsage
|
||||
}) ?? {};
|
||||
const route = splitRouteSelector(input.fallbackModel);
|
||||
const route = splitRequestLogRouteSelector(input.fallbackModel);
|
||||
const bodyModel = requestLogRequestedModel(input.requestBody, input.path);
|
||||
const requestModel = normalizeFilterValue(input.model) ?? bodyModel;
|
||||
const requestModelForStorage = requestLogStorageModel(requestModel);
|
||||
const usageRoute = decodedClaudeAppGatewayRouteParts(usage.model);
|
||||
const usageModelForStorage = usageRoute?.model ?? normalizeFilterValue(usage.model);
|
||||
const requestedModel = normalizeFilterValue(input.requestedModel) ?? bodyModel ?? "";
|
||||
const resolvedModel = normalizeFilterValue(input.resolvedModel) ??
|
||||
normalizeFilterValue(input.model) ??
|
||||
|
|
@ -535,7 +539,8 @@ export class RequestLogStore {
|
|||
normalizeFilterValue(input.providerName) ??
|
||||
readResponseHeader(input.responseHeaders, "x-gateway-target-provider-name") ??
|
||||
readResponseHeader(input.responseHeaders, "x-gateway-target-provider") ??
|
||||
route.provider;
|
||||
route.provider ??
|
||||
usageRoute?.provider;
|
||||
const inputTokens = normalizeCount(usage.inputTokens);
|
||||
const outputTokens = normalizeCount(usage.outputTokens);
|
||||
const reasoningTokens = normalizeCount(usage.reasoningTokens);
|
||||
|
|
@ -546,7 +551,7 @@ export class RequestLogStore {
|
|||
const totalTokens =
|
||||
normalizeCount(usage.totalTokens) ||
|
||||
inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
|
||||
const model = normalizeLabel(usage.model ?? route.model ?? requestModel ?? input.fallbackModel, "unknown");
|
||||
const model = normalizeLabel(usageModelForStorage ?? route.model ?? requestModelForStorage ?? input.fallbackModel, "unknown");
|
||||
const providerName = normalizeLabel(provider, "unknown");
|
||||
// CCR credential IDs are structured metadata, but their header names also
|
||||
// match the fail-closed secret classifier. Extract them before sanitizing;
|
||||
|
|
@ -785,7 +790,9 @@ export class RequestLogStore {
|
|||
const url = normalizeFilterValue(input.url);
|
||||
const path = normalizeFilterValue(input.path) ?? pathFromUrl(url);
|
||||
const usagePath = path ?? existingUsageContext.path;
|
||||
const modelFromTrace = normalizeFilterValue(input.model);
|
||||
const rawModelFromTrace = normalizeFilterValue(input.model);
|
||||
const modelFromTrace = requestLogStorageModel(rawModelFromTrace);
|
||||
const resolvedModelFromTrace = requestLogStorageModelSelector(rawModelFromTrace);
|
||||
const responseModelFromTrace = rawInput.responseBodyText === undefined
|
||||
? undefined
|
||||
: requestLogResponseModel(rawInput.responseBodyText);
|
||||
|
|
@ -806,7 +813,7 @@ export class RequestLogStore {
|
|||
pushValue("url", url);
|
||||
pushValue("provider", providerFromTrace);
|
||||
pushValue("model", modelFromTrace);
|
||||
pushValue("resolved_model", modelFromTrace);
|
||||
pushValue("resolved_model", resolvedModelFromTrace);
|
||||
pushValue("response_model", responseModelFromTrace);
|
||||
// The gateway's terminal failure is authoritative, even when it has only
|
||||
// an HTTP error status and no error string. A final-attempt raw failure may
|
||||
|
|
@ -847,7 +854,7 @@ export class RequestLogStore {
|
|||
const totalTokens =
|
||||
normalizeCount(usage.totalTokens) ||
|
||||
inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
|
||||
const model = normalizeLabel(usage.model ?? modelFromTrace ?? existingUsageContext.model, "unknown");
|
||||
const model = normalizeLabel(requestLogStorageModel(usage.model) ?? modelFromTrace ?? existingUsageContext.model, "unknown");
|
||||
const provider = normalizeLabel(providerFromTrace ?? existingUsageContext.provider, "unknown");
|
||||
const costInput = {
|
||||
cacheReadTokens,
|
||||
|
|
@ -4986,6 +4993,23 @@ function parseJson(value: string): unknown | undefined {
|
|||
}
|
||||
}
|
||||
|
||||
function decodedClaudeAppGatewayRouteParts(value: string | undefined): { model?: string; provider?: string } | undefined {
|
||||
const decoded = decodeClaudeAppGatewayRouteId(value ?? "");
|
||||
return decoded ? splitRouteSelector(decoded) : undefined;
|
||||
}
|
||||
|
||||
function requestLogStorageModel(value: string | undefined): string | undefined {
|
||||
return decodedClaudeAppGatewayRouteParts(value)?.model ?? normalizeFilterValue(value);
|
||||
}
|
||||
|
||||
function requestLogStorageModelSelector(value: string | undefined): string | undefined {
|
||||
return normalizeFilterValue(decodeClaudeAppGatewayRouteId(value ?? "") ?? value);
|
||||
}
|
||||
|
||||
function splitRequestLogRouteSelector(value: string | undefined): { model?: string; provider?: string } {
|
||||
return splitRouteSelector(decodeClaudeAppGatewayRouteId(value ?? "") ?? value);
|
||||
}
|
||||
|
||||
function splitRouteSelector(value: string | undefined): { model?: string; provider?: string } {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
|
|
|
|||
|
|
@ -366,7 +366,7 @@ const rpcHandlers: Record<string, RpcHandler> = {
|
|||
openProfile: async (request) => {
|
||||
const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig());
|
||||
const config = syncedClaudeAppConfig.config;
|
||||
const status = await gatewayService.start(config);
|
||||
const status = await gatewayService.ensureStarted(config);
|
||||
if (status.state !== "running") {
|
||||
throw new Error(status.lastError || "CCR gateway did not start.");
|
||||
}
|
||||
|
|
@ -444,7 +444,7 @@ const rpcHandlers: Record<string, RpcHandler> = {
|
|||
startGateway: async () => {
|
||||
const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig());
|
||||
const config = syncedClaudeAppConfig.config;
|
||||
const status = await gatewayService.start(config);
|
||||
const status = await gatewayService.ensureStarted(config);
|
||||
await applyProfileIfServiceRunning(config, status);
|
||||
return status;
|
||||
},
|
||||
|
|
@ -469,7 +469,7 @@ async function startConfiguredServices(reason: string): Promise<void> {
|
|||
} catch (error) {
|
||||
console.error(`Failed to sync Claude App gateway config during ${reason}: ${formatError(error)}`);
|
||||
}
|
||||
const status = await gatewayService.start(config);
|
||||
const status = await gatewayService.ensureStarted(config);
|
||||
if (status.state === "error") {
|
||||
console.error(`Failed to start gateway during ${reason}: ${status.lastError}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -316,6 +316,85 @@ test("RequestLogStore keeps list rows lightweight and detail rows complete", asy
|
|||
}
|
||||
});
|
||||
|
||||
test("RequestLogStore stores decoded Claude App route models for observability", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-claude-app-model-test-"));
|
||||
try {
|
||||
const store = new RequestLogStore(path.join(dir, "request-logs.sqlite"));
|
||||
const sessionId = "claude-app-hex-session";
|
||||
const routedModel = "Provider/real-model";
|
||||
const encodedModel = `anthropic/claude-ccr-h${Buffer.from(routedModel, "utf8").toString("hex")}`;
|
||||
const startedAt = new Date().toISOString();
|
||||
const responseBodyText = [
|
||||
"event: message_start",
|
||||
`data: ${JSON.stringify({
|
||||
message: {
|
||||
model: encodedModel,
|
||||
usage: {
|
||||
input_tokens: 3,
|
||||
output_tokens: 5,
|
||||
total_tokens: 8
|
||||
}
|
||||
},
|
||||
type: "message_start"
|
||||
})}`,
|
||||
"",
|
||||
"event: message_stop",
|
||||
"data: {\"type\":\"message_stop\"}",
|
||||
""
|
||||
].join("\n");
|
||||
|
||||
await store.record({
|
||||
completedAt: startedAt,
|
||||
durationMs: 42,
|
||||
fallbackModel: routedModel,
|
||||
method: "POST",
|
||||
model: routedModel,
|
||||
path: "/v1/messages",
|
||||
providerName: "Provider",
|
||||
providerProtocol: "anthropic_messages",
|
||||
requestedModel: encodedModel,
|
||||
requestBody: Buffer.from(JSON.stringify({
|
||||
messages: [{ content: "hello", role: "user" }],
|
||||
model: routedModel
|
||||
}), "utf8"),
|
||||
requestHeaders: {
|
||||
"content-type": "application/json",
|
||||
"user-agent": "openai-codex test",
|
||||
"x-codex-session-id": sessionId
|
||||
},
|
||||
requestId: "claude-app-hex-request",
|
||||
resolvedModel: routedModel,
|
||||
responseBodyText,
|
||||
responseHeaders: { "content-type": "text/event-stream" },
|
||||
startedAt,
|
||||
statusCode: 200,
|
||||
url: "http://127.0.0.1:3456/v1/messages"
|
||||
});
|
||||
|
||||
const page = await store.list({ pageSize: 25 });
|
||||
assert.equal(page.items.length, 1);
|
||||
assert.equal(page.items[0].provider, "Provider");
|
||||
assert.equal(page.items[0].model, "real-model");
|
||||
assert.equal(page.items[0].requestedModel, encodedModel);
|
||||
assert.equal(page.items[0].resolvedModel, routedModel);
|
||||
assert.equal(page.items[0].responseModel, encodedModel);
|
||||
|
||||
const selected = await store.analyze({
|
||||
range: "30d",
|
||||
sessionAgent: "codex",
|
||||
sessionId
|
||||
});
|
||||
assert.equal(selected.selectedSession?.requests[0]?.model, "real-model");
|
||||
assert.deepEqual(selected.selectedSession?.session.models, ["real-model"]);
|
||||
assert.equal(
|
||||
selected.selectedSession?.trace.runs.find((run) => run.kind === "llm")?.model,
|
||||
"real-model"
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("RequestLogStore persists actively reported route hops without synthesizing Core diffs", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-route-trace-test-"));
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,30 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { normalizeExternalHttpTarget, startWebManagementServer } from "@ccr/core/web/management-server.ts";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test, { after } from "node:test";
|
||||
|
||||
test("normalizeExternalHttpTarget accepts absolute http, https, and CCR plugin URLs only", () => {
|
||||
const previousRuntimeEnv = {
|
||||
appData: process.env.CCR_INTERNAL_APP_DATA_DIR,
|
||||
home: process.env.CCR_INTERNAL_HOME_DIR,
|
||||
userData: process.env.CCR_INTERNAL_USER_DATA_DIR
|
||||
};
|
||||
const runtimeRoot = mkdtempSync(path.join(os.tmpdir(), "ccr-web-management-test-"));
|
||||
process.env.CCR_INTERNAL_APP_DATA_DIR = path.join(runtimeRoot, "app-data");
|
||||
process.env.CCR_INTERNAL_HOME_DIR = path.join(runtimeRoot, "home");
|
||||
process.env.CCR_INTERNAL_USER_DATA_DIR = path.join(runtimeRoot, "user-data");
|
||||
|
||||
after(() => {
|
||||
setOptionalEnv("CCR_INTERNAL_APP_DATA_DIR", previousRuntimeEnv.appData);
|
||||
setOptionalEnv("CCR_INTERNAL_HOME_DIR", previousRuntimeEnv.home);
|
||||
setOptionalEnv("CCR_INTERNAL_USER_DATA_DIR", previousRuntimeEnv.userData);
|
||||
rmSync(runtimeRoot, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
test("normalizeExternalHttpTarget accepts absolute http, https, and CCR plugin URLs only", async () => {
|
||||
const { normalizeExternalHttpTarget } = await import("@ccr/core/web/management-server.ts");
|
||||
assert.equal(normalizeExternalHttpTarget(""), undefined);
|
||||
assert.equal(normalizeExternalHttpTarget(undefined), undefined);
|
||||
assert.equal(normalizeExternalHttpTarget("about:blank"), undefined);
|
||||
|
|
@ -14,6 +36,7 @@ test("normalizeExternalHttpTarget accepts absolute http, https, and CCR plugin U
|
|||
});
|
||||
|
||||
test("web RPC ignores Origin and Referer when the auth token is valid", async () => {
|
||||
const { startWebManagementServer } = await import("@ccr/core/web/management-server.ts");
|
||||
const authToken = "test-web-auth-token";
|
||||
const runtime = await startWebManagementServer({
|
||||
authToken,
|
||||
|
|
@ -42,3 +65,149 @@ test("web RPC ignores Origin and Referer when the auth token is valid", async ()
|
|||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("startGateway reuses an already healthy CCR gateway on the configured port", async () => {
|
||||
const { saveAppConfig } = await import("@ccr/core/config/config.ts");
|
||||
const { createDefaultAppConfig } = await import("@ccr/core/config/default-config.ts");
|
||||
const { gatewayService } = await import("@ccr/core/gateway/service.ts");
|
||||
const { startWebManagementServer } = await import("@ccr/core/web/management-server.ts");
|
||||
await gatewayService.stop();
|
||||
const gatewayPort = await findAvailablePort();
|
||||
const webAuthToken = "test-web-auth-token";
|
||||
const config = createDefaultAppConfig();
|
||||
config.gateway.port = gatewayPort;
|
||||
config.gateway.corePort = await findAvailablePort();
|
||||
config.Providers = [{
|
||||
api_base_url: "http://127.0.0.1:9/v1",
|
||||
api_key: "test-provider-key",
|
||||
id: "test-provider",
|
||||
models: ["test-model"],
|
||||
name: "Test Provider",
|
||||
type: "openai_chat_completions"
|
||||
}];
|
||||
const savedConfig = await saveAppConfig(config);
|
||||
const externalGateway = createHealthyCcrGateway(savedConfig.APIKEY);
|
||||
await listen(externalGateway, gatewayPort);
|
||||
const runtime = await startWebManagementServer({
|
||||
authToken: webAuthToken,
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
startGateway: false
|
||||
});
|
||||
|
||||
try {
|
||||
const payload = await rpc(runtime.url, webAuthToken, "startGateway");
|
||||
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.value.state, "running", payload.value.lastError);
|
||||
assert.equal(payload.value.endpoint, `http://127.0.0.1:${gatewayPort}/`);
|
||||
assert.equal(payload.value.gatewayManagedExternally, true);
|
||||
assert.equal(gatewayService.getStatus().state, "running");
|
||||
assert.equal(gatewayService.getStatus().gatewayManagedExternally, true);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
await gatewayService.stop();
|
||||
await closeServer(externalGateway);
|
||||
}
|
||||
});
|
||||
|
||||
async function rpc(baseUrl, authToken, method, args = []) {
|
||||
const response = await fetch(new URL("/api/ccr/rpc", baseUrl), {
|
||||
body: JSON.stringify({ args, method }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-ccr-web-auth": authToken
|
||||
},
|
||||
method: "POST"
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function createHealthyCcrGateway(apiKey) {
|
||||
return createServer((request, response) => {
|
||||
const url = new URL(request.url || "/", "http://127.0.0.1");
|
||||
if (url.pathname === "/health") {
|
||||
sendJson(response, 200, {
|
||||
core: "http://127.0.0.1:3457",
|
||||
status: "running",
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/") {
|
||||
sendJson(response, 200, {
|
||||
core: "next-ai-gateway",
|
||||
endpoints: ["GET /v1/models"],
|
||||
name: "claude-code-router",
|
||||
plugin: "claude-code-router"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/v1/models") {
|
||||
if (request.headers.authorization !== `Bearer ${apiKey}`) {
|
||||
sendJson(response, 401, { error: { message: "Invalid API key." } });
|
||||
return;
|
||||
}
|
||||
sendJson(response, 200, { data: [{ id: "test-model", object: "model" }], object: "list" });
|
||||
return;
|
||||
}
|
||||
sendJson(response, 404, { error: { message: "Not found." } });
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(response, statusCode, payload) {
|
||||
response.writeHead(statusCode, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
async function findAvailablePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === "object" && address ? address.port : undefined;
|
||||
server.close(() => {
|
||||
if (port) {
|
||||
resolve(port);
|
||||
} else {
|
||||
reject(new Error("Failed to allocate a web management test port."));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setOptionalEnv(key, value) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
return;
|
||||
}
|
||||
process.env[key] = value;
|
||||
}
|
||||
|
||||
async function listen(server, port) {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function closeServer(server) {
|
||||
if (!server.listening) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -991,7 +991,7 @@ async function routeMockRequest(runtime, method, url, request, requestBody) {
|
|||
}
|
||||
|
||||
if (isDesignRestApiRoutePath(path)) {
|
||||
return await handleDesignRestApi(runtime, method, path, request, requestBody);
|
||||
return await handleDesignRestApi(runtime, method, path, url, request, requestBody);
|
||||
}
|
||||
|
||||
if (path.startsWith("/v1/code/")) {
|
||||
|
|
@ -9812,7 +9812,7 @@ function upsertGenericRecord(store, collection, uuid, title, model, data) {
|
|||
store.persist();
|
||||
}
|
||||
|
||||
async function handleDesignRestApi(runtime, method, path, request, requestBody) {
|
||||
async function handleDesignRestApi(runtime, method, path, url, request, requestBody) {
|
||||
const restPath = normalizeDesignRestPath(path);
|
||||
|
||||
if (method === "GET" && restPath === "/v1/design/agents") {
|
||||
|
|
@ -9991,19 +9991,18 @@ async function handleDesignRestApi(runtime, method, path, request, requestBody)
|
|||
});
|
||||
}
|
||||
|
||||
const downloadMatch = restPath.match(/^\/v1\/design\/projects\/([^/]+)\/download$/);
|
||||
if (method === "GET" && downloadMatch) {
|
||||
const downloadMatch = restPath.match(/^\/v1\/design\/projects\/([^/]+)\/download(?:\/(.+))?$/);
|
||||
if ((method === "GET" || method === "HEAD") && downloadMatch) {
|
||||
const projectId = decodeURIComponent(downloadMatch[1]);
|
||||
const manifest = listProjectFileRows(runtime, projectId, "").map((row) => ({
|
||||
content_type: row.content_type,
|
||||
path: row.path,
|
||||
size: Buffer.from(row.body_base64 || "", "base64").length,
|
||||
version: row.version
|
||||
}));
|
||||
return textResponse(200, JSON.stringify({ files: manifest, project_id: projectId }, null, 2), {
|
||||
"content-disposition": `attachment; filename="${projectId}.json"`,
|
||||
"content-type": "application/json; charset=utf-8"
|
||||
});
|
||||
const filePath = designDownloadFilePath(downloadMatch[2], url);
|
||||
if (filePath) {
|
||||
const row = getProjectFileRow(runtime, projectId, filePath);
|
||||
if (!row) {
|
||||
return jsonResponse(404, { error: { message: `File not found: ${filePath}` } });
|
||||
}
|
||||
return serveProjectFileDownloadResponse(method, row, filePath);
|
||||
}
|
||||
return serveProjectArchiveDownloadResponse(method, runtime, projectId);
|
||||
}
|
||||
|
||||
if (method === "POST" && restPath === "/v1/design/drop-suggestions") {
|
||||
|
|
@ -10170,6 +10169,56 @@ function renderClaudeDesignPendingPreviewHtml() {
|
|||
</html>`;
|
||||
}
|
||||
|
||||
function designDownloadFilePath(pathSuffix, url) {
|
||||
const suffixPath = safelyDecodeDesignPath(pathSuffix);
|
||||
if (suffixPath) {
|
||||
return suffixPath;
|
||||
}
|
||||
for (const key of ["path", "file", "file_path", "download_path", "asset_path"]) {
|
||||
const queryPath = safelyDecodeDesignPath(url.searchParams.get(key));
|
||||
if (queryPath) {
|
||||
return queryPath;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function safelyDecodeDesignPath(value) {
|
||||
const raw = stringValue(value);
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return sanitizeProjectFilePath(decodeURIComponent(raw));
|
||||
} catch {
|
||||
return sanitizeProjectFilePath(raw);
|
||||
}
|
||||
}
|
||||
|
||||
function serveProjectFileDownloadResponse(method, row, filePath) {
|
||||
const body = method === "HEAD" ? Buffer.alloc(0) : Buffer.from(row.body_base64 || "", "base64");
|
||||
const contentType = row.content_type || guessContentType(filePath);
|
||||
return binaryResponse(200, body, {
|
||||
"cache-control": "no-store",
|
||||
"content-disposition": attachmentContentDisposition(pathModule.posix.basename(filePath) || "download"),
|
||||
"content-type": contentType
|
||||
});
|
||||
}
|
||||
|
||||
function serveProjectArchiveDownloadResponse(method, runtime, projectId) {
|
||||
const rows = listProjectFileRows(runtime, projectId, "");
|
||||
const files = rows.map((row) => ({
|
||||
body: Buffer.from(row.body_base64 || "", "base64"),
|
||||
path: row.path
|
||||
}));
|
||||
const archive = method === "HEAD" ? Buffer.alloc(0) : createZipArchive(files);
|
||||
return binaryResponse(200, archive, {
|
||||
"cache-control": "no-store",
|
||||
"content-disposition": attachmentContentDisposition(`${projectId || "claude-design-project"}.zip`),
|
||||
"content-type": "application/zip"
|
||||
});
|
||||
}
|
||||
|
||||
function serveProjectFileResponse(runtime, projectId, row, filePath) {
|
||||
const body = Buffer.from(row.body_base64 || "", "base64");
|
||||
const contentType = row.content_type || guessContentType(filePath);
|
||||
|
|
@ -16652,6 +16701,21 @@ function escapeHtmlAttribute(value) {
|
|||
return String(value).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
||||
}
|
||||
|
||||
function attachmentContentDisposition(fileName) {
|
||||
const basename = pathModule.posix.basename(sanitizeProjectFilePath(fileName) || "download") || "download";
|
||||
const fallback = basename
|
||||
.replace(/[^\x20-\x7e]+/g, "_")
|
||||
.replace(/["\\;\r\n]/g, "_")
|
||||
.trim() || "download";
|
||||
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeRfc5987ValueChars(basename)}`;
|
||||
}
|
||||
|
||||
function encodeRfc5987ValueChars(value) {
|
||||
return encodeURIComponent(value).replace(/['()*]/g, (char) =>
|
||||
`%${char.charCodeAt(0).toString(16).toUpperCase()}`
|
||||
);
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ ipcMain.handle(IPC_CHANNELS.appOpenProfile, async (_event, request: ProfileOpenR
|
|||
const config = profile.agent === CLAUDE_DESIGN_PLUGIN_ID
|
||||
? withClaudeDesignRuntimePluginConfig(syncedClaudeAppConfig.config)
|
||||
: syncedClaudeAppConfig.config;
|
||||
const status = await gatewayService.start(config);
|
||||
const status = await gatewayService.ensureStarted(config);
|
||||
if (status.state !== "running") {
|
||||
throw new Error(status.lastError || "CCR gateway did not start.");
|
||||
}
|
||||
|
|
@ -366,7 +366,7 @@ ipcMain.handle(IPC_CHANNELS.appRestartGateway, async () => {
|
|||
ipcMain.handle(IPC_CHANNELS.appStartGateway, async () => {
|
||||
const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig());
|
||||
const config = syncedClaudeAppConfig.config;
|
||||
const status = await gatewayService.start(config);
|
||||
const status = await gatewayService.ensureStarted(config);
|
||||
await applyProfileIfServiceRunning(config, status);
|
||||
await builtInBrowserService.syncProxy(config);
|
||||
return status;
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@ function startConfiguredServices(reason: string): Promise<void> {
|
|||
} catch (error) {
|
||||
console.error(`Failed to sync launch-at-login setting during ${reason}: ${formatError(error)}`);
|
||||
}
|
||||
const status = await gatewayService.start(config);
|
||||
const status = await gatewayService.ensureStarted(config);
|
||||
if (status.state === "error") {
|
||||
console.error(`Failed to start gateway during ${reason}: ${status.lastError}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,7 +196,70 @@ test("Claude Design mock covers local frontend project runtime routes", async ()
|
|||
assert.equal(typeof events.stream, "function");
|
||||
});
|
||||
|
||||
function fakeClaudeDesignRuntime() {
|
||||
test("Claude Design downloads return concrete files or a project zip", async () => {
|
||||
const projectId = "project-download-test";
|
||||
const pngBody = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01]);
|
||||
const runtime = fakeClaudeDesignRuntime([
|
||||
{
|
||||
body: Buffer.from("<!doctype html><title>Test</title>", "utf8"),
|
||||
contentType: "text/html; charset=utf-8",
|
||||
path: "index.html",
|
||||
projectId
|
||||
},
|
||||
{
|
||||
body: pngBody,
|
||||
contentType: "image/png",
|
||||
path: "分享大纲.png",
|
||||
projectId
|
||||
}
|
||||
]);
|
||||
|
||||
const image = await claudeDesignPlugin.__test.routeMockRequest(
|
||||
runtime,
|
||||
"GET",
|
||||
new URL("http://127.0.0.1/v1/design/projects/project-download-test/download?path=%E5%88%86%E4%BA%AB%E5%A4%A7%E7%BA%B2.png"),
|
||||
{ headers: {} },
|
||||
Buffer.alloc(0)
|
||||
);
|
||||
assert.equal(image.status, 200);
|
||||
assert.equal(image.headers["content-type"], "image/png");
|
||||
assert.match(image.headers["content-disposition"], /attachment/);
|
||||
assert.match(image.headers["content-disposition"], /filename\*=UTF-8''%E5%88%86%E4%BA%AB%E5%A4%A7%E7%BA%B2\.png/);
|
||||
assert.deepEqual(image.body, pngBody);
|
||||
|
||||
const archive = await claudeDesignPlugin.__test.routeMockRequest(
|
||||
runtime,
|
||||
"GET",
|
||||
new URL("http://127.0.0.1/v1/design/projects/project-download-test/download"),
|
||||
{ headers: {} },
|
||||
Buffer.alloc(0)
|
||||
);
|
||||
assert.equal(archive.status, 200);
|
||||
assert.equal(archive.headers["content-type"], "application/zip");
|
||||
assert.match(archive.headers["content-disposition"], /project-download-test\.zip/);
|
||||
assert.deepEqual(archive.body.subarray(0, 4), Buffer.from([0x50, 0x4b, 0x03, 0x04]));
|
||||
assert.match(archive.body.toString("utf8"), /index\.html/);
|
||||
assert.match(archive.body.toString("utf8"), /分享大纲\.png/);
|
||||
});
|
||||
|
||||
type FakeProjectFile = {
|
||||
body: Buffer;
|
||||
contentType: string;
|
||||
path: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
function fakeClaudeDesignRuntime(files: FakeProjectFile[] = []) {
|
||||
const fileRows = files.map((file, index) => ({
|
||||
body_base64: file.body.toString("base64"),
|
||||
content_type: file.contentType,
|
||||
created_at: "2026-08-10T00:00:00.000Z",
|
||||
path: file.path,
|
||||
project_id: file.projectId,
|
||||
updated_at: `2026-08-10T00:00:${String(index).padStart(2, "0")}.000Z`,
|
||||
version: 1
|
||||
}));
|
||||
|
||||
return {
|
||||
assetDir: "",
|
||||
frontendUrl: "http://127.0.0.1:6173/design",
|
||||
|
|
@ -209,19 +272,28 @@ function fakeClaudeDesignRuntime() {
|
|||
pluginId: "claude-design",
|
||||
store: {
|
||||
database: {
|
||||
prepare() {
|
||||
return {
|
||||
bind() {
|
||||
return this;
|
||||
prepare(sql: string) {
|
||||
let rows = fakeClaudeDesignRows(sql, [], fileRows);
|
||||
let index = -1;
|
||||
const statement = {
|
||||
bind(params: unknown[]) {
|
||||
rows = fakeClaudeDesignRows(sql, Array.from(params || []), fileRows);
|
||||
index = -1;
|
||||
return statement;
|
||||
},
|
||||
free() {},
|
||||
getAsObject() {
|
||||
return {};
|
||||
return rows[index] || {};
|
||||
},
|
||||
step() {
|
||||
return false;
|
||||
if (index + 1 >= rows.length) {
|
||||
return false;
|
||||
}
|
||||
index += 1;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
return statement;
|
||||
},
|
||||
run() {}
|
||||
},
|
||||
|
|
@ -229,3 +301,20 @@ function fakeClaudeDesignRuntime() {
|
|||
}
|
||||
};
|
||||
}
|
||||
|
||||
function fakeClaudeDesignRows(sql: string, params: unknown[], fileRows: Record<string, unknown>[]) {
|
||||
if (!sql.includes("FROM claude_design_files")) {
|
||||
return [];
|
||||
}
|
||||
if (sql.includes("WHERE project_id = ? AND path = ?")) {
|
||||
const [projectId, filePath] = params;
|
||||
return fileRows.filter((row) => row.project_id === projectId && row.path === filePath);
|
||||
}
|
||||
if (sql.includes("WHERE project_id = ?")) {
|
||||
const [projectId] = params;
|
||||
return fileRows.filter((row) => row.project_id === projectId).sort((left, right) =>
|
||||
String(left.path).localeCompare(String(right.path))
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue