Add context archive config and gateway support

This commit is contained in:
musi
2026-07-08 22:33:33 +08:00
parent 113c9059b4
commit 8945cd5bb8
6 changed files with 1707 additions and 1 deletions
+78 -1
View File
@@ -15,6 +15,7 @@ import type {
BotGatewaySavedConfig,
ClaudeCodeProfileConfig,
CodexProfileConfig,
ContextArchiveConfig,
GatewayAgentConfig,
GatewayMcpServerConfig,
GatewayMcpServerTransport,
@@ -67,11 +68,12 @@ type LoadedBotGatewayConfig = Partial<Omit<BotGatewayRuntimeConfig, "handoff">>
handoff?: Partial<BotGatewayRuntimeConfig["handoff"]>;
};
type LoadedAppConfig = Partial<Omit<AppConfig, "Router" | "agent" | "botGateway" | "gateway" | "observability" | "profile" | "proxy" | "toolHub">> & {
type LoadedAppConfig = Partial<Omit<AppConfig, "Router" | "agent" | "botGateway" | "contextArchive" | "gateway" | "observability" | "profile" | "proxy" | "toolHub">> & {
Router?: Partial<RouterConfig>;
agent?: Partial<GatewayAgentConfig>;
botConfigs?: BotGatewaySavedConfig[];
botGateway?: LoadedBotGatewayConfig;
contextArchive?: Partial<ContextArchiveConfig>;
gateway?: Partial<AppConfig["gateway"]>;
observability?: Partial<ObservabilityConfig>;
profile?: LoadedProfileConfig;
@@ -242,6 +244,14 @@ export async function loadAppConfig(): Promise<AppConfig> {
},
botConfigs: picked.botConfigs ?? DEFAULT_CONFIG.botConfigs,
botGateway: completeBotGatewayConfig(picked.botGateway),
contextArchive: {
...DEFAULT_CONFIG.contextArchive,
...(picked.contextArchive ?? {}),
llm: {
...DEFAULT_CONFIG.contextArchive.llm,
...(picked.contextArchive?.llm ?? {})
}
},
gateway: {
...DEFAULT_CONFIG.gateway,
...gatewayConfig,
@@ -588,6 +598,10 @@ function pickConfig(value: Partial<AppConfig>): LoadedAppConfig {
if (botConfigs) {
config.botConfigs = botConfigs;
}
const contextArchive = parseContextArchive((value as Record<string, unknown>).contextArchive ?? (value as Record<string, unknown>).context_archive);
if (contextArchive) {
config.contextArchive = contextArchive;
}
if (typeof value.autoStart === "boolean") {
config.autoStart = value.autoStart;
}
@@ -676,6 +690,69 @@ function pickConfig(value: Partial<AppConfig>): LoadedAppConfig {
return config;
}
function parseContextArchive(value: unknown): Partial<ContextArchiveConfig> | undefined {
if (!isObject(value)) {
return undefined;
}
const contextArchive: Partial<ContextArchiveConfig> = {};
if (typeof value.enabled === "boolean") {
contextArchive.enabled = value.enabled;
}
const mcpEnabled = value.mcpEnabled ?? value.mcp_enabled;
if (typeof mcpEnabled === "boolean") {
contextArchive.mcpEnabled = mcpEnabled;
}
const triggerTokenLimit = readNumber(value.triggerTokenLimit ?? value.trigger_token_limit);
if (triggerTokenLimit !== undefined) {
contextArchive.triggerTokenLimit = clampNumber(triggerTokenLimit, 1000, 2_000_000);
}
const retainRecentItems = readNumber(value.retainRecentItems ?? value.retain_recent_items);
if (retainRecentItems !== undefined) {
contextArchive.retainRecentItems = clampNumber(retainRecentItems, 2, 200);
}
const maxEntries = readNumber(value.maxEntries ?? value.max_entries);
if (maxEntries !== undefined) {
contextArchive.maxEntries = clampNumber(maxEntries, 50, 100000);
}
const maxSearchResults = readNumber(value.maxSearchResults ?? value.max_search_results);
if (maxSearchResults !== undefined) {
contextArchive.maxSearchResults = clampNumber(maxSearchResults, 1, 50);
}
const handoffMaxCharacters = readNumber(value.handoffMaxCharacters ?? value.handoff_max_characters);
if (handoffMaxCharacters !== undefined) {
contextArchive.handoffMaxCharacters = clampNumber(handoffMaxCharacters, 1000, 200000);
}
const toolName = readString(value.toolName ?? value.tool_name);
if (toolName !== undefined) {
contextArchive.toolName = toolName;
}
const rawLlm = isObject(value.llm) ? value.llm : value;
const llm: Partial<ContextArchiveConfig["llm"]> = {};
const apiKey = readString(rawLlm.apiKey) || readString(rawLlm.api_key);
if (apiKey !== undefined) {
llm.apiKey = apiKey;
}
const baseUrl = readString(rawLlm.baseUrl) || readString(rawLlm.base_url);
if (baseUrl !== undefined) {
llm.baseUrl = baseUrl;
}
const model = readString(rawLlm.model);
if (model !== undefined) {
llm.model = model;
}
const timeoutMs = readNumber(rawLlm.timeoutMs ?? rawLlm.timeout_ms);
if (timeoutMs !== undefined) {
llm.timeoutMs = clampNumber(timeoutMs, 8000, 600000);
}
if (Object.keys(llm).length > 0) {
contextArchive.llm = llm as ContextArchiveConfig["llm"];
}
return Object.keys(contextArchive).length ? contextArchive : undefined;
}
function parseObservability(value: unknown): Partial<ObservabilityConfig> | undefined {
if (!isObject(value)) {
return undefined;
@@ -82,6 +82,22 @@ export function createDefaultAppConfig(options: DefaultAppConfigOptions): AppCon
stateDir: "",
tenantId: "ccr"
},
contextArchive: {
enabled: false,
handoffMaxCharacters: 24000,
llm: {
apiKey: "",
baseUrl: "https://api.openai.com/v1",
model: "",
timeoutMs: 60000
},
maxEntries: 2000,
maxSearchResults: 8,
mcpEnabled: true,
retainRecentItems: 12,
toolName: "ccr_history_search",
triggerTokenLimit: 100000
},
gateway: {
coreHost,
corePort: 3457,
+20
View File
@@ -656,6 +656,25 @@ export type ToolHubConfig = {
requestTimeoutMs: number;
};
export type ContextArchiveLlmConfig = {
apiKey: string;
baseUrl: string;
model: string;
timeoutMs: number;
};
export type ContextArchiveConfig = {
enabled: boolean;
handoffMaxCharacters: number;
llm: ContextArchiveLlmConfig;
maxEntries: number;
maxSearchResults: number;
mcpEnabled: boolean;
retainRecentItems: number;
toolName: string;
triggerTokenLimit: number;
};
export const CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV = "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY";
export const CLAUDE_CODE_DEFAULT_ENV: Record<string, string> = {
[CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV]: "1"
@@ -1400,6 +1419,7 @@ export type AppConfig = {
autoStart: boolean;
botConfigs: BotGatewaySavedConfig[];
botGateway: BotGatewayRuntimeConfig;
contextArchive: ContextArchiveConfig;
gateway: GatewayRuntimeConfig;
launchAtLogin: boolean;
observability: ObservabilityConfig;
File diff suppressed because it is too large Load Diff
+63
View File
@@ -46,6 +46,13 @@ import { codexDefaultBaseUrl, readCodexAuth } from "@ccr/core/agents/local-provi
import { fetchWithSystemProxy, getSystemProxyUrlForProtocol } from "@ccr/core/proxy/system-proxy-fetch";
import { handleNetworkCaptureMcpRequest, isNetworkCaptureMcpPath } from "@ccr/core/mcp/network-capture-mcp";
import { BROWSER_AUTOMATION_MCP_PATH, TOOL_HUB_MCP_SERVER_NAME, browserAutomationMcpEnabled, toolHubBuiltInBackendServers, toolHubMcpRuntimeConfig, toolHubRequestTimeoutMs } from "@ccr/core/mcp/toolhub-config";
import {
contextArchiveMcpServer,
handleContextArchiveMcpRequest,
isContextArchiveMcpPath,
prepareContextArchiveRequest,
recordContextArchiveResponse
} from "@ccr/core/gateway/context-archive";
import { pluginService } from "@ccr/core/plugins/service";
import { proxyService } from "@ccr/core/proxy/service";
import { createSseErrorDetector, recordGatewayRequestLog, updateGatewayRequestLogFromRawTrace, type RequestLogRawTraceUpdateInput } from "@ccr/core/observability/request-log-store";
@@ -212,6 +219,8 @@ type UpstreamAttempt = {
model?: string;
};
type ContextArchiveForwardRecord = Parameters<typeof recordContextArchiveResponse>[0];
type UpstreamFailedAttempt = {
credentialChain?: string[];
credentialIds?: string[];
@@ -603,6 +612,15 @@ class GatewayService {
return;
}
if (isContextArchiveMcpPath(path)) {
const authorization = await authorize(request, response, this.config);
if (!authorization.ok) {
return;
}
await handleContextArchiveMcpRequest(request, response, this.config);
return;
}
const pluginRoute = pluginService.matchGatewayRoute(request.method, path);
if (pluginRoute) {
if (pluginRoute.auth !== "none") {
@@ -917,6 +935,23 @@ class GatewayService {
}
}
let contextArchiveRecord: ContextArchiveForwardRecord;
const contextArchivePreparation = await prepareContextArchiveRequest({
body: bodyToForward,
config: this.config,
headers: request.headers,
method,
path,
protocol: requestProtocolForPath(path),
requestId
});
if (contextArchivePreparation) {
bodyToForward = contextArchivePreparation.body;
contextArchiveRecord = contextArchivePreparation.record;
headers["content-type"] = "application/json";
headers["x-ccr-context-archive"] = sanitizeHeaderValue(contextArchivePreparation.diagnostic);
}
delete headers["content-length"];
const upstreamUrl = new URL(request.url || "/", this.status.coreEndpoint).toString();
let upstreamResult: UpstreamFetchResult;
@@ -1057,6 +1092,7 @@ class GatewayService {
responseBody.once("end", () => {
upstreamStreamEnded = true;
streamDetectedError ??= sseErrorDetector.finish();
recordContextArchiveResponse(contextArchiveRecord, sampler.read(), this.config);
if (responseCompleted || response.writableEnded) {
writeStreamLog();
}
@@ -1185,6 +1221,14 @@ async function writeCoreGatewayConfig(
...builtinToolArtifacts.mcpServers,
...(toolHubServer ? [toolHubServer] : externalMcpServers)
];
const contextArchiveServer = contextArchiveMcpServer(
config,
clientGatewayEndpoint(config.gateway.host, config.gateway.port),
firstConfiguredApiKey(config)
);
if (contextArchiveServer) {
mcpServers.push(contextArchiveServer);
}
const fallbackMcpServer = fusionToolFallbackMcpServer(virtualModelProfiles, [
...builtinToolArtifacts.mcpServers,
...externalMcpServers
@@ -1248,6 +1292,11 @@ function providerPluginEnabled(plugin: unknown): boolean {
return !isRecord(plugin) || plugin.enabled !== false;
}
function firstConfiguredApiKey(config: AppConfig): string | undefined {
return (Array.isArray(config.APIKEYS) ? config.APIKEYS : [])
.find((apiKey) => apiKey.key.trim())?.key.trim() || stringValue(config.APIKEY);
}
export function normalizeCoreGatewayVirtualModelProfiles(profiles: unknown[], config: AppConfig): unknown[] {
return profiles.map((profile) => normalizeCoreGatewayVirtualModelProfile(profile, config));
}
@@ -6867,6 +6916,20 @@ function endpoint(host: string, port: number): string {
return `http://${endpointHost}:${port}`;
}
function clientGatewayEndpoint(host: string, port: number): string {
let endpointHost = host;
if (endpointHost === "0.0.0.0") {
endpointHost = "127.0.0.1";
} else if (endpointHost === "::" || endpointHost === "[::]") {
endpointHost = "::1";
}
return `http://${formatUrlHost(endpointHost)}:${port}`;
}
function formatUrlHost(host: string): string {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
function gatewayNetworkEndpoints(host: string, port: number): GatewayNetworkEndpoint[] {
const normalizedHost = normalizeBindHost(host);
const lanAddresses = physicalLanAddresses();
+259
View File
@@ -0,0 +1,259 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createDefaultAppConfig } from "../../packages/core/src/config/default-config.ts";
import {
CONTEXT_ARCHIVE_MCP_PATH,
contextArchiveMcpServer,
contextArchiveService,
prepareContextArchiveRequest
} from "../../packages/core/src/gateway/context-archive.ts";
function testConfig(contextArchiveOverrides = {}) {
const config = createDefaultAppConfig({
generatedConfigFile: "/tmp/ccr-context-archive-test-gateway.json"
});
return {
...config,
APIKEY: "local-test-key",
APIKEYS: [{ id: "local", key: "local-test-key", name: "Local" }],
contextArchive: {
...config.contextArchive,
enabled: true,
handoffMaxCharacters: 12000,
maxEntries: 200,
maxSearchResults: 4,
retainRecentItems: 2,
triggerTokenLimit: 1,
...contextArchiveOverrides
}
};
}
test("context archive compacts OpenAI chat requests and preserves searchable pruned history", async () => {
contextArchiveService.clear();
const config = testConfig();
const body = {
messages: [
{ role: "system", content: "You are a coding agent." },
{ role: "user", content: "Historical decision: use SQLite for the archive index, not a JSON file." },
{ role: "assistant", content: "Acknowledged. I will use SQLite." },
{ role: "user", content: "Recent request: continue implementation." },
{ role: "assistant", content: "Working on it." }
],
model: "test-model"
};
const result = await prepareContextArchiveRequest({
body: Buffer.from(JSON.stringify(body)),
config,
headers: { "x-session-id": "session-a" },
method: "POST",
path: "/v1/chat/completions",
protocol: "openai_chat_completions",
requestId: "request-a"
});
assert.ok(result);
assert.match(result.diagnostic, /^compacted:session-a:/);
const compacted = JSON.parse(result.body.toString("utf8"));
assert.equal(compacted.messages[0].role, "system");
assert.match(compacted.messages[1].content, /CCR CONTEXT HANDOFF/);
assert.match(compacted.messages[1].content, /ccr_history_search/);
assert.equal(compacted.messages.at(-1).content, "Working on it.");
const search = await contextArchiveService.search({
prompt: "Which storage was chosen for the archive index?",
sessionId: "session-a"
}, config.contextArchive);
assert.equal(search.evidence.length > 0, true);
assert.match(search.answer, /SQLite/);
});
test("context archive adapts Codex compact requests without pruning the client payload", async () => {
contextArchiveService.clear();
const config = testConfig({ triggerTokenLimit: 999999 });
const body = {
instructions: "You are Codex.",
input: [
{
content: [
{
text: "Historical decision: the archive search should use a radix index for quick prefix lookup.",
type: "input_text"
}
],
role: "user",
type: "message"
},
{
content: [
{
text: "Please summarize the conversation so far for context compaction. Include decisions and next steps.",
type: "input_text"
}
],
role: "user",
type: "message"
}
],
model: "gpt-5-codex"
};
const result = await prepareContextArchiveRequest({
body: Buffer.from(JSON.stringify(body)),
config,
headers: { "user-agent": "codex-cli/1.0", "x-codex-session-id": "codex-s1" },
method: "POST",
path: "/v1/responses",
protocol: "openai_responses",
requestId: "request-codex-compact"
});
assert.ok(result);
assert.match(result.diagnostic, /^client-compact:codex:codex-s1:/);
const prepared = JSON.parse(result.body.toString("utf8"));
assert.equal(prepared.input.length, body.input.length);
assert.match(prepared.instructions, /Archived history access/);
assert.match(prepared.instructions, /ccr_history_search/);
assert.match(prepared.instructions, /codex-s1/);
const search = await contextArchiveService.search({
prompt: "Which index was chosen for archive search?",
sessionId: "codex-s1"
}, config.contextArchive);
assert.match(search.answer, /radix index/);
});
test("context archive adapts Claude Code compact requests without pruning messages", async () => {
contextArchiveService.clear();
const config = testConfig({ triggerTokenLimit: 999999 });
const body = {
messages: [
{
content: "Important result: npm run test:main passes after the context archive changes.",
role: "assistant"
},
{
content: "Summarize the conversation so far for handoff into a new context window.",
role: "user"
}
],
model: "claude-sonnet-4-5",
system: "You are Claude Code."
};
const result = await prepareContextArchiveRequest({
body: Buffer.from(JSON.stringify(body)),
config,
headers: { "user-agent": "claude-code/2.0", "x-claude-code-session-id": "claude-s1" },
method: "POST",
path: "/v1/messages",
protocol: "anthropic_messages",
requestId: "request-claude-compact"
});
assert.ok(result);
assert.match(result.diagnostic, /^client-compact:claude-code:claude-s1:/);
const prepared = JSON.parse(result.body.toString("utf8"));
assert.equal(prepared.messages.length, body.messages.length);
assert.match(prepared.system, /Archived history access/);
assert.match(prepared.system, /ccr_history_search/);
assert.match(prepared.system, /claude-s1/);
const search = await contextArchiveService.search({
prompt: "What test command passed?",
sessionId: "claude-s1"
}, config.contextArchive);
assert.match(search.answer, /npm run test:main/);
});
test("context archive does not treat generic summary prompts as client compact requests", async () => {
contextArchiveService.clear();
const config = testConfig({ triggerTokenLimit: 999999 });
const body = {
messages: [
{ content: "Please summarize the conversation so far for context compaction.", role: "user" }
],
model: "test-model"
};
const result = await prepareContextArchiveRequest({
body: Buffer.from(JSON.stringify(body)),
config,
headers: { "user-agent": "generic-openai-client/1.0", "x-session-id": "generic-s1" },
method: "POST",
path: "/v1/chat/completions",
protocol: "openai_chat_completions",
requestId: "request-generic-summary"
});
assert.ok(result);
assert.match(result.diagnostic, /^archived:generic-s1:/);
assert.deepEqual(JSON.parse(result.body.toString("utf8")), body);
});
test("context archive does not treat unrelated Claude Code compact wording as context compaction", async () => {
contextArchiveService.clear();
const config = testConfig({ triggerTokenLimit: 999999 });
const body = {
messages: [
{ content: "Please set the UI density option to compact.", role: "user" }
],
model: "claude-sonnet-4-5"
};
const result = await prepareContextArchiveRequest({
body: Buffer.from(JSON.stringify(body)),
config,
headers: { "user-agent": "claude-code/2.0", "x-claude-code-session-id": "claude-s2" },
method: "POST",
path: "/v1/messages",
protocol: "anthropic_messages",
requestId: "request-claude-unrelated-compact"
});
assert.ok(result);
assert.match(result.diagnostic, /^archived:claude-s2:/);
assert.deepEqual(JSON.parse(result.body.toString("utf8")), body);
});
test("context archive deep search expands neighboring evidence", async () => {
contextArchiveService.clear();
const config = testConfig();
const body = {
messages: [
{ role: "user", content: "First note: alpha marker belongs to the retry policy discussion." },
{ role: "assistant", content: "Neighbor note: the retry policy uses exponential backoff." },
{ role: "user", content: "Recent request." }
],
model: "test-model"
};
await prepareContextArchiveRequest({
body: Buffer.from(JSON.stringify(body)),
config,
headers: { "x-session-id": "session-b" },
method: "POST",
path: "/v1/chat/completions",
protocol: "openai_chat_completions",
requestId: "request-b"
});
const shallow = await contextArchiveService.search({ prompt: "alpha marker", sessionId: "session-b" }, config.contextArchive);
const deep = await contextArchiveService.search({ deep: true, prompt: "alpha marker", sessionId: "session-b" }, config.contextArchive);
assert.equal(shallow.evidence.length > 0, true);
assert.equal(deep.evidence.length >= shallow.evidence.length, true);
assert.match(deep.answer, /exponential backoff|alpha marker/);
});
test("context archive MCP server points at the built-in gateway endpoint", () => {
const config = testConfig();
const server = contextArchiveMcpServer(config, "http://127.0.0.1:3456", "local-test-key");
assert.ok(server);
assert.equal(server.name, "ccr-context-archive");
assert.equal(server.transport, "streamable-http");
assert.equal(server.apiKey, "local-test-key");
assert.equal(server.url, `http://127.0.0.1:3456${CONTEXT_ARCHIVE_MCP_PATH}`);
});