Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions crates/agent-gateway/test/webui/web-settings.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
50 changes: 50 additions & 0 deletions crates/agent-gateway/web/src/i18n/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1183,6 +1183,31 @@ export const translations: Record<Locale, Record<string, string>> = {
"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": "请求格式",
Expand Down Expand Up @@ -3033,6 +3058,31 @@ export const translations: Record<Locale, Record<string, string>> = {
"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",
Expand Down
18 changes: 18 additions & 0 deletions crates/agent-gateway/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
40 changes: 40 additions & 0 deletions crates/agent-gateway/web/src/lib/providers/customHeaders.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>,
customHeaders?: CustomProvider["customHeaders"],
): Record<string, string> {
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;
}
36 changes: 36 additions & 0 deletions crates/agent-gateway/web/src/lib/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -245,6 +248,7 @@ export type CustomProvider = {
baseUrl: string;
apiKey: string;
apiKeyConfigured?: boolean;
customHeaders?: { key: string; value: string }[];
models: ProviderModelConfig[];
activeModels: string[];
requestFormat?: CodexRequestFormat;
Expand Down Expand Up @@ -368,6 +372,7 @@ export function getBuiltinCustomProviders(): CustomProvider[] {
type: "claude_code",
baseUrl: "https://api.anthropic.com/v1",
apiKey: "",
customHeaders: [],
models: [],
activeModels: [],
reasoning: "off",
Expand All @@ -381,6 +386,7 @@ export function getBuiltinCustomProviders(): CustomProvider[] {
type: "codex",
baseUrl: "https://api.openai.com/v1",
apiKey: "",
customHeaders: [],
models: [],
activeModels: [],
requestFormat: "openai-responses",
Expand All @@ -395,6 +401,7 @@ export function getBuiltinCustomProviders(): CustomProvider[] {
type: "gemini",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
apiKey: "",
customHeaders: [],
models: [],
activeModels: [],
reasoning: "off",
Expand Down Expand Up @@ -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<ModelCapability>();
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,
Expand All @@ -1224,13 +1246,15 @@ 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),
maxOutputToken: normalizePositiveInteger(
obj.maxOutputToken ?? obj.maxTokens,
defaults.maxOutputToken,
),
...(capabilities !== undefined ? { capabilities } : {}),
};
}

Expand Down Expand Up @@ -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<string, unknown>;
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<string, unknown>;
const type = normalizeProviderId(obj.type);
Expand All @@ -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),
Expand Down
Loading
Loading