diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 93e916306c..941e8a4eba 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -750,6 +750,70 @@ paths: "200": description: Updated settings + /api/settings/payload-rules: + get: + tags: [Settings] + summary: Get payload rules configuration + description: | + Returns the current payload rules used to mutate outgoing request payloads before they + are sent upstream. + + Requires a dashboard management session cookie when management auth is enabled. + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Current payload rules configuration + content: + application/json: + schema: + $ref: "#/components/schemas/PayloadRulesConfig" + "401": + $ref: "#/components/responses/ManagementAuthenticationRequired" + "403": + $ref: "#/components/responses/ManagementInvalidToken" + "500": + description: Failed to read payload rules configuration + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + put: + tags: [Settings] + summary: Update payload rules configuration + description: | + Persists and hot reloads payload rules. The legacy input field `default-raw` is accepted + on writes and normalized to `defaultRaw` in responses/runtime state. + + Requires a dashboard management session cookie when management auth is enabled. + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdatePayloadRulesRequest" + responses: + "200": + description: Updated payload rules configuration + content: + application/json: + schema: + $ref: "#/components/schemas/PayloadRulesConfig" + "400": + $ref: "#/components/responses/ValidationError" + "401": + $ref: "#/components/responses/ManagementAuthenticationRequired" + "403": + $ref: "#/components/responses/ManagementInvalidToken" + "500": + description: Failed to update payload rules configuration + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + /api/settings/combo-defaults: get: tags: [Settings] @@ -1997,6 +2061,11 @@ components: type: http scheme: bearer description: API key obtained from the OmniRoute dashboard + ManagementSessionAuth: + type: apiKey + in: cookie + name: auth_token + description: Dashboard management session cookie for protected management routes parameters: ResourceId: @@ -2017,8 +2086,171 @@ components: error: type: string example: Unauthorized + ManagementAuthenticationRequired: + description: Authentication required for management routes + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + example: + error: + message: Authentication required + type: invalid_request + requestId: 3f9f6f5a-509a-4b35-b0a7-2d2d99d73a01 + ManagementInvalidToken: + description: Bearer tokens are not accepted for management routes + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + example: + error: + message: Invalid management token + type: invalid_request + requestId: 1b6a6ff8-d60c-4900-8d0a-25f81749f0a3 + ValidationError: + description: Request body failed validation + content: + application/json: + schema: + $ref: "#/components/schemas/ValidationErrorResponse" schemas: + ApiErrorResponse: + type: object + properties: + error: + type: object + properties: + message: + type: string + type: + type: string + details: + description: Optional additional error details + requestId: + type: string + format: uuid + + ValidationErrorResponse: + type: object + properties: + error: + type: object + required: [message, details] + properties: + message: + type: string + example: Invalid request + details: + type: array + items: + type: object + required: [field, message] + properties: + field: + type: string + message: + type: string + + PayloadRuleModelSpec: + type: object + additionalProperties: false + required: [name] + properties: + name: + type: string + minLength: 1 + protocol: + type: string + minLength: 1 + + PayloadMutationRule: + type: object + additionalProperties: false + required: [models, params] + properties: + models: + type: array + minItems: 1 + items: + $ref: "#/components/schemas/PayloadRuleModelSpec" + params: + type: object + minProperties: 1 + additionalProperties: true + + PayloadFilterRule: + type: object + additionalProperties: false + required: [models, params] + properties: + models: + type: array + minItems: 1 + items: + $ref: "#/components/schemas/PayloadRuleModelSpec" + params: + type: array + minItems: 1 + items: + type: string + minLength: 1 + + PayloadRulesConfig: + type: object + additionalProperties: false + required: [default, override, filter, defaultRaw] + properties: + default: + type: array + items: + $ref: "#/components/schemas/PayloadMutationRule" + override: + type: array + items: + $ref: "#/components/schemas/PayloadMutationRule" + filter: + type: array + items: + $ref: "#/components/schemas/PayloadFilterRule" + defaultRaw: + type: array + items: + $ref: "#/components/schemas/PayloadMutationRule" + + UpdatePayloadRulesRequest: + type: object + additionalProperties: false + description: At least one payload-rules section must be present in the request body. + properties: + default: + type: array + items: + $ref: "#/components/schemas/PayloadMutationRule" + override: + type: array + items: + $ref: "#/components/schemas/PayloadMutationRule" + filter: + type: array + items: + $ref: "#/components/schemas/PayloadFilterRule" + defaultRaw: + type: array + items: + $ref: "#/components/schemas/PayloadMutationRule" + default-raw: + type: array + items: + $ref: "#/components/schemas/PayloadMutationRule" + anyOf: + - required: [default] + - required: [override] + - required: [filter] + - required: [defaultRaw] + - required: [default-raw] + ChatCompletionRequest: type: object required: [model, messages] diff --git a/scripts/build-next-isolated.mjs b/scripts/build-next-isolated.mjs index 52fa1dcaeb..4bfa6b744d 100644 --- a/scripts/build-next-isolated.mjs +++ b/scripts/build-next-isolated.mjs @@ -15,18 +15,26 @@ import { pathToFileURL } from "node:url"; const projectRoot = process.cwd(); const backupRoot = path.join(os.tmpdir(), `omniroute-build-isolated-${process.pid}-${Date.now()}`); -const transientBuildPaths = [ - { - label: "legacy app snapshot", - sourcePath: path.join(projectRoot, "app"), - backupPath: path.join(backupRoot, "app"), - }, - { - label: "task planning workspace", - sourcePath: path.join(projectRoot, "_tasks"), - backupPath: path.join(backupRoot, "_tasks"), - }, -]; + +export function getTransientBuildPaths(rootDir = projectRoot, env = process.env) { + const paths = [ + { + label: "legacy app snapshot", + sourcePath: path.join(rootDir, "app"), + backupPath: path.join(backupRoot, "app"), + }, + ]; + + if (env.OMNIROUTE_BUILD_MOVE_TASKS === "1") { + paths.push({ + label: "task planning workspace", + sourcePath: path.join(rootDir, "_tasks"), + backupPath: path.join(backupRoot, "_tasks"), + }); + } + + return paths; +} async function exists(targetPath) { try { @@ -38,7 +46,8 @@ async function exists(targetPath) { } export async function movePath(sourcePath, destinationPath, fsImpl = fs) { - await fsImpl.mkdir(path.dirname(destinationPath), { recursive: true }); + const mkdir = typeof fsImpl.mkdir === "function" ? fsImpl.mkdir.bind(fsImpl) : fs.mkdir.bind(fs); + await mkdir(path.dirname(destinationPath), { recursive: true }); try { await fsImpl.rename(sourcePath, destinationPath); @@ -95,8 +104,22 @@ export function resolveNextBuildEnv(baseEnv = process.env) { }; } +export async function pruneStandaloneArtifacts(rootDir = projectRoot, fsImpl = fs) { + const standaloneRoot = path.join(rootDir, ".next", "standalone"); + const pruneTargets = [path.join(standaloneRoot, "_tasks")]; + + for (const targetPath of pruneTargets) { + if (!(await exists(targetPath))) continue; + await fsImpl.rm(targetPath, { recursive: true, force: true }); + console.log( + `[build-next-isolated] Pruned standalone artifact: ${path.relative(rootDir, targetPath)}` + ); + } +} + export async function main() { const movedPaths = []; + const transientBuildPaths = getTransientBuildPaths(); try { for (const entry of transientBuildPaths) { @@ -122,6 +145,15 @@ export async function main() { } catch (copyErr) { console.warn("[build-next-isolated] Non-fatal error copying static assets:", copyErr); } + + try { + await pruneStandaloneArtifacts(projectRoot); + } catch (pruneErr) { + console.warn( + "[build-next-isolated] Non-fatal error pruning standalone artifacts:", + pruneErr + ); + } } process.exitCode = result.code; } catch (error) { diff --git a/src/app/(dashboard)/dashboard/agents/page.tsx b/src/app/(dashboard)/dashboard/agents/page.tsx index 85354278cf..5714e2b250 100644 --- a/src/app/(dashboard)/dashboard/agents/page.tsx +++ b/src/app/(dashboard)/dashboard/agents/page.tsx @@ -5,7 +5,7 @@ import Link from "next/link"; import { Card, Button, Input } from "@/shared/components"; import ProviderIcon from "@/shared/components/ProviderIcon"; import { useTranslations } from "next-intl"; -import { AI_PROVIDERS } from "@/shared/constants/config"; +import { AI_PROVIDERS } from "@/shared/constants/providers"; import { CLI_COMPAT_PROVIDER_IDS } from "@/shared/constants/cliCompatProviders"; interface AgentInfo { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 7d839277e0..55056e3fbb 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -23,9 +23,6 @@ import { ProxyConfigModal, } from "@/shared/components"; import { - FREE_PROVIDERS, - OAUTH_PROVIDERS, - APIKEY_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, @@ -52,6 +49,7 @@ import { maskEmail, pickMaskedDisplayValue, pickDisplayValue } from "@/shared/ut import useEmailPrivacyStore from "@/store/emailPrivacyStore"; import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle"; import { getCodexRequestDefaults as _getCodexRequestDefaults } from "@/lib/providers/requestDefaults"; +import { resolveDashboardProviderInfo } from "../providerPageUtils"; type CompatByProtocolMap = Partial< Record< @@ -990,35 +988,16 @@ export default function ProviderDetailPage() { const isCompatible = isOpenAICompatible || isAnthropicCompatible || isCcCompatible; const isAnthropicProtocolCompatible = isAnthropicCompatible || isCcCompatible; - const providerInfo = providerNode - ? { - id: providerNode.id, - name: - providerNode.name || - (isCcCompatible - ? CC_COMPATIBLE_LABEL - : providerNode.type === "anthropic-compatible" - ? t("anthropicCompatibleName") - : t("openaiCompatibleName")), - color: isCcCompatible - ? "#B45309" - : providerNode.type === "anthropic-compatible" - ? "#D97757" - : "#10A37F", - textIcon: isCcCompatible - ? "CC" - : providerNode.type === "anthropic-compatible" - ? "AC" - : "OC", - apiType: providerNode.apiType, - baseUrl: providerNode.baseUrl, - type: providerNode.type, - } - : (FREE_PROVIDERS as any)[providerId] || - (OAUTH_PROVIDERS as any)[providerId] || - (APIKEY_PROVIDERS as any)[providerId]; + const providerInfo = resolveDashboardProviderInfo(providerId, { + providerNode, + compatibleLabels: { + ccCompatibleName: CC_COMPATIBLE_LABEL, + anthropicCompatibleName: t("anthropicCompatibleName"), + openAiCompatibleName: t("openaiCompatibleName"), + }, + }); const providerSupportsOAuth = - !!(FREE_PROVIDERS as any)[providerId] || !!(OAUTH_PROVIDERS as any)[providerId]; + providerInfo?.toggleAuthType === "oauth" || providerInfo?.toggleAuthType === "free"; const providerSupportsPat = supportsApiKeyOnFreeProvider(providerId); const isOAuth = providerSupportsOAuth && !providerSupportsPat; const registryModels = getModelsByProviderId(providerId); @@ -1052,6 +1031,7 @@ export default function ProviderDetailPage() { const providerAlias = getProviderAlias(providerId); const isManagedAvailableModelsProvider = isCompatible || providerId === "openrouter"; const isSearchProvider = providerId.endsWith("-search"); + const isUpstreamProxyProvider = providerInfo?.category === "upstream-proxy"; const compatibleSupportsModelImport = compatibleProviderSupportsModelImport(providerId); const providerStorageAlias = isCompatible ? providerId : providerAlias; @@ -2658,294 +2638,341 @@ export default function ProviderDetailPage() { )} {/* Connections */} - -
-
-

{t("connections")}

- {/* Provider-level proxy indicator/button */} - -
- {connections.length > 1 && ( - - )} - {!isCompatible ? ( -
- - {providerId === "qoder" && ( - - )} + {!isUpstreamProxyProvider && ( + +
+
+

{t("connections")}

+ {/* Provider-level proxy indicator/button */} +
- ) : ( - connections.length === 0 && ( - - ) - )} -
- - {connections.length === 0 ? ( -
-
- - {isOAuth ? "lock" : "key"} - -
-

{t("noConnectionsYet")}

-

{t("addFirstConnectionHint")}

- {!isCompatible && ( -
- + )} + {!isCompatible ? ( +
+ {providerId === "qoder" && ( - )}
+ ) : ( + connections.length === 0 && ( + + ) )}
- ) : ( - (() => { - // Group connections by tag (providerSpecificData.tag) - const sorted = [...connections].sort((a, b) => (a.priority || 0) - (b.priority || 0)); - const hasAnyTag = sorted.some((c) => c.providerSpecificData?.tag as string | undefined); - if (!hasAnyTag) { - // No tags — render flat list as before + {connections.length === 0 ? ( +
+
+ + {isOAuth ? "lock" : "key"} + +
+

{t("noConnectionsYet")}

+

{t("addFirstConnectionHint")}

+ {!isCompatible && ( +
+ + {providerId === "qoder" && ( + + )} +
+ )} +
+ ) : ( + (() => { + // Group connections by tag (providerSpecificData.tag) + const sorted = [...connections].sort((a, b) => (a.priority || 0) - (b.priority || 0)); + const hasAnyTag = sorted.some( + (c) => c.providerSpecificData?.tag as string | undefined + ); + + if (!hasAnyTag) { + // No tags — render flat list as before + return ( +
+ {sorted.map((conn, index) => ( + handleSwapPriority(conn, sorted[index - 1])} + onMoveDown={() => handleSwapPriority(conn, sorted[index + 1])} + onToggleActive={(isActive) => + handleUpdateConnectionStatus(conn.id, isActive) + } + onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)} + isCodex={providerId === "codex"} + isCcCompatible={isCcCompatible} + cliproxyapiEnabled={cpaProviderEnabled} + onToggleCliproxyapiMode={(enabled) => + handleToggleCliproxyapiMode(conn.id, enabled) + } + onToggleCodex5h={(enabled) => + handleToggleCodexLimit(conn.id, "use5h", enabled) + } + onToggleCodexWeekly={(enabled) => + handleToggleCodexLimit(conn.id, "useWeekly", enabled) + } + onRetest={() => handleRetestConnection(conn.id)} + isRetesting={retestingId === conn.id} + onEdit={() => { + setSelectedConnection(conn); + setShowEditModal(true); + }} + onDelete={() => handleDelete(conn.id)} + onReauth={ + conn.authType === "oauth" ? () => setShowOAuthModal(true) : undefined + } + onRefreshToken={ + conn.authType === "oauth" ? () => handleRefreshToken(conn.id) : undefined + } + isRefreshing={refreshingId === conn.id} + onApplyCodexAuthLocal={ + providerId === "codex" + ? () => handleApplyCodexAuthLocal(conn.id) + : undefined + } + isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id} + onExportCodexAuthFile={ + providerId === "codex" + ? () => handleExportCodexAuthFile(conn.id) + : undefined + } + isExportingCodexAuthFile={exportingCodexAuthId === conn.id} + onProxy={() => + setProxyTarget({ + level: "key", + id: conn.id, + label: pickDisplayValue( + [conn.name, conn.email], + emailsVisible, + conn.id + ), + }) + } + hasProxy={!!connProxyMap[conn.id]?.proxy} + proxySource={connProxyMap[conn.id]?.level || null} + proxyHost={connProxyMap[conn.id]?.proxy?.host || null} + /> + ))} +
+ ); + } + + // Build ordered tag groups: untagged first, then alphabetically + const groupMap = new Map(); + for (const conn of sorted) { + const tag = (conn.providerSpecificData?.tag as string | undefined)?.trim() || ""; + if (!groupMap.has(tag)) groupMap.set(tag, []); + groupMap.get(tag)!.push(conn); + } + const groupKeys = Array.from(groupMap.keys()).sort((a, b) => { + if (a === "") return -1; + if (b === "") return 1; + return a.localeCompare(b); + }); + return ( -
- {sorted.map((conn, index) => ( - handleSwapPriority(conn, sorted[index - 1])} - onMoveDown={() => handleSwapPriority(conn, sorted[index + 1])} - onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)} - onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)} - isCodex={providerId === "codex"} - isCcCompatible={isCcCompatible} - cliproxyapiEnabled={cpaProviderEnabled} - onToggleCliproxyapiMode={(enabled) => - handleToggleCliproxyapiMode(conn.id, enabled) - } - onToggleCodex5h={(enabled) => - handleToggleCodexLimit(conn.id, "use5h", enabled) - } - onToggleCodexWeekly={(enabled) => - handleToggleCodexLimit(conn.id, "useWeekly", enabled) - } - onRetest={() => handleRetestConnection(conn.id)} - isRetesting={retestingId === conn.id} - onEdit={() => { - setSelectedConnection(conn); - setShowEditModal(true); - }} - onDelete={() => handleDelete(conn.id)} - onReauth={ - conn.authType === "oauth" ? () => setShowOAuthModal(true) : undefined - } - onRefreshToken={ - conn.authType === "oauth" ? () => handleRefreshToken(conn.id) : undefined - } - isRefreshing={refreshingId === conn.id} - onApplyCodexAuthLocal={ - providerId === "codex" - ? () => handleApplyCodexAuthLocal(conn.id) - : undefined - } - isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id} - onExportCodexAuthFile={ - providerId === "codex" - ? () => handleExportCodexAuthFile(conn.id) - : undefined - } - isExportingCodexAuthFile={exportingCodexAuthId === conn.id} - onProxy={() => - setProxyTarget({ - level: "key", - id: conn.id, - label: pickDisplayValue([conn.name, conn.email], emailsVisible, conn.id), - }) - } - hasProxy={!!connProxyMap[conn.id]?.proxy} - proxySource={connProxyMap[conn.id]?.level || null} - proxyHost={connProxyMap[conn.id]?.proxy?.host || null} - /> - ))} +
+ {groupKeys.map((tag, gi) => { + const groupConns = groupMap.get(tag)!; + return ( +
0 + ? "border-t border-black/[0.06] dark:border-white/[0.06] mt-1 pt-1" + : "" + } + > + {tag && ( +
+ + label + + + {tag} + +
+ + {groupConns.length} + +
+ )} +
+ {groupConns.map((conn, index) => ( + + handleSwapPriority(conn, sorted[sorted.indexOf(conn) - 1]) + } + onMoveDown={() => + handleSwapPriority(conn, sorted[sorted.indexOf(conn) + 1]) + } + onToggleActive={(isActive) => + handleUpdateConnectionStatus(conn.id, isActive) + } + onToggleRateLimit={(enabled) => + handleToggleRateLimit(conn.id, enabled) + } + isCodex={providerId === "codex"} + onToggleCodex5h={(enabled) => + handleToggleCodexLimit(conn.id, "use5h", enabled) + } + onToggleCodexWeekly={(enabled) => + handleToggleCodexLimit(conn.id, "useWeekly", enabled) + } + onRetest={() => handleRetestConnection(conn.id)} + isRetesting={retestingId === conn.id} + onEdit={() => { + setSelectedConnection(conn); + setShowEditModal(true); + }} + onDelete={() => handleDelete(conn.id)} + onReauth={ + conn.authType === "oauth" + ? () => setShowOAuthModal(true) + : undefined + } + onRefreshToken={ + conn.authType === "oauth" + ? () => handleRefreshToken(conn.id) + : undefined + } + isRefreshing={refreshingId === conn.id} + onApplyCodexAuthLocal={ + providerId === "codex" + ? () => handleApplyCodexAuthLocal(conn.id) + : undefined + } + isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id} + onExportCodexAuthFile={ + providerId === "codex" + ? () => handleExportCodexAuthFile(conn.id) + : undefined + } + isExportingCodexAuthFile={exportingCodexAuthId === conn.id} + onProxy={() => + setProxyTarget({ + level: "key", + id: conn.id, + label: pickDisplayValue( + [conn.name, conn.email], + emailsVisible, + conn.id + ), + }) + } + hasProxy={!!connProxyMap[conn.id]?.proxy} + proxySource={connProxyMap[conn.id]?.level || null} + proxyHost={connProxyMap[conn.id]?.proxy?.host || null} + /> + ))} +
+
+ ); + })}
); - } + })() + )} + + )} - // Build ordered tag groups: untagged first, then alphabetically - const groupMap = new Map(); - for (const conn of sorted) { - const tag = (conn.providerSpecificData?.tag as string | undefined)?.trim() || ""; - if (!groupMap.has(tag)) groupMap.set(tag, []); - groupMap.get(tag)!.push(conn); - } - const groupKeys = Array.from(groupMap.keys()).sort((a, b) => { - if (a === "") return -1; - if (b === "") return 1; - return a.localeCompare(b); - }); - - return ( -
- {groupKeys.map((tag, gi) => { - const groupConns = groupMap.get(tag)!; - return ( -
0 - ? "border-t border-black/[0.06] dark:border-white/[0.06] mt-1 pt-1" - : "" - } - > - {tag && ( -
- - label - - - {tag} - -
- - {groupConns.length} - -
- )} -
- {groupConns.map((conn, index) => ( - - handleSwapPriority(conn, sorted[sorted.indexOf(conn) - 1]) - } - onMoveDown={() => - handleSwapPriority(conn, sorted[sorted.indexOf(conn) + 1]) - } - onToggleActive={(isActive) => - handleUpdateConnectionStatus(conn.id, isActive) - } - onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)} - isCodex={providerId === "codex"} - onToggleCodex5h={(enabled) => - handleToggleCodexLimit(conn.id, "use5h", enabled) - } - onToggleCodexWeekly={(enabled) => - handleToggleCodexLimit(conn.id, "useWeekly", enabled) - } - onRetest={() => handleRetestConnection(conn.id)} - isRetesting={retestingId === conn.id} - onEdit={() => { - setSelectedConnection(conn); - setShowEditModal(true); - }} - onDelete={() => handleDelete(conn.id)} - onReauth={ - conn.authType === "oauth" ? () => setShowOAuthModal(true) : undefined - } - onRefreshToken={ - conn.authType === "oauth" - ? () => handleRefreshToken(conn.id) - : undefined - } - isRefreshing={refreshingId === conn.id} - onApplyCodexAuthLocal={ - providerId === "codex" - ? () => handleApplyCodexAuthLocal(conn.id) - : undefined - } - isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id} - onExportCodexAuthFile={ - providerId === "codex" - ? () => handleExportCodexAuthFile(conn.id) - : undefined - } - isExportingCodexAuthFile={exportingCodexAuthId === conn.id} - onProxy={() => - setProxyTarget({ - level: "key", - id: conn.id, - label: pickDisplayValue( - [conn.name, conn.email], - emailsVisible, - conn.id - ), - }) - } - hasProxy={!!connProxyMap[conn.id]?.proxy} - proxySource={connProxyMap[conn.id]?.level || null} - proxyHost={connProxyMap[conn.id]?.proxy?.host || null} - /> - ))} -
-
- ); - })} -
- ); - })() - )} - + {isUpstreamProxyProvider && ( + +
+
+

Managed via Upstream Proxy Settings

+

+ CLIProxyAPI is configured as an upstream proxy layer, not as a direct provider + connection. Manage the binary/runtime in CLI Tools and enable proxy routing on each + provider via the provider proxy controls. +

+
+
+ + terminal + Open CLI Tools + + + settings + Open Settings + +
+
+
+ )} {/* Models — hidden for search providers (they don't have models) */} - {!isSearchProvider && ( + {!isSearchProvider && !isUpstreamProxyProvider && (

{t("availableModels")}

{renderModelsSection()} @@ -3007,51 +3034,56 @@ export default function ProviderDetailPage() { )} {/* Modals */} - {providerId === "kiro" ? ( - { - setShowOAuthModal(false); - }} - /> - ) : providerId === "cursor" ? ( - { - setShowOAuthModal(false); - }} - /> - ) : ( - { + setShowOAuthModal(false); + }} + /> + ) : providerId === "cursor" ? ( + { + setShowOAuthModal(false); + }} + /> + ) : ( + { + setShowOAuthModal(false); + }} + /> + ))} + {!isUpstreamProxyProvider && ( + { - setShowOAuthModal(false); - }} + providerName={providerInfo.name} + isCompatible={isCompatible} + isAnthropic={isAnthropicProtocolCompatible} + isCcCompatible={isCcCompatible} + onSave={handleSaveApiKey} + onClose={() => setShowAddApiKeyModal(false)} /> )} - setShowAddApiKeyModal(false)} - /> - setShowEditModal(false)} - /> - {isCompatible && ( + {!isUpstreamProxyProvider && ( + setShowEditModal(false)} + /> + )} + {!isUpstreamProxyProvider && isCompatible && (
diff --git a/src/app/(dashboard)/dashboard/providers/new/page.tsx b/src/app/(dashboard)/dashboard/providers/new/page.tsx index 8dadb07130..819133ea07 100644 --- a/src/app/(dashboard)/dashboard/providers/new/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/new/page.tsx @@ -1,217 +1,5 @@ -"use client"; - -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import Link from "next/link"; -import { Card, Button, Input, Select, Toggle } from "@/shared/components"; -import { AI_PROVIDERS, AUTH_METHODS } from "@/shared/constants/config"; -import { useTranslations } from "next-intl"; - -const providerOptions = Object.values(AI_PROVIDERS).map((p) => ({ - value: p.id, - label: p.name, -})); - -const authMethodOptions = Object.values(AUTH_METHODS).map((m) => ({ - value: m.id, - label: m.name, -})); +import { redirect } from "next/navigation"; export default function NewProviderPage() { - const router = useRouter(); - const [loading, setLoading] = useState(false); - const t = useTranslations("providers"); - const [formData, setFormData] = useState({ - provider: "", - authMethod: "api_key", - apiKey: "", - displayName: "", - isActive: true, - }); - const [errors, setErrors] = useState({}); - - const handleChange = (field, value) => { - setFormData((prev) => ({ ...prev, [field]: value })); - if (errors[field]) { - setErrors((prev) => ({ ...prev, [field]: null })); - } - }; - - const validate = () => { - const newErrors: any = {}; - if (!formData.provider) newErrors.provider = t("selectProvider"); - if (formData.authMethod === "api_key" && !formData.apiKey) { - newErrors.apiKey = t("apiKeyRequired"); - } - setErrors(newErrors); - return Object.keys(newErrors).length === 0; - }; - - const handleSubmit = async (e) => { - e.preventDefault(); - if (!validate()) return; - - setLoading(true); - try { - const response = await fetch("/api/providers", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(formData), - }); - - if (response.ok) { - router.push("/dashboard/providers"); - } else { - const data = await response.json(); - setErrors({ submit: data.error || t("failedCreate") }); - } - } catch (error) { - setErrors({ submit: t("errorOccurred") }); - } finally { - setLoading(false); - } - }; - - const selectedProvider = (AI_PROVIDERS as any)[formData.provider]; - - return ( -
- {/* Header */} -
- - arrow_back - {t("backToProviders")} - -

- {t("addNewProvider")} -

-

{t("configureNewProvider")}

-
- - {/* Form */} - -
- {/* Provider Selection */} - handleChange("apiKey", e.target.value)} - error={errors.apiKey as string} - hint={t("apiKeySecure")} - required - /> - )} - - {/* OAuth2 Button */} - {formData.authMethod === "oauth2" && ( - -

{t("oauth2Desc")}

- -
- )} - - {/* Display Name */} - handleChange("displayName", e.target.value)} - hint={t("displayNameHint")} - /> - - {/* Active Toggle */} - handleChange("isActive", checked)} - label={t("active")} - description={t("activeDescription")} - /> - - {/* Error Message */} - {errors.submit && ( -
- {errors.submit as string} -
- )} - - {/* Actions */} -
- - - - -
- -
-
- ); + redirect("/dashboard/providers"); } diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index dbe260d0ae..de86e770cb 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -14,15 +14,9 @@ import { Select, Toggle, } from "@/shared/components"; -import { - OAUTH_PROVIDERS, - APIKEY_PROVIDERS, - WEB_COOKIE_PROVIDERS, - SEARCH_PROVIDERS, - AUDIO_ONLY_PROVIDERS, -} from "@/shared/constants/config"; import { FREE_PROVIDERS, + OAUTH_PROVIDERS, isAnthropicCompatibleProvider, isClaudeCodeCompatibleProvider, isOpenAICompatibleProvider, @@ -36,7 +30,7 @@ import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge"; import { useTranslations } from "next-intl"; import { buildMergedOAuthProviderEntries, - buildProviderEntries, + buildStaticProviderEntries, filterConfiguredProviderEntries, } from "./providerPageUtils"; import { readConfiguredOnlyPreference, writeConfiguredOnlyPreference } from "./providerPageStorage"; @@ -403,22 +397,22 @@ export default function ProvidersPage() { ); const apiKeyProviderEntries = filterConfiguredProviderEntries( - buildProviderEntries(APIKEY_PROVIDERS, "apikey", "apikey", getProviderStats), + buildStaticProviderEntries("apikey", getProviderStats), showConfiguredOnly ); const webCookieProviderEntries = filterConfiguredProviderEntries( - buildProviderEntries(WEB_COOKIE_PROVIDERS, "apikey", "apikey", getProviderStats), + buildStaticProviderEntries("web-cookie", getProviderStats), showConfiguredOnly ); const searchProviderEntries = filterConfiguredProviderEntries( - buildProviderEntries(SEARCH_PROVIDERS, "apikey", "apikey", getProviderStats), + buildStaticProviderEntries("search", getProviderStats), showConfiguredOnly ); const audioProviderEntries = filterConfiguredProviderEntries( - buildProviderEntries(AUDIO_ONLY_PROVIDERS, "apikey", "apikey", getProviderStats), + buildStaticProviderEntries("audio", getProviderStats), showConfiguredOnly ); diff --git a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts index 55cd0fd77b..d2f75617db 100644 --- a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts @@ -1,3 +1,13 @@ +import { + getStaticProviderCatalogGroup, + resolveProviderCatalogEntry, + type CompatibleProviderLabels, + type CompatibleProviderNodeLike, + type ProviderCatalogMetadata, + type ResolvedProviderCatalogEntry, + type StaticProviderCatalogCategory, +} from "@/lib/providers/catalog"; + export interface ProviderStatsSnapshot { total?: number; [key: string]: unknown; @@ -44,6 +54,19 @@ export function buildMergedOAuthProviderEntries[] { + const group = getStaticProviderCatalogGroup(category); + return buildProviderEntries( + group.providers, + group.displayAuthType, + group.toggleAuthType, + getProviderStats + ); +} + export function filterConfiguredProviderEntries( entries: ProviderEntry[], showConfiguredOnly: boolean @@ -52,3 +75,13 @@ export function filterConfiguredProviderEntries( return entries.filter((entry) => Number(entry.stats?.total || 0) > 0); } + +export function resolveDashboardProviderInfo( + providerId: string, + options?: { + providerNode?: CompatibleProviderNodeLike | null; + compatibleLabels?: CompatibleProviderLabels | null; + } +): ResolvedProviderCatalogEntry | null { + return resolveProviderCatalogEntry(providerId, options); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/PayloadRulesTab.tsx b/src/app/(dashboard)/dashboard/settings/components/PayloadRulesTab.tsx new file mode 100644 index 0000000000..31af651749 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/PayloadRulesTab.tsx @@ -0,0 +1,282 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Card, Button } from "@/shared/components"; + +const EMPTY_PAYLOAD_RULES_TEMPLATE = { + default: [], + override: [], + filter: [], + defaultRaw: [], +}; + +const EMPTY_PAYLOAD_RULES_TEXT = JSON.stringify(EMPTY_PAYLOAD_RULES_TEMPLATE, null, 2); + +type StatusMessage = { + type: "success" | "error" | "info"; + text: string; +}; + +function isObjectRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function getRuleSectionCount(value: unknown, keys: string[]): number { + for (const key of keys) { + if (isObjectRecord(value) && Array.isArray(value[key])) { + return value[key].length; + } + } + return 0; +} + +function getErrorMessage(payload: unknown): string { + if (!isObjectRecord(payload)) return "Failed to save payload rules"; + + const nestedError = payload.error; + if (typeof nestedError === "string" && nestedError.trim()) { + return nestedError; + } + + if (isObjectRecord(nestedError)) { + if (typeof nestedError.message === "string" && nestedError.message.trim()) { + return nestedError.message; + } + + if (Array.isArray(nestedError.details) && nestedError.details.length > 0) { + const detail = nestedError.details[0]; + if (isObjectRecord(detail) && typeof detail.message === "string" && detail.message.trim()) { + return detail.message; + } + } + } + + return "Failed to save payload rules"; +} + +export default function PayloadRulesTab() { + const [editorValue, setEditorValue] = useState(EMPTY_PAYLOAD_RULES_TEXT); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(null); + + const parsedEditor = useMemo(() => { + try { + const parsed = JSON.parse(editorValue); + if (!isObjectRecord(parsed)) { + return { value: null, error: "Payload rules must be a JSON object." }; + } + return { value: parsed, error: null }; + } catch (error) { + return { + value: null, + error: error instanceof Error ? error.message : "Invalid JSON payload.", + }; + } + }, [editorValue]); + + const summary = useMemo(() => { + const source = parsedEditor.value; + return { + default: getRuleSectionCount(source, ["default"]), + override: getRuleSectionCount(source, ["override"]), + filter: getRuleSectionCount(source, ["filter"]), + defaultRaw: getRuleSectionCount(source, ["defaultRaw", "default-raw"]), + }; + }, [parsedEditor.value]); + + const loadConfig = useCallback(async () => { + setLoading(true); + setMessage(null); + try { + const response = await fetch("/api/settings/payload-rules"); + const payload = await response.json(); + if (!response.ok) { + throw new Error(getErrorMessage(payload)); + } + + setEditorValue(JSON.stringify(payload, null, 2)); + } catch (error) { + setMessage({ + type: "error", + text: error instanceof Error ? error.message : "Failed to load payload rules", + }); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadConfig(); + }, [loadConfig]); + + const handleReset = () => { + setEditorValue(EMPTY_PAYLOAD_RULES_TEXT); + setMessage({ + type: "info", + text: "Editor reset to the neutral template. Save to apply it.", + }); + }; + + const handleSave = async () => { + if (parsedEditor.error || !parsedEditor.value) { + setMessage({ + type: "error", + text: parsedEditor.error || "Payload rules must be valid JSON before saving.", + }); + return; + } + + setSaving(true); + setMessage(null); + try { + const response = await fetch("/api/settings/payload-rules", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(parsedEditor.value), + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(getErrorMessage(payload)); + } + + setEditorValue(JSON.stringify(payload, null, 2)); + setMessage({ type: "success", text: "Payload rules saved and hot reloaded." }); + } catch (error) { + setMessage({ + type: "error", + text: error instanceof Error ? error.message : "Failed to save payload rules", + }); + } finally { + setSaving(false); + } + }; + + return ( + +
+
+
+ +
+
+

Payload Rules

+

+ Configure request payload mutations by model and protocol. Changes are persisted in + settings and hot reloaded into the runtime immediately after save. +

+
+
+ +
+
+

default

+

+ Applies params only when the target path is missing from the outgoing payload. +

+
+
+

override

+

+ Forces values onto the payload, replacing anything already present at that path. +

+
+
+

filter

+

+ Removes blocked params from the payload before the upstream request is sent. +

+
+
+

defaultRaw

+

+ Like default, but string values are parsed as JSON first when possible. + The legacy input alias default-raw is also accepted on save. +

+
+
+ +
+ + default: {summary.default} + + + override: {summary.override} + + + filter: {summary.filter} + + + defaultRaw: {summary.defaultRaw} + +
+ + {message && ( +
+ + {message.type === "success" + ? "check_circle" + : message.type === "info" + ? "info" + : "error"} + + {message.text} +
+ )} + +
+
+
+

Editor

+

+ Use the runtime schema shape: default, override,{" "} + filter, defaultRaw. The API also accepts the legacy input + key default-raw. +

+
+
{loading ? "Loading..." : "Ready"}
+
+