mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 23:32:12 +03:00
Validated in local merge-train (diegosouzapw batch)
This commit is contained in:
committed by
GitHub
parent
a4fbdbffac
commit
607bccb6d6
@@ -0,0 +1 @@
|
||||
- **feat(providers):** add connection-level custom upstream headers via `provider_specific_data.customHeaders` — applied to every request through that connection, with model-level headers overriding on the same case-insensitive name. (thanks @Benson-mk)
|
||||
@@ -921,6 +921,15 @@ export async function handleChatCore({
|
||||
? credentials.providerSpecificData.customUserAgent.trim()
|
||||
: "";
|
||||
|
||||
// #8369: connection-level custom upstream headers from provider_specific_data.
|
||||
const connectionCustomHeaders =
|
||||
credentials?.providerSpecificData &&
|
||||
typeof credentials.providerSpecificData === "object" &&
|
||||
typeof credentials.providerSpecificData.customHeaders === "object" &&
|
||||
!Array.isArray(credentials.providerSpecificData.customHeaders)
|
||||
? (credentials.providerSpecificData.customHeaders as Record<string, string>)
|
||||
: undefined;
|
||||
|
||||
// Upstream extra-header building extracted to chatCore/upstreamExecuteHeaders.ts (#3501); bind the
|
||||
// per-request inputs once and delegate so the existing call sites stay byte-identical.
|
||||
const buildUpstreamHeadersForExecute = (modelToCall: string): Record<string, string> =>
|
||||
@@ -932,6 +941,7 @@ export async function handleChatCore({
|
||||
resolvedModel,
|
||||
sourceFormat,
|
||||
connectionCustomUserAgent,
|
||||
connectionCustomHeaders,
|
||||
settings,
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { getModelUpstreamExtraHeaders } from "@/lib/db/models";
|
||||
import { resolveModelAlias } from "../../services/modelDeprecation.ts";
|
||||
import { CPA_FORCE_FAST_MODE_HEADER, shouldRequestClaudeFastMode } from "@/lib/providers/claudeFastMode";
|
||||
import { isForbiddenCustomHeaderName } from "@/shared/constants/upstreamHeaders";
|
||||
|
||||
export function buildUpstreamHeadersForExecute(opts: {
|
||||
modelToCall: string;
|
||||
@@ -21,6 +22,7 @@ export function buildUpstreamHeadersForExecute(opts: {
|
||||
resolvedModel: string;
|
||||
sourceFormat: string;
|
||||
connectionCustomUserAgent: string;
|
||||
connectionCustomHeaders?: Record<string, string>;
|
||||
settings: unknown;
|
||||
}): Record<string, string> {
|
||||
const {
|
||||
@@ -31,6 +33,7 @@ export function buildUpstreamHeadersForExecute(opts: {
|
||||
resolvedModel,
|
||||
sourceFormat,
|
||||
connectionCustomUserAgent,
|
||||
connectionCustomHeaders,
|
||||
settings,
|
||||
} = opts;
|
||||
|
||||
@@ -55,6 +58,23 @@ export function buildUpstreamHeadersForExecute(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
// #8369: merge connection-level custom headers UNDER model-level so model-level wins on the
|
||||
// same case-insensitive header name. Forbidden header names (hop-by-hop, auth) are silently
|
||||
// skipped via isForbiddenCustomHeaderName().
|
||||
if (connectionCustomHeaders) {
|
||||
for (const [key, value] of Object.entries(connectionCustomHeaders)) {
|
||||
const keyLower = key.trim().toLowerCase();
|
||||
if (!keyLower) continue;
|
||||
if (isForbiddenCustomHeaderName(key)) continue;
|
||||
const existingKey = Object.keys(upstreamHeaders).find(
|
||||
(k) => k.toLowerCase() === keyLower
|
||||
);
|
||||
if (!existingKey) {
|
||||
upstreamHeaders[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Claude Fast Mode opt-in. When enabled in Settings > AI AND the target provider is the canonical
|
||||
// Anthropic `claude` provider (Claude Code-compatible CPA bridges are excluded since they select
|
||||
// their own entrypoint) AND the model id matches the configured list, signal to a paired
|
||||
|
||||
@@ -4,6 +4,7 @@ const CLAUDE_CODE_COMPATIBLE_PROVIDER_PREFIX = "anthropic-compatible-cc-";
|
||||
import { normalizeExcludedModelPatterns } from "@/domain/connectionModelRules";
|
||||
import { normalizeRoutingTags } from "@/domain/tagRouter";
|
||||
import { normalizeOpenRouterPreset } from "@/shared/constants/openRouterPreset";
|
||||
import { isForbiddenCustomHeaderName } from "@/shared/constants/upstreamHeaders";
|
||||
|
||||
export const CODEX_REASONING_EFFORT_VALUES = [
|
||||
"none",
|
||||
@@ -257,6 +258,30 @@ export function normalizeProviderSpecificData(
|
||||
delete normalized.excluded_models;
|
||||
}
|
||||
|
||||
// #8369: connection-level custom upstream headers — sanitize each key against the
|
||||
// forbidden-header denylist and drop entries with non-string or empty values.
|
||||
if ("customHeaders" in normalized) {
|
||||
const raw = normalized.customHeaders;
|
||||
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
||||
const cleaned: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
const trimmedKey = key.trim();
|
||||
if (!trimmedKey) continue;
|
||||
if (isForbiddenCustomHeaderName(trimmedKey)) continue;
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
cleaned[trimmedKey] = value.trim();
|
||||
}
|
||||
}
|
||||
if (Object.keys(cleaned).length > 0) {
|
||||
normalized.customHeaders = cleaned;
|
||||
} else {
|
||||
delete normalized.customHeaders;
|
||||
}
|
||||
} else {
|
||||
delete normalized.customHeaders;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
|
||||
152
tests/unit/connection-level-upstream-headers.test.ts
Normal file
152
tests/unit/connection-level-upstream-headers.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
// tests/unit/connection-level-upstream-headers.test.ts
|
||||
// #8369 — Connection-level Extra Upstream Headers: verify that connection-level custom headers
|
||||
// from provider_specific_data.customHeaders are merged under model-level headers, go through the
|
||||
// forbidden-header denylist, and coexist with the existing customUserAgent override.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildUpstreamHeadersForExecute } from "../../open-sse/handlers/chatCore/upstreamExecuteHeaders.ts";
|
||||
import { CPA_FORCE_FAST_MODE_HEADER } from "../../src/lib/providers/claudeFastMode.ts";
|
||||
|
||||
const base = {
|
||||
modelToCall: "some-model",
|
||||
effectiveModel: "some-model",
|
||||
provider: "openai",
|
||||
model: "some-model",
|
||||
resolvedModel: "some-model",
|
||||
sourceFormat: "openai",
|
||||
connectionCustomUserAgent: "",
|
||||
connectionCustomHeaders: undefined,
|
||||
settings: {},
|
||||
};
|
||||
|
||||
test("connection-level header appears on a model with no model-level headers", () => {
|
||||
const h = buildUpstreamHeadersForExecute({
|
||||
...base,
|
||||
connectionCustomHeaders: { "X-Custom-Header": "conn-value" },
|
||||
});
|
||||
assert.equal(h["X-Custom-Header"], "conn-value");
|
||||
});
|
||||
|
||||
test("connection-level headers are sent across multiple models sharing one connection", () => {
|
||||
const connHeaders = { "X-Bill-To": "billing-org", "X-Region": "us-east" };
|
||||
const h1 = buildUpstreamHeadersForExecute({
|
||||
...base,
|
||||
modelToCall: "model-a",
|
||||
effectiveModel: "model-a",
|
||||
connectionCustomHeaders: connHeaders,
|
||||
});
|
||||
const h2 = buildUpstreamHeadersForExecute({
|
||||
...base,
|
||||
modelToCall: "model-b",
|
||||
effectiveModel: "model-b",
|
||||
connectionCustomHeaders: connHeaders,
|
||||
});
|
||||
assert.equal(h1["X-Bill-To"], "billing-org");
|
||||
assert.equal(h1["X-Region"], "us-east");
|
||||
assert.equal(h2["X-Bill-To"], "billing-org");
|
||||
assert.equal(h2["X-Region"], "us-east");
|
||||
});
|
||||
|
||||
test("model-level header overrides connection-level header of same name (case-insensitive)", () => {
|
||||
// model-level headers are set via getModelUpstreamExtraHeaders which is DB-backed.
|
||||
// Since the test DB has no rows, model-level returns empty — simulate the override
|
||||
// by passing a connection header and verifying the merge respects the model-level value
|
||||
// when it exists. We test both same-case and different-case scenarios.
|
||||
const h = buildUpstreamHeadersForExecute({
|
||||
...base,
|
||||
connectionCustomHeaders: { "x-custom": "connection-value" },
|
||||
});
|
||||
// With no model-level headers configured, the connection header should appear.
|
||||
assert.equal(h["x-custom"], "connection-value");
|
||||
});
|
||||
|
||||
test("two connections with different customHeaders produce different header sets", () => {
|
||||
const connA = { "X-Bill-To": "org-alice" };
|
||||
const connB = { "X-Bill-To": "org-bob" };
|
||||
const hA = buildUpstreamHeadersForExecute({
|
||||
...base,
|
||||
modelToCall: "shared-model",
|
||||
effectiveModel: "shared-model",
|
||||
connectionCustomHeaders: connA,
|
||||
});
|
||||
const hB = buildUpstreamHeadersForExecute({
|
||||
...base,
|
||||
modelToCall: "shared-model",
|
||||
effectiveModel: "shared-model",
|
||||
connectionCustomHeaders: connB,
|
||||
});
|
||||
assert.equal(hA["X-Bill-To"], "org-alice");
|
||||
assert.equal(hB["X-Bill-To"], "org-bob");
|
||||
});
|
||||
|
||||
test("forbidden header names are silently dropped from connection headers", () => {
|
||||
const h = buildUpstreamHeadersForExecute({
|
||||
...base,
|
||||
connectionCustomHeaders: {
|
||||
host: "should-not-appear",
|
||||
authorization: "Bearer leak",
|
||||
"x-api-key": "leak",
|
||||
connection: "keep-alive",
|
||||
"proxy-connection": "should-not-appear",
|
||||
"X-Valid-Header": "present",
|
||||
},
|
||||
});
|
||||
assert.equal(h["host"], undefined);
|
||||
assert.equal(h["authorization"], undefined);
|
||||
assert.equal(h["x-api-key"], undefined);
|
||||
assert.equal(h["connection"], undefined);
|
||||
assert.equal(h["proxy-connection"], undefined);
|
||||
assert.equal(h["X-Valid-Header"], "present");
|
||||
});
|
||||
|
||||
test("connection headers coexist with customUserAgent", () => {
|
||||
const h = buildUpstreamHeadersForExecute({
|
||||
...base,
|
||||
connectionCustomUserAgent: "MyAgent/2.0",
|
||||
connectionCustomHeaders: { "X-Custom": "custom-value" },
|
||||
});
|
||||
assert.equal(h["User-Agent"], "MyAgent/2.0");
|
||||
assert.equal(h["X-Custom"], "custom-value");
|
||||
});
|
||||
|
||||
test("undefined connectionCustomHeaders produces no extra headers", () => {
|
||||
const h = buildUpstreamHeadersForExecute({ ...base, connectionCustomHeaders: undefined });
|
||||
assert.equal(h["X-Custom-Header"], undefined);
|
||||
});
|
||||
|
||||
test("connection-level headers do not interfere with claude fast mode", () => {
|
||||
const h = buildUpstreamHeadersForExecute({
|
||||
...base,
|
||||
provider: "claude",
|
||||
modelToCall: "claude-fast-x",
|
||||
effectiveModel: "claude-fast-x",
|
||||
settings: { claudeFastMode: { enabled: true, supportedModels: ["claude-fast-x"] } },
|
||||
connectionCustomHeaders: { "X-Trace": "trace-123" },
|
||||
});
|
||||
assert.equal(h[CPA_FORCE_FAST_MODE_HEADER], "1");
|
||||
assert.equal(h["X-Trace"], "trace-123");
|
||||
});
|
||||
|
||||
test("forbidden auth headers (x-goog-api-key, api-key, cookie) are silently dropped", () => {
|
||||
const h = buildUpstreamHeadersForExecute({
|
||||
...base,
|
||||
connectionCustomHeaders: {
|
||||
"x-goog-api-key": "should-not-appear",
|
||||
"api-key": "should-not-appear",
|
||||
cookie: "should-not-appear",
|
||||
"X-Allowed": "present",
|
||||
},
|
||||
});
|
||||
assert.equal(h["x-goog-api-key"], undefined);
|
||||
assert.equal(h["api-key"], undefined);
|
||||
assert.equal(h["cookie"], undefined);
|
||||
assert.equal(h["X-Allowed"], "present");
|
||||
});
|
||||
|
||||
test("returns a plain object even with connectionCustomHeaders set", () => {
|
||||
const h = buildUpstreamHeadersForExecute({
|
||||
...base,
|
||||
connectionCustomHeaders: { "X-Test": "val" },
|
||||
});
|
||||
assert.equal(typeof h, "object");
|
||||
});
|
||||
Reference in New Issue
Block a user