diff --git a/docs/guides/FEATURES.md b/docs/guides/FEATURES.md index df79c1d0ef..0ae629121b 100644 --- a/docs/guides/FEATURES.md +++ b/docs/guides/FEATURES.md @@ -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`. + ![Providers Dashboard](../screenshots/01-providers.png) --- diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 2ec6fbd6b3..a585c0a72f 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -151,6 +151,14 @@ function normalizeOpenAIChatUrl(baseUrl) { return `${normalized}/v1/chat/completions`; } +function getOpenRouterConnectionPreset( + providerSpecificData?: Record | 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).preset === undefined) { + withDefaults = { + ...(withDefaults as Record), + preset: connectionPreset, + }; + } + } } if (this.provider === "qwen" && typeof withDefaults === "object" && withDefaults !== null) { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/OpenRouterPresetInput.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/OpenRouterPresetInput.tsx new file mode 100644 index 0000000000..280f420f69 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/OpenRouterPresetInput.tsx @@ -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 ( + 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) => { + 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 ? : null), + [isOpenRouter, t, value] + ); + return { applyTo, getPatch, input, isOpenRouter, setValue }; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx index df432143dc..59d94f0131 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx @@ -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 | undefined; + const bulkProviderSpecificData: Record = {}; 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" && (

{t("bulkAddFormatHint")}

+ {openRouterPreset.input}