feat(providers): client-identity header profiles for compatible nodes (re-cut) + forbid cookie in custom headers (#5812)

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-03 02:23:23 -03:00
committed by GitHub
parent 6a04114f0e
commit e12bbd33ad
6 changed files with 286 additions and 1 deletions

View File

@@ -4,6 +4,10 @@ import { useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Badge, Button, Input, Modal, Select } from "@/shared/components";
import {
CLIENT_IDENTITY_PROFILE_OPTIONS,
getClientIdentityProfileHeaders,
} from "@/shared/constants/clientIdentityProfiles";
type CompatibleMode = "openai" | "anthropic" | "cc";
type CompatibleProviderNode = { id: string } & Record<string, unknown>;
@@ -24,6 +28,7 @@ interface CompatibleFormState {
chatPath: string;
modelsPath: string;
iconUrl: string;
clientIdentityProfile: string;
}
const CC_DEFAULT_CHAT_PATH = "/v1/messages?beta=true";
@@ -77,6 +82,7 @@ function createInitialForm(mode: CompatibleMode): CompatibleFormState {
chatPath: defaults.chatPath,
modelsPath: "",
iconUrl: "",
clientIdentityProfile: "default",
};
}
@@ -184,6 +190,12 @@ export default function AddCompatibleProviderModal({
if (defaults.hasModelsPath) body.modelsPath = formData.modelsPath || "";
if (defaults.compatMode) body.compatMode = defaults.compatMode;
body.iconUrl = formData.iconUrl.trim();
// Merge the selected identity profile's preset headers into the SAME
// `customHeaders` field the node already persists (see
// src/lib/db/providers/nodes.ts + open-sse/executors/default.ts
// `applyCustomHeaders`) — no separate profile field, no new pipeline.
const identityHeaders = getClientIdentityProfileHeaders(formData.clientIdentityProfile);
if (Object.keys(identityHeaders).length > 0) body.customHeaders = identityHeaders;
const res = await fetch("/api/provider-nodes", {
method: "POST",
@@ -320,6 +332,13 @@ export default function AddCompatibleProviderModal({
hint={t("modelsPathHint")}
/>
)}
<Select
label={t("clientIdentityLabel")}
options={CLIENT_IDENTITY_PROFILE_OPTIONS.map((option) => ({ ...option }))}
value={formData.clientIdentityProfile}
onChange={(e) => setFormData({ ...formData, clientIdentityProfile: e.target.value })}
hint={t("clientIdentityHint")}
/>
</div>
)}

View File

@@ -4508,6 +4508,8 @@
"modelsPathLabel": "Models Endpoint Path",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)",
"clientIdentityLabel": "Client Identity",
"clientIdentityHint": "Optional. Adds client fingerprint headers (e.g. User-Agent) matching a known CLI for compatible gateways that expect one.",
"statusDeactivated": "Deactivated (Manual)",
"statusBanned": "Banned / Sandbox Violation",
"statusCreditsExhausted": "Insufficient Balance / Quota Exhausted",

View File

@@ -0,0 +1,89 @@
/**
* Named "client identity" header presets for OpenAI-/Anthropic-compatible
* provider nodes (e.g. mimicking a known CLI's `User-Agent`).
*
* This module is intentionally dumb: it only supplies preset header
* VALUES. It introduces NO new header-merge path — selecting a profile in
* the compatible-provider UI merges its headers into the SAME
* `providerSpecificData.customHeaders` field already wired end-to-end
* (node -> connection -> `DefaultExecutor.buildHeaders()` ->
* `applyCustomHeaders()` in `open-sse/executors/default.ts`), which already
* sanitizes via `isForbiddenCustomHeaderName` (`upstreamHeaders.ts`) and is
* applied AFTER the credential-auth headers are set. Auth/cookie headers
* therefore always win over anything a profile (or a hand-edited custom
* header) tries to set — no new precedence logic is needed here.
*/
export interface ClientIdentityProfile {
readonly id: string;
readonly label: string;
readonly headers: Readonly<Record<string, string>>;
}
const DEFAULT_PROFILE: ClientIdentityProfile = Object.freeze({
id: "default",
label: "Default",
headers: Object.freeze({}),
});
const CLAUDE_CLI_PROFILE: ClientIdentityProfile = Object.freeze({
id: "claude-cli",
label: "Claude CLI",
headers: Object.freeze({
"User-Agent": "claude-cli/2.1.195 (external, cli)",
"X-App": "cli",
}),
});
const CODEX_CLI_PROFILE: ClientIdentityProfile = Object.freeze({
id: "codex-cli",
label: "Codex CLI",
headers: Object.freeze({
"User-Agent": "codex_cli_rs/0.136.0",
originator: "codex_cli_rs",
}),
});
const GEMINI_CLI_PROFILE: ClientIdentityProfile = Object.freeze({
id: "gemini-cli",
label: "Gemini CLI",
headers: Object.freeze({
"User-Agent": "GeminiCLI/0.1.0 (linux; x64)",
}),
});
/** Ordered so `CLIENT_IDENTITY_PROFILE_OPTIONS` renders "Default" first. */
export const CLIENT_IDENTITY_PROFILES: Readonly<Record<string, ClientIdentityProfile>> =
Object.freeze({
default: DEFAULT_PROFILE,
"claude-cli": CLAUDE_CLI_PROFILE,
"codex-cli": CODEX_CLI_PROFILE,
"gemini-cli": GEMINI_CLI_PROFILE,
});
export const CLIENT_IDENTITY_PROFILE_IDS: readonly string[] = Object.keys(
CLIENT_IDENTITY_PROFILES
);
export const CLIENT_IDENTITY_PROFILE_OPTIONS: ReadonlyArray<{ value: string; label: string }> =
CLIENT_IDENTITY_PROFILE_IDS.map((id) => ({
value: id,
label: CLIENT_IDENTITY_PROFILES[id].label,
}));
export function isClientIdentityProfileId(value: unknown): value is string {
return typeof value === "string" && Object.prototype.hasOwnProperty.call(CLIENT_IDENTITY_PROFILES, value);
}
/**
* Returns a plain (mutable, unfrozen) copy of the preset's headers so callers
* can safely spread/merge it into `providerSpecificData.customHeaders`
* without ever needing to sanitize here — that happens downstream in
* `applyCustomHeaders()` regardless of where the header entries came from.
*/
export function getClientIdentityProfileHeaders(
profileId: string | undefined | null
): Record<string, string> {
if (!isClientIdentityProfileId(profileId)) return {};
return { ...CLIENT_IDENTITY_PROFILES[profileId].headers };
}

View File

@@ -28,7 +28,7 @@ export function isForbiddenUpstreamHeaderName(name: string): boolean {
* apply-loop (open-sse/executors/default.ts) cannot drift apart.
*/
const FORBIDDEN_AUTH = new Set(
["authorization", "x-api-key", "x-goog-api-key", "api-key"].map((s) => s.toLowerCase())
["authorization", "x-api-key", "x-goog-api-key", "api-key", "cookie"].map((s) => s.toLowerCase())
);
/**