mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
feat: add OpenRouter connection presets (#3878)
Integrated into release/v3.8.26
This commit is contained in:
@@ -53,6 +53,8 @@ The v3.7.x → v3.8.0 cycle added zero-config auto routing, new providers, OAuth
|
||||
|
||||
Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (Qoder, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage.
|
||||
|
||||
OpenRouter connections can store a per-connection `preset` in Advanced Settings. When set, OmniRoute sends it as the OpenRouter top-level request field, for example `"preset": "email-copywriter"`, unless the client request already supplied its own `preset`.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
@@ -151,6 +151,14 @@ function normalizeOpenAIChatUrl(baseUrl) {
|
||||
return `${normalized}/v1/chat/completions`;
|
||||
}
|
||||
|
||||
function getOpenRouterConnectionPreset(
|
||||
providerSpecificData?: Record<string, unknown> | null
|
||||
): string | null {
|
||||
const preset =
|
||||
typeof providerSpecificData?.preset === "string" ? providerSpecificData.preset.trim() : "";
|
||||
return preset || null;
|
||||
}
|
||||
|
||||
export class DefaultExecutor extends BaseExecutor {
|
||||
constructor(provider) {
|
||||
super(provider, PROVIDERS[provider] || PROVIDERS.openai);
|
||||
@@ -564,6 +572,16 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.provider === "openrouter") {
|
||||
const connectionPreset = getOpenRouterConnectionPreset(credentials?.providerSpecificData);
|
||||
if (connectionPreset && (withDefaults as Record<string, unknown>).preset === undefined) {
|
||||
withDefaults = {
|
||||
...(withDefaults as Record<string, unknown>),
|
||||
preset: connectionPreset,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.provider === "qwen" && typeof withDefaults === "object" && withDefaults !== null) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Input } from "@/shared/components";
|
||||
import { OPENROUTER_PRESET_MAX_LENGTH } from "@/shared/constants/openRouterPreset";
|
||||
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
|
||||
|
||||
interface OpenRouterPresetInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
t: ProviderMessageTranslator;
|
||||
}
|
||||
|
||||
export default function OpenRouterPresetInput({ value, onChange, t }: OpenRouterPresetInputProps) {
|
||||
return (
|
||||
<Input
|
||||
label={providerText(t, "openRouterPresetLabel", "OpenRouter preset")}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="email-copywriter"
|
||||
maxLength={OPENROUTER_PRESET_MAX_LENGTH}
|
||||
hint={providerText(
|
||||
t,
|
||||
"openRouterPresetHint",
|
||||
"Sends this connection's preset as the OpenRouter top-level preset field."
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function useOpenRouterPresetControl(
|
||||
provider: string | null | undefined,
|
||||
t: ProviderMessageTranslator
|
||||
) {
|
||||
const [value, setValue] = useState("");
|
||||
const isOpenRouter = provider === "openrouter";
|
||||
const applyTo = useCallback(
|
||||
(data: Record<string, unknown>) => {
|
||||
const preset = value.trim();
|
||||
if (isOpenRouter && preset) data.preset = preset;
|
||||
},
|
||||
[isOpenRouter, value]
|
||||
);
|
||||
const getPatch = useCallback(() => {
|
||||
if (!isOpenRouter) return {};
|
||||
const preset = value.trim();
|
||||
return { preset: preset || null };
|
||||
}, [isOpenRouter, value]);
|
||||
const input = useMemo(
|
||||
() => (isOpenRouter ? <OpenRouterPresetInput t={t} value={value} onChange={setValue} /> : null),
|
||||
[isOpenRouter, t, value]
|
||||
);
|
||||
return { applyTo, getPatch, input, isOpenRouter, setValue };
|
||||
}
|
||||
@@ -1,15 +1,9 @@
|
||||
"use client";
|
||||
|
||||
// Issue #3501 Phase 1c — extracted from the god-component.
|
||||
// ~787-LOC modal for adding a new API key / credential to a provider.
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Badge, Input, Modal, Toggle } from "@/shared/components";
|
||||
import {
|
||||
providerAllowsOptionalApiKey,
|
||||
supportsBulkApiKey,
|
||||
} from "@/shared/constants/providers";
|
||||
import { providerAllowsOptionalApiKey, supportsBulkApiKey } from "@/shared/constants/providers";
|
||||
import { parseBulkApiKeys } from "@/shared/utils/bulkApiKeyParser";
|
||||
import {
|
||||
isBaseUrlConfigurableProvider,
|
||||
@@ -30,6 +24,7 @@ import {
|
||||
type CommandCodeAuthFlowState,
|
||||
} from "../../providerPageHelpers";
|
||||
import { getWebSessionCredentialRequirement } from "../../webSessionCredentials";
|
||||
import { useOpenRouterPresetControl } from "../OpenRouterPresetInput";
|
||||
import WebSessionCredentialGuide from "../WebSessionCredentialGuide";
|
||||
|
||||
export interface AddApiKeyModalProps {
|
||||
@@ -76,6 +71,7 @@ export default function AddApiKeyModal({
|
||||
const defaultRegion = isBedrock ? "eu-west-2" : "us-central1";
|
||||
const isGlm = isGlmProvider(provider);
|
||||
const isQoder = provider === "qoder";
|
||||
const openRouterPreset = useOpenRouterPresetControl(provider, t);
|
||||
const isCloudflare = provider === "cloudflare-ai";
|
||||
const localProviderMetadata = getLocalProviderMetadata(provider);
|
||||
const isLocalSelfHostedProvider = !!localProviderMetadata;
|
||||
@@ -133,7 +129,6 @@ export default function AddApiKeyModal({
|
||||
baseUrl: initialBaseUrl || defaultBaseUrl,
|
||||
}));
|
||||
}, [defaultBaseUrl, initialBaseUrl, isOpen]);
|
||||
|
||||
const bulkSupported = supportsBulkApiKey(provider);
|
||||
const [mode, setMode] = useState<"single" | "bulk">("single");
|
||||
const [bulkText, setBulkText] = useState("");
|
||||
@@ -278,7 +273,6 @@ export default function AddApiKeyModal({
|
||||
|
||||
if (!isValid) {
|
||||
if (apiKeyOptional && !credentialInput) {
|
||||
// Bypass validation block for local/optional providers when no key is provided
|
||||
console.debug("Validation failed but apiKey is optional; proceeding to save.");
|
||||
} else {
|
||||
setSaveError(validationError || credentialValidationFailedMessage);
|
||||
@@ -290,6 +284,7 @@ export default function AddApiKeyModal({
|
||||
if (formData.customUserAgent.trim()) {
|
||||
providerSpecificData.customUserAgent = formData.customUserAgent.trim();
|
||||
}
|
||||
openRouterPreset.applyTo(providerSpecificData);
|
||||
if (formData.routingTags.trim()) {
|
||||
providerSpecificData.tags = parseRoutingTagsInput(formData.routingTags);
|
||||
}
|
||||
@@ -347,15 +342,18 @@ export default function AddApiKeyModal({
|
||||
setSaveError(null);
|
||||
|
||||
try {
|
||||
let providerSpecificData: Record<string, unknown> | undefined;
|
||||
const bulkProviderSpecificData: Record<string, unknown> = {};
|
||||
if (usesBaseUrl) {
|
||||
const checked = normalizeAndValidateHttpBaseUrl(formData.baseUrl, defaultBaseUrl);
|
||||
if (checked.error) {
|
||||
setSaveError(checked.error);
|
||||
return;
|
||||
}
|
||||
providerSpecificData = { baseUrl: checked.value };
|
||||
bulkProviderSpecificData.baseUrl = checked.value;
|
||||
}
|
||||
openRouterPreset.applyTo(bulkProviderSpecificData);
|
||||
const providerSpecificData =
|
||||
Object.keys(bulkProviderSpecificData).length > 0 ? bulkProviderSpecificData : undefined;
|
||||
|
||||
const res = await fetch("/api/providers/bulk", {
|
||||
method: "POST",
|
||||
@@ -432,6 +430,7 @@ export default function AddApiKeyModal({
|
||||
{bulkSupported && mode === "bulk" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-text-muted">{t("bulkAddFormatHint")}</p>
|
||||
{openRouterPreset.input}
|
||||
<textarea
|
||||
className="w-full rounded border border-border bg-background p-2 text-sm font-mono resize-y min-h-[140px] focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
placeholder={"name1|sk-key1\nname2|sk-key2\nsk-key-only-auto-named"}
|
||||
@@ -725,6 +724,7 @@ export default function AddApiKeyModal({
|
||||
placeholder="my-app/1.0"
|
||||
hint={t("customUserAgentHint")}
|
||||
/>
|
||||
{openRouterPreset.input}
|
||||
<Input
|
||||
label={t("routingTagsLabel")}
|
||||
value={formData.routingTags}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
"use client";
|
||||
|
||||
// Issue #3501 Phase 1c — extracted from the god-component.
|
||||
// ~1091-LOC modal for editing an existing provider connection.
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Badge, Input, Modal, Toggle, Select } from "@/shared/components";
|
||||
@@ -48,6 +45,7 @@ import {
|
||||
formatTimeAgo,
|
||||
} from "../../providerPageHelpers";
|
||||
import { getWebSessionCredentialRequirement } from "../../webSessionCredentials";
|
||||
import { useOpenRouterPresetControl } from "../OpenRouterPresetInput";
|
||||
import WebSessionCredentialGuide from "../WebSessionCredentialGuide";
|
||||
|
||||
export interface EditConnectionModalConnection {
|
||||
@@ -145,6 +143,8 @@ export default function EditConnectionModal({
|
||||
const showsRegion = isVertex || isBedrock;
|
||||
const isGlm = isGlmProvider(connection?.provider);
|
||||
const isCloudflare = connection?.provider === "cloudflare-ai";
|
||||
const openRouterPreset = useOpenRouterPresetControl(connection?.provider, t);
|
||||
const setOpenRouterPreset = openRouterPreset.setValue;
|
||||
const isCodex = connection?.provider === "codex";
|
||||
const isClaude = connection?.provider === "claude";
|
||||
const isGeminiCli = connection?.provider === "gemini-cli";
|
||||
@@ -201,6 +201,9 @@ export default function EditConnectionModal({
|
||||
const rawCustomUserAgent = connection.providerSpecificData?.customUserAgent;
|
||||
const existingCustomUserAgent =
|
||||
typeof rawCustomUserAgent === "string" ? rawCustomUserAgent : "";
|
||||
const rawOpenRouterPreset = connection.providerSpecificData?.preset;
|
||||
const existingOpenRouterPreset =
|
||||
typeof rawOpenRouterPreset === "string" ? rawOpenRouterPreset : "";
|
||||
const rawCx = connection.providerSpecificData?.cx;
|
||||
const existingCx = typeof rawCx === "string" ? rawCx : "";
|
||||
const rawAccountId = connection.providerSpecificData?.accountId;
|
||||
@@ -270,10 +273,8 @@ export default function EditConnectionModal({
|
||||
passthroughModels: connection?.providerSpecificData?.passthroughModels === true,
|
||||
disableCooling: connection?.providerSpecificData?.disableCooling === true,
|
||||
});
|
||||
// Load existing extra keys from providerSpecificData
|
||||
const existing = connection.providerSpecificData?.extraApiKeys;
|
||||
setExtraApiKeys(Array.isArray(existing) ? existing : []);
|
||||
// Load API key health status
|
||||
const health = connection.providerSpecificData?.apiKeyHealth as
|
||||
| Record<
|
||||
string,
|
||||
@@ -288,13 +289,16 @@ export default function EditConnectionModal({
|
||||
| undefined;
|
||||
setApiKeyHealth(health || {});
|
||||
setNewExtraKey("");
|
||||
setShowAdvanced(!!existingCustomUserAgent);
|
||||
// email visibility controlled by global store
|
||||
setOpenRouterPreset(existingOpenRouterPreset);
|
||||
setShowAdvanced(
|
||||
!!existingCustomUserAgent ||
|
||||
(connection.provider === "openrouter" && !!existingOpenRouterPreset)
|
||||
);
|
||||
setTestResult(null);
|
||||
setValidationResult(null);
|
||||
setSaveError(null);
|
||||
}
|
||||
}, [isOpen, connection, defaultBaseUrl, showsRegion, defaultRegion]);
|
||||
}, [isOpen, connection, defaultBaseUrl, showsRegion, defaultRegion, setOpenRouterPreset]);
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!connection?.provider) return;
|
||||
@@ -392,7 +396,6 @@ export default function EditConnectionModal({
|
||||
healthCheckInterval: formData.healthCheckInterval,
|
||||
};
|
||||
|
||||
// Build rateLimitOverrides from non-empty fields
|
||||
const overrides: Record<string, number> = {};
|
||||
if (formData.rpm.trim()) overrides.rpm = Number(formData.rpm);
|
||||
if (formData.tpm.trim()) overrides.tpm = Number(formData.tpm);
|
||||
@@ -460,7 +463,6 @@ export default function EditConnectionModal({
|
||||
updates.rateLimitedUntil = null;
|
||||
}
|
||||
}
|
||||
// Persist extra API keys and baseUrl in providerSpecificData
|
||||
if (!isOAuth) {
|
||||
updates.providerSpecificData = {
|
||||
...(connection.providerSpecificData || {}),
|
||||
@@ -469,7 +471,7 @@ export default function EditConnectionModal({
|
||||
tags: parseRoutingTagsInput(formData.routingTags),
|
||||
excludedModels: parseExcludedModelsInput(formData.excludedModels),
|
||||
customUserAgent: formData.customUserAgent.trim(),
|
||||
// Only write when explicitly enabled; omit to let registry default take effect
|
||||
...openRouterPreset.getPatch(),
|
||||
...(formData.passthroughModels ? { passthroughModels: true } : {}),
|
||||
};
|
||||
if (connection.provider === "bailian-coding-plan") {
|
||||
@@ -513,7 +515,6 @@ export default function EditConnectionModal({
|
||||
Object.keys(currentRequestDefaults).length > 0 ? currentRequestDefaults : undefined;
|
||||
}
|
||||
} else {
|
||||
// Also persist tag for OAuth accounts
|
||||
updates.providerSpecificData = {
|
||||
...(connection.providerSpecificData || {}),
|
||||
tag: formData.tag.trim() || undefined,
|
||||
@@ -546,8 +547,6 @@ export default function EditConnectionModal({
|
||||
),
|
||||
};
|
||||
}
|
||||
// #2997: persist the transient-cooldown opt-out; write only when enabled,
|
||||
// clear it otherwise so a disabled toggle does not linger as `false`.
|
||||
if (updates.providerSpecificData) {
|
||||
updates.providerSpecificData.disableCooling = formData.disableCooling ? true : undefined;
|
||||
}
|
||||
@@ -831,6 +830,7 @@ export default function EditConnectionModal({
|
||||
placeholder="my-app/1.0"
|
||||
hint={t("customUserAgentHint")}
|
||||
/>
|
||||
{openRouterPreset.input}
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={formData.passthroughModels}
|
||||
|
||||
@@ -3,6 +3,7 @@ const CLAUDE_CODE_COMPATIBLE_PROVIDER_PREFIX = "anthropic-compatible-cc-";
|
||||
|
||||
import { normalizeExcludedModelPatterns } from "@/domain/connectionModelRules";
|
||||
import { normalizeRoutingTags } from "@/domain/tagRouter";
|
||||
import { normalizeOpenRouterPreset } from "@/shared/constants/openRouterPreset";
|
||||
|
||||
export const CODEX_REASONING_EFFORT_VALUES = ["none", "low", "medium", "high", "xhigh"] as const;
|
||||
|
||||
@@ -132,6 +133,15 @@ export function normalizeProviderSpecificData(
|
||||
delete normalized.autoFetchModels;
|
||||
}
|
||||
|
||||
if ("preset" in normalized) {
|
||||
const preset = provider === "openrouter" ? normalizeOpenRouterPreset(normalized.preset) : null;
|
||||
if (preset) {
|
||||
normalized.preset = preset;
|
||||
} else {
|
||||
delete normalized.preset;
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === "bedrock" && "region" in normalized) {
|
||||
const region = normalizeAwsRegion(normalized.region);
|
||||
if (region) {
|
||||
|
||||
11
src/shared/constants/openRouterPreset.ts
Normal file
11
src/shared/constants/openRouterPreset.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const OPENROUTER_PRESET_MAX_LENGTH = 200;
|
||||
|
||||
export function isOpenRouterPresetValue(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length <= OPENROUTER_PRESET_MAX_LENGTH;
|
||||
}
|
||||
|
||||
export function normalizeOpenRouterPreset(value: unknown): string | undefined {
|
||||
if (!isOpenRouterPresetValue(value)) return undefined;
|
||||
const normalized = value.trim();
|
||||
return normalized || undefined;
|
||||
}
|
||||
265
src/shared/validation/providerSpecificData.ts
Normal file
265
src/shared/validation/providerSpecificData.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
OPENROUTER_PRESET_MAX_LENGTH,
|
||||
isOpenRouterPresetValue,
|
||||
} from "@/shared/constants/openRouterPreset";
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const CODEX_REASONING_EFFORT_VALUES = new Set(["none", "low", "medium", "high", "xhigh"]);
|
||||
const REQUEST_DEFAULT_SERVICE_TIER_VALUES = new Set(["default", "priority", "fast", "flex"]);
|
||||
|
||||
export function validateProviderSpecificData(
|
||||
data: Record<string, unknown> | undefined,
|
||||
ctx: z.RefinementCtx
|
||||
): void {
|
||||
if (!data) return;
|
||||
|
||||
const baseUrl = data.baseUrl;
|
||||
if (baseUrl !== undefined && (typeof baseUrl !== "string" || !isHttpUrl(baseUrl))) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.baseUrl must be a valid http(s) URL",
|
||||
path: ["baseUrl"],
|
||||
});
|
||||
}
|
||||
|
||||
const customUserAgent = data.customUserAgent;
|
||||
if (
|
||||
customUserAgent !== undefined &&
|
||||
customUserAgent !== null &&
|
||||
(typeof customUserAgent !== "string" || customUserAgent.length > 500)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.customUserAgent must be a string up to 500 chars",
|
||||
path: ["customUserAgent"],
|
||||
});
|
||||
}
|
||||
|
||||
const cx = data.cx;
|
||||
if (cx !== undefined && cx !== null && (typeof cx !== "string" || cx.length > 500)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.cx must be a string up to 500 chars",
|
||||
path: ["cx"],
|
||||
});
|
||||
}
|
||||
|
||||
const region = data.region;
|
||||
if (
|
||||
region !== undefined &&
|
||||
region !== null &&
|
||||
(typeof region !== "string" || region.length > 64)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.region must be a string up to 64 chars",
|
||||
path: ["region"],
|
||||
});
|
||||
}
|
||||
|
||||
const openaiStoreEnabled = data.openaiStoreEnabled;
|
||||
if (openaiStoreEnabled !== undefined && typeof openaiStoreEnabled !== "boolean") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.openaiStoreEnabled must be a boolean",
|
||||
path: ["openaiStoreEnabled"],
|
||||
});
|
||||
}
|
||||
|
||||
const blockExtraUsage = data.blockExtraUsage;
|
||||
if (blockExtraUsage !== undefined && typeof blockExtraUsage !== "boolean") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.blockExtraUsage must be a boolean",
|
||||
path: ["blockExtraUsage"],
|
||||
});
|
||||
}
|
||||
|
||||
const autoFetchModels = data.autoFetchModels;
|
||||
if (autoFetchModels !== undefined && typeof autoFetchModels !== "boolean") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.autoFetchModels must be a boolean",
|
||||
path: ["autoFetchModels"],
|
||||
});
|
||||
}
|
||||
|
||||
const disableStreamOptions = data.disableStreamOptions;
|
||||
if (disableStreamOptions !== undefined && typeof disableStreamOptions !== "boolean") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.disableStreamOptions must be a boolean",
|
||||
path: ["disableStreamOptions"],
|
||||
});
|
||||
}
|
||||
|
||||
const preset = data.preset;
|
||||
if (preset !== undefined && preset !== null && !isOpenRouterPresetValue(preset)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `providerSpecificData.preset must be a string up to ${OPENROUTER_PRESET_MAX_LENGTH} chars`,
|
||||
path: ["preset"],
|
||||
});
|
||||
}
|
||||
|
||||
const requestDefaults = data.requestDefaults;
|
||||
if (requestDefaults !== undefined) {
|
||||
if (!requestDefaults || typeof requestDefaults !== "object" || Array.isArray(requestDefaults)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.requestDefaults must be an object",
|
||||
path: ["requestDefaults"],
|
||||
});
|
||||
} else {
|
||||
const requestDefaultsRecord = requestDefaults as Record<string, unknown>;
|
||||
const reasoningEffort = requestDefaultsRecord.reasoningEffort;
|
||||
if (
|
||||
reasoningEffort !== undefined &&
|
||||
reasoningEffort !== null &&
|
||||
(typeof reasoningEffort !== "string" ||
|
||||
!CODEX_REASONING_EFFORT_VALUES.has(reasoningEffort.trim().toLowerCase()))
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"providerSpecificData.requestDefaults.reasoningEffort must be one of none, low, medium, high, xhigh",
|
||||
path: ["requestDefaults", "reasoningEffort"],
|
||||
});
|
||||
}
|
||||
|
||||
const serviceTier = requestDefaultsRecord.serviceTier;
|
||||
if (
|
||||
serviceTier !== undefined &&
|
||||
serviceTier !== null &&
|
||||
(typeof serviceTier !== "string" ||
|
||||
!REQUEST_DEFAULT_SERVICE_TIER_VALUES.has(serviceTier.trim().toLowerCase()))
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"providerSpecificData.requestDefaults.serviceTier must be one of default, priority, fast, flex when provided",
|
||||
path: ["requestDefaults", "serviceTier"],
|
||||
});
|
||||
}
|
||||
|
||||
const context1m = requestDefaultsRecord.context1m;
|
||||
if (context1m !== undefined && context1m !== null && typeof context1m !== "boolean") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.requestDefaults.context1m must be a boolean",
|
||||
path: ["requestDefaults", "context1m"],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const consoleApiKey = data.consoleApiKey;
|
||||
if (consoleApiKey !== undefined && consoleApiKey !== null && typeof consoleApiKey !== "string") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.consoleApiKey must be a string",
|
||||
path: ["consoleApiKey"],
|
||||
});
|
||||
}
|
||||
if (typeof consoleApiKey === "string" && consoleApiKey.length > 10000) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.consoleApiKey must be at most 10000 characters",
|
||||
path: ["consoleApiKey"],
|
||||
});
|
||||
}
|
||||
|
||||
const groupTag = data.tag;
|
||||
if (
|
||||
groupTag !== undefined &&
|
||||
groupTag !== null &&
|
||||
(typeof groupTag !== "string" || groupTag.length > 100)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.tag must be a string up to 100 chars",
|
||||
path: ["tag"],
|
||||
});
|
||||
}
|
||||
|
||||
const routingTags = data.tags;
|
||||
if (routingTags !== undefined && routingTags !== null) {
|
||||
if (!Array.isArray(routingTags) || routingTags.length > 50) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.tags must be an array with at most 50 items",
|
||||
path: ["tags"],
|
||||
});
|
||||
} else if (
|
||||
routingTags.some(
|
||||
(tag) => typeof tag !== "string" || tag.trim().length === 0 || tag.trim().length > 64
|
||||
)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"providerSpecificData.tags must contain non-empty strings up to 64 characters each",
|
||||
path: ["tags"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const excludedModels = data.excludedModels ?? data.excluded_models;
|
||||
if (excludedModels !== undefined && excludedModels !== null) {
|
||||
if (typeof excludedModels === "string") {
|
||||
if (excludedModels.length > 5000) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.excludedModels string must be up to 5000 chars",
|
||||
path: ["excludedModels"],
|
||||
});
|
||||
}
|
||||
} else if (!Array.isArray(excludedModels) || excludedModels.length > 100) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.excludedModels must be an array with at most 100 items",
|
||||
path: ["excludedModels"],
|
||||
});
|
||||
} else if (
|
||||
excludedModels.some(
|
||||
(pattern) =>
|
||||
typeof pattern !== "string" ||
|
||||
pattern.trim().length === 0 ||
|
||||
pattern.trim().length > 200 ||
|
||||
pattern.trim() === "**"
|
||||
)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"providerSpecificData.excludedModels must contain non-empty patterns up to 200 characters",
|
||||
path: ["excludedModels"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const clientProfile = data.clientProfile;
|
||||
if (clientProfile !== undefined && clientProfile !== null) {
|
||||
const normalized = typeof clientProfile === "string" ? clientProfile.trim().toLowerCase() : "";
|
||||
if (
|
||||
typeof clientProfile !== "string" ||
|
||||
!["ide", "harness", "cli", "sdk"].includes(normalized)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"providerSpecificData.clientProfile must be ide, harness, cli, or sdk (cli/sdk map to harness)",
|
||||
path: ["clientProfile"],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,259 +13,7 @@ import {
|
||||
isForbiddenCustomHeaderName,
|
||||
} from "@/shared/constants/upstreamHeaders";
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "@/shared/utils/runtimeTimeouts";
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const CODEX_REASONING_EFFORT_VALUES = new Set(["none", "low", "medium", "high", "xhigh"]);
|
||||
const REQUEST_DEFAULT_SERVICE_TIER_VALUES = new Set(["default", "priority", "fast", "flex"]);
|
||||
|
||||
function validateProviderSpecificData(
|
||||
data: Record<string, unknown> | undefined,
|
||||
ctx: z.RefinementCtx
|
||||
): void {
|
||||
if (!data) return;
|
||||
|
||||
const baseUrl = data.baseUrl;
|
||||
if (baseUrl !== undefined && (typeof baseUrl !== "string" || !isHttpUrl(baseUrl))) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.baseUrl must be a valid http(s) URL",
|
||||
path: ["baseUrl"],
|
||||
});
|
||||
}
|
||||
|
||||
const customUserAgent = data.customUserAgent;
|
||||
if (
|
||||
customUserAgent !== undefined &&
|
||||
customUserAgent !== null &&
|
||||
(typeof customUserAgent !== "string" || customUserAgent.length > 500)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.customUserAgent must be a string up to 500 chars",
|
||||
path: ["customUserAgent"],
|
||||
});
|
||||
}
|
||||
|
||||
const cx = data.cx;
|
||||
if (cx !== undefined && cx !== null && (typeof cx !== "string" || cx.length > 500)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.cx must be a string up to 500 chars",
|
||||
path: ["cx"],
|
||||
});
|
||||
}
|
||||
|
||||
const region = data.region;
|
||||
if (
|
||||
region !== undefined &&
|
||||
region !== null &&
|
||||
(typeof region !== "string" || region.length > 64)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.region must be a string up to 64 chars",
|
||||
path: ["region"],
|
||||
});
|
||||
}
|
||||
|
||||
const openaiStoreEnabled = data.openaiStoreEnabled;
|
||||
if (openaiStoreEnabled !== undefined && typeof openaiStoreEnabled !== "boolean") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.openaiStoreEnabled must be a boolean",
|
||||
path: ["openaiStoreEnabled"],
|
||||
});
|
||||
}
|
||||
|
||||
const blockExtraUsage = data.blockExtraUsage;
|
||||
if (blockExtraUsage !== undefined && typeof blockExtraUsage !== "boolean") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.blockExtraUsage must be a boolean",
|
||||
path: ["blockExtraUsage"],
|
||||
});
|
||||
}
|
||||
|
||||
const autoFetchModels = data.autoFetchModels;
|
||||
if (autoFetchModels !== undefined && typeof autoFetchModels !== "boolean") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.autoFetchModels must be a boolean",
|
||||
path: ["autoFetchModels"],
|
||||
});
|
||||
}
|
||||
|
||||
const disableStreamOptions = data.disableStreamOptions;
|
||||
if (disableStreamOptions !== undefined && typeof disableStreamOptions !== "boolean") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.disableStreamOptions must be a boolean",
|
||||
path: ["disableStreamOptions"],
|
||||
});
|
||||
}
|
||||
|
||||
const requestDefaults = data.requestDefaults;
|
||||
if (requestDefaults !== undefined) {
|
||||
if (!requestDefaults || typeof requestDefaults !== "object" || Array.isArray(requestDefaults)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.requestDefaults must be an object",
|
||||
path: ["requestDefaults"],
|
||||
});
|
||||
} else {
|
||||
const requestDefaultsRecord = requestDefaults as Record<string, unknown>;
|
||||
const reasoningEffort = requestDefaultsRecord.reasoningEffort;
|
||||
if (
|
||||
reasoningEffort !== undefined &&
|
||||
reasoningEffort !== null &&
|
||||
(typeof reasoningEffort !== "string" ||
|
||||
!CODEX_REASONING_EFFORT_VALUES.has(reasoningEffort.trim().toLowerCase()))
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"providerSpecificData.requestDefaults.reasoningEffort must be one of none, low, medium, high, xhigh",
|
||||
path: ["requestDefaults", "reasoningEffort"],
|
||||
});
|
||||
}
|
||||
|
||||
const serviceTier = requestDefaultsRecord.serviceTier;
|
||||
if (
|
||||
serviceTier !== undefined &&
|
||||
serviceTier !== null &&
|
||||
(typeof serviceTier !== "string" ||
|
||||
!REQUEST_DEFAULT_SERVICE_TIER_VALUES.has(serviceTier.trim().toLowerCase()))
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"providerSpecificData.requestDefaults.serviceTier must be one of default, priority, fast, flex when provided",
|
||||
path: ["requestDefaults", "serviceTier"],
|
||||
});
|
||||
}
|
||||
|
||||
const context1m = requestDefaultsRecord.context1m;
|
||||
if (context1m !== undefined && context1m !== null && typeof context1m !== "boolean") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.requestDefaults.context1m must be a boolean",
|
||||
path: ["requestDefaults", "context1m"],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// [Oracle CONDITIONAL] consoleApiKey는 bailian-coding-plan 전용 필드.
|
||||
// 다른 프로바이더 공통 규약으로 재사용하지 않는다.
|
||||
const consoleApiKey = data.consoleApiKey;
|
||||
if (consoleApiKey !== undefined && consoleApiKey !== null && typeof consoleApiKey !== "string") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.consoleApiKey must be a string",
|
||||
path: ["consoleApiKey"],
|
||||
});
|
||||
}
|
||||
if (typeof consoleApiKey === "string" && consoleApiKey.length > 10000) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.consoleApiKey must be at most 10000 characters",
|
||||
path: ["consoleApiKey"],
|
||||
});
|
||||
}
|
||||
|
||||
const groupTag = data.tag;
|
||||
if (
|
||||
groupTag !== undefined &&
|
||||
groupTag !== null &&
|
||||
(typeof groupTag !== "string" || groupTag.length > 100)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.tag must be a string up to 100 chars",
|
||||
path: ["tag"],
|
||||
});
|
||||
}
|
||||
|
||||
const routingTags = data.tags;
|
||||
if (routingTags !== undefined && routingTags !== null) {
|
||||
if (!Array.isArray(routingTags) || routingTags.length > 50) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.tags must be an array with at most 50 items",
|
||||
path: ["tags"],
|
||||
});
|
||||
} else if (
|
||||
routingTags.some(
|
||||
(tag) => typeof tag !== "string" || tag.trim().length === 0 || tag.trim().length > 64
|
||||
)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"providerSpecificData.tags must contain non-empty strings up to 64 characters each",
|
||||
path: ["tags"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const excludedModels = data.excludedModels ?? data.excluded_models;
|
||||
if (excludedModels !== undefined && excludedModels !== null) {
|
||||
if (typeof excludedModels === "string") {
|
||||
if (excludedModels.length > 5000) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.excludedModels string must be up to 5000 chars",
|
||||
path: ["excludedModels"],
|
||||
});
|
||||
}
|
||||
} else if (!Array.isArray(excludedModels) || excludedModels.length > 100) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.excludedModels must be an array with at most 100 items",
|
||||
path: ["excludedModels"],
|
||||
});
|
||||
} else if (
|
||||
excludedModels.some(
|
||||
(pattern) =>
|
||||
typeof pattern !== "string" ||
|
||||
pattern.trim().length === 0 ||
|
||||
pattern.trim().length > 200 ||
|
||||
pattern.trim() === "**"
|
||||
)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"providerSpecificData.excludedModels must contain non-empty patterns up to 200 characters",
|
||||
path: ["excludedModels"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const clientProfile = data.clientProfile;
|
||||
if (clientProfile !== undefined && clientProfile !== null) {
|
||||
const normalized = typeof clientProfile === "string" ? clientProfile.trim().toLowerCase() : "";
|
||||
if (
|
||||
typeof clientProfile !== "string" ||
|
||||
!["ide", "harness", "cli", "sdk"].includes(normalized)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"providerSpecificData.clientProfile must be ide, harness, cli, or sdk (cli/sdk map to harness)",
|
||||
path: ["clientProfile"],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
import { validateProviderSpecificData } from "./providerSpecificData";
|
||||
|
||||
// Re-export validation helpers from dedicated module to avoid webpack barrel-file
|
||||
// optimization bug that truncates exports from large files.
|
||||
|
||||
@@ -103,10 +103,7 @@ test("DefaultExecutor.buildUrl uses full chat endpoints for hosted OpenAI-compat
|
||||
bazaarlink.buildUrl("auto:free", true),
|
||||
"https://bazaarlink.ai/api/v1/chat/completions"
|
||||
);
|
||||
assert.equal(
|
||||
crof.buildUrl("gpt-4.1", true),
|
||||
"https://crof.ai/v1/chat/completions"
|
||||
);
|
||||
assert.equal(crof.buildUrl("gpt-4.1", true), "https://crof.ai/v1/chat/completions");
|
||||
});
|
||||
|
||||
test("DefaultExecutor.buildUrl handles openai-compatible and anthropic-compatible providers", () => {
|
||||
@@ -742,6 +739,44 @@ test("DefaultExecutor.transformRequest respects disableStreamOptions for OpenAI
|
||||
assert.deepEqual((chatResultEnabled as any).stream_options, { include_usage: true });
|
||||
});
|
||||
|
||||
test("DefaultExecutor.transformRequest injects OpenRouter connection preset", () => {
|
||||
const executor = new DefaultExecutor("openrouter");
|
||||
const body = { model: "openai/gpt-4", messages: [{ role: "user", content: "hi" }] };
|
||||
|
||||
const result = executor.transformRequest("openai/gpt-4", body, true, {
|
||||
providerSpecificData: { preset: " email-copywriter " },
|
||||
});
|
||||
|
||||
assert.equal((result as any).preset, "email-copywriter");
|
||||
assert.deepEqual((result as any).stream_options, { include_usage: true });
|
||||
assert.equal((body as any).preset, undefined);
|
||||
|
||||
const explicit = executor.transformRequest(
|
||||
"openai/gpt-4",
|
||||
{ ...body, preset: "client-preset" },
|
||||
true,
|
||||
{ providerSpecificData: { preset: "connection-preset" } }
|
||||
);
|
||||
|
||||
assert.equal((explicit as any).preset, "client-preset");
|
||||
|
||||
const explicitNull = executor.transformRequest("openai/gpt-4", { ...body, preset: null }, true, {
|
||||
providerSpecificData: { preset: "connection-preset" },
|
||||
});
|
||||
assert.equal((explicitNull as any).preset, null);
|
||||
|
||||
const explicitEmpty = executor.transformRequest("openai/gpt-4", { ...body, preset: "" }, true, {
|
||||
providerSpecificData: { preset: "connection-preset" },
|
||||
});
|
||||
assert.equal((explicitEmpty as any).preset, "");
|
||||
|
||||
const blank = executor.transformRequest("openai/gpt-4", body, true, {
|
||||
providerSpecificData: { preset: " " },
|
||||
});
|
||||
|
||||
assert.equal((blank as any).preset, undefined);
|
||||
});
|
||||
|
||||
test("DefaultExecutor.transformRequest strips stream_options from Anthropic-compatible targets", () => {
|
||||
const anthropicCompat = new DefaultExecutor("anthropic-compatible-test");
|
||||
const anthropicCcCompat = new DefaultExecutor("anthropic-compatible-cc-test");
|
||||
|
||||
@@ -127,3 +127,47 @@ test("provider schemas reject unknown Codex service tiers", () => {
|
||||
assert.equal(created.success, false);
|
||||
assert.equal(updated.success, false);
|
||||
});
|
||||
|
||||
test("provider schemas accept OpenRouter preset in providerSpecificData", () => {
|
||||
const created = createProviderSchema.safeParse({
|
||||
provider: "openrouter",
|
||||
apiKey: "token",
|
||||
name: "OpenRouter",
|
||||
providerSpecificData: {
|
||||
preset: "email-copywriter",
|
||||
},
|
||||
});
|
||||
const updated = updateProviderConnectionSchema.safeParse({
|
||||
providerSpecificData: {
|
||||
preset: "code-reviewer",
|
||||
},
|
||||
});
|
||||
const padded = updateProviderConnectionSchema.safeParse({
|
||||
providerSpecificData: {
|
||||
preset: `${" ".repeat(120)}prefer${" ".repeat(120)}`,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(created.success, true);
|
||||
assert.equal(updated.success, true);
|
||||
assert.equal(padded.success, true);
|
||||
});
|
||||
|
||||
test("provider schemas reject oversized OpenRouter preset values", () => {
|
||||
const created = createProviderSchema.safeParse({
|
||||
provider: "openrouter",
|
||||
apiKey: "token",
|
||||
name: "OpenRouter",
|
||||
providerSpecificData: {
|
||||
preset: "x".repeat(201),
|
||||
},
|
||||
});
|
||||
const updated = updateProviderConnectionSchema.safeParse({
|
||||
providerSpecificData: {
|
||||
preset: 123,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(created.success, false);
|
||||
assert.equal(updated.success, false);
|
||||
});
|
||||
|
||||
@@ -70,3 +70,37 @@ test("normalizeProviderSpecificData keeps only boolean CC-compatible 1M request
|
||||
customFlag: "keep-me",
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizeProviderSpecificData trims OpenRouter preset and clears empty values", () => {
|
||||
const normalized = normalizeProviderSpecificData("openrouter", {
|
||||
preset: " email-copywriter ",
|
||||
tag: "primary",
|
||||
});
|
||||
|
||||
assert.equal(normalized?.preset, "email-copywriter");
|
||||
assert.equal(normalized?.tag, "primary");
|
||||
|
||||
const stripped = normalizeProviderSpecificData("openrouter", {
|
||||
preset: " ",
|
||||
tag: "primary",
|
||||
});
|
||||
|
||||
assert.equal(stripped?.preset, undefined);
|
||||
assert.equal(stripped?.tag, "primary");
|
||||
|
||||
const oversized = normalizeProviderSpecificData("openrouter", {
|
||||
preset: "x".repeat(201),
|
||||
tag: "primary",
|
||||
});
|
||||
|
||||
assert.equal(oversized?.preset, undefined);
|
||||
assert.equal(oversized?.tag, "primary");
|
||||
|
||||
const ignored = normalizeProviderSpecificData("openai", {
|
||||
preset: "email-copywriter",
|
||||
tag: "primary",
|
||||
});
|
||||
|
||||
assert.equal(ignored?.preset, undefined);
|
||||
assert.equal(ignored?.tag, "primary");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user