From 8cf78ddf00502c864efd92963f3db97e385789f5 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 17 Apr 2026 16:45:27 -0300 Subject: [PATCH] feat(auth): enforce dashboard sessions for management routes Require dashboard session cookies on protected management APIs and reject bearer API keys with explicit 403 responses to prevent privilege escalation across provider, settings, and model alias routes. Add a dedicated payload rules management surface with dashboard UI, OpenAPI documentation, route normalization, and tests for hot-reloaded runtime updates. Consolidate provider catalog metadata for dashboard pages, add Perplexity web-cookie provider support, retire the legacy provider creation page, and improve upstream proxy handling. Harden startup and runtime behavior by moving cloud sync bootstrap to server instrumentation, skipping background services during build/test, making models.dev sync abortable, pruning isolated build artifacts, and improving DB backup and recovery safeguards. --- docs/openapi.yaml | 232 ++++++ scripts/build-next-isolated.mjs | 58 +- src/app/(dashboard)/dashboard/agents/page.tsx | 2 +- .../dashboard/providers/[id]/page.tsx | 757 ++++++++++-------- .../dashboard/providers/new/page.tsx | 216 +---- .../(dashboard)/dashboard/providers/page.tsx | 18 +- .../dashboard/providers/providerPageUtils.ts | 33 + .../settings/components/PayloadRulesTab.tsx | 282 +++++++ .../(dashboard)/dashboard/settings/page.tsx | 4 +- src/app/api/init/route.ts | 6 +- src/app/api/models/alias/route.ts | 42 +- src/app/api/providers/[id]/route.ts | 10 + src/app/api/providers/route.ts | 15 +- src/app/api/providers/test-batch/route.ts | 4 + src/app/api/settings/payload-rules/route.ts | 18 +- src/app/api/settings/route.ts | 11 +- src/app/layout.tsx | 1 - src/app/page.tsx | 2 - src/instrumentation-node.ts | 6 + src/lib/api/requireManagementAuth.ts | 27 +- src/lib/config/runtimeSettings.ts | 17 + src/lib/dataPaths.js | 68 +- src/lib/dataPaths.ts | 11 +- src/lib/db/backup.ts | 25 +- src/lib/db/core.ts | 339 ++++++-- src/lib/db/migrationRunner.ts | 28 +- src/lib/initCloudSync.ts | 37 +- src/lib/localHealthCheck.ts | 19 +- src/lib/modelsDevSync.ts | 111 ++- src/lib/providers/catalog.ts | 228 ++++++ src/lib/providers/validation.ts | 81 ++ src/lib/tokenHealthCheck.ts | 25 +- src/proxy.ts | 3 +- src/shared/components/Footer.tsx | 2 +- src/shared/components/ProviderIcon.tsx | 2 + src/shared/components/Sidebar.tsx | 2 +- src/shared/constants/appConfig.ts | 12 + src/shared/constants/config.ts | 15 +- src/shared/constants/providers.ts | 21 +- src/shared/services/cloudSyncScheduler.ts | 4 +- src/shared/utils/apiAuth.ts | 206 +++-- src/store/themeStore.ts | 2 +- tests/helpers/managementSession.ts | 64 ++ tests/integration/api-keys.test.ts | 88 +- tests/integration/api-routes-critical.test.ts | 83 +- tests/integration/integration-wiring.test.ts | 64 ++ tests/unit/admin-audit-events.test.ts | 17 +- tests/unit/api-auth.test.ts | 35 +- .../unit/bailian-coding-plan-provider.test.ts | 13 +- tests/unit/build-next-isolated.test.ts | 37 +- tests/unit/db-core-init.test.ts | 256 +++++- tests/unit/db-migration-runner.test.ts | 26 +- tests/unit/model-alias-route.test.ts | 30 +- tests/unit/model-sync-scheduler.test.ts | 35 + tests/unit/modelsDevSync-extended.test.ts | 52 ++ tests/unit/payload-rules-route.test.ts | 194 +++++ .../provider-validation-specialty.test.ts | 51 ++ tests/unit/providers-page-utils.test.ts | 83 ++ .../providers-route-managed-catalog.test.ts | 112 +++ tests/unit/proxy-login-bootstrap-auth.test.ts | 23 +- tests/unit/route-edge-coverage.test.ts | 85 +- tests/unit/settings-api.test.ts | 6 +- tests/unit/settings-route-password.test.ts | 15 +- tests/unit/sync-routes.test.ts | 35 +- 64 files changed, 3299 insertions(+), 1107 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/settings/components/PayloadRulesTab.tsx create mode 100644 src/lib/providers/catalog.ts create mode 100644 src/shared/constants/appConfig.ts create mode 100644 tests/helpers/managementSession.ts create mode 100644 tests/unit/payload-rules-route.test.ts create mode 100644 tests/unit/providers-route-managed-catalog.test.ts 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"}
+
+