diff --git a/crates/agent-gateway/test/webui/web-settings.test.mjs b/crates/agent-gateway/test/webui/web-settings.test.mjs index 54749b0bc..c4047a0d7 100644 --- a/crates/agent-gateway/test/webui/web-settings.test.mjs +++ b/crates/agent-gateway/test/webui/web-settings.test.mjs @@ -9,6 +9,53 @@ const settingsSync = loader.loadModule("@/lib/settings/sync.ts"); const chatHelpers = loader.loadModule("@/lib/chat/chatPageHelpers.ts"); const RIGHT_DOCK_TAB_IDS = settings.RIGHT_DOCK_SINGLETON_TAB_IDS; +test("custom provider normalization defaults and filters ordered custom headers", () => { + assert.deepEqual(settings.normalizeCustomProvider({}).customHeaders, []); + + const provider = settings.normalizeCustomProvider({ + customHeaders: [ + { key: " X-Request-ID ", value: " request-123 " }, + { key: "", value: "ignored" }, + { key: " ", value: "ignored" }, + { key: "anthropic-beta", value: "feature-flag" }, + null, + ], + }); + + assert.deepEqual(provider.customHeaders, [ + { key: "X-Request-ID", value: " request-123 " }, + { key: "anthropic-beta", value: "feature-flag" }, + ]); +}); + +test("provider model capabilities preserve legacy data and filter unknown values", () => { + const legacy = settings.normalizeCustomProvider({ + type: "codex", + models: [{ id: "legacy-model", contextWindow: 64_000, maxOutputToken: 4_096 }], + }); + assert.equal(legacy.models[0].capabilities, undefined); + + const normalized = settings.normalizeCustomProvider({ + type: "codex", + models: [ + { + id: "capable-model", + contextWindow: 128_000, + maxOutputToken: 8_192, + capabilities: ["reasoning", "unknown", "vision", "reasoning", 42, "tools"], + }, + { + id: "unknown-only", + contextWindow: 32_000, + maxOutputToken: 2_048, + capabilities: ["audio", null], + }, + ], + }); + + assert.deepEqual(normalized.models[0].capabilities, ["reasoning", "vision", "tools"]); + assert.deepEqual(normalized.models[1].capabilities, []); +}); test("gateway model picker keeps same-name provider instances in separate groups", () => { const modelOptions = chatHelpers.buildModelOptions({ customProviders: [ diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index a281239f3..cdf20568f 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -1183,6 +1183,31 @@ export const translations: Record> = { "settings.providerName": "分组名称", "settings.baseUrl": "Base URL", "settings.apiKey": "API Key", + "settings.customHeaders": "自定义请求头", + "settings.providerDialogNavigation": "供应商配置导航", + "settings.providerDialogGeneral": "普通配置", + "settings.providerDialogNetwork": "网络配置", + "settings.providerDialogHeaders": "请求头配置", + "settings.basicInformation": "基本信息", + "settings.customHeaderName": "Header 名称", + "settings.customHeaderValue": "值", + "settings.noCustomHeaders": "暂无自定义请求头", + "settings.customHeaderReservedTitle": "保留头,由系统管理", + "settings.showCustomHeaderValue": "显示请求头值", + "settings.hideCustomHeaderValue": "隐藏请求头值", + "settings.manualAddModel": "手动添加", + "settings.capabilityTypes": "能力类型", + "settings.capability.reasoning": "推理", + "settings.capability.vision": "视觉", + "settings.capability.tools": "工具调用", + "settings.customHeaderKeyPlaceholder": "请求头名称,例如 X-Request-ID", + "settings.addCustomHeader": "添加请求头", + "settings.removeCustomHeader": "删除请求头", + "settings.invalidCustomHeaderKey": + "请求头名称无效。仅支持合法 HTTP Header 名称,例如 anthropic-beta、X-Request-ID。", + "settings.customHeaderReservedHint": + "Authorization、x-api-key、x-goog-api-key、anthropic-version、Content-Type、Host、Content-Length 由应用管理,自定义值不会覆盖它们。", + "settings.close": "关闭", "settings.hideApiKey": "隐藏 API Key", "settings.showApiKey": "显示 API Key", "settings.requestFormat": "请求格式", @@ -3033,6 +3058,31 @@ export const translations: Record> = { "settings.providerName": "Name", "settings.baseUrl": "Base URL", "settings.apiKey": "API Key", + "settings.customHeaders": "Custom request headers", + "settings.providerDialogNavigation": "Provider configuration", + "settings.providerDialogGeneral": "Basic", + "settings.providerDialogNetwork": "Network", + "settings.providerDialogHeaders": "Headers", + "settings.basicInformation": "Basic information", + "settings.customHeaderName": "Header name", + "settings.customHeaderValue": "Value", + "settings.noCustomHeaders": "No custom request headers", + "settings.customHeaderReservedTitle": "Reserved header managed by the system", + "settings.showCustomHeaderValue": "Show header value", + "settings.hideCustomHeaderValue": "Hide header value", + "settings.manualAddModel": "Add manually", + "settings.capabilityTypes": "Capabilities", + "settings.capability.reasoning": "Reasoning", + "settings.capability.vision": "Vision", + "settings.capability.tools": "Tool use", + "settings.customHeaderKeyPlaceholder": "Select or enter a header name", + "settings.addCustomHeader": "Add header", + "settings.removeCustomHeader": "Remove header", + "settings.invalidCustomHeaderKey": + "Invalid HTTP header name. Examples: anthropic-beta, X-Request-ID.", + "settings.customHeaderReservedHint": + "Authorization, x-api-key, x-goog-api-key, anthropic-version, Content-Type, Host, and Content-Length are managed by the app and cannot be overridden.", + "settings.close": "Close", "settings.hideApiKey": "Hide API Key", "settings.showApiKey": "Show API Key", "settings.requestFormat": "Request Format", diff --git a/crates/agent-gateway/web/src/index.css b/crates/agent-gateway/web/src/index.css index 8e54978d1..2413f7340 100644 --- a/crates/agent-gateway/web/src/index.css +++ b/crates/agent-gateway/web/src/index.css @@ -794,6 +794,23 @@ animation: settingsSectionTitleIn 0.22s cubic-bezier(0.16, 1, 0.3, 1); } + /* Provider dialog: panel switches (sections are keyed by panel id, so the + * class replays on every switch). */ + .provider-panel-enter { + animation: providerPanelIn 0.18s cubic-bezier(0.16, 1, 0.3, 1); + } + + @keyframes providerPanelIn { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + .settings-section-shell { position: relative; isolation: isolate; @@ -965,6 +982,7 @@ @media (prefers-reduced-motion: reduce) { .settings-section-enter, .settings-section-title-enter, + .provider-panel-enter, .chat-history-scope-enter { animation: none; } diff --git a/crates/agent-gateway/web/src/lib/providers/customHeaders.ts b/crates/agent-gateway/web/src/lib/providers/customHeaders.ts new file mode 100644 index 000000000..2340337b5 --- /dev/null +++ b/crates/agent-gateway/web/src/lib/providers/customHeaders.ts @@ -0,0 +1,40 @@ +import type { CustomProvider } from "../settings"; + +const RESERVED_CUSTOM_HEADER_KEYS = new Set([ + "authorization", + "x-api-key", + "x-goog-api-key", + "anthropic-version", + "content-type", + "host", + "content-length", +]); +const HTTP_HEADER_TOKEN_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +export function isValidCustomHeaderKey(key: string): boolean { + return HTTP_HEADER_TOKEN_PATTERN.test(key); +} + +export function isReservedCustomHeaderKey(key: string): boolean { + return RESERVED_CUSTOM_HEADER_KEYS.has(key.toLowerCase()); +} +export function mergeCustomHeaders( + base: Record, + customHeaders?: CustomProvider["customHeaders"], +): Record { + const merged = { ...base }; + + for (const header of customHeaders ?? []) { + if (!isValidCustomHeaderKey(header.key) || isReservedCustomHeaderKey(header.key)) { + continue; + } + + const existingKey = Object.keys(merged).find( + (key) => key.toLowerCase() === header.key.toLowerCase(), + ); + if (existingKey) delete merged[existingKey]; + merged[header.key] = header.value; + } + + return merged; +} diff --git a/crates/agent-gateway/web/src/lib/settings/index.ts b/crates/agent-gateway/web/src/lib/settings/index.ts index 39fc9dbed..d68a9f780 100644 --- a/crates/agent-gateway/web/src/lib/settings/index.ts +++ b/crates/agent-gateway/web/src/lib/settings/index.ts @@ -176,10 +176,13 @@ export type SelectedModel = { model: string; }; +export type ModelCapability = "reasoning" | "vision" | "tools"; + export type ProviderModelConfig = { id: string; contextWindow: number; maxOutputToken: number; + capabilities?: ModelCapability[]; }; export type ChatRuntimeControls = { @@ -245,6 +248,7 @@ export type CustomProvider = { baseUrl: string; apiKey: string; apiKeyConfigured?: boolean; + customHeaders?: { key: string; value: string }[]; models: ProviderModelConfig[]; activeModels: string[]; requestFormat?: CodexRequestFormat; @@ -368,6 +372,7 @@ export function getBuiltinCustomProviders(): CustomProvider[] { type: "claude_code", baseUrl: "https://api.anthropic.com/v1", apiKey: "", + customHeaders: [], models: [], activeModels: [], reasoning: "off", @@ -381,6 +386,7 @@ export function getBuiltinCustomProviders(): CustomProvider[] { type: "codex", baseUrl: "https://api.openai.com/v1", apiKey: "", + customHeaders: [], models: [], activeModels: [], requestFormat: "openai-responses", @@ -395,6 +401,7 @@ export function getBuiltinCustomProviders(): CustomProvider[] { type: "gemini", baseUrl: "https://generativelanguage.googleapis.com/v1beta", apiKey: "", + customHeaders: [], models: [], activeModels: [], reasoning: "off", @@ -1205,6 +1212,21 @@ export function createProviderModelConfig( }; } +function normalizeModelCapabilities(input: unknown): ModelCapability[] | undefined { + if (!Array.isArray(input)) return undefined; + + const out: ModelCapability[] = []; + const seen = new Set(); + for (const value of input) { + if (value !== "reasoning" && value !== "vision" && value !== "tools") continue; + if (seen.has(value)) continue; + seen.add(value); + out.push(value); + } + return out; +} + +// Capabilities are persisted for display only; runtime behavior will be connected in a later iteration. export function normalizeProviderModelConfig( input: unknown, providerId: ProviderId, @@ -1224,6 +1246,7 @@ export function normalizeProviderModelConfig( if (!id) return null; const defaults = getProviderModelDefaults(providerId, id); + const capabilities = normalizeModelCapabilities(obj.capabilities); return { id, contextWindow: normalizePositiveInteger(obj.contextWindow, defaults.contextWindow), @@ -1231,6 +1254,7 @@ export function normalizeProviderModelConfig( obj.maxOutputToken ?? obj.maxTokens, defaults.maxOutputToken, ), + ...(capabilities !== undefined ? { capabilities } : {}), }; } @@ -1310,6 +1334,17 @@ function normalizeProviderName(id: string, input: unknown): string { return name; } +function normalizeCustomHeaders(input: unknown): { key: string; value: string }[] { + if (!Array.isArray(input)) return []; + return input.flatMap((item) => { + if (!item || typeof item !== "object") return []; + const header = item as Record; + const key = typeof header.key === "string" ? header.key.trim() : ""; + if (!key) return []; + return [{ key, value: typeof header.value === "string" ? header.value : "" }]; + }); +} + export function normalizeCustomProvider(input: unknown): CustomProvider { const obj = (input && typeof input === "object" ? input : {}) as Record; const type = normalizeProviderId(obj.type); @@ -1329,6 +1364,7 @@ export function normalizeCustomProvider(input: unknown): CustomProvider { : normalizeBaseUrl(typeof obj.baseUrl === "string" ? obj.baseUrl : ""), apiKey, apiKeyConfigured: apiKey.length > 0 || obj.apiKeyConfigured === true, + customHeaders: normalizeCustomHeaders(obj.customHeaders), models, activeModels: normalizeModels(normalizeStringArray(obj.activeModels)).filter((modelId) => validModelIds.has(modelId), diff --git a/crates/agent-gateway/web/src/pages/settings/ProvidersSection.tsx b/crates/agent-gateway/web/src/pages/settings/ProvidersSection.tsx index cd7e1f91c..08b78e635 100644 --- a/crates/agent-gateway/web/src/pages/settings/ProvidersSection.tsx +++ b/crates/agent-gateway/web/src/pages/settings/ProvidersSection.tsx @@ -5,13 +5,14 @@ import { Eye, EyeOff, GeminiIcon, + Globe, + List, OpenaiChatgptIcon, Pencil, Plus, RefreshCw, Search, Settings, - Settings2, Trash2, Waypoints, X, @@ -29,11 +30,16 @@ import { } from "../../components/ui/select"; import { useLocale } from "../../i18n"; import { buildModelOptions } from "../../lib/chat/chatPageHelpers"; +import { + isReservedCustomHeaderKey, + isValidCustomHeaderKey, +} from "../../lib/providers/customHeaders"; import { parseModelValue, toModelValue } from "../../lib/providers/llm"; import { CODEX_REQUEST_FORMAT_LABELS, type CodexRequestFormat, type CustomProvider, + type ModelCapability, type ProviderId, type ProviderModelConfig, updateCustomProviders, @@ -41,6 +47,7 @@ import { } from "../../lib/settings"; import { createUuid } from "../../lib/shared/id"; import { useModalMotion } from "../../lib/shared/modalMotion"; +import { cn } from "../../lib/shared/utils"; import { buildProviderModelsFetchKey, createDraftModelConfig, @@ -60,14 +67,25 @@ type ModalProps = { onClose: () => void; }; -type ModelSettingsModalProps = { +type ProviderDialogPanel = "general" | "network" | "headers"; + +type ModelEditDraft = { model: ProviderModelConfig; - onClose: () => void; - onSave: (model: ProviderModelConfig) => void; + contextWindow: string; + maxOutputToken: string; + capabilities: ModelCapability[]; }; - const PROVIDER_TABS: ProviderId[] = ["claude_code", "codex", "gemini"]; const TITLE_MODEL_FOLLOW_CURRENT_VALUE = "__conversation_title_follow_current__"; +const CUSTOM_HEADER_KEY_PRESETS = [ + "anthropic-beta", + "X-Request-ID", + "X-User-ID", + "X-Environment", + "HTTP-Referer", + "X-Title", +] as const; +const MODEL_CAPABILITIES: ModelCapability[] = ["reasoning", "vision", "tools"]; const PROVIDER_LABELS: Record = { claude_code: "Anthropic", codex: "OpenAI", @@ -86,10 +104,6 @@ function ProviderBrandIcon({ type }: { type: ProviderId }) { const REDACTED_API_KEY_DISPLAY = "API Key"; -function normalizeModelDomId(modelId: string) { - return modelId.replace(/[^a-zA-Z0-9_-]+/g, "-"); -} - function parsePositiveInteger(input: string): number | null { const value = Number(input.trim()); if (!Number.isFinite(value)) return null; @@ -97,91 +111,49 @@ function parsePositiveInteger(input: string): number | null { return normalized > 0 ? normalized : null; } -function ModelSettingsModal({ model, onClose, onSave }: ModelSettingsModalProps) { - const { t } = useLocale(); - const [contextWindow, setContextWindow] = useState(String(model.contextWindow)); - const [maxOutputToken, setMaxOutputToken] = useState(String(model.maxOutputToken)); - const { isClosing, modalState, requestClose } = useModalMotion(onClose); - - const parsedContextWindow = parsePositiveInteger(contextWindow); - const parsedMaxOutputToken = parsePositiveInteger(maxOutputToken); - const canSave = parsedContextWindow !== null && parsedMaxOutputToken !== null; +type CustomHeaderKeyIssue = "reserved" | "invalid"; - function handleSave() { - if (!canSave) return; - onSave({ - ...model, - contextWindow: parsedContextWindow, - maxOutputToken: parsedMaxOutputToken, - }); - requestClose(); - } +function getCustomHeaderKeyIssue(key: string, includeEmpty = false): CustomHeaderKeyIssue | null { + if (!key && !includeEmpty) return null; + if (isReservedCustomHeaderKey(key)) return "reserved"; + return isValidCustomHeaderKey(key) ? null : "invalid"; +} - return createPortal( -
void; + ariaLabel: string; +}) { + const { checked, onCheckedChange, ariaLabel } = props; + return ( + -
- -
-
- - -
- -
- - setContextWindow(e.currentTarget.value)} - /> -
- -
- - setMaxOutputToken(e.currentTarget.value)} - /> -
- - {!canSave ? ( -
- {t("settings.positiveIntegerRequired")} -
- ) : null} -
- -
- - -
- - , - document.body, + + + + ); } - +function formatTokenCount(value: number): string { + if (value < 1_000) return String(value); + return String(Math.round(value / 1_000)) + "K"; +} function ProviderModal({ providerType, initialData, onSave, onClose }: ModalProps) { const { t } = useLocale(); const isGatewayWebui = isGatewayWebuiRuntime(); @@ -193,6 +165,9 @@ function ProviderModal({ providerType, initialData, onSave, onClose }: ModalProp const [apiKey, setApiKey] = useState( initialUsesRedactedApiKey ? REDACTED_API_KEY_DISPLAY : initialApiKey, ); + const [customHeaders, setCustomHeaders] = useState(() => + (initialData?.customHeaders ?? []).map((header) => ({ ...header })), + ); const [models, setModels] = useState(() => normalizeFetchedModels(initialData?.models ?? [], providerType), ); @@ -208,12 +183,17 @@ function ProviderModal({ providerType, initialData, onSave, onClose }: ModalProp const [addingModel, setAddingModel] = useState(false); const [newModelName, setNewModelName] = useState(""); const [modelSearch, setModelSearch] = useState(""); - const [editingModel, setEditingModel] = useState(null); + const [editingModel, setEditingModel] = useState(null); const [showApiKey, setShowApiKey] = useState(false); + const [activePanel, setActivePanel] = useState("general"); + const [visibleHeaderValues, setVisibleHeaderValues] = useState>(new Set()); + const [headerValidationSubmitted, setHeaderValidationSubmitted] = useState(false); const { isClosing, modalState, requestClose } = useModalMotion(onClose); const debounceRef = useRef | null>(null); const prevFetchKey = useRef(""); + const headerKeyRefs = useRef>([]); + const headerValueRefs = useRef>([]); const apiKeyIsRedactedDisplay = initialUsesRedactedApiKey && apiKey === REDACTED_API_KEY_DISPLAY; const apiKeyForRequest = apiKeyIsRedactedDisplay ? "" : apiKey.trim(); const canFetchModels = baseUrl.trim().length > 0 && apiKeyForRequest.length > 0; @@ -272,17 +252,6 @@ function ProviderModal({ providerType, initialData, onSave, onClose }: ModalProp }); } - function setVisibleModelsSelected(selected: boolean) { - setActiveModels((prev) => { - const visibleModelIds = new Set(visibleModels.map((model) => model.id)); - const next = new Set(Array.from(prev).filter((model) => !visibleModelIds.has(model))); - if (selected) { - for (const model of visibleModels) next.add(model.id); - } - return next; - }); - } - function handleAddModel() { const model = newModelName.trim(); if (!model) return; @@ -301,21 +270,117 @@ function ProviderModal({ providerType, initialData, onSave, onClose }: ModalProp next.delete(model); return next; }); - setEditingModel((prev) => (prev?.id === model ? null : prev)); + setEditingModel((prev) => (prev?.model.id === model ? null : prev)); } function openModelSettings(modelId: string) { const target = models.find((item) => item.id === modelId); if (!target) return; - setEditingModel(target); + setEditingModel((prev) => + prev?.model.id === target.id + ? null + : { + model: target, + contextWindow: String(target.contextWindow), + maxOutputToken: String(target.maxOutputToken), + capabilities: [...(target.capabilities ?? [])], + }, + ); + } + + function toggleModelCapability(capability: ModelCapability) { + setEditingModel((prev) => { + if (!prev) return prev; + const capabilities = prev.capabilities.includes(capability) + ? prev.capabilities.filter((item) => item !== capability) + : [...prev.capabilities, capability]; + return { ...prev, capabilities }; + }); } - function saveModelSettings(nextModel: ProviderModelConfig) { + const editingModelContextWindow = editingModel + ? parsePositiveInteger(editingModel.contextWindow) + : null; + const editingModelMaxOutputToken = editingModel + ? parsePositiveInteger(editingModel.maxOutputToken) + : null; + const canSaveEditingModel = + editingModelContextWindow !== null && editingModelMaxOutputToken !== null; + + function saveInlineModelSettings() { + if ( + !editingModel || + editingModelContextWindow === null || + editingModelMaxOutputToken === null + ) { + return; + } + const nextModel: ProviderModelConfig = { + ...editingModel.model, + contextWindow: editingModelContextWindow, + maxOutputToken: editingModelMaxOutputToken, + capabilities: editingModel.capabilities, + }; setModels((prev) => prev.map((item) => (item.id === nextModel.id ? nextModel : item))); + setEditingModel(null); + } + function updateCustomHeader(index: number, field: "key" | "value", value: string) { + setCustomHeaders((prev) => + prev.map((header, headerIndex) => + headerIndex === index ? { ...header, [field]: value } : header, + ), + ); + setHeaderValidationSubmitted(false); + } + + function focusCustomHeader(index: number, field: "key" | "value") { + requestAnimationFrame(() => { + const target = + field === "key" ? headerKeyRefs.current[index] : headerValueRefs.current[index]; + target?.focus(); + }); + } + + function addCustomHeader(key = "", focusField: "key" | "value" = "key") { + const nextIndex = customHeaders.length; + setCustomHeaders((prev) => [...prev, { key, value: "" }]); + setHeaderValidationSubmitted(false); + focusCustomHeader(nextIndex, focusField); + } + + function removeCustomHeader(index: number) { + setCustomHeaders((prev) => prev.filter((_, headerIndex) => headerIndex !== index)); + setVisibleHeaderValues((prev) => { + const next = new Set(); + for (const visibleIndex of prev) { + if (visibleIndex < index) next.add(visibleIndex); + if (visibleIndex > index) next.add(visibleIndex - 1); + } + return next; + }); + setHeaderValidationSubmitted(false); + } + + function toggleCustomHeaderValue(index: number) { + setVisibleHeaderValues((prev) => { + const next = new Set(prev); + if (next.has(index)) next.delete(index); + else next.add(index); + return next; + }); } function handleSave() { if (!name.trim()) return; + const invalidHeaderIndex = customHeaders.findIndex( + (header) => getCustomHeaderKeyIssue(header.key, true) !== null, + ); + if (invalidHeaderIndex >= 0) { + setHeaderValidationSubmitted(true); + setActivePanel("headers"); + focusCustomHeader(invalidHeaderIndex, "key"); + return; + } const nextApiKey = apiKeyIsRedactedDisplay ? "" : apiKey.trim(); onSave({ name: name.trim(), @@ -326,6 +391,7 @@ function ProviderModal({ providerType, initialData, onSave, onClose }: ModalProp nextApiKey.length > 0 || apiKeyIsRedactedDisplay || (isGatewayWebui && initialData?.apiKeyConfigured === true), + customHeaders, models, activeModels: Array.from(activeModels), requestFormat: providerType === "codex" ? requestFormat : undefined, @@ -354,276 +420,607 @@ function ProviderModal({ providerType, initialData, onSave, onClose }: ModalProp : orderedModels, [orderedModels, modelSearchQuery], ); - const allVisibleModelsSelected = - visibleModels.length > 0 && visibleModels.every((model) => activeModels.has(model.id)); return createPortal(
-
-
-
- -
-
-
- {isEditing ? t("settings.editProvider") : t("settings.addProvider")} +
+
+
+
+
-
- {typeLabel} {t("settings.compatible")} +
+
+ {isEditing ? t("settings.editProvider") : t("settings.addProvider")} +
+ + {typeLabel} {t("settings.compatible")} +
+
-
-
- - setName(e.currentTarget.value)} /> -
+
+ + +
+ {activePanel === "general" ? ( +
+
{t("settings.basicInformation")}
+ +
+ + setName(event.currentTarget.value)} + /> +
-
- - setBaseUrl(e.currentTarget.value)} - /> -
+
+
+ + setBaseUrl(event.currentTarget.value)} + /> +
-
- -
- setApiKey(e.currentTarget.value)} - onFocus={(e) => { - if (apiKeyIsRedactedDisplay) e.currentTarget.select(); - }} - /> - -
-
+
+ +
+ setApiKey(event.currentTarget.value)} + onFocus={(event) => { + if (apiKeyIsRedactedDisplay) event.currentTarget.select(); + }} + /> + +
+
+
- {providerType === "codex" ? ( -
- - -
- ) : null} - - - -
-
- -
- {fetchingModels ? ( - - {t("settings.fetching")} - + {providerType === "codex" ? ( +
+ + +
) : null} - - - -
-
- {fetchError ? ( -
- {fetchError} -
- ) : null} - - {addingModel ? ( -
- setNewModelName(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === "Enter") handleAddModel(); - if (e.key === "Escape") setAddingModel(false); - }} - /> - - -
- ) : null} - - {models.length > 0 ? ( -
- - setModelSearch(event.currentTarget.value)} - onKeyDown={(event) => { - if (event.key === "Escape") setModelSearch(""); - }} - /> - {modelSearch ? ( - - ) : null} -
- ) : null} - -
- {visibleModels.length === 0 ? ( -
- {models.length > 0 && modelSearchQuery - ? t("settings.noMatchingModels") - : baseUrl.trim() && apiKeyForRequest - ? t("settings.fetchFailed") - : t("settings.fetchHint")} -
- ) : ( - visibleModels.map((model) => { - const checkboxId = `model-${providerType}-${normalizeModelDomId(model.id)}`; - return ( -
{t("settings.models")}
+
+
+
+ + setModelSearch(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Escape") setModelSearch(""); + }} + /> + {modelSearch ? ( + + ) : null} +
+ + +
+ + {fetchError ? ( +
+ {fetchError} +
+ ) : null} + + {addingModel ? ( +
+ setNewModelName(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Enter") handleAddModel(); + if (event.key === "Escape") setAddingModel(false); + }} /> - + -
- ); - }) - )} -
+ ) : null} + +
+ {visibleModels.length === 0 ? ( +
+ {models.length > 0 && modelSearchQuery + ? t("settings.noMatchingModels") + : baseUrl.trim() && apiKeyForRequest + ? t("settings.fetchFailed") + : t("settings.fetchHint")} +
+ ) : ( + visibleModels.map((model) => { + const isEditingModel = editingModel?.model.id === model.id; + return ( +
+
+ toggleModel(model.id)} + ariaLabel={model.id} + /> +
+
+ {model.id} + {model.capabilities?.length ? ( + + {model.capabilities.map((capability) => ( + + {t("settings.capability." + capability)} + + ))} + + ) : null} +
+
+
+ {formatTokenCount(model.contextWindow)} ctx ·{" "} + {formatTokenCount(model.maxOutputToken)} out +
+ + +
+ + {isEditingModel && editingModel ? ( +
+
+
+ + + setEditingModel((prev) => + prev + ? { + ...prev, + contextWindow: event.currentTarget.value, + } + : prev, + ) + } + /> +
+
+ + + setEditingModel((prev) => + prev + ? { + ...prev, + maxOutputToken: event.currentTarget.value, + } + : prev, + ) + } + /> +
+
+ +
+ {t("settings.capabilityTypes")} +
+
+ {MODEL_CAPABILITIES.map((capability) => { + const selected = editingModel.capabilities.includes(capability); + return ( + + ); + })} +
+ + {!canSaveEditingModel ? ( +
+ {t("settings.positiveIntegerRequired")} +
+ ) : null} + +
+ + +
+
+ ) : null} +
+ ); + }) + )} +
+
+
+ ) : activePanel === "network" ? ( +
+
{t("settings.providerDialogNetwork")}
+
+
+ {t("settings.providerUseSystemProxy")} +
+ +
+
+ ) : ( +
+
{t("settings.customHeaders")}
+ +
+
+
{t("settings.customHeaderName")}
+
{t("settings.customHeaderValue")}
+
+
+
+ + {customHeaders.length === 0 ? ( +
+ {t("settings.noCustomHeaders")} +
+ ) : ( + customHeaders.map((header, index) => { + const issue = getCustomHeaderKeyIssue(header.key, headerValidationSubmitted); + const issueTitle = + issue === "reserved" + ? t("settings.customHeaderReservedTitle") + : issue === "invalid" + ? t("settings.invalidCustomHeaderKey") + : undefined; + const valueVisible = visibleHeaderValues.has(index); + + return ( +
+ { + headerKeyRefs.current[index] = element; + }} + value={header.key} + className={cn( + "h-10 rounded-none border-0 border-r bg-transparent px-3 font-mono text-xs shadow-none focus-visible:ring-1 focus-visible:ring-inset max-[720px]:col-span-3 max-[720px]:border-b max-[720px]:border-r-0 max-[720px]:bg-muted/40", + issue && + "ring-1 ring-inset ring-destructive focus-visible:ring-destructive", + )} + placeholder={t("settings.customHeaderKeyPlaceholder")} + aria-label={t("settings.customHeaderName")} + aria-invalid={issue ? true : undefined} + title={issueTitle} + autoComplete="off" + spellCheck={false} + onChange={(event) => + updateCustomHeader(index, "key", event.currentTarget.value) + } + /> + { + headerValueRefs.current[index] = element; + }} + type={valueVisible ? "text" : "password"} + value={header.value} + className="h-10 rounded-none border-0 bg-transparent px-3 font-mono text-xs shadow-none focus-visible:ring-1 focus-visible:ring-inset max-[720px]:col-start-1 max-[720px]:row-start-2" + placeholder={t("settings.customHeaderValue")} + aria-label={t("settings.customHeaderValue")} + autoComplete="off" + spellCheck={false} + onChange={(event) => + updateCustomHeader(index, "value", event.currentTarget.value) + } + /> + + +
+ ); + }) + )} +
+ + + +
+ {CUSTOM_HEADER_KEY_PRESETS.map((preset) => ( + + ))} +
+ +

+ {t("settings.customHeaderReservedHint")} +

+
+ )}
-
- -
- - {editingModel ? ( - setEditingModel(null)} - onSave={saveModelSettings} - /> - ) : null}
, document.body, @@ -702,7 +1099,7 @@ function CustomSettingsDrawer(props: SettingsSectionProps & { onClose: () => voi />
-
+
-
+
{provider.name} {provider.useSystemProxy ? ( diff --git a/crates/agent-gui/src/components/cron/CronPromptRunner.tsx b/crates/agent-gui/src/components/cron/CronPromptRunner.tsx index 690769578..c8a78fac7 100644 --- a/crates/agent-gui/src/components/cron/CronPromptRunner.tsx +++ b/crates/agent-gui/src/components/cron/CronPromptRunner.tsx @@ -210,6 +210,7 @@ async function executeCronPromptRun( runtime: { baseUrl: provider.baseUrl, apiKey: provider.apiKey, + customHeaders: provider.customHeaders, requestFormat: provider.requestFormat, reasoning: resolveCronReasoning(request.reasoning), promptCachingEnabled: true, diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 9199b6948..33851b5d2 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -1246,6 +1246,31 @@ export const translations: Record> = { "settings.providerName": "分组名称", "settings.baseUrl": "Base URL", "settings.apiKey": "API Key", + "settings.customHeaders": "自定义请求头", + "settings.providerDialogNavigation": "供应商配置导航", + "settings.providerDialogGeneral": "普通配置", + "settings.providerDialogNetwork": "网络配置", + "settings.providerDialogHeaders": "请求头配置", + "settings.basicInformation": "基本信息", + "settings.customHeaderName": "Header 名称", + "settings.customHeaderValue": "值", + "settings.noCustomHeaders": "暂无自定义请求头", + "settings.customHeaderReservedTitle": "保留头,由系统管理", + "settings.showCustomHeaderValue": "显示请求头值", + "settings.hideCustomHeaderValue": "隐藏请求头值", + "settings.manualAddModel": "手动添加", + "settings.capabilityTypes": "能力类型", + "settings.capability.reasoning": "推理", + "settings.capability.vision": "视觉", + "settings.capability.tools": "工具调用", + "settings.customHeaderKeyPlaceholder": "请求头名称,例如 X-Request-ID", + "settings.addCustomHeader": "添加请求头", + "settings.removeCustomHeader": "删除请求头", + "settings.invalidCustomHeaderKey": + "请求头名称无效。仅支持合法 HTTP Header 名称,例如 anthropic-beta、X-Request-ID。", + "settings.customHeaderReservedHint": + "Authorization、x-api-key、x-goog-api-key、anthropic-version、Content-Type、Host、Content-Length 由应用管理,自定义值不会覆盖它们。", + "settings.close": "关闭", "settings.hideApiKey": "隐藏 API Key", "settings.showApiKey": "显示 API Key", "settings.requestFormat": "请求格式", @@ -3184,6 +3209,31 @@ export const translations: Record> = { "settings.providerName": "Name", "settings.baseUrl": "Base URL", "settings.apiKey": "API Key", + "settings.customHeaders": "Custom request headers", + "settings.providerDialogNavigation": "Provider configuration", + "settings.providerDialogGeneral": "Basic", + "settings.providerDialogNetwork": "Network", + "settings.providerDialogHeaders": "Headers", + "settings.basicInformation": "Basic information", + "settings.customHeaderName": "Header name", + "settings.customHeaderValue": "Value", + "settings.noCustomHeaders": "No custom request headers", + "settings.customHeaderReservedTitle": "Reserved header managed by the system", + "settings.showCustomHeaderValue": "Show header value", + "settings.hideCustomHeaderValue": "Hide header value", + "settings.manualAddModel": "Add manually", + "settings.capabilityTypes": "Capabilities", + "settings.capability.reasoning": "Reasoning", + "settings.capability.vision": "Vision", + "settings.capability.tools": "Tool use", + "settings.customHeaderKeyPlaceholder": "Select or enter a header name", + "settings.addCustomHeader": "Add header", + "settings.removeCustomHeader": "Remove header", + "settings.invalidCustomHeaderKey": + "Invalid HTTP header name. Examples: anthropic-beta, X-Request-ID.", + "settings.customHeaderReservedHint": + "Authorization, x-api-key, x-goog-api-key, anthropic-version, Content-Type, Host, and Content-Length are managed by the app and cannot be overridden.", + "settings.close": "Close", "settings.hideApiKey": "Hide API Key", "settings.showApiKey": "Show API Key", "settings.requestFormat": "Request Format", diff --git a/crates/agent-gui/src/index.css b/crates/agent-gui/src/index.css index 0e5049642..46b677361 100644 --- a/crates/agent-gui/src/index.css +++ b/crates/agent-gui/src/index.css @@ -1119,6 +1119,23 @@ animation: settingsSectionTitleIn 0.22s cubic-bezier(0.16, 1, 0.3, 1); } + /* Provider dialog: panel switches (sections are keyed by panel id, so the + * class replays on every switch). */ + .provider-panel-enter { + animation: providerPanelIn 0.18s cubic-bezier(0.16, 1, 0.3, 1); + } + + @keyframes providerPanelIn { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + .settings-section-shell { position: relative; isolation: isolate; @@ -1350,6 +1367,7 @@ .settings-section-enter, .settings-section-title-enter, .settings-tools-view-enter, + .provider-panel-enter, .chat-history-scope-enter, .chat-hero-logo-enter, .chat-hero-logo-float, diff --git a/crates/agent-gui/src/lib/chat/compaction/types.ts b/crates/agent-gui/src/lib/chat/compaction/types.ts index 668946807..55455142a 100644 --- a/crates/agent-gui/src/lib/chat/compaction/types.ts +++ b/crates/agent-gui/src/lib/chat/compaction/types.ts @@ -1,4 +1,9 @@ -import type { CodexRequestFormat, ProviderModelConfig, ReasoningLevel } from "../../settings"; +import type { + CodexRequestFormat, + CustomProvider, + ProviderModelConfig, + ReasoningLevel, +} from "../../settings"; export type CompactionTrigger = "pre-send" | "mid-stream" | "post-tool"; @@ -8,6 +13,7 @@ export type CompactionIntent = "optimization" | "protection"; export type ProviderRuntimeConfig = { baseUrl: string; apiKey: string; + customHeaders?: CustomProvider["customHeaders"]; requestFormat?: CodexRequestFormat; reasoning?: ReasoningLevel; promptCachingEnabled?: boolean; diff --git a/crates/agent-gui/src/lib/chat/runner/agentRunner.ts b/crates/agent-gui/src/lib/chat/runner/agentRunner.ts index 049551c8d..6441d9a2a 100644 --- a/crates/agent-gui/src/lib/chat/runner/agentRunner.ts +++ b/crates/agent-gui/src/lib/chat/runner/agentRunner.ts @@ -9,6 +9,7 @@ import type { } from "@earendil-works/pi-ai"; import { buildStreamRequestDebugPayload, type StreamDebugLogger } from "../../debug/agentDebug"; import { buildMemoryToolsSuffixSection } from "../../memory/prompts/injection"; +import { mergeCustomHeaders } from "../../providers/customHeaders"; import { createHostedSearchEventAggregator, createHostedSearchProbeId, @@ -41,6 +42,7 @@ import { } from "../../runtimePlatform"; import type { CodexRequestFormat, + CustomProvider, ProviderId, ProviderModelConfig, ReasoningLevel, @@ -645,6 +647,7 @@ export async function runAssistantWithTools(params: { runtime: { baseUrl: string; apiKey: string; + customHeaders?: CustomProvider["customHeaders"]; requestFormat?: CodexRequestFormat; reasoning?: ReasoningLevel; promptCachingEnabled?: boolean; @@ -704,7 +707,10 @@ export async function runAssistantWithTools(params: { const proxyRequest = await prepareProxyRequest( params.providerId, params.runtime.baseUrl.trim(), - buildProviderAuthHeaders(params.providerId, params.runtime.apiKey), + mergeCustomHeaders( + buildProviderAuthHeaders(params.providerId, params.runtime.apiKey), + params.runtime.customHeaders, + ), { useSystemProxy: params.runtime.useSystemProxy === true }, ); diff --git a/crates/agent-gui/src/lib/debug/agentDebug.ts b/crates/agent-gui/src/lib/debug/agentDebug.ts index c8f7806c9..f907b9c00 100644 --- a/crates/agent-gui/src/lib/debug/agentDebug.ts +++ b/crates/agent-gui/src/lib/debug/agentDebug.ts @@ -126,6 +126,15 @@ function sanitizeDebugValue(value: unknown, seen = new WeakSet()): unkno for (const [key, nested] of Object.entries(record)) { if (isSensitiveDebugKey(key)) { out[key] = REDACTED_DEBUG_CREDENTIAL; + } else if ( + key.toLowerCase() === "headers" && + nested && + typeof nested === "object" && + !Array.isArray(nested) + ) { + out[key] = Object.fromEntries( + Object.keys(nested).map((name) => [name, REDACTED_DEBUG_CREDENTIAL]), + ); } else if (isBase64Source && key === "data") { out[key] = `[redacted base64: ${sourceMimeType}, chars=${sourceData.length}]`; } else if (isTextDocumentSource && key === "data") { diff --git a/crates/agent-gui/src/lib/memory/organizer/service.ts b/crates/agent-gui/src/lib/memory/organizer/service.ts index 9d4e5ee34..40d4974d9 100644 --- a/crates/agent-gui/src/lib/memory/organizer/service.ts +++ b/crates/agent-gui/src/lib/memory/organizer/service.ts @@ -245,6 +245,7 @@ async function runOrganizerModelPrompt(params: { runtime: { baseUrl: provider.baseUrl, apiKey: provider.apiKey, + customHeaders: provider.customHeaders, requestFormat: provider.requestFormat, reasoning: DEFAULT_CHAT_RUNTIME_CONTROLS.reasoning, promptCachingEnabled: true, diff --git a/crates/agent-gui/src/lib/providers/customHeaders.ts b/crates/agent-gui/src/lib/providers/customHeaders.ts new file mode 100644 index 000000000..2340337b5 --- /dev/null +++ b/crates/agent-gui/src/lib/providers/customHeaders.ts @@ -0,0 +1,40 @@ +import type { CustomProvider } from "../settings"; + +const RESERVED_CUSTOM_HEADER_KEYS = new Set([ + "authorization", + "x-api-key", + "x-goog-api-key", + "anthropic-version", + "content-type", + "host", + "content-length", +]); +const HTTP_HEADER_TOKEN_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +export function isValidCustomHeaderKey(key: string): boolean { + return HTTP_HEADER_TOKEN_PATTERN.test(key); +} + +export function isReservedCustomHeaderKey(key: string): boolean { + return RESERVED_CUSTOM_HEADER_KEYS.has(key.toLowerCase()); +} +export function mergeCustomHeaders( + base: Record, + customHeaders?: CustomProvider["customHeaders"], +): Record { + const merged = { ...base }; + + for (const header of customHeaders ?? []) { + if (!isValidCustomHeaderKey(header.key) || isReservedCustomHeaderKey(header.key)) { + continue; + } + + const existingKey = Object.keys(merged).find( + (key) => key.toLowerCase() === header.key.toLowerCase(), + ); + if (existingKey) delete merged[existingKey]; + merged[header.key] = header.value; + } + + return merged; +} diff --git a/crates/agent-gui/src/lib/providers/llm.ts b/crates/agent-gui/src/lib/providers/llm.ts index 72837c6fd..ade6e3ede 100644 --- a/crates/agent-gui/src/lib/providers/llm.ts +++ b/crates/agent-gui/src/lib/providers/llm.ts @@ -29,6 +29,8 @@ export { buildGeminiAuthHeaders, buildProviderAuthHeaders, buildProviderRequestMetadata, + isValidCustomHeaderKey, + mergeCustomHeaders, resolveProviderCacheRetention, toSimpleStreamReasoning, } from "./runtime/requestOptions"; diff --git a/crates/agent-gui/src/lib/providers/runtime/requestOptions.ts b/crates/agent-gui/src/lib/providers/runtime/requestOptions.ts index 888900c09..52d297391 100644 --- a/crates/agent-gui/src/lib/providers/runtime/requestOptions.ts +++ b/crates/agent-gui/src/lib/providers/runtime/requestOptions.ts @@ -1,7 +1,10 @@ import type { CacheRetention, SimpleStreamOptions } from "@earendil-works/pi-ai"; -import type { ProviderId, ReasoningLevel } from "../../settings"; +import type { CustomProvider, ProviderId, ReasoningLevel } from "../../settings"; +import { mergeCustomHeaders as mergeCustomHeadersBase } from "../customHeaders"; import { normalizeSessionId } from "./common"; +export { isValidCustomHeaderKey } from "../customHeaders"; + export function buildDualAuthHeaders(apiKey: string): Record { return { Authorization: `Bearer ${apiKey}`, @@ -22,6 +25,13 @@ export function buildProviderAuthHeaders( return providerId === "gemini" ? buildGeminiAuthHeaders(apiKey) : buildDualAuthHeaders(apiKey); } +export function mergeCustomHeaders( + base: Record, + customHeaders?: CustomProvider["customHeaders"], +): Record { + return mergeCustomHeadersBase(base, customHeaders); +} + export function toSimpleStreamReasoning( reasoning: ReasoningLevel | undefined, ): SimpleStreamOptions["reasoning"] | undefined { diff --git a/crates/agent-gui/src/lib/providers/runtime/textOnlyRuntime.ts b/crates/agent-gui/src/lib/providers/runtime/textOnlyRuntime.ts index 9f84dc3e9..e16b4a367 100644 --- a/crates/agent-gui/src/lib/providers/runtime/textOnlyRuntime.ts +++ b/crates/agent-gui/src/lib/providers/runtime/textOnlyRuntime.ts @@ -24,6 +24,7 @@ import { finalizeProviderStreamOptions } from "./payloadPipeline"; import { buildProviderAuthHeaders, buildProviderRequestMetadata, + mergeCustomHeaders, resolveProviderCacheRetention, toSimpleStreamReasoning, } from "./requestOptions"; @@ -137,7 +138,10 @@ export async function streamAssistantMessage(params: { const proxyRequest = await prepareProxyRequest( params.providerId, params.runtime.baseUrl.trim(), - buildProviderAuthHeaders(params.providerId, params.runtime.apiKey), + mergeCustomHeaders( + buildProviderAuthHeaders(params.providerId, params.runtime.apiKey), + params.runtime.customHeaders, + ), { useSystemProxy: params.runtime.useSystemProxy === true }, ); @@ -327,7 +331,10 @@ export async function completeAssistantMessage(params: { const proxyRequest = await prepareProxyRequest( params.providerId, params.runtime.baseUrl.trim(), - buildProviderAuthHeaders(params.providerId, params.runtime.apiKey), + mergeCustomHeaders( + buildProviderAuthHeaders(params.providerId, params.runtime.apiKey), + params.runtime.customHeaders, + ), { useSystemProxy: params.runtime.useSystemProxy === true }, ); diff --git a/crates/agent-gui/src/lib/providers/runtime/types.ts b/crates/agent-gui/src/lib/providers/runtime/types.ts index 44d6b0f96..d42fef17a 100644 --- a/crates/agent-gui/src/lib/providers/runtime/types.ts +++ b/crates/agent-gui/src/lib/providers/runtime/types.ts @@ -1,6 +1,7 @@ import type { SimpleStreamOptions } from "@earendil-works/pi-ai"; import type { CodexRequestFormat, + CustomProvider, ProviderId, ProviderModelConfig, ReasoningLevel, @@ -19,6 +20,7 @@ export type ModelOption = { export type ProviderRuntimeConfig = { baseUrl: string; apiKey: string; + customHeaders?: CustomProvider["customHeaders"]; requestFormat?: CodexRequestFormat; reasoning?: ReasoningLevel; promptCachingEnabled?: boolean; diff --git a/crates/agent-gui/src/lib/settings/index.ts b/crates/agent-gui/src/lib/settings/index.ts index 5aa7d1915..8b2ddf8ea 100644 --- a/crates/agent-gui/src/lib/settings/index.ts +++ b/crates/agent-gui/src/lib/settings/index.ts @@ -188,10 +188,13 @@ export type SelectedModel = { model: string; }; +export type ModelCapability = "reasoning" | "vision" | "tools"; + export type ProviderModelConfig = { id: string; contextWindow: number; maxOutputToken: number; + capabilities?: ModelCapability[]; }; export type ChatRuntimeControls = { @@ -257,6 +260,7 @@ export type CustomProvider = { baseUrl: string; apiKey: string; apiKeyConfigured?: boolean; + customHeaders?: { key: string; value: string }[]; models: ProviderModelConfig[]; activeModels: string[]; requestFormat?: CodexRequestFormat; @@ -388,6 +392,7 @@ export function getBuiltinCustomProviders(): CustomProvider[] { type: "claude_code", baseUrl: "https://api.anthropic.com/v1", apiKey: "", + customHeaders: [], models: [], activeModels: [], reasoning: "off", @@ -401,6 +406,7 @@ export function getBuiltinCustomProviders(): CustomProvider[] { type: "codex", baseUrl: "https://api.openai.com/v1", apiKey: "", + customHeaders: [], models: [], activeModels: [], requestFormat: "openai-responses", @@ -415,6 +421,7 @@ export function getBuiltinCustomProviders(): CustomProvider[] { type: "gemini", baseUrl: "https://generativelanguage.googleapis.com/v1beta", apiKey: "", + customHeaders: [], models: [], activeModels: [], reasoning: "off", @@ -1085,6 +1092,21 @@ export function createProviderModelConfig( }; } +function normalizeModelCapabilities(input: unknown): ModelCapability[] | undefined { + if (!Array.isArray(input)) return undefined; + + const out: ModelCapability[] = []; + const seen = new Set(); + for (const value of input) { + if (value !== "reasoning" && value !== "vision" && value !== "tools") continue; + if (seen.has(value)) continue; + seen.add(value); + out.push(value); + } + return out; +} + +// Capabilities are persisted for display only; runtime behavior will be connected in a later iteration. export function normalizeProviderModelConfig( input: unknown, providerId: ProviderId, @@ -1104,6 +1126,7 @@ export function normalizeProviderModelConfig( if (!id) return null; const defaults = getProviderModelDefaults(providerId, id); + const capabilities = normalizeModelCapabilities(obj.capabilities); return { id, contextWindow: normalizePositiveInteger(obj.contextWindow, defaults.contextWindow), @@ -1111,6 +1134,7 @@ export function normalizeProviderModelConfig( obj.maxOutputToken ?? obj.maxTokens, defaults.maxOutputToken, ), + ...(capabilities !== undefined ? { capabilities } : {}), }; } @@ -1175,6 +1199,17 @@ function normalizeProviderName(id: string, input: unknown): string { return name; } +function normalizeCustomHeaders(input: unknown): { key: string; value: string }[] { + if (!Array.isArray(input)) return []; + return input.flatMap((item) => { + if (!item || typeof item !== "object") return []; + const header = item as Record; + const key = typeof header.key === "string" ? header.key.trim() : ""; + if (!key) return []; + return [{ key, value: typeof header.value === "string" ? header.value : "" }]; + }); +} + export function normalizeCustomProvider(input: unknown): CustomProvider { const obj = (input && typeof input === "object" ? input : {}) as Record; const type = normalizeProviderId(obj.type); @@ -1194,6 +1229,7 @@ export function normalizeCustomProvider(input: unknown): CustomProvider { : normalizeBaseUrl(typeof obj.baseUrl === "string" ? obj.baseUrl : ""), apiKey, apiKeyConfigured: apiKey.length > 0 || obj.apiKeyConfigured === true, + customHeaders: normalizeCustomHeaders(obj.customHeaders), models, activeModels: normalizeModels(normalizeStringArray(obj.activeModels)).filter((modelId) => validModelIds.has(modelId), diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 1fb4e0813..cbc7840eb 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -565,6 +565,7 @@ function buildProviderRuntimeConfig( return { baseUrl: provider.baseUrl, apiKey: provider.apiKey, + customHeaders: provider.customHeaders, requestFormat: provider.requestFormat, reasoning: reasoningSupported ? controls.thinkingEnabled diff --git a/crates/agent-gui/src/pages/settings/ProvidersSection.tsx b/crates/agent-gui/src/pages/settings/ProvidersSection.tsx index 8212ddd3c..3cd66542f 100644 --- a/crates/agent-gui/src/pages/settings/ProvidersSection.tsx +++ b/crates/agent-gui/src/pages/settings/ProvidersSection.tsx @@ -11,7 +11,9 @@ import { Eye, EyeOff, GeminiIcon, + Globe, Key, + List, Loader2, OpenaiChatgptIcon, Pencil, @@ -19,7 +21,6 @@ import { RefreshCw, Search, Settings, - Settings2, Trash2, Waypoints, X, @@ -45,11 +46,16 @@ import { } from "../../components/ui/select"; import { useLocale } from "../../i18n"; import { buildModelOptions } from "../../lib/chat/page/chatPageHelpers"; +import { + isReservedCustomHeaderKey, + isValidCustomHeaderKey, +} from "../../lib/providers/customHeaders"; import { parseModelValue, toModelValue } from "../../lib/providers/llm"; import { CODEX_REQUEST_FORMAT_LABELS, type CodexRequestFormat, type CustomProvider, + type ModelCapability, type ProviderId, type ProviderModelConfig, updateCustomProviders, @@ -81,12 +87,14 @@ type ModalProps = { onClose: () => void; }; -type ModelSettingsModalProps = { +type ProviderDialogPanel = "general" | "network" | "headers"; + +type ModelEditDraft = { model: ProviderModelConfig; - onClose: () => void; - onSave: (model: ProviderModelConfig) => void; + contextWindow: string; + maxOutputToken: string; + capabilities: ModelCapability[]; }; - type CcsProviderImportItem = { sourceId: string; appType: string; @@ -105,6 +113,15 @@ type CcsProvidersResponse = { }; const PROVIDER_TABS: ProviderId[] = ["claude_code", "codex", "gemini"]; +const CUSTOM_HEADER_KEY_PRESETS = [ + "anthropic-beta", + "X-Request-ID", + "X-User-ID", + "X-Environment", + "HTTP-Referer", + "X-Title", +] as const; +const MODEL_CAPABILITIES: ModelCapability[] = ["reasoning", "vision", "tools"]; const TITLE_MODEL_FOLLOW_CURRENT_VALUE = "__conversation_title_follow_current__"; const PROVIDER_LABELS: Record = { claude_code: "Anthropic", @@ -145,10 +162,6 @@ function readCherryDataPath() { } } -function normalizeModelDomId(modelId: string) { - return modelId.replace(/[^a-zA-Z0-9_-]+/g, "-"); -} - function parsePositiveInteger(input: string): number | null { const value = Number(input.trim()); if (!Number.isFinite(value)) return null; @@ -156,86 +169,49 @@ function parsePositiveInteger(input: string): number | null { return normalized > 0 ? normalized : null; } -function ModelSettingsModal({ model, onClose, onSave }: ModelSettingsModalProps) { - const { t } = useLocale(); - const [contextWindow, setContextWindow] = useState(String(model.contextWindow)); - const [maxOutputToken, setMaxOutputToken] = useState(String(model.maxOutputToken)); - - const parsedContextWindow = parsePositiveInteger(contextWindow); - const parsedMaxOutputToken = parsePositiveInteger(maxOutputToken); - const canSave = parsedContextWindow !== null && parsedMaxOutputToken !== null; - - function handleSave() { - if (!canSave) return; - onSave({ - ...model, - contextWindow: parsedContextWindow, - maxOutputToken: parsedMaxOutputToken, - }); - } - - return createPortal( -
-
- -
-
-
-
{t("settings.modelSettings")}
-
{model.id}
-
- -
- -
-
- - -
- -
- - setContextWindow(e.currentTarget.value)} - /> -
- -
- - setMaxOutputToken(e.currentTarget.value)} - /> -
+type CustomHeaderKeyIssue = "reserved" | "invalid"; - {!canSave ? ( -
- {t("settings.positiveIntegerRequired")} -
- ) : null} -
+function getCustomHeaderKeyIssue(key: string, includeEmpty = false): CustomHeaderKeyIssue | null { + if (!key && !includeEmpty) return null; + if (isReservedCustomHeaderKey(key)) return "reserved"; + return isValidCustomHeaderKey(key) ? null : "invalid"; +} -
- - -
-
-
, - document.body, +function DialogSwitch(props: { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + ariaLabel: string; +}) { + const { checked, onCheckedChange, ariaLabel } = props; + return ( + ); } - +function formatTokenCount(value: number): string { + if (value < 1_000) return String(value); + return String(Math.round(value / 1_000)) + "K"; +} function ProviderModal({ providerType, initialData, onSave, onClose }: ModalProps) { const { t } = useLocale(); const isGatewayWebui = isGatewayWebuiRuntime(); @@ -247,6 +223,9 @@ function ProviderModal({ providerType, initialData, onSave, onClose }: ModalProp const [apiKey, setApiKey] = useState( initialUsesRedactedApiKey ? REDACTED_API_KEY_DISPLAY : initialApiKey, ); + const [customHeaders, setCustomHeaders] = useState(() => + (initialData?.customHeaders ?? []).map((header) => ({ ...header })), + ); const [models, setModels] = useState(() => normalizeFetchedModels(initialData?.models ?? [], providerType), ); @@ -262,11 +241,16 @@ function ProviderModal({ providerType, initialData, onSave, onClose }: ModalProp const [addingModel, setAddingModel] = useState(false); const [newModelName, setNewModelName] = useState(""); const [modelSearch, setModelSearch] = useState(""); - const [editingModel, setEditingModel] = useState(null); + const [editingModel, setEditingModel] = useState(null); + const [activePanel, setActivePanel] = useState("general"); + const [visibleHeaderValues, setVisibleHeaderValues] = useState>(new Set()); + const [headerValidationSubmitted, setHeaderValidationSubmitted] = useState(false); const [showApiKey, setShowApiKey] = useState(false); const debounceRef = useRef | null>(null); const prevFetchKey = useRef(""); + const headerKeyRefs = useRef>([]); + const headerValueRefs = useRef>([]); const apiKeyIsRedactedDisplay = initialUsesRedactedApiKey && apiKey === REDACTED_API_KEY_DISPLAY; const apiKeyForRequest = apiKeyIsRedactedDisplay ? "" : apiKey.trim(); const canFetchModels = baseUrl.trim().length > 0 && apiKeyForRequest.length > 0; @@ -325,17 +309,6 @@ function ProviderModal({ providerType, initialData, onSave, onClose }: ModalProp }); } - function setVisibleModelsSelected(selected: boolean) { - setActiveModels((prev) => { - const visibleModelIds = new Set(visibleModels.map((model) => model.id)); - const next = new Set(Array.from(prev).filter((model) => !visibleModelIds.has(model))); - if (selected) { - for (const model of visibleModels) next.add(model.id); - } - return next; - }); - } - function handleAddModel() { const model = newModelName.trim(); if (!model) return; @@ -354,22 +327,117 @@ function ProviderModal({ providerType, initialData, onSave, onClose }: ModalProp next.delete(model); return next; }); - setEditingModel((prev) => (prev?.id === model ? null : prev)); + setEditingModel((prev) => (prev?.model.id === model ? null : prev)); } function openModelSettings(modelId: string) { const target = models.find((item) => item.id === modelId); if (!target) return; - setEditingModel(target); + setEditingModel((prev) => + prev?.model.id === target.id + ? null + : { + model: target, + contextWindow: String(target.contextWindow), + maxOutputToken: String(target.maxOutputToken), + capabilities: [...(target.capabilities ?? [])], + }, + ); + } + + function toggleModelCapability(capability: ModelCapability) { + setEditingModel((prev) => { + if (!prev) return prev; + const capabilities = prev.capabilities.includes(capability) + ? prev.capabilities.filter((item) => item !== capability) + : [...prev.capabilities, capability]; + return { ...prev, capabilities }; + }); } - function saveModelSettings(nextModel: ProviderModelConfig) { + const editingModelContextWindow = editingModel + ? parsePositiveInteger(editingModel.contextWindow) + : null; + const editingModelMaxOutputToken = editingModel + ? parsePositiveInteger(editingModel.maxOutputToken) + : null; + const canSaveEditingModel = + editingModelContextWindow !== null && editingModelMaxOutputToken !== null; + + function saveInlineModelSettings() { + if ( + !editingModel || + editingModelContextWindow === null || + editingModelMaxOutputToken === null + ) { + return; + } + const nextModel: ProviderModelConfig = { + ...editingModel.model, + contextWindow: editingModelContextWindow, + maxOutputToken: editingModelMaxOutputToken, + capabilities: editingModel.capabilities, + }; setModels((prev) => prev.map((item) => (item.id === nextModel.id ? nextModel : item))); setEditingModel(null); } + function updateCustomHeader(index: number, field: "key" | "value", value: string) { + setCustomHeaders((prev) => + prev.map((header, headerIndex) => + headerIndex === index ? { ...header, [field]: value } : header, + ), + ); + setHeaderValidationSubmitted(false); + } + + function focusCustomHeader(index: number, field: "key" | "value") { + requestAnimationFrame(() => { + const target = + field === "key" ? headerKeyRefs.current[index] : headerValueRefs.current[index]; + target?.focus(); + }); + } + + function addCustomHeader(key = "", focusField: "key" | "value" = "key") { + const nextIndex = customHeaders.length; + setCustomHeaders((prev) => [...prev, { key, value: "" }]); + setHeaderValidationSubmitted(false); + focusCustomHeader(nextIndex, focusField); + } + + function removeCustomHeader(index: number) { + setCustomHeaders((prev) => prev.filter((_, headerIndex) => headerIndex !== index)); + setVisibleHeaderValues((prev) => { + const next = new Set(); + for (const visibleIndex of prev) { + if (visibleIndex < index) next.add(visibleIndex); + if (visibleIndex > index) next.add(visibleIndex - 1); + } + return next; + }); + setHeaderValidationSubmitted(false); + } + + function toggleCustomHeaderValue(index: number) { + setVisibleHeaderValues((prev) => { + const next = new Set(prev); + if (next.has(index)) next.delete(index); + else next.add(index); + return next; + }); + } function handleSave() { if (!name.trim()) return; + const invalidHeaderIndex = customHeaders.findIndex( + (header) => getCustomHeaderKeyIssue(header.key, true) !== null, + ); + if (invalidHeaderIndex >= 0) { + setHeaderValidationSubmitted(true); + setActivePanel("headers"); + focusCustomHeader(invalidHeaderIndex, "key"); + return; + } const nextApiKey = apiKeyIsRedactedDisplay ? "" : apiKey.trim(); onSave({ name: name.trim(), @@ -380,6 +448,7 @@ function ProviderModal({ providerType, initialData, onSave, onClose }: ModalProp nextApiKey.length > 0 || apiKeyIsRedactedDisplay || (isGatewayWebui && initialData?.apiKeyConfigured === true), + customHeaders, models, activeModels: Array.from(activeModels), requestFormat: providerType === "codex" ? requestFormat : undefined, @@ -407,273 +476,601 @@ function ProviderModal({ providerType, initialData, onSave, onClose }: ModalProp : orderedModels, [orderedModels, modelSearchQuery], ); - const allVisibleModelsSelected = - visibleModels.length > 0 && visibleModels.every((model) => activeModels.has(model.id)); return createPortal( -
+
-
-
-
- -
-
-
- {isEditing ? t("settings.editProvider") : t("settings.addProvider")} +
+
+
+
+
-
- {typeLabel} {t("settings.compatible")} +
+
+ {isEditing ? t("settings.editProvider") : t("settings.addProvider")} +
+ + {typeLabel} {t("settings.compatible")} +
+
-
-
- - setName(e.currentTarget.value)} /> -
+
+ + +
+ {activePanel === "general" ? ( +
+
{t("settings.basicInformation")}
+ +
+ + setName(event.currentTarget.value)} + /> +
-
- - setBaseUrl(e.currentTarget.value)} - /> -
+
+
+ + setBaseUrl(event.currentTarget.value)} + /> +
-
- -
- setApiKey(e.currentTarget.value)} - onFocus={(e) => { - if (apiKeyIsRedactedDisplay) e.currentTarget.select(); - }} - /> - -
-
+
+ +
+ setApiKey(event.currentTarget.value)} + onFocus={(event) => { + if (apiKeyIsRedactedDisplay) event.currentTarget.select(); + }} + /> + +
+
+
- {providerType === "codex" ? ( -
- - -
- ) : null} - - - -
-
- -
- {fetchingModels ? ( - - {t("settings.fetching")} - + {providerType === "codex" ? ( +
+ + +
) : null} - - - -
-
- {fetchError ? ( -
- {fetchError} -
- ) : null} - - {addingModel ? ( -
- setNewModelName(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === "Enter") handleAddModel(); - if (e.key === "Escape") setAddingModel(false); - }} - /> - - -
- ) : null} - - {models.length > 0 ? ( -
- - setModelSearch(event.currentTarget.value)} - onKeyDown={(event) => { - if (event.key === "Escape") setModelSearch(""); - }} - /> - {modelSearch ? ( - - ) : null} -
- ) : null} - -
- {visibleModels.length === 0 ? ( -
- {models.length > 0 && modelSearchQuery - ? t("settings.noMatchingModels") - : baseUrl.trim() && apiKeyForRequest - ? t("settings.fetchFailed") - : t("settings.fetchHint")} -
- ) : ( - visibleModels.map((model) => { - const checkboxId = `model-${providerType}-${normalizeModelDomId(model.id)}`; - return ( -
{t("settings.models")}
+
+
+
+ + setModelSearch(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Escape") setModelSearch(""); + }} + /> + {modelSearch ? ( + + ) : null} +
+ + +
+ + {fetchError ? ( +
+ {fetchError} +
+ ) : null} + + {addingModel ? ( +
+ setNewModelName(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Enter") handleAddModel(); + if (event.key === "Escape") setAddingModel(false); + }} /> - + -
- ); - }) - )} -
+ ) : null} + +
+ {visibleModels.length === 0 ? ( +
+ {models.length > 0 && modelSearchQuery + ? t("settings.noMatchingModels") + : baseUrl.trim() && apiKeyForRequest + ? t("settings.fetchFailed") + : t("settings.fetchHint")} +
+ ) : ( + visibleModels.map((model) => { + const isEditingModel = editingModel?.model.id === model.id; + return ( +
+
+ toggleModel(model.id)} + ariaLabel={model.id} + /> +
+
+ {model.id} + {model.capabilities?.length ? ( + + {model.capabilities.map((capability) => ( + + {t("settings.capability." + capability)} + + ))} + + ) : null} +
+
+
+ {formatTokenCount(model.contextWindow)} ctx ·{" "} + {formatTokenCount(model.maxOutputToken)} out +
+ + +
+ + {isEditingModel && editingModel ? ( +
+
+
+ + + setEditingModel((prev) => + prev + ? { + ...prev, + contextWindow: event.currentTarget.value, + } + : prev, + ) + } + /> +
+
+ + + setEditingModel((prev) => + prev + ? { + ...prev, + maxOutputToken: event.currentTarget.value, + } + : prev, + ) + } + /> +
+
+ +
+ {t("settings.capabilityTypes")} +
+
+ {MODEL_CAPABILITIES.map((capability) => { + const selected = editingModel.capabilities.includes(capability); + return ( + + ); + })} +
+ + {!canSaveEditingModel ? ( +
+ {t("settings.positiveIntegerRequired")} +
+ ) : null} + +
+ + +
+
+ ) : null} +
+ ); + }) + )} +
+
+
+ ) : activePanel === "network" ? ( +
+
{t("settings.providerDialogNetwork")}
+
+
+ {t("settings.providerUseSystemProxy")} +
+ +
+
+ ) : ( +
+
{t("settings.customHeaders")}
+ +
+
+
{t("settings.customHeaderName")}
+
{t("settings.customHeaderValue")}
+
+
+
+ + {customHeaders.length === 0 ? ( +
+ {t("settings.noCustomHeaders")} +
+ ) : ( + customHeaders.map((header, index) => { + const issue = getCustomHeaderKeyIssue(header.key, headerValidationSubmitted); + const issueTitle = + issue === "reserved" + ? t("settings.customHeaderReservedTitle") + : issue === "invalid" + ? t("settings.invalidCustomHeaderKey") + : undefined; + const valueVisible = visibleHeaderValues.has(index); + + return ( +
+ { + headerKeyRefs.current[index] = element; + }} + value={header.key} + className={cn( + "h-10 rounded-none border-0 border-r bg-transparent px-3 font-mono text-xs shadow-none focus-visible:ring-1 focus-visible:ring-inset max-[720px]:col-span-3 max-[720px]:border-b max-[720px]:border-r-0 max-[720px]:bg-muted/40", + issue && + "ring-1 ring-inset ring-destructive focus-visible:ring-destructive", + )} + placeholder={t("settings.customHeaderKeyPlaceholder")} + aria-label={t("settings.customHeaderName")} + aria-invalid={issue ? true : undefined} + title={issueTitle} + autoComplete="off" + spellCheck={false} + onChange={(event) => + updateCustomHeader(index, "key", event.currentTarget.value) + } + /> + { + headerValueRefs.current[index] = element; + }} + type={valueVisible ? "text" : "password"} + value={header.value} + className="h-10 rounded-none border-0 bg-transparent px-3 font-mono text-xs shadow-none focus-visible:ring-1 focus-visible:ring-inset max-[720px]:col-start-1 max-[720px]:row-start-2" + placeholder={t("settings.customHeaderValue")} + aria-label={t("settings.customHeaderValue")} + autoComplete="off" + spellCheck={false} + onChange={(event) => + updateCustomHeader(index, "value", event.currentTarget.value) + } + /> + + +
+ ); + }) + )} +
+ + + +
+ {CUSTOM_HEADER_KEY_PRESETS.map((preset) => ( + + ))} +
+ +

+ {t("settings.customHeaderReservedHint")} +

+
+ )}
-
- -
- - {editingModel ? ( - setEditingModel(null)} - onSave={saveModelSettings} - /> - ) : null}
, document.body, @@ -752,7 +1149,7 @@ function CustomSettingsDrawer(props: SettingsSectionProps & { onClose: () => voi />
-
+
-
+
从 CC Switch 导入
左侧选择供应商类型,右侧勾选要导入的配置,导入后自动获取并激活模型 @@ -1092,7 +1489,7 @@ function CcsImportModal(props: {
-
+
{groups.length === 0 ? (
未发现可导入的供应商 @@ -1121,7 +1518,7 @@ function CcsImportModal(props: { - + {getProviderLabel(group.type)} @@ -1172,7 +1569,7 @@ function CcsImportModal(props: { disabled={!selectable || importing} onChange={() => toggleRow(key)} /> -
+
{item.name} {exists ? ( @@ -1217,7 +1614,12 @@ function CcsImportModal(props: { 共已选 {selectedCount} / {selectableKeys.length} 个可导入
-
-
+
{provider.name} {provider.useSystemProxy ? ( diff --git a/crates/agent-gui/test/debug/agent-debug.test.mjs b/crates/agent-gui/test/debug/agent-debug.test.mjs index c24165ecb..729aa2e3d 100644 --- a/crates/agent-gui/test/debug/agent-debug.test.mjs +++ b/crates/agent-gui/test/debug/agent-debug.test.mjs @@ -75,6 +75,7 @@ test("debug sanitizer redacts nested credentials without hiding token usage", () Authorization: "Bearer raw-authorization", "X-API-Key": "raw-header-key", Cookie: "session=raw-cookie", + "X-Request-ID": "raw-custom-header", }, provider: { client_secret: "raw-client-secret", @@ -94,6 +95,7 @@ test("debug sanitizer redacts nested credentials without hiding token usage", () "raw-authorization", "raw-header-key", "raw-cookie", + "raw-custom-header", "raw-client-secret", "raw-refresh-token", "raw-password", @@ -102,6 +104,7 @@ test("debug sanitizer redacts nested credentials without hiding token usage", () } assert.equal(sanitized.apiKey, "[redacted credential]"); assert.equal(sanitized.headers.Authorization, "[redacted credential]"); + assert.equal(sanitized.headers["X-Request-ID"], "[redacted credential]"); assert.equal(sanitized.provider.client_secret, "[redacted credential]"); assert.equal(sanitized.hasApiKey, true); assert.equal(sanitized.inputTokens, 123); diff --git a/crates/agent-gui/test/providers/request-options.test.mjs b/crates/agent-gui/test/providers/request-options.test.mjs index 496dac10b..cb9acf536 100644 --- a/crates/agent-gui/test/providers/request-options.test.mjs +++ b/crates/agent-gui/test/providers/request-options.test.mjs @@ -69,6 +69,8 @@ test("llm facade preserves provider runtime exports", () => { "buildGeminiAuthHeaders", "buildProviderAuthHeaders", "buildProviderRequestMetadata", + "isValidCustomHeaderKey", + "mergeCustomHeaders", "completeAssistantMessage", "composePayloadMiddlewares", "createModelFromConfig", @@ -1159,3 +1161,62 @@ test("streaming text reconciler emits only missing final text suffixes", () => { assert.equal(reconciler.reconcileFinalText("round-1", "different"), ""); assert.equal(reconciler.reconcileFinalText("round-2", "new"), "new"); }); + +test("custom provider headers merge without mutating the base headers", () => { + const base = { Accept: "application/json", "X-Tenant": "old" }; + assert.deepEqual( + providers.mergeCustomHeaders(base, [ + { key: "X-Tenant", value: "new" }, + { key: "X-Request-ID", value: "request-123" }, + ]), + { Accept: "application/json", "X-Tenant": "new", "X-Request-ID": "request-123" }, + ); + assert.deepEqual(base, { Accept: "application/json", "X-Tenant": "old" }); +}); + +test("custom provider headers cannot override reserved headers case-insensitively", () => { + const base = { + Authorization: "Bearer real", + "x-api-key": "real-api-key", + "x-goog-api-key": "real-google-key", + "anthropic-version": "2023-06-01", + "Content-Type": "application/json", + Host: "api.example.com", + "Content-Length": "42", + }; + assert.deepEqual( + providers.mergeCustomHeaders(base, [ + { key: "authorization", value: "Bearer attacker" }, + { key: "X-API-KEY", value: "attacker" }, + { key: "X-GOOG-API-KEY", value: "attacker" }, + { key: "Anthropic-Version", value: "attacker" }, + { key: "content-type", value: "text/plain" }, + { key: "host", value: "attacker.example" }, + { key: "content-length", value: "0" }, + ]), + base, + ); +}); + +test("custom provider headers filter invalid HTTP token keys", () => { + assert.deepEqual( + providers.mergeCustomHeaders({}, [ + { key: "", value: "empty" }, + { key: "Bad Header", value: "space" }, + { key: "Bad:Header", value: "colon" }, + { key: "Bad\nHeader", value: "newline" }, + { key: "X.Valid-Header_1", value: "kept" }, + ]), + { "X.Valid-Header_1": "kept" }, + ); + assert.equal(providers.isValidCustomHeaderKey("anthropic-beta"), true); + assert.equal(providers.isValidCustomHeaderKey("X-Request-ID"), true); + assert.equal(providers.isValidCustomHeaderKey("Bad Header"), false); +}); + +test("custom provider headers accept undefined and empty arrays", () => { + const base = { Accept: "application/json" }; + assert.deepEqual(providers.mergeCustomHeaders(base, undefined), base); + assert.deepEqual(providers.mergeCustomHeaders(base, []), base); +}); + diff --git a/crates/agent-gui/test/settings/normalization.test.mjs b/crates/agent-gui/test/settings/normalization.test.mjs index 1040fff2b..ee7d7dde1 100644 --- a/crates/agent-gui/test/settings/normalization.test.mjs +++ b/crates/agent-gui/test/settings/normalization.test.mjs @@ -18,6 +18,53 @@ test("basic provider field normalizers trim values and remove duplicate models", ); }); +test("custom provider normalization defaults and filters ordered custom headers", () => { + assert.deepEqual(settings.normalizeCustomProvider({}).customHeaders, []); + + const provider = settings.normalizeCustomProvider({ + customHeaders: [ + { key: " X-Request-ID ", value: " request-123 " }, + { key: "", value: "ignored" }, + { key: " ", value: "ignored" }, + { key: "anthropic-beta", value: "feature-flag" }, + null, + ], + }); + + assert.deepEqual(provider.customHeaders, [ + { key: "X-Request-ID", value: " request-123 " }, + { key: "anthropic-beta", value: "feature-flag" }, + ]); +}); + +test("provider model capabilities preserve legacy data and filter unknown values", () => { + const legacy = settings.normalizeCustomProvider({ + type: "codex", + models: [{ id: "legacy-model", contextWindow: 64_000, maxOutputToken: 4_096 }], + }); + assert.equal(legacy.models[0].capabilities, undefined); + + const normalized = settings.normalizeCustomProvider({ + type: "codex", + models: [ + { + id: "capable-model", + contextWindow: 128_000, + maxOutputToken: 8_192, + capabilities: ["reasoning", "unknown", "vision", "reasoning", 42, "tools"], + }, + { + id: "unknown-only", + contextWindow: 32_000, + maxOutputToken: 2_048, + capabilities: ["audio", null], + }, + ], + }); + + assert.deepEqual(normalized.models[0].capabilities, ["reasoning", "vision", "tools"]); + assert.deepEqual(normalized.models[1].capabilities, []); +}); test("codex provider normalization strips route suffixes and keeps only configured active models", () => { const provider = settings.normalizeCustomProvider({ id: "codex-1", diff --git a/docs/worklog/provider-custom-headers.md b/docs/worklog/provider-custom-headers.md new file mode 100644 index 000000000..e295fe764 --- /dev/null +++ b/docs/worklog/provider-custom-headers.md @@ -0,0 +1,34 @@ +# Provider custom headers worklog + +## Goal + +Add ordered custom HTTP headers to `CustomProvider` in both mirrored TypeScript frontends, inject them safely into LLM and model-discovery requests, redact header values in debug logs, and expose an editable settings UI. + +## Constraints + +- Branch: `feat/provider-custom-headers`, based on `upstream/main` at `b11a0a5`. +- Keep `crates/agent-gui` and `crates/agent-gateway/web` mirrored. +- Do not commit, push, or create a PR before manual Tauri validation. +- Do not change Rust unless the gateway model-discovery path proves it is required and the user approves. + +## Progress + +- [x] Fetched remotes and created the feature branch from the latest `upstream/main`. +- [x] Explored settings, runtime, UI, logging, and test paths with CodeGraph and read-only subagents. +- [x] Stage 1: settings model and normalization (GUI 42 tests, gateway 29 tests, both TypeScript checks passed). +- [x] Stage 2: request injection and debug redaction (Node 22 tests 42/42, both type checks, Rust `cargo check --tests`, and Go handler/server tests passed). +- [x] Stage 3: provider UI and i18n (both Node 22 type checks and GUI i18n tests passed; mirrored custom-header lines match). +- [x] Stage 4: tests and full validation. + - GUI frontend tests pass 1012/1012; gateway WebUI tests pass 353/353. + - Both TypeScript checks and Vite production builds pass under Node 22.19.0. + - Full gateway `go test ./...`, GUI `cargo check --tests`, and GUI `cargo test` (430/430) pass; GUI backend tests pass 10/10. + - Windows MSVC now links the Common Controls v6 manifest into both production and test executables; both artifacts were inspected after build. + - Final independent security and mirror reviews found no actionable issues; `git diff --check` passes. +- [x] Applied manual UI feedback: the header-name field is now an editable preset dropdown in both frontends, while arbitrary valid header names remain supported. +- [x] Applied follow-up UI feedback: preset menus align with the header-name field's left edge, and header values use vertically resizable multiline textareas. +- [x] Re-ran both type checks, full frontend test suites, production builds, and mirror checks; restarted the Tauri dev client. +- [x] Manual revalidation completed as part of the provider dialog redesign approval. + +## Resume + +Manual validation is complete; include this foundation in the validated provider dialog feature submission. diff --git a/docs/worklog/provider-dialog-redesign.md b/docs/worklog/provider-dialog-redesign.md new file mode 100644 index 000000000..bf9b1008d --- /dev/null +++ b/docs/worklog/provider-dialog-redesign.md @@ -0,0 +1,46 @@ +# Provider dialog redesign worklog + +## Goal + +Redesign the mirrored provider add/edit dialogs in agent-gui and agent-gateway/web using mockups/provider-dialog.html as the interaction baseline: responsive two-column navigation, dedicated basic/network/header panels, table/card header editing, and inline model capability editing. + +## Constraints + +- Branch: feat/provider-dialog-redesign, based on upstream/main at eff71a0. +- Preserve the existing uncommitted custom-header foundation. +- Keep both TypeScript frontends mirrored while retaining their intentional component API differences. +- Do not change Rust or connect model capabilities to runtime behavior. +- Do not commit, push, or create a PR before manual Tauri validation. +- Do not touch untracked .codegraph/, mockups/, or uploads/. + +## Progress + +- [x] Read global rules, applicable redesign skills, and the full provider dialog prototype. +- [x] Fetched upstream/main and created feat/provider-dialog-redesign at eff71a0. +- [x] Completed parallel read-only exploration of both dialogs, settings normalization, i18n references, UI primitives, tests, and prototype mapping. +- [x] Stage 1: two-column dialog shell and three local UI panels. + - Both TypeScript checks pass; GUI settings/i18n tests pass 130/130; gateway settings tests pass 29/29. +- [x] Stage 2: basic/network/header panel content and header editor redesign. + - Both TypeScript checks pass; relevant GUI tests pass 169/169; gateway settings tests pass 29/29. +- [x] Stage 3: ModelCapability normalization and inline model editing. + - Both TypeScript checks pass; GUI settings/i18n tests pass 131/131; gateway settings tests pass 30/30. +- [x] Stage 4: responsive layout at max-width 720px. + - Both TypeScript checks pass; GUI settings/i18n tests pass 131/131; gateway settings tests pass 30/30. +- [x] Stage 5: mirrored i18n, compatibility tests, and complete validation. + - Full GUI tests pass 1013/1013; full gateway/web tests pass 354/354. + - Both TypeScript checks and Vite production builds pass under Node 22.19.0; git diff --check passes. +- [x] Started the Tauri debug client, completed manual validation, and stopped the debug process after approval. + +## Key findings + +- Gateway uses useModalMotion plus settings-modal-* classes; GUI does not. +- Gateway DropdownMenu uses asChild; GUI uses Base UI's render prop. +- No reusable Switch primitive exists in either frontend, so the dialog needs a small local accessible switch component. +- promptCachingEnabled and nativeWebSearchEnabled remain persisted/runtime fields but have no current toggle state in ProviderModal; preserve defaults and omit UI. +- ProviderModelConfig capabilities are mirrored, filtered to reasoning/vision/tools, and covered for legacy and unknown-value normalization. + +## Resume + +1. Commit the validated changes with a conventional commit message. +2. Push feat/provider-dialog-redesign to the source remote. +3. Open a PR with design notes, changed areas, validation results, and screenshots when available. diff --git a/scripts/mirror-manifest.json b/scripts/mirror-manifest.json index d474f543e..c06b23dab 100644 --- a/scripts/mirror-manifest.json +++ b/scripts/mirror-manifest.json @@ -32,6 +32,7 @@ "components/chat/promptHistory.ts", "components/Markdown.tsx", "lib/normalizeLatexDelimiters.ts", + "lib/providers/customHeaders.ts", "components/workspace-editor/workspaceMarkdownAssets.ts", "components/workspace-editor/WorkspaceMarkdownPreview.tsx", "lib/chat-scroll/scrollFollowCore.ts",