diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index d7f114570d..905fab1659 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -419,6 +419,38 @@ export class DefaultExecutor extends BaseExecutor { } } + const isCompatibleProvider = + this.provider?.startsWith?.("openai-compatible-") || + this.provider?.startsWith?.("anthropic-compatible-"); + + if (isCompatibleProvider) { + const rawCustomHeaders = credentials.providerSpecificData?.customHeaders; + let customHeaders: Record | null = null; + if (rawCustomHeaders && typeof rawCustomHeaders === "object" && !Array.isArray(rawCustomHeaders)) { + customHeaders = rawCustomHeaders as Record; + } else if (typeof rawCustomHeaders === "string") { + try { + const parsed = JSON.parse(rawCustomHeaders); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + customHeaders = parsed; + } + } catch { /* ignore invalid JSON */ } + } + if (customHeaders) { + const forbidden = new Set([ + "host", "connection", "content-length", "keep-alive", + "proxy-connection", "transfer-encoding", "te", "trailer", "upgrade", + ]); + const authHeaders = new Set(["authorization", "x-api-key", "x-goog-api-key", "api-key"]); + for (const [k, v] of Object.entries(customHeaders)) { + if (typeof k !== "string" || typeof v !== "string") continue; + if (forbidden.has(k.toLowerCase())) continue; + if (authHeaders.has(k.toLowerCase())) continue; + headers[k] = v; + } + } + } + // Forward client request metadata headers (from OpenCode or similar clients) // Allowlist-based: only specific x-opencode-* headers and User-Agent are forwarded if (clientHeaders) { diff --git a/src/app/api/provider-nodes/[id]/route.ts b/src/app/api/provider-nodes/[id]/route.ts index b11daa306e..feba366e20 100644 --- a/src/app/api/provider-nodes/[id]/route.ts +++ b/src/app/api/provider-nodes/[id]/route.ts @@ -33,7 +33,7 @@ function sanitizeClaudeCodeCompatibleBaseUrl(baseUrl: string) { // PUT /api/provider-nodes/[id] - Update provider node export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) { - let rawBody; + let rawBody: unknown; try { rawBody = await request.json(); } catch { @@ -54,7 +54,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: if (isValidationFailure(validation)) { return NextResponse.json({ error: validation.error }, { status: 400 }); } - const { name, prefix, apiType, baseUrl, chatPath, modelsPath } = validation.data; + const { name, prefix, apiType, baseUrl, chatPath, modelsPath, customHeaders } = + validation.data; const node: any = await getProviderNodeById(id); if (!node) { @@ -89,6 +90,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: baseUrl: sanitizedBaseUrl, chatPath: chatPath || null, modelsPath: isClaudeCodeCompatibleProvider(id) ? null : modelsPath || null, + customHeaders: customHeaders || null, }; if (node.type === "openai-compatible") { @@ -110,6 +112,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: baseUrl: sanitizedBaseUrl, nodeName: updated.name, chatPath: updated.chatPath || undefined, + customHeaders: updated.customHeaders || undefined, } as JsonRecord; if (updated.modelsPath) { providerSpecificData.modelsPath = updated.modelsPath; @@ -136,7 +139,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: } // DELETE /api/provider-nodes/[id] - Delete provider node and its connections -export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { +export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) { try { const { id } = await params; const node = await getProviderNodeById(id); diff --git a/src/app/api/provider-nodes/route.ts b/src/app/api/provider-nodes/route.ts index b58f159d27..9eefbbcebe 100644 --- a/src/app/api/provider-nodes/route.ts +++ b/src/app/api/provider-nodes/route.ts @@ -68,7 +68,7 @@ export async function POST(request) { if (isValidationFailure(validation)) { return NextResponse.json({ error: validation.error }, { status: 400 }); } - const { name, prefix, apiType, baseUrl, type, compatMode, chatPath, modelsPath } = + const { name, prefix, apiType, baseUrl, type, compatMode, chatPath, modelsPath, customHeaders } = validation.data; // Determine type @@ -84,6 +84,7 @@ export async function POST(request) { name: name.trim(), chatPath: chatPath || null, modelsPath: modelsPath || null, + customHeaders: customHeaders || null, }); return NextResponse.json({ node }, { status: 201 }); } @@ -110,6 +111,7 @@ export async function POST(request) { name: name.trim(), chatPath: chatPath || null, modelsPath: compatMode === "cc" ? null : modelsPath || null, + customHeaders: customHeaders || null, }); return NextResponse.json({ node }, { status: 201 }); } diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts index e732508444..98bbeacc1f 100644 --- a/src/app/api/providers/route.ts +++ b/src/app/api/providers/route.ts @@ -126,6 +126,7 @@ export async function POST(request: Request) { nodeName: node.name, ...(node.chatPath ? { chatPath: node.chatPath } : {}), ...(node.modelsPath ? { modelsPath: node.modelsPath } : {}), + ...(node.customHeaders ? { customHeaders: node.customHeaders } : {}), }; } else if (isAnthropicCompatibleProvider(provider)) { const node: any = await getProviderNodeById(provider); @@ -150,6 +151,7 @@ export async function POST(request: Request) { nodeName: node.name, ...(node.chatPath ? { chatPath: node.chatPath } : {}), ...(node.modelsPath ? { modelsPath: node.modelsPath } : {}), + ...(node.customHeaders ? { customHeaders: node.customHeaders } : {}), }; } diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index a365c6ff74..e844614b97 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -219,6 +219,9 @@ const SCHEMA_SQL = ` prefix TEXT, api_type TEXT, base_url TEXT, + chat_path TEXT, + models_path TEXT, + custom_headers_json TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); diff --git a/src/lib/db/migrations/095_provider_node_custom_headers.sql b/src/lib/db/migrations/095_provider_node_custom_headers.sql new file mode 100644 index 0000000000..b2952eafb2 --- /dev/null +++ b/src/lib/db/migrations/095_provider_node_custom_headers.sql @@ -0,0 +1,5 @@ +-- Add custom_headers_json column to provider_nodes +-- Stores JSON object of custom HTTP headers to send with requests to this provider +-- NULL = no custom headers (backward compatible) +-- Column uses _json suffix so rowToCamel auto-parses it +ALTER TABLE provider_nodes ADD COLUMN custom_headers_json TEXT; diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index d593e492be..f55f8e6dc8 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -783,6 +783,8 @@ export async function createProviderNode(data: JsonRecord) { const db = getDbInstance() as unknown as DbLike; const now = new Date().toISOString(); + const customHeadersJson = data.customHeaders ? JSON.stringify(data.customHeaders) : null; + const node = { id: data.id || uuidv4(), type: data.type, @@ -792,19 +794,32 @@ export async function createProviderNode(data: JsonRecord) { baseUrl: data.baseUrl || null, chatPath: data.chatPath || null, modelsPath: data.modelsPath || null, + customHeadersJson, createdAt: now, updatedAt: now, }; db.prepare( ` - INSERT INTO provider_nodes (id, type, name, prefix, api_type, base_url, chat_path, models_path, created_at, updated_at) - VALUES (@id, @type, @name, @prefix, @apiType, @baseUrl, @chatPath, @modelsPath, @createdAt, @updatedAt) + INSERT INTO provider_nodes (id, type, name, prefix, api_type, base_url, chat_path, models_path, custom_headers_json, created_at, updated_at) + VALUES (@id, @type, @name, @prefix, @apiType, @baseUrl, @chatPath, @modelsPath, @customHeadersJson, @createdAt, @updatedAt) ` ).run(node); backupDbFile("pre-write"); - return node; + + const result: JsonRecord = { ...node }; + if (customHeadersJson) { + try { + result.customHeaders = JSON.parse(customHeadersJson); + } catch { + result.customHeaders = null; + } + } else { + result.customHeaders = null; + } + delete result.customHeadersJson; + return result; } export async function updateProviderNode(id: string, data: JsonRecord) { @@ -818,11 +833,15 @@ export async function updateProviderNode(id: string, data: JsonRecord) { updatedAt: new Date().toISOString(), }; + if (data.customHeaders !== undefined) { + merged["customHeadersJson"] = data.customHeaders ? JSON.stringify(data.customHeaders) : null; + } + db.prepare( ` UPDATE provider_nodes SET type = @type, name = @name, prefix = @prefix, api_type = @apiType, base_url = @baseUrl, chat_path = @chatPath, - models_path = @modelsPath, updated_at = @updatedAt + models_path = @modelsPath, custom_headers_json = @customHeadersJson, updated_at = @updatedAt WHERE id = @id ` ).run({ @@ -834,11 +853,25 @@ export async function updateProviderNode(id: string, data: JsonRecord) { baseUrl: merged["baseUrl"] || null, chatPath: merged["chatPath"] || null, modelsPath: merged["modelsPath"] || null, + customHeadersJson: merged["customHeadersJson"] || null, updatedAt: merged["updatedAt"], }); backupDbFile("pre-write"); - return merged; + + const result: JsonRecord = { ...merged }; + const storedJson = merged["customHeadersJson"] as string | null; + if (storedJson) { + try { + result.customHeaders = JSON.parse(storedJson); + } catch { + result.customHeaders = null; + } + } else { + result.customHeaders = null; + } + delete result.customHeadersJson; + return result; } export async function deleteProviderNode(id: string) { diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index c9e2b0609a..5576ce6a98 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -1917,6 +1917,32 @@ export const updateKeyPermissionsSchema = z } }); +const customHeadersSchema = z + .record(z.string(), z.string()) + .nullable() + .optional() + .refine( + (val) => { + if (!val) return true; + const forbidden = new Set([ + "host", + "connection", + "content-length", + "keep-alive", + "proxy-connection", + "transfer-encoding", + "te", + "trailer", + "upgrade", + ]); + for (const key of Object.keys(val)) { + if (forbidden.has(key.toLowerCase())) return false; + } + return true; + }, + { message: "Custom headers contain forbidden hop-by-hop or framing headers" } + ); + export const createProviderNodeSchema = z .object({ name: z.string().trim().min(1, "Name is required"), @@ -1936,6 +1962,7 @@ export const createProviderNodeSchema = z compatMode: z.enum(["cc"]).optional(), chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")), modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")), + customHeaders: customHeadersSchema, }) .superRefine((value, ctx) => { const nodeType = value.type || "openai-compatible"; @@ -1964,6 +1991,7 @@ export const updateProviderNodeSchema = z.object({ baseUrl: z.string().trim().min(1, "Base URL is required"), chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")), modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")), + customHeaders: customHeadersSchema, }); export const providerNodeValidateSchema = z.object({ diff --git a/tests/unit/custom-headers-provider-nodes.test.ts b/tests/unit/custom-headers-provider-nodes.test.ts new file mode 100644 index 0000000000..d98d844796 --- /dev/null +++ b/tests/unit/custom-headers-provider-nodes.test.ts @@ -0,0 +1,592 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-custom-headers-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const providerNodesRoute = await import("../../src/app/api/provider-nodes/route.ts"); +const providerNodesIdRoute = await import("../../src/app/api/provider-nodes/[id]/route.ts"); +const { OPENAI_COMPATIBLE_PREFIX } = await import("../../src/shared/constants/providers.ts"); +const { createProviderNodeSchema, updateProviderNodeSchema } = await import("../../src/shared/validation/schemas.ts"); +const { DefaultExecutor } = await import("../../open-sse/executors/default.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function makeRequest(body: Record) { + return new Request("http://localhost/api/provider-nodes", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function makeUpdateRequest(id: string, body: Record) { + return new Request(`http://localhost/api/provider-nodes/${id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("createProviderNodeSchema accepts valid customHeaders as record of strings", () => { + const validInputs = [ + { name: "Test", prefix: "test", apiType: "chat", customHeaders: { "X-Custom-1": "value1" } }, + { name: "Test", prefix: "test", apiType: "chat", customHeaders: { "X-Header": "value", "X-Another": "value2" } }, + { name: "Test", prefix: "test", apiType: "chat", customHeaders: {} }, + { name: "Test", prefix: "test", apiType: "chat" }, + ]; + + for (const input of validInputs) { + const result = createProviderNodeSchema.safeParse(input); + assert.equal(result.success, true, `Should accept: ${JSON.stringify(input)}`); + } +}); + +test("createProviderNodeSchema rejects customHeaders with non-string values", () => { + const invalidInputs = [ + { customHeaders: { "X-Custom": 123 } }, + { customHeaders: { "X-Custom": null } }, + { customHeaders: { "X-Custom": true } }, + { customHeaders: { "X-Custom": ["array"] } }, + { customHeaders: { "X-Custom": { nested: "object" } } }, + ]; + + for (const input of invalidInputs) { + const result = createProviderNodeSchema.safeParse(input); + assert.equal(result.success, false, `Should reject: ${JSON.stringify(input)}`); + } +}); + +test("createProviderNodeSchema rejects forbidden hop-by-hop headers", () => { + const forbiddenHeaders = [ + "host", "connection", "content-length", "keep-alive", + "proxy-connection", "transfer-encoding", "te", "trailer", "upgrade", + ]; + + for (const header of forbiddenHeaders) { + const result = createProviderNodeSchema.safeParse({ + customHeaders: { [header]: "value" }, + }); + assert.equal(result.success, false, `Should reject forbidden header: ${header}`); + } + + const result = createProviderNodeSchema.safeParse({ + customHeaders: { "HOST": "evil", "Content-Length": "999" }, + }); + assert.equal(result.success, false, "Should reject case-insensitive forbidden headers"); +}); + +test("updateProviderNodeSchema accepts valid customHeaders", () => { + const validInputs = [ + { customHeaders: { "X-Updated": "new-value" } }, + { customHeaders: { "X-A": "v1", "X-B": "v2" } }, + { customHeaders: null }, + {}, + ]; + + for (const input of validInputs) { + const result = updateProviderNodeSchema.safeParse({ + name: "Test", + prefix: "test", + baseUrl: "https://test.com", + ...input, + }); + assert.equal(result.success, true, `Should accept: ${JSON.stringify(input)}`); + } +}); + +test("updateProviderNodeSchema rejects forbidden headers", () => { + const result = updateProviderNodeSchema.safeParse({ + name: "Test", + prefix: "test", + baseUrl: "https://test.com", + customHeaders: { "host": "evil.com" }, + }); + assert.equal(result.success, false); +}); + +test("provider nodes route creates OpenAI-compatible nodes with customHeaders", async () => { + const response = await providerNodesRoute.POST( + makeRequest({ + name: "Custom Headers Node", + prefix: "custom-headers", + apiType: "chat", + baseUrl: "https://custom.example.com/v1", + customHeaders: { + "X-Custom-Auth": "auth-token-123", + "X-Request-ID": "req-abc", + }, + }) + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 201); + assert.match(body.node.id, new RegExp(`^${OPENAI_COMPATIBLE_PREFIX}chat-`)); + assert.deepEqual(body.node.customHeaders, { + "X-Custom-Auth": "auth-token-123", + "X-Request-ID": "req-abc", + }); +}); + +test("provider nodes route creates nodes without customHeaders (null)", async () => { + const response = await providerNodesRoute.POST( + makeRequest({ + name: "No Custom Headers", + prefix: "no-custom", + apiType: "chat", + baseUrl: "https://nocustom.example.com/v1", + }) + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 201); + assert.equal(body.node.customHeaders, null); +}); + +test("provider nodes route update modifies customHeaders", async () => { + const createResponse = await providerNodesRoute.POST( + makeRequest({ + name: "Original Node", + prefix: "original", + apiType: "chat", + baseUrl: "https://original.example.com/v1", + customHeaders: { "X-Original": "original-value" }, + }) + ); + const created = (await createResponse.json()) as any; + const nodeId = created.node.id; + + const updateBody = { + name: "Updated Node", + prefix: "updated", + apiType: "chat", + baseUrl: "https://updated.example.com/v1", + customHeaders: { "X-Updated": "updated-value", "X-New": "new-header" }, + }; + const updateResponse = await providerNodesIdRoute.PUT( + makeUpdateRequest(nodeId, updateBody), + { params: Promise.resolve({ id: nodeId }) } + ); + const updated = (await updateResponse.json()) as any; + + assert.equal(updateResponse.status, 200); + assert.deepEqual(updated.node.customHeaders, { + "X-Updated": "updated-value", + "X-New": "new-header", + }); +}); + +test("provider nodes route update can clear customHeaders by passing null", async () => { + const createResponse = await providerNodesRoute.POST( + makeRequest({ + name: "Node With Headers", + prefix: "with-headers", + apiType: "chat", + baseUrl: "https://withheaders.example.com/v1", + customHeaders: { "X-Keep": "keep-value" }, + }) + ); + const created = (await createResponse.json()) as any; + const nodeId = created.node.id; + +const clearBody = { + name: "Node Without Headers", + prefix: "no-headers", + apiType: "chat", + baseUrl: "https://noclear.example.com/v1", + customHeaders: null, + }; + const updateResponse = await providerNodesIdRoute.PUT( + makeUpdateRequest(nodeId, clearBody), + { params: Promise.resolve({ id: nodeId }) } + ); + const updated = (await updateResponse.json()) as any; + + assert.equal(updateResponse.status, 200); + assert.equal(updated.node.customHeaders, null); +}); + +test("DefaultExecutor.buildHeaders applies customHeaders from providerSpecificData", () => { + const executor = new DefaultExecutor("openai-compatible-test"); + +const headers = executor.buildHeaders( + { + apiKey: "test-key", + providerSpecificData: { + baseUrl: "https://proxy.example.com/v1", + customHeaders: { + "X-Custom-Auth": "custom-auth-value", + "X-Request-ID": "request-123", + "X-Custom-Header": "extra-value", + }, + }, + }, + true + ) as Record; + + assert.equal(headers["X-Custom-Auth"], "custom-auth-value"); + assert.equal(headers["X-Request-ID"], "request-123"); + assert.equal(headers["X-Custom-Header"], "extra-value"); + assert.equal(headers["Content-Type"], "application/json"); + assert.equal(headers["Authorization"], "Bearer test-key"); +}); + +test("DefaultExecutor.buildHeaders does NOT override auth headers with customHeaders", () => { + const executor = new DefaultExecutor("openai-compatible-test"); + + const headers = executor.buildHeaders( + { + apiKey: "real-key", + providerSpecificData: { + baseUrl: "https://proxy.example.com/v1", + customHeaders: { + "Authorization": "Bearer fake-token", + "x-api-key": "fake-key", + "X-Custom": "custom-value", + }, + }, + }, + true + ) as Record; + + assert.equal(headers.Authorization, "Bearer real-key"); + assert.equal(headers["x-api-key"], undefined); + assert.equal(headers["X-Custom"], "custom-value"); +}); + +test("DefaultExecutor.buildHeaders blocks forbidden hop-by-hop headers from customHeaders", () => { + const executor = new DefaultExecutor("openai-compatible-test"); + + const headers = executor.buildHeaders( + { + apiKey: "test-key", + providerSpecificData: { + baseUrl: "https://proxy.example.com/v1", + customHeaders: { + "host": "evil.com", + "content-length": "999", + "connection": "close", + "X-Legitimate": "good-header", + }, + }, + }, + true + ) as Record; + + assert.equal(headers.host, undefined); + assert.equal(headers["content-length"], undefined); + assert.equal(headers.connection, undefined); + assert.equal(headers["X-Legitimate"], "good-header"); +}); + +test("DefaultExecutor.buildHeaders handles string customHeaders (JSON parsed)", () => { + const executor = new DefaultExecutor("openai-compatible-test"); + + const headers = executor.buildHeaders( + { + apiKey: "test-key", + providerSpecificData: { + baseUrl: "https://proxy.example.com/v1", + customHeaders: JSON.stringify({ "X-From-String": "parsed-value" }), + }, + }, + true + ) as Record; + + assert.equal(headers["X-From-String"], "parsed-value"); +}); + +test("DefaultExecutor.buildHeaders handles invalid JSON in string customHeaders gracefully", () => { + const executor = new DefaultExecutor("openai-compatible-test"); + + const headers = executor.buildHeaders( + { + apiKey: "test-key", + providerSpecificData: { + baseUrl: "https://proxy.example.com/v1", + customHeaders: "not valid json {", + }, + }, + true + ) as Record; + + assert.equal(headers["X-From-String"], undefined); + assert.equal(headers.Authorization, "Bearer test-key"); +}); + +test("DefaultExecutor.buildHeaders handles array customHeaders gracefully", () => { + const executor = new DefaultExecutor("openai-compatible-test"); + + const headers = executor.buildHeaders( + { + apiKey: "test-key", + providerSpecificData: { + baseUrl: "https://proxy.example.com/v1", + customHeaders: ["array", "not", "valid"], + }, + }, + true + ) as Record; + + assert.equal(headers["X-From-String"], undefined); + assert.equal(headers.Authorization, "Bearer test-key"); +}); + +test("DefaultExecutor.buildHeaders handles null/undefined/empty customHeaders", () => { + const executor = new DefaultExecutor("openai-compatible-test"); + + const nullHeaders = executor.buildHeaders( + { apiKey: "key", providerSpecificData: { customHeaders: null } }, + true + ) as Record; + const undefinedHeaders = executor.buildHeaders( + { apiKey: "key", providerSpecificData: {} }, + true + ) as Record; + const emptyHeaders = executor.buildHeaders( + { apiKey: "key", providerSpecificData: { customHeaders: {} } }, + true + ) as Record; + + for (const headers of [nullHeaders, undefinedHeaders, emptyHeaders]) { + assert.equal(headers["X-Anything"], undefined); + assert.equal(headers.Authorization, "Bearer key"); + } +}); + +test("DefaultExecutor.buildHeaders customHeaders are case-sensitive for header names", () => { + const executor = new DefaultExecutor("openai-compatible-test"); + + const headers = executor.buildHeaders( + { + apiKey: "test-key", + providerSpecificData: { + baseUrl: "https://proxy.example.com/v1", + customHeaders: { + "x-lower": "lower-value", + "X-Upper": "upper-value", + "X-Mixed": "mixed-value", + }, + }, + }, + true + ) as Record; + + assert.equal(headers["x-lower"], "lower-value"); + assert.equal(headers["X-Upper"], "upper-value"); + assert.equal(headers["X-Mixed"], "mixed-value"); +}); + +test("DefaultExecutor.buildHeaders for non-openai-compatible providers ignores customHeaders", () => { + const executor = new DefaultExecutor("openai"); + + const headers = executor.buildHeaders( + { + apiKey: "test-key", + providerSpecificData: { + customHeaders: { + "X-Custom": "should-be-ignored", + }, + }, + }, + true + ) as Record; + + assert.equal(headers["X-Custom"], undefined); + assert.equal(headers.Authorization, "Bearer test-key"); +}); + +test("DefaultExecutor.execute sends customHeaders in the actual HTTP request", async () => { + const executor = new DefaultExecutor("openai-compatible-test"); + const originalFetch = globalThis.fetch; + let capturedHeaders: Record = {}; + + globalThis.fetch = async (_url: string | URL | Request, init: RequestInit = {}) => { + capturedHeaders = (init.headers as Record) || {}; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + try { + await executor.execute({ + model: "gpt-4.1", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { + apiKey: "test-key", + providerSpecificData: { + baseUrl: "https://test.proxy.com/v1", + customHeaders: { + "X-Request-ID": "test-req-123", + "X-Trace-Id": "trace-abc", + }, + }, + }, + }); + + assert.equal(capturedHeaders["X-Request-ID"], "test-req-123"); + assert.equal(capturedHeaders["X-Trace-Id"], "trace-abc"); + assert.equal(capturedHeaders["Authorization"], "Bearer test-key"); + assert.equal(capturedHeaders["Content-Type"], "application/json"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("DefaultExecutor.execute does NOT send forbidden headers from customHeaders in HTTP request", async () => { + const executor = new DefaultExecutor("openai-compatible-test"); + const originalFetch = globalThis.fetch; + let capturedHeaders: Record = {}; + + globalThis.fetch = async (_url: string | URL | Request, init: RequestInit = {}) => { + capturedHeaders = (init.headers as Record) || {}; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + try { + await executor.execute({ + model: "gpt-4.1", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { + apiKey: "test-key", + providerSpecificData: { + baseUrl: "https://test.proxy.com/v1", + customHeaders: { + "host": "evil.com", + "content-length": "9999", + "X-Legitimate": "good", + }, + }, + }, + }); + + assert.equal(capturedHeaders.host, undefined); + assert.equal(capturedHeaders["content-length"], undefined); + assert.equal(capturedHeaders["X-Legitimate"], "good"); + assert.equal(capturedHeaders.Authorization, "Bearer test-key"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("DefaultExecutor.execute does NOT allow customHeaders to override Authorization", async () => { + const executor = new DefaultExecutor("openai-compatible-test"); + const originalFetch = globalThis.fetch; + let capturedHeaders: Record = {}; + + globalThis.fetch = async (_url: string | URL | Request, init: RequestInit = {}) => { + capturedHeaders = (init.headers as Record) || {}; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + try { + await executor.execute({ + model: "gpt-4.1", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { + apiKey: "real-key", + providerSpecificData: { + baseUrl: "https://test.proxy.com/v1", + customHeaders: { + "Authorization": "Bearer forged-key", + }, + }, + }, + }); + + assert.equal(capturedHeaders.Authorization, "Bearer real-key"); + assert.notEqual(capturedHeaders.Authorization, "Bearer forged-key"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("db: createProviderNode and getProviderNodeById handle customHeaders as JSON", async () => { + const node = await providersDb.createProviderNode({ + id: "openai-compatible-chat-custom-headers-db", + type: "openai-compatible", + name: "DB Custom Headers Test", + prefix: "db-custom", + apiType: "chat", + baseUrl: "https://db.example.com/v1", + customHeaders: { "X-DB-Header": "db-value", "X-Another": "another" }, + }); + + assert.deepEqual(node.customHeaders, { "X-DB-Header": "db-value", "X-Another": "another" }); + + const retrieved = await providersDb.getProviderNodeById("openai-compatible-chat-custom-headers-db"); + assert.deepEqual(retrieved.customHeaders, { "X-DB-Header": "db-value", "X-Another": "another" }); +}); + +test("db: updateProviderNode modifies customHeaders", async () => { + const node = await providersDb.createProviderNode({ + id: "openai-compatible-chat-update-custom", + type: "openai-compatible", + name: "Update Custom Test", + prefix: "update-custom", + apiType: "chat", + baseUrl: "https://update.example.com/v1", + customHeaders: { "X-Initial": "initial-value" }, + }); + + assert.deepEqual(node.customHeaders, { "X-Initial": "initial-value" }); + + const updated = await providersDb.updateProviderNode( + "openai-compatible-chat-update-custom", + { customHeaders: { "X-Updated": "updated-value", "X-New-Header": "new" } } + ); + + assert.deepEqual(updated.customHeaders, { "X-Updated": "updated-value", "X-New-Header": "new" }); + + const retrieved = await providersDb.getProviderNodeById("openai-compatible-chat-update-custom"); + assert.deepEqual(retrieved.customHeaders, { "X-Updated": "updated-value", "X-New-Header": "new" }); +}); + +test("db: updateProviderNode can clear customHeaders by passing null", async () => { + const node = await providersDb.createProviderNode({ + id: "openai-compatible-chat-clear-custom", + type: "openai-compatible", + name: "Clear Custom Test", + prefix: "clear-custom", + apiType: "chat", + baseUrl: "https://clear.example.com/v1", + customHeaders: { "X-ToClear": "clear-me" }, + }); + + assert.deepEqual(node.customHeaders, { "X-ToClear": "clear-me" }); + + const updated = await providersDb.updateProviderNode( + "openai-compatible-chat-clear-custom", + { customHeaders: null } + ); + + assert.equal(updated.customHeaders, null); +}); \ No newline at end of file