From d8b9759db6a1931f021e352c20e4eb49760bbdf7 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:10:41 -0300 Subject: [PATCH] =?UTF-8?q?refactor(dashboard):=20extract=20remaining=20sh?= =?UTF-8?q?ared=20helpers=20to=20providerPageHelpers=20=E2=80=94=20#3501?= =?UTF-8?q?=20Phase=202b=20(#3658)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3501 Phase 2b: extract remaining shared helpers to providerPageHelpers (leaf, cycle-safe). Heavy modals unblocked. Co-authored-by: oyi77 --- CHANGELOG.md | 1 + file-size-baseline.json | 2 +- .../[id]/ProviderDetailPageClient.tsx | 361 ++-------------- .../providers/[id]/providerPageHelpers.ts | 391 +++++++++++++++++- tests/unit/provider-page-helpers-3501.test.ts | 175 ++++++++ 5 files changed, 593 insertions(+), 337 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a7f215316..52c4b079b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### ♻️ Code Quality +- **Provider-detail god-component decomposition — Phase 2b (remaining shared helpers→leaf)** ([#3501]): extended `providers/[id]/providerPageHelpers.ts` with all remaining pure helpers needed by the heavy modals (`AddApiKeyModal`/`EditConnectionModal`) before they can be extracted. Moved 22 symbols: web-session credential label/hint/check/title helpers; upstream-headers helpers (`upstreamHeadersRecordsEqual`, `headerRowsToRecord`, `effectiveUpstreamHeadersForProtocol`, `anyUpstreamHeadersBadge`, `getProtoSlice`) plus their `HeaderDraftRow`/`CompatModelRow`/`CompatModelMap`/`CompatByProtocolMap` types; Codex consts and helpers (`CODEX_REASONING_STRENGTH_OPTIONS`, `CODEX_ACCOUNT_SERVICE_TIER_VALUES`, `CODEX_GLOBAL_SERVICE_MODE_VALUES`, `getCodexServiceTierLabel`, `normalizeCodexLimitPolicy`, `getCodexRequestDefaults`, `getClaudeCodeCompatibleRequestDefaults`); misc helpers (`compatProtocolLabelKey`, `extractCommandCodeCredentialInput`, `normalizeAndValidateHttpBaseUrl`, `SILICONFLOW_ENDPOINTS`, `CommandCodeAuthFlowState`). New transitive imports wired into the leaf: `MODEL_COMPAT_PROTOCOL_KEYS` (`@/shared/constants/modelCompat`), `CodexServiceTier`/`getCodexRequestDefaults`/`getClaudeCodeCompatibleRequestDefaults` (`@/lib/providers/requestDefaults`), `CodexGlobalServiceMode` (`@/lib/providers/codexFastTier`), `WebSessionCredentialRequirement` (`./webSessionCredentials`). `ProviderDetailPageClient.tsx`: 10,288 → 9,980 LOC. Leaf module: 589 LOC (acyclic). 25-assertion unit test suite passes; smoke test 3/3; no import cycles. Co-authored with @oyi77. - **Provider-detail god-component decomposition — Phase 2 (helpers→lib)** ([#3501]): extracted the pure shared helpers — `ProviderMessageTranslator`/`LocalProviderMetadata` types, `providerText`/`providerCountText`/`readBooleanToggle`, and the provider base-URL + routing-tag/excluded-model parse/format block — into a new leaf `providers/[id]/providerPageHelpers.ts` (imports only `@/shared`, so the client and modals share them with no import cycle). `ProviderDetailPageClient.tsx`: 10,435 → 10,288 LOC. Unblocks extracting the heavier `AddApiKeyModal`/`EditConnectionModal` (which depend on these helpers) without cycling. The Phase 0 smoke test caught a missing transitive import (`isSelfHostedChatProvider`) at mount — now wired + locked by a new helpers unit test (12 assertions). Co-authored with @oyi77. - **#3500 fully resolved** — Hard Rule #5 (no raw SQL in route handlers): all 13 internal offenders migrated to `src/lib/db/` modules across slices (call_logs, usage_history/daily_usage_summary, community_servers, usage_logs, semantic_cache, proxy_logs, skills UPDATE, db-backups). The gate's `KNOWN_RAW_SQL` set is renamed to `EXTERNAL_DB_ALLOWED` (with a back-compat alias) and now holds only the **2 external-DB reads** (`oauth/cursor/auto-import`, `oauth/kiro/auto-import`) — these open *another app's* SQLite to import credentials, so by design they cannot live in OmniRoute's `db/` domain. The gate still blocks any NEW raw SQL against OmniRoute's DB. diff --git a/file-size-baseline.json b/file-size-baseline.json index 4a0d61c437..6cf62ccde3 100644 --- a/file-size-baseline.json +++ b/file-size-baseline.json @@ -48,7 +48,7 @@ "src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2570, "src/app/(dashboard)/dashboard/health/page.tsx": 1091, "src/app/(dashboard)/dashboard/playground/components/tabs/ApiTab.tsx": 847, - "src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 10288, + "src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 9981, "src/app/(dashboard)/dashboard/providers/components/onboarding/ProviderOnboardingWizard.tsx": 906, "src/app/(dashboard)/dashboard/providers/page.tsx": 1925, "src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1127, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx index 9b94660abe..5745520805 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx @@ -72,11 +72,7 @@ import { maskEmail, pickMaskedDisplayValue, pickDisplayValue } from "@/shared/ut import useEmailPrivacyStore from "@/store/emailPrivacyStore"; import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle"; import ProviderIcon from "@/shared/components/ProviderIcon"; -import { - getClaudeCodeCompatibleRequestDefaults as _getClaudeCodeCompatibleRequestDefaults, - getCodexRequestDefaults as _getCodexRequestDefaults, - type CodexServiceTier, -} from "@/lib/providers/requestDefaults"; +import { type CodexServiceTier } from "@/lib/providers/requestDefaults"; import { CODEX_FAST_TIER_DEFAULT_SUPPORTED_MODELS, getCodexEffectiveServiceTier, @@ -126,20 +122,35 @@ import { providerText, providerCountText, readBooleanToggle, + getWebSessionCredentialLabel, + getWebSessionCredentialHint, + getWebSessionCredentialCheckLabel, + getAddCredentialModalTitle, + upstreamHeadersRecordsEqual, + UPSTREAM_HEADERS_UI_MAX, + headerRowsToRecord, + effectiveUpstreamHeadersForProtocol, + anyUpstreamHeadersBadge, + getProtoSlice, + CODEX_REASONING_STRENGTH_OPTIONS, + CODEX_ACCOUNT_SERVICE_TIER_VALUES, + CODEX_GLOBAL_SERVICE_MODE_VALUES, + getCodexServiceTierLabel, + normalizeCodexLimitPolicy, + getCodexRequestDefaults, + getClaudeCodeCompatibleRequestDefaults, + compatProtocolLabelKey, + extractCommandCodeCredentialInput, + normalizeAndValidateHttpBaseUrl, + SILICONFLOW_ENDPOINTS, type ProviderMessageTranslator, type LocalProviderMetadata, + type CommandCodeAuthFlowState, + type HeaderDraftRow, + type CompatByProtocolMap, + type CompatModelRow, + type CompatModelMap, } from "./providerPageHelpers"; -type CompatByProtocolMap = Partial< - Record< - ModelCompatProtocolKey, - { - normalizeToolCallId?: boolean; - preserveOpenAIDeveloperRole?: boolean; - upstreamHeaders?: Record; - } - > ->; - /** PATCH fields for provider model compat (matches API + `ModelCompatPerProtocol` shape). */ type ModelCompatSavePatch = { normalizeToolCallId?: boolean; @@ -149,35 +160,12 @@ type ModelCompatSavePatch = { isHidden?: boolean; }; -type CompatModelRow = { - id?: string; - name?: string; - source?: string; - apiFormat?: string; - supportedEndpoints?: string[]; - normalizeToolCallId?: boolean; - preserveOpenAIDeveloperRole?: boolean; - isHidden?: boolean; - upstreamHeaders?: Record; - compatByProtocol?: CompatByProtocolMap; -}; - -type CompatModelMap = Map; - function buildCompatMap(rows: CompatModelRow[]): CompatModelMap { const m = new Map(); for (const r of rows) if (r.id) m.set(r.id, r); return m; } -function getProtoSlice( - c: CompatModelRow | undefined, - o: CompatModelRow | undefined, - protocol: string -) { - return c?.compatByProtocol?.[protocol] ?? o?.compatByProtocol?.[protocol]; -} - function isModelHidden( modelId: string, customMap: CompatModelMap, @@ -194,90 +182,6 @@ function isModelHidden( return false; } -function getWebSessionCredentialLabel( - t: ProviderMessageTranslator, - requirement: WebSessionCredentialRequirement, - optional: boolean -): string { - if (requirement.kind === "none") { - return providerText(t, "webNoAuthCredentialLabel", "No credential required"); - } - const baseLabel = - requirement.kind === "token" - ? providerText(t, "webTokenCredentialLabel", "Web session token") - : t("sessionCookieLabel"); - return optional ? `${baseLabel} (${t("optional").toLowerCase()})` : baseLabel; -} - -function getWebSessionCredentialHint( - t: ProviderMessageTranslator, - requirement: WebSessionCredentialRequirement, - providerName: string, - editing: boolean -): string | undefined { - if (requirement.kind === "none") return undefined; - - const values = { provider: providerName, credential: requirement.credentialName }; - if (editing) { - return requirement.kind === "token" - ? providerText( - t, - "webTokenEditHint", - "Leave blank to keep the current web session token. Credential: {credential}.", - values - ) - : providerText( - t, - "webCookieEditHint", - "Leave blank to keep the current session cookie. Required cookie: {credential}.", - values - ); - } - - return requirement.kind === "token" - ? providerText( - t, - "webTokenCredentialHint", - "Credential: {credential}. Paste the token value from your own signed-in {provider} web session, or a DevTools HAR export if the provider supports it.", - values - ) - : providerText( - t, - "webCookieCredentialHint", - "Required cookie: {credential}. Paste the Cookie header value from your own signed-in {provider} web session. Do not include the Cookie: prefix.", - values - ); -} - -function getWebSessionCredentialCheckLabel( - t: ProviderMessageTranslator, - requirement: WebSessionCredentialRequirement -): string { - if (requirement.kind === "token") return providerText(t, "checkWebToken", "Check token"); - return providerText(t, "checkCookie", "Check cookie"); -} - -function getAddCredentialModalTitle( - t: ProviderMessageTranslator, - providerName: string, - requirement: WebSessionCredentialRequirement | null -): string { - if (!requirement) return t("addProviderApiKeyTitle", { provider: providerName }); - if (requirement.kind === "none") { - return providerText(t, "addProviderConnectionTitle", "Add {provider} connection", { - provider: providerName, - }); - } - if (requirement.kind === "token") { - return providerText(t, "addProviderWebTokenTitle", "Add {provider} web token", { - provider: providerName, - }); - } - return providerText(t, "addProviderSessionCookieTitle", "Add {provider} session cookie", { - provider: providerName, - }); -} - function WebSessionCredentialGuide({ requirement, providerName, @@ -468,36 +372,12 @@ function anyNoPreserveCompatBadge( return false; } -function upstreamHeadersRecordsEqual( - a: Record, - b: Record -): boolean { - const ka = Object.keys(a).sort(); - const kb = Object.keys(b).sort(); - if (ka.length !== kb.length) return false; - return ka.every((k, i) => k === kb[i] && a[k] === b[k]); -} - -type HeaderDraftRow = { id: string; name: string; value: string }; - -const UPSTREAM_HEADERS_UI_MAX = 16; - function recordToHeaderRows(rec: Record, genId: () => string): HeaderDraftRow[] { const entries = Object.entries(rec).filter(([k]) => k.trim()); if (entries.length === 0) return [{ id: genId(), name: "", value: "" }]; return entries.map(([name, value]) => ({ id: genId(), name, value })); } -function headerRowsToRecord(rows: HeaderDraftRow[]): Record { - const out: Record = {}; - for (const r of rows) { - const k = r.name.trim(); - if (!k) continue; - out[k] = r.value; - } - return out; -} - type ProviderModelsApiErrorBody = { error?: { message?: string; @@ -528,44 +408,6 @@ async function formatProviderModelsErrorResponse(res: Response): Promise return st || `HTTP ${res.status}`; } -function effectiveUpstreamHeadersForProtocol( - modelId: string, - protocol: string, - customMap: CompatModelMap, - overrideMap: CompatModelMap -): Record { - const c = customMap.get(modelId); - const o = overrideMap.get(modelId); - const base: Record = {}; - if (c?.upstreamHeaders && typeof c.upstreamHeaders === "object") { - Object.assign(base, c.upstreamHeaders); - } else if (o?.upstreamHeaders && typeof o.upstreamHeaders === "object") { - Object.assign(base, o.upstreamHeaders); - } - const pc = getProtoSlice(c, o, protocol); - if (pc?.upstreamHeaders && typeof pc.upstreamHeaders === "object") { - Object.assign(base, pc.upstreamHeaders); - } - return base; -} - -function anyUpstreamHeadersBadge( - modelId: string, - customMap: CompatModelMap, - overrideMap: CompatModelMap -): boolean { - const c = customMap.get(modelId); - const o = overrideMap.get(modelId); - const nonempty = (u: unknown) => - u && typeof u === "object" && !Array.isArray(u) && Object.keys(u as object).length > 0; - if (nonempty(c?.upstreamHeaders) || nonempty(o?.upstreamHeaders)) return true; - for (const p of MODEL_COMPAT_PROTOCOL_KEYS) { - const pc = getProtoSlice(c, o, p); - if (nonempty(pc?.upstreamHeaders)) return true; - } - return false; -} - interface ModelRowProps { model: { id: string; name?: string; source?: string; isHidden?: boolean }; fullModel: string; @@ -829,28 +671,6 @@ interface AddApiKeyModalProps { onClose: () => void; } -type CommandCodeAuthFlowState = { - phase: - | "idle" - | "starting" - | "polling" - | "received" - | "applying" - | "applied" - | "expired" - | "error"; - state: string; - authUrl: string; - callbackUrl: string; - expiresAt: string | null; - message?: string; -}; - -const SILICONFLOW_ENDPOINTS = [ - { id: "siliconflow", label: "Global", baseUrl: "https://api.siliconflow.com/v1" }, - { id: "siliconflow-cn", label: "China", baseUrl: "https://api.siliconflow.cn/v1" }, -] as const; - interface EditConnectionModalConnection { id?: string; name?: string; @@ -887,74 +707,6 @@ interface EditConnectionModalProps { onSave: (data: unknown) => Promise; onClose: () => void; } -const CODEX_REASONING_STRENGTH_OPTIONS = [ - { value: "none", label: "None" }, - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - { value: "xhigh", label: "XHigh" }, -]; - -const CODEX_ACCOUNT_SERVICE_TIER_VALUES: CodexServiceTier[] = ["default", "priority", "flex"]; -const CODEX_GLOBAL_SERVICE_MODE_VALUES: CodexGlobalServiceMode[] = [ - "none", - ...CODEX_ACCOUNT_SERVICE_TIER_VALUES, -]; - -function getCodexServiceTierLabel( - t: ProviderMessageTranslator, - value: CodexGlobalServiceMode -): string { - if (value === "none") { - return providerText(t, "codexServiceModeNone", "No global setting"); - } - if (value === "default") return providerText(t, "codexServiceTierDefault", "Default"); - if (value === "priority") return providerText(t, "codexServiceTierPriority", "Priority"); - return providerText(t, "codexServiceTierFlex", "Flex"); -} - -function normalizeCodexLimitPolicy(policy: unknown): { use5h: boolean; useWeekly: boolean } { - const record = - policy && typeof policy === "object" && !Array.isArray(policy) - ? (policy as Record) - : {}; - return { - use5h: typeof record.use5h === "boolean" ? record.use5h : true, - useWeekly: typeof record.useWeekly === "boolean" ? record.useWeekly : true, - }; -} - -/** - * UI adapter around the canonical getCodexRequestDefaults from requestDefaults.ts. - * Adds the "medium" fallback for reasoningEffort required by the connection form. - */ -function getCodexRequestDefaults(providerSpecificData: unknown): { - reasoningEffort: string; - serviceTier?: CodexServiceTier; -} { - const defaults = _getCodexRequestDefaults(providerSpecificData); - return { - reasoningEffort: defaults.reasoningEffort ?? "medium", - ...(defaults.serviceTier ? { serviceTier: defaults.serviceTier } : {}), - }; -} - -function getClaudeCodeCompatibleRequestDefaults(providerSpecificData: unknown): { - context1m: boolean; -} { - const defaults = _getClaudeCodeCompatibleRequestDefaults(providerSpecificData); - return { - context1m: defaults.context1m === true, - }; -} - -function compatProtocolLabelKey(protocol: string): string { - if (protocol === "openai") return "compatProtocolOpenAI"; - if (protocol === "openai-responses") return "compatProtocolOpenAIResponses"; - if (protocol === "claude") return "compatProtocolClaude"; - return "compatProtocolOpenAI"; -} - function ModelCompatPopover({ t, effectiveModelNormalize, @@ -8298,52 +8050,6 @@ function ConnectionRow({ ); } -function extractCommandCodeCredentialInput(value: string): string { - const trimmed = value.trim(); - if (!trimmed) return ""; - - try { - const parsed = JSON.parse(trimmed) as unknown; - if (parsed && typeof parsed === "object") { - const record = parsed as Record; - const direct = record.apiKey || record.api_key || record.key || record.token; - if (typeof direct === "string" && direct.trim()) return direct.trim(); - const nested = record.data; - if (nested && typeof nested === "object") { - const nestedRecord = nested as Record; - const nestedKey = nestedRecord.apiKey || nestedRecord.api_key || nestedRecord.key; - if (typeof nestedKey === "string" && nestedKey.trim()) return nestedKey.trim(); - } - } - } catch { - // Not JSON; continue with URL/raw parsing. - } - - try { - const url = new URL(trimmed); - const key = - url.searchParams.get("apiKey") || - url.searchParams.get("api_key") || - url.searchParams.get("key") || - url.searchParams.get("token"); - if (key?.trim()) return key.trim(); - const hash = url.hash.replace(/^#/, ""); - if (hash) { - const hashParams = new URLSearchParams(hash); - const hashKey = - hashParams.get("apiKey") || - hashParams.get("api_key") || - hashParams.get("key") || - hashParams.get("token"); - if (hashKey?.trim()) return hashKey.trim(); - } - } catch { - // Not a URL; use the raw value. - } - - return trimmed; -} - function SiliconFlowEndpointModal({ isOpen, onSelect, @@ -9181,19 +8887,6 @@ function AddApiKeyModal({ ); } -function normalizeAndValidateHttpBaseUrl(rawValue, fallbackUrl) { - const value = (typeof rawValue === "string" ? rawValue.trim() : "") || fallbackUrl; - try { - const parsed = new URL(value); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - return { value: null, error: "Base URL must use http or https" }; - } - return { value, error: null }; - } catch { - return { value: null, error: "Base URL must be a valid URL" }; - } -} - function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnectionModalProps) { const t = useTranslations("providers"); const notify = useNotificationStore(); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts index 40cc924397..dedbc0f1af 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts @@ -1,9 +1,25 @@ // Pure, shared helpers for the provider-detail page and its extracted modals // (Issue #3501 strangler-fig decomposition, Phase 2). Leaf module — imports only -// from @/shared, so the page client AND colocated modals can import these without -// a circular dependency. Extracting them here unblocks moving the heavier modals +// from @/shared, @/lib and colocated sibling modules that are themselves acyclic, +// so the page client AND colocated modals can import these without a circular +// dependency. Extracting them here unblocks moving the heavier modals // (AddApiKeyModal / EditConnectionModal) out of the god-component in later phases. import { LOCAL_PROVIDERS, isSelfHostedChatProvider } from "@/shared/constants/providers"; +import { + MODEL_COMPAT_PROTOCOL_KEYS, + type ModelCompatProtocolKey, +} from "@/shared/constants/modelCompat"; +import { + getClaudeCodeCompatibleRequestDefaults as _getClaudeCodeCompatibleRequestDefaults, + getCodexRequestDefaults as _getCodexRequestDefaults, + type CodexServiceTier, +} from "@/lib/providers/requestDefaults"; +import { type CodexGlobalServiceMode } from "@/lib/providers/codexFastTier"; +import { type WebSessionCredentialRequirement } from "./webSessionCredentials"; + +// --------------------------------------------------------------------------- +// Types shared between page + modals +// --------------------------------------------------------------------------- export type ProviderMessageTranslator = (( key: string, @@ -18,6 +34,59 @@ export type LocalProviderMetadata = { [key: string]: unknown; }; +export type CommandCodeAuthFlowState = { + phase: + | "idle" + | "starting" + | "polling" + | "received" + | "applying" + | "applied" + | "expired" + | "error"; + state: string; + authUrl: string; + callbackUrl: string; + expiresAt: string | null; + message?: string; +}; + +// --------------------------------------------------------------------------- +// Compat model map types (shared by upstream-headers helpers and the page) +// --------------------------------------------------------------------------- + +export type CompatByProtocolMap = Partial< + Record< + ModelCompatProtocolKey, + { + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; + upstreamHeaders?: Record; + } + > +>; + +export type CompatModelRow = { + id?: string; + name?: string; + source?: string; + apiFormat?: string; + supportedEndpoints?: string[]; + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; + isHidden?: boolean; + upstreamHeaders?: Record; + compatByProtocol?: CompatByProtocolMap; +}; + +export type CompatModelMap = Map; + +export type HeaderDraftRow = { id: string; name: string; value: string }; + +// --------------------------------------------------------------------------- +// Utility — message translation with fallback +// --------------------------------------------------------------------------- + export function providerText( t: ProviderMessageTranslator, key: string, @@ -57,6 +126,10 @@ export function readBooleanToggle(value: unknown, fallback: boolean): boolean { return fallback; } +// --------------------------------------------------------------------------- +// Base-URL helpers +// --------------------------------------------------------------------------- + export const CONFIGURABLE_BASE_URL_PROVIDERS = new Set([ "azure-openai", "azure-ai", @@ -160,6 +233,10 @@ export function isGlmProvider(providerId?: string | null) { return providerId === "glm" || providerId === "glm-cn" || providerId === "glmt"; } +// --------------------------------------------------------------------------- +// Routing-tags / excluded-models parse + format +// --------------------------------------------------------------------------- + export function parseRoutingTagsInput(value: string): string[] | undefined { const tags = Array.from( new Set( @@ -199,3 +276,313 @@ export function formatExcludedModelsInput(value: unknown): string { ) .join(", "); } + +// --------------------------------------------------------------------------- +// Web-session credential label / hint helpers (Phase 2b) +// --------------------------------------------------------------------------- + +export function getWebSessionCredentialLabel( + t: ProviderMessageTranslator, + requirement: WebSessionCredentialRequirement, + optional: boolean +): string { + if (requirement.kind === "none") { + return providerText(t, "webNoAuthCredentialLabel", "No credential required"); + } + const baseLabel = + requirement.kind === "token" + ? providerText(t, "webTokenCredentialLabel", "Web session token") + : t("sessionCookieLabel"); + return optional ? `${baseLabel} (${t("optional").toLowerCase()})` : baseLabel; +} + +export function getWebSessionCredentialHint( + t: ProviderMessageTranslator, + requirement: WebSessionCredentialRequirement, + providerName: string, + editing: boolean +): string | undefined { + if (requirement.kind === "none") return undefined; + + const values = { provider: providerName, credential: requirement.credentialName }; + if (editing) { + return requirement.kind === "token" + ? providerText( + t, + "webTokenEditHint", + "Leave blank to keep the current web session token. Credential: {credential}.", + values + ) + : providerText( + t, + "webCookieEditHint", + "Leave blank to keep the current session cookie. Required cookie: {credential}.", + values + ); + } + + return requirement.kind === "token" + ? providerText( + t, + "webTokenCredentialHint", + "Credential: {credential}. Paste the token value from your own signed-in {provider} web session, or a DevTools HAR export if the provider supports it.", + values + ) + : providerText( + t, + "webCookieCredentialHint", + "Required cookie: {credential}. Paste the Cookie header value from your own signed-in {provider} web session. Do not include the Cookie: prefix.", + values + ); +} + +export function getWebSessionCredentialCheckLabel( + t: ProviderMessageTranslator, + requirement: WebSessionCredentialRequirement +): string { + if (requirement.kind === "token") return providerText(t, "checkWebToken", "Check token"); + return providerText(t, "checkCookie", "Check cookie"); +} + +export function getAddCredentialModalTitle( + t: ProviderMessageTranslator, + providerName: string, + requirement: WebSessionCredentialRequirement | null +): string { + if (!requirement) return t("addProviderApiKeyTitle", { provider: providerName }); + if (requirement.kind === "none") { + return providerText(t, "addProviderConnectionTitle", "Add {provider} connection", { + provider: providerName, + }); + } + if (requirement.kind === "token") { + return providerText(t, "addProviderWebTokenTitle", "Add {provider} web token", { + provider: providerName, + }); + } + return providerText(t, "addProviderSessionCookieTitle", "Add {provider} session cookie", { + provider: providerName, + }); +} + +// --------------------------------------------------------------------------- +// Upstream-headers helpers (Phase 2b) +// --------------------------------------------------------------------------- + +export const UPSTREAM_HEADERS_UI_MAX = 16; + +export function upstreamHeadersRecordsEqual( + a: Record, + b: Record +): boolean { + const ka = Object.keys(a).sort(); + const kb = Object.keys(b).sort(); + if (ka.length !== kb.length) return false; + return ka.every((k, i) => k === kb[i] && a[k] === b[k]); +} + +export function headerRowsToRecord(rows: HeaderDraftRow[]): Record { + const out: Record = {}; + for (const r of rows) { + const k = r.name.trim(); + if (!k) continue; + out[k] = r.value; + } + return out; +} + +// Internal helper: returns the per-protocol compat slice for a model (custom +// overrides take precedence over overrideMap). +export function getProtoSlice( + c: CompatModelRow | undefined, + o: CompatModelRow | undefined, + protocol: string +) { + return c?.compatByProtocol?.[protocol] ?? o?.compatByProtocol?.[protocol]; +} + +export function effectiveUpstreamHeadersForProtocol( + modelId: string, + protocol: string, + customMap: CompatModelMap, + overrideMap: CompatModelMap +): Record { + const c = customMap.get(modelId); + const o = overrideMap.get(modelId); + const base: Record = {}; + if (c?.upstreamHeaders && typeof c.upstreamHeaders === "object") { + Object.assign(base, c.upstreamHeaders); + } else if (o?.upstreamHeaders && typeof o.upstreamHeaders === "object") { + Object.assign(base, o.upstreamHeaders); + } + const pc = getProtoSlice(c, o, protocol); + if (pc?.upstreamHeaders && typeof pc.upstreamHeaders === "object") { + Object.assign(base, pc.upstreamHeaders); + } + return base; +} + +export function anyUpstreamHeadersBadge( + modelId: string, + customMap: CompatModelMap, + overrideMap: CompatModelMap +): boolean { + const c = customMap.get(modelId); + const o = overrideMap.get(modelId); + const nonempty = (u: unknown) => + u && typeof u === "object" && !Array.isArray(u) && Object.keys(u as object).length > 0; + if (nonempty(c?.upstreamHeaders) || nonempty(o?.upstreamHeaders)) return true; + for (const p of MODEL_COMPAT_PROTOCOL_KEYS) { + const pc = getProtoSlice(c, o, p); + if (nonempty(pc?.upstreamHeaders)) return true; + } + return false; +} + +// --------------------------------------------------------------------------- +// Codex helpers + consts (Phase 2b) +// --------------------------------------------------------------------------- + +export const CODEX_REASONING_STRENGTH_OPTIONS = [ + { value: "none", label: "None" }, + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "xhigh", label: "XHigh" }, +]; + +export const CODEX_ACCOUNT_SERVICE_TIER_VALUES: CodexServiceTier[] = [ + "default", + "priority", + "flex", +]; + +export const CODEX_GLOBAL_SERVICE_MODE_VALUES: CodexGlobalServiceMode[] = [ + "none", + ...CODEX_ACCOUNT_SERVICE_TIER_VALUES, +]; + +export function getCodexServiceTierLabel( + t: ProviderMessageTranslator, + value: CodexGlobalServiceMode +): string { + if (value === "none") { + return providerText(t, "codexServiceModeNone", "No global setting"); + } + if (value === "default") return providerText(t, "codexServiceTierDefault", "Default"); + if (value === "priority") return providerText(t, "codexServiceTierPriority", "Priority"); + return providerText(t, "codexServiceTierFlex", "Flex"); +} + +export function normalizeCodexLimitPolicy(policy: unknown): { use5h: boolean; useWeekly: boolean } { + const record = + policy && typeof policy === "object" && !Array.isArray(policy) + ? (policy as Record) + : {}; + return { + use5h: typeof record.use5h === "boolean" ? record.use5h : true, + useWeekly: typeof record.useWeekly === "boolean" ? record.useWeekly : true, + }; +} + +/** + * UI adapter around the canonical getCodexRequestDefaults from requestDefaults.ts. + * Adds the "medium" fallback for reasoningEffort required by the connection form. + */ +export function getCodexRequestDefaults(providerSpecificData: unknown): { + reasoningEffort: string; + serviceTier?: CodexServiceTier; +} { + const defaults = _getCodexRequestDefaults(providerSpecificData); + return { + reasoningEffort: defaults.reasoningEffort ?? "medium", + ...(defaults.serviceTier ? { serviceTier: defaults.serviceTier } : {}), + }; +} + +export function getClaudeCodeCompatibleRequestDefaults(providerSpecificData: unknown): { + context1m: boolean; +} { + const defaults = _getClaudeCodeCompatibleRequestDefaults(providerSpecificData); + return { + context1m: defaults.context1m === true, + }; +} + +// --------------------------------------------------------------------------- +// Misc pure helpers (Phase 2b) +// --------------------------------------------------------------------------- + +export const SILICONFLOW_ENDPOINTS = [ + { id: "siliconflow", label: "Global", baseUrl: "https://api.siliconflow.com/v1" }, + { id: "siliconflow-cn", label: "China", baseUrl: "https://api.siliconflow.cn/v1" }, +] as const; + +export function compatProtocolLabelKey(protocol: string): string { + if (protocol === "openai") return "compatProtocolOpenAI"; + if (protocol === "openai-responses") return "compatProtocolOpenAIResponses"; + if (protocol === "claude") return "compatProtocolClaude"; + return "compatProtocolOpenAI"; +} + +export function extractCommandCodeCredentialInput(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ""; + + try { + const parsed = JSON.parse(trimmed) as unknown; + if (parsed && typeof parsed === "object") { + const record = parsed as Record; + const direct = record.apiKey || record.api_key || record.key || record.token; + if (typeof direct === "string" && direct.trim()) return direct.trim(); + const nested = record.data; + if (nested && typeof nested === "object") { + const nestedRecord = nested as Record; + const nestedKey = nestedRecord.apiKey || nestedRecord.api_key || nestedRecord.key; + if (typeof nestedKey === "string" && nestedKey.trim()) return nestedKey.trim(); + } + } + } catch { + // Not JSON; continue with URL/raw parsing. + } + + try { + const url = new URL(trimmed); + const key = + url.searchParams.get("apiKey") || + url.searchParams.get("api_key") || + url.searchParams.get("key") || + url.searchParams.get("token"); + if (key?.trim()) return key.trim(); + const hash = url.hash.replace(/^#/, ""); + if (hash) { + const hashParams = new URLSearchParams(hash); + const hashKey = + hashParams.get("apiKey") || + hashParams.get("api_key") || + hashParams.get("key") || + hashParams.get("token"); + if (hashKey?.trim()) return hashKey.trim(); + } + } catch { + // Not a URL; use the raw value. + } + + return trimmed; +} + +export function normalizeAndValidateHttpBaseUrl( + rawValue: unknown, + fallbackUrl: string +): { value: string | null; error: string | null } { + const value = (typeof rawValue === "string" ? rawValue.trim() : "") || fallbackUrl; + try { + const parsed = new URL(value); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return { value: null, error: "Base URL must use http or https" }; + } + return { value, error: null }; + } catch { + return { value: null, error: "Base URL must be a valid URL" }; + } +} diff --git a/tests/unit/provider-page-helpers-3501.test.ts b/tests/unit/provider-page-helpers-3501.test.ts index 7550e4ec19..81eef3648d 100644 --- a/tests/unit/provider-page-helpers-3501.test.ts +++ b/tests/unit/provider-page-helpers-3501.test.ts @@ -19,6 +19,31 @@ import { parseExcludedModelsInput, formatRoutingTagsInput, formatExcludedModelsInput, + // Phase 2b additions + getWebSessionCredentialLabel, + getWebSessionCredentialHint, + getWebSessionCredentialCheckLabel, + getAddCredentialModalTitle, + upstreamHeadersRecordsEqual, + UPSTREAM_HEADERS_UI_MAX, + headerRowsToRecord, + effectiveUpstreamHeadersForProtocol, + anyUpstreamHeadersBadge, + getProtoSlice, + CODEX_REASONING_STRENGTH_OPTIONS, + CODEX_ACCOUNT_SERVICE_TIER_VALUES, + CODEX_GLOBAL_SERVICE_MODE_VALUES, + getCodexServiceTierLabel, + normalizeCodexLimitPolicy, + getCodexRequestDefaults, + getClaudeCodeCompatibleRequestDefaults, + compatProtocolLabelKey, + extractCommandCodeCredentialInput, + normalizeAndValidateHttpBaseUrl, + SILICONFLOW_ENDPOINTS, + type HeaderDraftRow, + type CompatModelRow, + type CompatModelMap, } from "../../src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts"; const tStub = Object.assign((key: string) => key, { has: (_k: string) => false }); @@ -62,3 +87,153 @@ test("routing-tags / excluded-models parse + format round-trip", () => { assert.equal(formatExcludedModelsInput(["a", "b"]), "a, b"); assert.equal(formatRoutingTagsInput(undefined), ""); }); + +// --------------------------------------------------------------------------- +// Phase 2b — runtime guards for the newly-moved helpers +// --------------------------------------------------------------------------- + +const tokenReq = { kind: "token" as const, credentialName: "myToken" }; +const cookieReq = { kind: "cookie" as const, credentialName: "SESS" }; +const noneReq = { kind: "none" as const, credentialName: "" }; + +test("getWebSessionCredentialLabel (Phase 2b — transitive import guard)", () => { + assert.ok(getWebSessionCredentialLabel(tStub, tokenReq, false).length > 0); + assert.ok(getWebSessionCredentialLabel(tStub, cookieReq, true).length > 0); + assert.ok(getWebSessionCredentialLabel(tStub, noneReq, false).length > 0); +}); + +test("getWebSessionCredentialHint returns undefined for none-kind, string otherwise", () => { + assert.equal(getWebSessionCredentialHint(tStub, noneReq, "Acme", false), undefined); + assert.equal(typeof getWebSessionCredentialHint(tStub, tokenReq, "Acme", false), "string"); + assert.equal(typeof getWebSessionCredentialHint(tStub, cookieReq, "Acme", true), "string"); +}); + +test("getWebSessionCredentialCheckLabel returns a non-empty string", () => { + assert.ok(getWebSessionCredentialCheckLabel(tStub, tokenReq).length > 0); + assert.ok(getWebSessionCredentialCheckLabel(tStub, cookieReq).length > 0); +}); + +test("getAddCredentialModalTitle handles null/none/token/cookie requirements", () => { + assert.ok(getAddCredentialModalTitle(tStub, "Acme", null).length > 0); + assert.ok(getAddCredentialModalTitle(tStub, "Acme", noneReq).length > 0); + assert.ok(getAddCredentialModalTitle(tStub, "Acme", tokenReq).length > 0); + assert.ok(getAddCredentialModalTitle(tStub, "Acme", cookieReq).length > 0); +}); + +test("upstreamHeadersRecordsEqual is order-insensitive and correct", () => { + assert.ok(upstreamHeadersRecordsEqual({ a: "1", b: "2" }, { b: "2", a: "1" })); + assert.ok(!upstreamHeadersRecordsEqual({ a: "1" }, { a: "2" })); + assert.ok(!upstreamHeadersRecordsEqual({ a: "1" }, { a: "1", b: "2" })); + assert.equal(UPSTREAM_HEADERS_UI_MAX, 16); +}); + +test("headerRowsToRecord filters blank name rows and builds record", () => { + const rows: HeaderDraftRow[] = [ + { id: "1", name: "X-Foo", value: "bar" }, + { id: "2", name: " ", value: "ignored" }, + { id: "3", name: "X-Baz", value: "qux" }, + ]; + assert.deepEqual(headerRowsToRecord(rows), { "X-Foo": "bar", "X-Baz": "qux" }); +}); + +test("effectiveUpstreamHeadersForProtocol merges base and protocol-specific headers", () => { + const row: CompatModelRow = { + id: "m1", + upstreamHeaders: { "X-Base": "base" }, + compatByProtocol: { openai: { upstreamHeaders: { "X-Proto": "proto" } } }, + }; + const customMap: CompatModelMap = new Map([["m1", row]]); + const overrideMap: CompatModelMap = new Map(); + const result = effectiveUpstreamHeadersForProtocol("m1", "openai", customMap, overrideMap); + assert.equal(result["X-Base"], "base"); + assert.equal(result["X-Proto"], "proto"); +}); + +test("anyUpstreamHeadersBadge detects non-empty upstream headers", () => { + const row: CompatModelRow = { id: "m1", upstreamHeaders: { "X-Foo": "bar" } }; + const customMap: CompatModelMap = new Map([["m1", row]]); + const overrideMap: CompatModelMap = new Map(); + assert.ok(anyUpstreamHeadersBadge("m1", customMap, overrideMap)); + const emptyRow: CompatModelRow = { id: "m2", upstreamHeaders: {} }; + const emptyMap: CompatModelMap = new Map([["m2", emptyRow]]); + assert.ok(!anyUpstreamHeadersBadge("m2", emptyMap, new Map())); +}); + +test("getProtoSlice returns custom compat over override", () => { + const custom: CompatModelRow = { + compatByProtocol: { openai: { normalizeToolCallId: true } }, + }; + const override: CompatModelRow = { + compatByProtocol: { openai: { normalizeToolCallId: false } }, + }; + const result = getProtoSlice(custom, override, "openai"); + assert.equal(result?.normalizeToolCallId, true); +}); + +test("CODEX_REASONING_STRENGTH_OPTIONS has expected values", () => { + const values = CODEX_REASONING_STRENGTH_OPTIONS.map((o) => o.value); + assert.ok(values.includes("none")); + assert.ok(values.includes("high")); +}); + +test("CODEX_ACCOUNT_SERVICE_TIER_VALUES contains expected tiers", () => { + assert.ok(CODEX_ACCOUNT_SERVICE_TIER_VALUES.includes("default")); + assert.ok(CODEX_ACCOUNT_SERVICE_TIER_VALUES.includes("flex")); +}); + +test("CODEX_GLOBAL_SERVICE_MODE_VALUES starts with none", () => { + assert.equal(CODEX_GLOBAL_SERVICE_MODE_VALUES[0], "none"); + assert.ok(CODEX_GLOBAL_SERVICE_MODE_VALUES.includes("priority")); +}); + +test("getCodexServiceTierLabel returns a string for every mode (transitive import guard)", () => { + for (const mode of CODEX_GLOBAL_SERVICE_MODE_VALUES) { + assert.equal(typeof getCodexServiceTierLabel(tStub, mode), "string"); + } +}); + +test("normalizeCodexLimitPolicy defaults use5h and useWeekly to true", () => { + assert.deepEqual(normalizeCodexLimitPolicy(null), { use5h: true, useWeekly: true }); + assert.deepEqual(normalizeCodexLimitPolicy({ use5h: false }), { use5h: false, useWeekly: true }); +}); + +test("getCodexRequestDefaults returns reasoningEffort (transitive import guard)", () => { + const result = getCodexRequestDefaults(null); + assert.equal(typeof result.reasoningEffort, "string"); +}); + +test("getClaudeCodeCompatibleRequestDefaults returns context1m boolean", () => { + const result = getClaudeCodeCompatibleRequestDefaults(null); + assert.equal(typeof result.context1m, "boolean"); +}); + +test("compatProtocolLabelKey maps protocol strings to i18n keys", () => { + assert.equal(compatProtocolLabelKey("openai"), "compatProtocolOpenAI"); + assert.equal(compatProtocolLabelKey("claude"), "compatProtocolClaude"); + assert.equal(compatProtocolLabelKey("unknown"), "compatProtocolOpenAI"); +}); + +test("extractCommandCodeCredentialInput extracts from JSON/URL/raw (transitive import guard)", () => { + assert.equal(extractCommandCodeCredentialInput(" "), ""); + assert.equal(extractCommandCodeCredentialInput("rawtoken"), "rawtoken"); + assert.equal( + extractCommandCodeCredentialInput(JSON.stringify({ apiKey: "abc123" })), + "abc123" + ); +}); + +test("normalizeAndValidateHttpBaseUrl validates http/https URLs", () => { + const ok = normalizeAndValidateHttpBaseUrl("https://api.example.com/v1", ""); + assert.equal(ok.error, null); + assert.equal(ok.value, "https://api.example.com/v1"); + const bad = normalizeAndValidateHttpBaseUrl("ftp://nope.com", ""); + assert.ok(bad.error !== null); + const invalid = normalizeAndValidateHttpBaseUrl("not-a-url", ""); + assert.ok(invalid.error !== null); +}); + +test("SILICONFLOW_ENDPOINTS has global and china entries", () => { + const ids = SILICONFLOW_ENDPOINTS.map((e) => e.id); + assert.ok(ids.includes("siliconflow")); + assert.ok(ids.includes("siliconflow-cn")); +});