Merge branch 'main' into dev/3.1

# Conflicts:
#	README.md
#	README_zh.md
This commit is contained in:
musistudio
2026-07-24 23:03:14 +08:00
5 changed files with 131 additions and 1 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

@@ -7,6 +7,9 @@ type UpstreamRequest = {
};
type ProviderPluginRequestInput = {
request?: {
headers?: Record<string, string | string[] | undefined>;
};
upstreamRequest: UpstreamRequest;
};
@@ -15,6 +18,35 @@ const ccrAuthHeaderNames = new Set([
"x-auth-sub"
]);
const ccrRoutingHeaderNames = new Set([
"x-gateway-target-provider",
"x-gateway-target-provider-name",
"x-target-model",
"x-target-provider",
"x-target-providers"
]);
const clientAuthHeaderNames = new Set([
"api-key",
"authorization",
"x-api-key"
]);
const transportHeaderNames = new Set([
"connection",
"content-encoding",
"content-length",
"expect",
"host",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade"
]);
/**
* Removes CCR-owned routing, authentication and observability metadata at the
* final provider boundary. Provider credentials and non-CCR custom X-Auth
@@ -30,6 +62,53 @@ export function sanitizeUpstreamProviderHeaders(headers: Record<string, string>)
return sanitized;
}
/**
* Restores client headers after the core protocol adapter has rebuilt the
* provider request. Provider-generated auth and content headers win on name
* collisions, while transport and CCR-owned headers never cross the boundary.
*/
export function mergeUpstreamProviderHeaders(
requestHeaders: Record<string, string | string[] | undefined> | undefined,
upstreamHeaders: Record<string, string>
): Record<string, string> {
const connectionHeaders = new Set(transportHeaderNames);
for (const value of headerValues(requestHeaders?.connection)) {
for (const name of value.split(",")) {
const normalized = name.trim().toLowerCase();
if (normalized) connectionHeaders.add(normalized);
}
}
const merged: Record<string, string> = {};
for (const [name, value] of Object.entries(requestHeaders ?? {})) {
const normalized = name.trim().toLowerCase();
if (
!normalized ||
value === undefined ||
normalized.startsWith("x-ccr-") ||
ccrAuthHeaderNames.has(normalized) ||
ccrRoutingHeaderNames.has(normalized) ||
clientAuthHeaderNames.has(normalized) ||
connectionHeaders.has(normalized)
) {
continue;
}
merged[normalized] = Array.isArray(value) ? value.join(",") : value;
}
for (const [name, value] of Object.entries(sanitizeUpstreamProviderHeaders(upstreamHeaders))) {
const normalized = name.trim().toLowerCase();
if (!normalized || connectionHeaders.has(normalized)) continue;
merged[normalized] = value;
}
return merged;
}
function headerValues(value: string | string[] | undefined): string[] {
if (value === undefined) return [];
return Array.isArray(value) ? value : [value];
}
export function createGatewayPlugin() {
return {
providerHooks: [{
@@ -39,7 +118,7 @@ export function createGatewayPlugin() {
ok: true as const,
value: {
...input.upstreamRequest,
headers: sanitizeUpstreamProviderHeaders(input.upstreamRequest.headers)
headers: mergeUpstreamProviderHeaders(input.request?.headers, input.upstreamRequest.headers)
}
};
}
@@ -43,3 +43,54 @@ test("gateway sanitizer hook runs on the final upstream request shape", async ()
});
assert.equal(upstreamRequest.headers["x-ccr-provider-credential-id"], "credential-id");
});
test("gateway sanitizer hook forwards client headers without overriding provider headers", async () => {
const [hook] = createGatewayPlugin().providerHooks;
const upstreamRequest = {
body: { model: "provider-model" },
headers: {
authorization: "Bearer provider-token",
"Content-Type": "application/json"
},
method: "POST",
url: "https://provider.example/v1/responses"
};
const result = await hook.transformRequest({
request: {
headers: {
authorization: "Bearer ccr-client-token",
connection: "keep-alive, x-hop-only",
"content-length": "123",
"content-type": "text/plain",
cookie: "client-cookie=value",
host: "127.0.0.1:3457",
"http-referer": "https://cherry-ai.com",
"user-agent": "Codex Desktop",
"x-auth-api-key-id": "profile:codex",
"x-auth-provider-extension": "provider-extension",
"x-ccr-core-auth": "core-secret",
"x-custom-provider-header": "custom-value",
"x-custom-list": ["one", "two"],
"x-hop-only": "remove-me",
"x-target-model": "internal-provider/internal-model",
"x-target-provider": "internal-provider",
"x-title": "Claude Code Router"
}
},
upstreamRequest
});
assert.equal(result.ok, true);
assert.deepEqual(result.value.headers, {
authorization: "Bearer provider-token",
"content-type": "application/json",
cookie: "client-cookie=value",
"http-referer": "https://cherry-ai.com",
"user-agent": "Codex Desktop",
"x-auth-provider-extension": "provider-extension",
"x-custom-provider-header": "custom-value",
"x-custom-list": "one,two",
"x-title": "Claude Code Router"
});
});