mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 22:32:12 +03:00
#3501 Phase 2: extract pure helpers to providerPageHelpers (leaf, cycle-safe). Co-authored-by: oyi77 <oyi77@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
c4bdd4a90b
commit
2ec54d06f6
@@ -8,6 +8,8 @@
|
||||
|
||||
### ♻️ Code Quality
|
||||
|
||||
- **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.
|
||||
- **chore(db-gate):** reclassify `KNOWN_UNEXPORTED` → `INTENTIONALLY_INTERNAL` in `scripts/check/check-db-rules.mjs` ([#3499]): a full audit of all 25 db modules confirmed each is consumed via direct/dynamic import per Hard Rule #2 ("Never barrel-import from localDb.ts"). The old framing labelled them as "debt", which was misleading — they are the correct pattern. The gate's blocking behaviour is unchanged (a NEW unexported module still fails); only the name, comments, and per-module justifications were updated to reflect audited truth. Four modules flagged `DEAD?` (`compressionScheduler`, `discovery`, `pluginMetrics`, `prompts`) have zero production importers and are documented as schema-reserved. A new regression-guard test (`tests/unit/check-db-rules-classification.test.ts`) asserts every non-dead module in the set has ≥1 real importer, so a future consumer removal surfaces as a test failure requiring explicit reclassification.
|
||||
- **refactor(db): move `call_logs` aggregations into `callLogStats` db module** ([#3500]): extracted raw SQL from three route handlers (`/api/provider-metrics`, `/api/search/stats`, `/api/v1/search/analytics`) into a new `src/lib/db/callLogStats.ts` domain module (`getProviderMetrics`, `getSearchProviderStats`, `getRecentSearchLogs`, `getSearchAggregateStats`, `getSearchProviderCounts`). First slice of #3500 (call_logs cluster). Behavior unchanged; the three routes are removed from `KNOWN_RAW_SQL` in the gate. Validated with TDD unit tests (6 assertions seeding an in-memory SQLite fixture).
|
||||
|
||||
@@ -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": 10435,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 10288,
|
||||
"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,
|
||||
|
||||
@@ -95,12 +95,40 @@ import {
|
||||
getWebSessionCredentialRequirement,
|
||||
type WebSessionCredentialRequirement,
|
||||
} from "./webSessionCredentials";
|
||||
import { ImportCodexAuthModal, ApplyCodexAuthModal } from "./components/modals/ImportCodexAuthModal";
|
||||
import { ImportClaudeAuthModal, ApplyClaudeAuthModal } from "./components/modals/ImportClaudeAuthModal";
|
||||
import { ImportGeminiAuthModal, ApplyGeminiAuthModal } from "./components/modals/ImportGeminiAuthModal";
|
||||
import {
|
||||
ImportCodexAuthModal,
|
||||
ApplyCodexAuthModal,
|
||||
} from "./components/modals/ImportCodexAuthModal";
|
||||
import {
|
||||
ImportClaudeAuthModal,
|
||||
ApplyClaudeAuthModal,
|
||||
} from "./components/modals/ImportClaudeAuthModal";
|
||||
import {
|
||||
ImportGeminiAuthModal,
|
||||
ApplyGeminiAuthModal,
|
||||
} from "./components/modals/ImportGeminiAuthModal";
|
||||
|
||||
import EditCompatibleNodeModal from "./components/modals/EditCompatibleNodeModal";
|
||||
import { CC_COMPATIBLE_DEFAULT_CHAT_PATH } from "./providerDetailConstants";
|
||||
import {
|
||||
CONFIGURABLE_BASE_URL_PROVIDERS,
|
||||
DEFAULT_PROVIDER_BASE_URLS,
|
||||
getLocalProviderMetadata,
|
||||
isBaseUrlConfigurableProvider,
|
||||
getProviderBaseUrlDefault,
|
||||
getProviderBaseUrlHint,
|
||||
getProviderBaseUrlPlaceholder,
|
||||
isGlmProvider,
|
||||
parseRoutingTagsInput,
|
||||
parseExcludedModelsInput,
|
||||
formatRoutingTagsInput,
|
||||
formatExcludedModelsInput,
|
||||
providerText,
|
||||
providerCountText,
|
||||
readBooleanToggle,
|
||||
type ProviderMessageTranslator,
|
||||
type LocalProviderMetadata,
|
||||
} from "./providerPageHelpers";
|
||||
type CompatByProtocolMap = Partial<
|
||||
Record<
|
||||
ModelCompatProtocolKey,
|
||||
@@ -135,11 +163,6 @@ type CompatModelRow = {
|
||||
};
|
||||
|
||||
type CompatModelMap = Map<string, CompatModelRow>;
|
||||
type LocalProviderMetadata = {
|
||||
name?: string;
|
||||
localDefault?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function buildCompatMap(rows: CompatModelRow[]): CompatModelMap {
|
||||
const m = new Map<string, CompatModelRow>();
|
||||
@@ -171,49 +194,6 @@ function isModelHidden(
|
||||
return false;
|
||||
}
|
||||
|
||||
type ProviderMessageTranslator = ((key: string, values?: Record<string, unknown>) => string) & {
|
||||
has?: (key: string) => boolean;
|
||||
};
|
||||
|
||||
function providerText(
|
||||
t: ProviderMessageTranslator,
|
||||
key: string,
|
||||
fallback: string,
|
||||
values?: Record<string, unknown>
|
||||
): string {
|
||||
if (typeof t.has === "function" && t.has(key)) {
|
||||
return t(key, values);
|
||||
}
|
||||
if (values) {
|
||||
return Object.entries(values).reduce(
|
||||
(acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)),
|
||||
fallback
|
||||
);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function providerCountText(
|
||||
t: ProviderMessageTranslator,
|
||||
key: string,
|
||||
count: number,
|
||||
singularFallback: string,
|
||||
pluralFallback: string
|
||||
): string {
|
||||
return providerText(t, key, count === 1 ? singularFallback : pluralFallback, { count });
|
||||
}
|
||||
|
||||
function readBooleanToggle(value: unknown, fallback: boolean): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return value === 1;
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "1" || normalized === "true") return true;
|
||||
if (normalized === "0" || normalized === "false") return false;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function getWebSessionCredentialLabel(
|
||||
t: ProviderMessageTranslator,
|
||||
requirement: WebSessionCredentialRequirement,
|
||||
@@ -1570,7 +1550,7 @@ export default function ProviderDetailPageClient() {
|
||||
source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom",
|
||||
}));
|
||||
const allModels = [...builtInModels, ...syncedExtras, ...customExtras];
|
||||
const deduped = new Map<string, typeof allModels[0]>();
|
||||
const deduped = new Map<string, (typeof allModels)[0]>();
|
||||
for (const m of allModels) {
|
||||
if (m.id && !deduped.has(m.id)) deduped.set(m.id, m);
|
||||
}
|
||||
@@ -1673,10 +1653,9 @@ export default function ProviderDetailPageClient() {
|
||||
const handleDeleteAlias = useCallback(
|
||||
async (alias: string) => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/models/alias?alias=${encodeURIComponent(alias)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
const res = await fetch(`/api/models/alias?alias=${encodeURIComponent(alias)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (res.ok) {
|
||||
await fetchAliases();
|
||||
notify.success(t("deleteAliasSuccess", { alias }));
|
||||
@@ -2029,20 +2008,14 @@ export default function ProviderDetailPageClient() {
|
||||
} catch (e) {
|
||||
error++;
|
||||
}
|
||||
setTestProgress((prev) =>
|
||||
prev ? { done: prev.done + 1, total: prev.total } : null
|
||||
);
|
||||
setTestProgress((prev) => (prev ? { done: prev.done + 1, total: prev.total } : null));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
notify.info(
|
||||
providerText(t, "testAllResults", "{ok} ok, {error} error", { ok, error })
|
||||
);
|
||||
notify.info(providerText(t, "testAllResults", "{ok} ok, {error} error", { ok, error }));
|
||||
if (hiddenCount > 0) {
|
||||
notify.info(
|
||||
providerText(t, "testAllFailedHidden", "{count} hidden", { count: hiddenCount })
|
||||
);
|
||||
notify.info(providerText(t, "testAllFailedHidden", "{count} hidden", { count: hiddenCount }));
|
||||
}
|
||||
setTestingAll(false);
|
||||
setTestProgress(null);
|
||||
@@ -2677,8 +2650,7 @@ export default function ProviderDetailPageClient() {
|
||||
const handleDistributeProxies = async (tagFilter?: string) => {
|
||||
const targetConnections = tagFilter
|
||||
? connections.filter(
|
||||
(c: any) =>
|
||||
(c.providerSpecificData?.tag as string | undefined)?.trim() === tagFilter
|
||||
(c: any) => (c.providerSpecificData?.tag as string | undefined)?.trim() === tagFilter
|
||||
)
|
||||
: connections;
|
||||
if (targetConnections.length === 0) return;
|
||||
@@ -2687,9 +2659,7 @@ export default function ProviderDetailPageClient() {
|
||||
const proxiesRes = await fetch("/api/settings/proxies");
|
||||
if (!proxiesRes.ok) throw new Error("Failed to fetch proxies");
|
||||
const proxiesData = await proxiesRes.json();
|
||||
const savedProxies = (proxiesData?.items || []).filter(
|
||||
(p: any) => p.status === "active"
|
||||
);
|
||||
const savedProxies = (proxiesData?.items || []).filter((p: any) => p.status === "active");
|
||||
if (savedProxies.length === 0) {
|
||||
notify.error("No saved proxies found. Add proxies in Settings → Proxy first.");
|
||||
return;
|
||||
@@ -3681,8 +3651,7 @@ export default function ProviderDetailPageClient() {
|
||||
const providerAliasEntries = useMemo(
|
||||
() =>
|
||||
Object.entries(modelAliases).filter(
|
||||
([, model]) =>
|
||||
typeof model === "string" && model.startsWith(`${providerStorageAlias}/`)
|
||||
([, model]) => typeof model === "string" && model.startsWith(`${providerStorageAlias}/`)
|
||||
),
|
||||
[modelAliases, providerStorageAlias]
|
||||
);
|
||||
@@ -4786,15 +4755,24 @@ export default function ProviderDetailPageClient() {
|
||||
{ value: "active", label: t("filterActive", "Active") },
|
||||
{ value: "error", label: t("filterError", "Error") },
|
||||
{ value: "banned", label: t("filterBanned", "Banned") },
|
||||
{ value: "credits_exhausted", label: t("filterCreditsExhausted", "Credits Exhausted") },
|
||||
{
|
||||
value: "credits_exhausted",
|
||||
label: t("filterCreditsExhausted", "Credits Exhausted"),
|
||||
},
|
||||
];
|
||||
const filtered = healthFilter === "all"
|
||||
? sorted
|
||||
: sorted.filter((c) => {
|
||||
if (healthFilter === "active") return isHealthy(c);
|
||||
if (healthFilter === "error") return !isHealthy(c) && c.testStatus !== "banned" && c.testStatus !== "credits_exhausted";
|
||||
return c.testStatus === healthFilter;
|
||||
});
|
||||
const filtered =
|
||||
healthFilter === "all"
|
||||
? sorted
|
||||
: sorted.filter((c) => {
|
||||
if (healthFilter === "active") return isHealthy(c);
|
||||
if (healthFilter === "error")
|
||||
return (
|
||||
!isHealthy(c) &&
|
||||
c.testStatus !== "banned" &&
|
||||
c.testStatus !== "credits_exhausted"
|
||||
);
|
||||
return c.testStatus === healthFilter;
|
||||
});
|
||||
|
||||
const totalFilteredPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
const clampedPage = Math.min(page, totalFilteredPages - 1);
|
||||
@@ -4823,36 +4801,38 @@ export default function ProviderDetailPageClient() {
|
||||
</div>
|
||||
);
|
||||
|
||||
const paginationBar = totalFilteredPages > 1 ? (
|
||||
<div className="flex items-center justify-between px-3 py-2 border-t border-border">
|
||||
<span className="text-xs text-text-muted">
|
||||
{pageStart + 1}–{Math.min(pageEnd, filtered.length)} / {filtered.length}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="chevron_left"
|
||||
disabled={clampedPage === 0}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
/>
|
||||
<span className="text-xs text-text-muted min-w-[4rem] text-center">
|
||||
{clampedPage + 1} / {totalFilteredPages}
|
||||
const paginationBar =
|
||||
totalFilteredPages > 1 ? (
|
||||
<div className="flex items-center justify-between px-3 py-2 border-t border-border">
|
||||
<span className="text-xs text-text-muted">
|
||||
{pageStart + 1}–{Math.min(pageEnd, filtered.length)} / {filtered.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="chevron_right"
|
||||
disabled={clampedPage >= totalFilteredPages - 1}
|
||||
onClick={() => setPage((p) => Math.min(totalFilteredPages - 1, p + 1))}
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="chevron_left"
|
||||
disabled={clampedPage === 0}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
/>
|
||||
<span className="text-xs text-text-muted min-w-[4rem] text-center">
|
||||
{clampedPage + 1} / {totalFilteredPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="chevron_right"
|
||||
disabled={clampedPage >= totalFilteredPages - 1}
|
||||
onClick={() => setPage((p) => Math.min(totalFilteredPages - 1, p + 1))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
) : null;
|
||||
|
||||
if (!hasAnyTag) {
|
||||
const pageConnections = filtered.slice(pageStart, pageEnd);
|
||||
const allSelected = pageConnections.length > 0 && pageConnections.every((c) => selectedIds.has(c.id));
|
||||
const allSelected =
|
||||
pageConnections.length > 0 && pageConnections.every((c) => selectedIds.has(c.id));
|
||||
const someSelected = pageConnections.some((c) => selectedIds.has(c.id));
|
||||
return (
|
||||
<>
|
||||
@@ -4913,113 +4893,117 @@ export default function ProviderDetailPageClient() {
|
||||
</div>
|
||||
) : (
|
||||
pageConnections.map((conn, index) => (
|
||||
<ConnectionRow
|
||||
key={conn.id}
|
||||
connection={conn}
|
||||
isOAuth={conn.authType === "oauth"}
|
||||
isClaude={providerId === "claude"}
|
||||
codexGlobalServiceMode={codexGlobalServiceMode}
|
||||
isFirst={index === 0}
|
||||
isLast={index === pageConnections.length - 1}
|
||||
isSelected={selectedIds.has(conn.id)}
|
||||
onToggleSelect={() => handleToggleSelectOne(conn.id)}
|
||||
onMoveUp={() => handleSwapPriority(conn, sorted[index - 1])}
|
||||
onMoveDown={() => handleSwapPriority(conn, sorted[index + 1])}
|
||||
onToggleActive={(isActive) =>
|
||||
handleUpdateConnectionStatus(conn.id, isActive)
|
||||
}
|
||||
onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)}
|
||||
onToggleClaudeExtraUsage={(enabled) =>
|
||||
handleToggleClaudeExtraUsage(conn.id, enabled)
|
||||
}
|
||||
isCodex={providerId === "codex"}
|
||||
isGeminiCli={providerId === "gemini-cli"}
|
||||
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"
|
||||
? () => gateConnectionFlow(() => setShowOAuthModal(true, conn))
|
||||
: undefined
|
||||
}
|
||||
onRefreshToken={
|
||||
conn.authType === "oauth"
|
||||
? () => handleRefreshToken(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isRefreshing={refreshingId === conn.id}
|
||||
onApplyCodexAuthLocal={
|
||||
providerId === "codex"
|
||||
? () => setApplyCodexModalConnectionId(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id}
|
||||
onExportCodexAuthFile={
|
||||
providerId === "codex"
|
||||
? () => handleExportCodexAuthFile(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isExportingCodexAuthFile={exportingCodexAuthId === conn.id}
|
||||
onApplyClaudeAuthLocal={
|
||||
providerId === "claude"
|
||||
? () => setApplyClaudeModalConnectionId(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isApplyingClaudeAuthLocal={applyingClaudeAuthId === conn.id}
|
||||
onExportClaudeAuthFile={
|
||||
providerId === "claude"
|
||||
? () => handleExportClaudeAuthFile(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isExportingClaudeAuthFile={exportingClaudeAuthId === conn.id}
|
||||
onApplyGeminiAuthLocal={
|
||||
providerId === "gemini-cli"
|
||||
? () => setApplyGeminiModalConnectionId(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isApplyingGeminiAuthLocal={applyingGeminiAuthId === conn.id}
|
||||
onExportGeminiAuthFile={
|
||||
providerId === "gemini-cli"
|
||||
? () => handleExportGeminiAuthFile(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isExportingGeminiAuthFile={exportingGeminiAuthId === 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}
|
||||
proxyEnabled={readBooleanToggle(conn.proxyEnabled, true)}
|
||||
onToggleProxyEnabled={(enabled) => handleToggleProxyEnabled(conn.id, enabled)}
|
||||
perKeyProxyEnabled={readBooleanToggle(conn.perKeyProxyEnabled, false)}
|
||||
onTogglePerKeyProxyEnabled={(enabled) => handleTogglePerKeyProxyEnabled(conn.id, enabled)}
|
||||
/>
|
||||
)))
|
||||
}
|
||||
<ConnectionRow
|
||||
key={conn.id}
|
||||
connection={conn}
|
||||
isOAuth={conn.authType === "oauth"}
|
||||
isClaude={providerId === "claude"}
|
||||
codexGlobalServiceMode={codexGlobalServiceMode}
|
||||
isFirst={index === 0}
|
||||
isLast={index === pageConnections.length - 1}
|
||||
isSelected={selectedIds.has(conn.id)}
|
||||
onToggleSelect={() => handleToggleSelectOne(conn.id)}
|
||||
onMoveUp={() => handleSwapPriority(conn, sorted[index - 1])}
|
||||
onMoveDown={() => handleSwapPriority(conn, sorted[index + 1])}
|
||||
onToggleActive={(isActive) =>
|
||||
handleUpdateConnectionStatus(conn.id, isActive)
|
||||
}
|
||||
onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)}
|
||||
onToggleClaudeExtraUsage={(enabled) =>
|
||||
handleToggleClaudeExtraUsage(conn.id, enabled)
|
||||
}
|
||||
isCodex={providerId === "codex"}
|
||||
isGeminiCli={providerId === "gemini-cli"}
|
||||
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"
|
||||
? () => gateConnectionFlow(() => setShowOAuthModal(true, conn))
|
||||
: undefined
|
||||
}
|
||||
onRefreshToken={
|
||||
conn.authType === "oauth"
|
||||
? () => handleRefreshToken(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isRefreshing={refreshingId === conn.id}
|
||||
onApplyCodexAuthLocal={
|
||||
providerId === "codex"
|
||||
? () => setApplyCodexModalConnectionId(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id}
|
||||
onExportCodexAuthFile={
|
||||
providerId === "codex"
|
||||
? () => handleExportCodexAuthFile(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isExportingCodexAuthFile={exportingCodexAuthId === conn.id}
|
||||
onApplyClaudeAuthLocal={
|
||||
providerId === "claude"
|
||||
? () => setApplyClaudeModalConnectionId(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isApplyingClaudeAuthLocal={applyingClaudeAuthId === conn.id}
|
||||
onExportClaudeAuthFile={
|
||||
providerId === "claude"
|
||||
? () => handleExportClaudeAuthFile(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isExportingClaudeAuthFile={exportingClaudeAuthId === conn.id}
|
||||
onApplyGeminiAuthLocal={
|
||||
providerId === "gemini-cli"
|
||||
? () => setApplyGeminiModalConnectionId(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isApplyingGeminiAuthLocal={applyingGeminiAuthId === conn.id}
|
||||
onExportGeminiAuthFile={
|
||||
providerId === "gemini-cli"
|
||||
? () => handleExportGeminiAuthFile(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isExportingGeminiAuthFile={exportingGeminiAuthId === 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}
|
||||
proxyEnabled={readBooleanToggle(conn.proxyEnabled, true)}
|
||||
onToggleProxyEnabled={(enabled) =>
|
||||
handleToggleProxyEnabled(conn.id, enabled)
|
||||
}
|
||||
perKeyProxyEnabled={readBooleanToggle(conn.perKeyProxyEnabled, false)}
|
||||
onTogglePerKeyProxyEnabled={(enabled) =>
|
||||
handleTogglePerKeyProxyEnabled(conn.id, enabled)
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{paginationBar}
|
||||
</>
|
||||
@@ -5226,9 +5210,16 @@ export default function ProviderDetailPageClient() {
|
||||
proxySource={connProxyMap[conn.id]?.level || null}
|
||||
proxyHost={connProxyMap[conn.id]?.proxy?.host || null}
|
||||
proxyEnabled={readBooleanToggle(conn.proxyEnabled, true)}
|
||||
onToggleProxyEnabled={(enabled) => handleToggleProxyEnabled(conn.id, enabled)}
|
||||
perKeyProxyEnabled={readBooleanToggle(conn.perKeyProxyEnabled, false)}
|
||||
onTogglePerKeyProxyEnabled={(enabled) => handleTogglePerKeyProxyEnabled(conn.id, enabled)}
|
||||
onToggleProxyEnabled={(enabled) =>
|
||||
handleToggleProxyEnabled(conn.id, enabled)
|
||||
}
|
||||
perKeyProxyEnabled={readBooleanToggle(
|
||||
conn.perKeyProxyEnabled,
|
||||
false
|
||||
)}
|
||||
onTogglePerKeyProxyEnabled={(enabled) =>
|
||||
handleTogglePerKeyProxyEnabled(conn.id, enabled)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -5447,10 +5438,7 @@ export default function ProviderDetailPageClient() {
|
||||
/>
|
||||
)}
|
||||
{/* Codex CLI Guide Modal */}
|
||||
<CodexCliGuideModal
|
||||
isOpen={codexCliGuideOpen}
|
||||
onClose={() => setCodexCliGuideOpen(false)}
|
||||
/>
|
||||
<CodexCliGuideModal isOpen={codexCliGuideOpen} onClose={() => setCodexCliGuideOpen(false)} />
|
||||
{/* Codex Import Auth Modal */}
|
||||
{providerId === "codex" && importCodexModalOpen && (
|
||||
<ImportCodexAuthModal
|
||||
@@ -5471,8 +5459,8 @@ export default function ProviderDetailPageClient() {
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-text-muted">
|
||||
Compartilhe este link com quem vai autenticar a conta do Codex. A pessoa abre a
|
||||
página, faz o login da OpenAI no próprio navegador e a conexão é cadastrada aqui.
|
||||
Uso único, expira em 15 minutos.
|
||||
página, faz o login da OpenAI no próprio navegador e a conexão é cadastrada aqui. Uso
|
||||
único, expira em 15 minutos.
|
||||
</p>
|
||||
{externalLinkLoading ? (
|
||||
<p className="text-sm text-text-muted">Gerando link…</p>
|
||||
@@ -5743,7 +5731,7 @@ export default function ProviderDetailPageClient() {
|
||||
{importProgress.logs.length > 0 && (
|
||||
<div className="max-h-48 overflow-y-auto rounded-lg bg-black/5 dark:bg-white/5 p-3 border border-black/5 dark:border-white/5">
|
||||
<div className="flex flex-col gap-1">
|
||||
{importProgress.logs.map((log, i) => (
|
||||
{importProgress.logs.map((log, i) => (
|
||||
<p
|
||||
key={i}
|
||||
className={`text-xs font-mono ${
|
||||
@@ -6505,80 +6493,80 @@ function PassthroughModelRow({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
onClick={() => onCopy(fullModel, `model-${modelId}`)}
|
||||
className="rounded p-0.5 text-text-muted hover:bg-sidebar hover:text-primary"
|
||||
title={t("copyModel")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">
|
||||
{copied === `model-${modelId}` ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
{onTestModel && (
|
||||
<button
|
||||
onClick={() => onTestModel(modelId, fullModel)}
|
||||
disabled={testingModel}
|
||||
className={`rounded p-0.5 hover:bg-sidebar transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${testStatus === "ok" ? "text-green-500" : testStatus === "error" ? "text-red-500" : "text-text-muted hover:text-primary"}`}
|
||||
title={
|
||||
testingModel
|
||||
? t("testingModel")
|
||||
: testStatus === "ok"
|
||||
? "OK"
|
||||
: testStatus === "error"
|
||||
? "Error"
|
||||
: t("testModel")
|
||||
}
|
||||
>
|
||||
{testingModel ? (
|
||||
<span className="material-symbols-outlined text-sm animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
) : testStatus === "ok" ? (
|
||||
<span className="material-symbols-outlined text-sm">check_circle</span>
|
||||
) : testStatus === "error" ? (
|
||||
<span className="material-symbols-outlined text-sm">error</span>
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-sm">play_circle</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{onToggleHidden && (
|
||||
<button
|
||||
onClick={() => onToggleHidden(modelId, !isHidden)}
|
||||
disabled={togglingHidden}
|
||||
className="rounded p-0.5 text-text-muted hover:bg-sidebar hover:text-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title={
|
||||
isHidden
|
||||
? providerText(t, "showModel", "Show model")
|
||||
: providerText(t, "hideModel", "Hide model")
|
||||
}
|
||||
onClick={() => onCopy(fullModel, `model-${modelId}`)}
|
||||
className="rounded p-0.5 text-text-muted hover:bg-sidebar hover:text-primary"
|
||||
title={t("copyModel")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">
|
||||
{isHidden ? "visibility_off" : "visibility"}
|
||||
{copied === `model-${modelId}` ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<ModelCompatPopover
|
||||
t={t}
|
||||
effectiveModelNormalize={(p) => effectiveModelNormalize(modelId, p)}
|
||||
effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(modelId, p)}
|
||||
getUpstreamHeadersRecord={getUpstreamHeadersRecord}
|
||||
onCompatPatch={(protocol, payload) =>
|
||||
saveModelCompatFlags(modelId, { compatByProtocol: { [protocol]: payload } })
|
||||
}
|
||||
showDeveloperToggle={showDeveloperToggle}
|
||||
compact
|
||||
disabled={compatDisabled}
|
||||
/>
|
||||
{onDeleteAlias && (
|
||||
<button
|
||||
onClick={onDeleteAlias}
|
||||
className="rounded p-1 text-red-500 hover:bg-red-50"
|
||||
title={t("removeModel")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">delete</span>
|
||||
</button>
|
||||
)}
|
||||
{onTestModel && (
|
||||
<button
|
||||
onClick={() => onTestModel(modelId, fullModel)}
|
||||
disabled={testingModel}
|
||||
className={`rounded p-0.5 hover:bg-sidebar transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${testStatus === "ok" ? "text-green-500" : testStatus === "error" ? "text-red-500" : "text-text-muted hover:text-primary"}`}
|
||||
title={
|
||||
testingModel
|
||||
? t("testingModel")
|
||||
: testStatus === "ok"
|
||||
? "OK"
|
||||
: testStatus === "error"
|
||||
? "Error"
|
||||
: t("testModel")
|
||||
}
|
||||
>
|
||||
{testingModel ? (
|
||||
<span className="material-symbols-outlined text-sm animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
) : testStatus === "ok" ? (
|
||||
<span className="material-symbols-outlined text-sm">check_circle</span>
|
||||
) : testStatus === "error" ? (
|
||||
<span className="material-symbols-outlined text-sm">error</span>
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-sm">play_circle</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{onToggleHidden && (
|
||||
<button
|
||||
onClick={() => onToggleHidden(modelId, !isHidden)}
|
||||
disabled={togglingHidden}
|
||||
className="rounded p-0.5 text-text-muted hover:bg-sidebar hover:text-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title={
|
||||
isHidden
|
||||
? providerText(t, "showModel", "Show model")
|
||||
: providerText(t, "hideModel", "Hide model")
|
||||
}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">
|
||||
{isHidden ? "visibility_off" : "visibility"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<ModelCompatPopover
|
||||
t={t}
|
||||
effectiveModelNormalize={(p) => effectiveModelNormalize(modelId, p)}
|
||||
effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(modelId, p)}
|
||||
getUpstreamHeadersRecord={getUpstreamHeadersRecord}
|
||||
onCompatPatch={(protocol, payload) =>
|
||||
saveModelCompatFlags(modelId, { compatByProtocol: { [protocol]: payload } })
|
||||
}
|
||||
showDeveloperToggle={showDeveloperToggle}
|
||||
compact
|
||||
disabled={compatDisabled}
|
||||
/>
|
||||
{onDeleteAlias && (
|
||||
<button
|
||||
onClick={onDeleteAlias}
|
||||
className="rounded p-1 text-red-500 hover:bg-red-50"
|
||||
title={t("removeModel")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">delete</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -8097,13 +8085,21 @@ function ConnectionRow({
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
<button
|
||||
onClick={() => onTogglePerKeyProxyEnabled(!perKeyProxyEnabled)}
|
||||
aria-label={perKeyProxyEnabled ? t("perKeyProxyEnabledTitle") : t("perKeyProxyDisabledTitle")}
|
||||
aria-label={
|
||||
perKeyProxyEnabled
|
||||
? t("perKeyProxyEnabledTitle")
|
||||
: t("perKeyProxyDisabledTitle")
|
||||
}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
|
||||
perKeyProxyEnabled
|
||||
? "bg-violet-500/15 text-violet-500 hover:bg-violet-500/25"
|
||||
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
|
||||
}`}
|
||||
title={perKeyProxyEnabled ? t("perKeyProxyEnabledTitle") : t("perKeyProxyDisabledTitle")}
|
||||
title={
|
||||
perKeyProxyEnabled
|
||||
? t("perKeyProxyEnabledTitle")
|
||||
: t("perKeyProxyDisabledTitle")
|
||||
}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">key</span>
|
||||
{perKeyProxyEnabled ? (
|
||||
@@ -8302,149 +8298,6 @@ function ConnectionRow({
|
||||
);
|
||||
}
|
||||
|
||||
const CONFIGURABLE_BASE_URL_PROVIDERS = new Set([
|
||||
"azure-openai",
|
||||
"azure-ai",
|
||||
"bailian-coding-plan",
|
||||
"xiaomi-mimo",
|
||||
"siliconflow",
|
||||
"heroku",
|
||||
"databricks",
|
||||
"snowflake",
|
||||
"searxng-search",
|
||||
"petals",
|
||||
]);
|
||||
|
||||
const DEFAULT_PROVIDER_BASE_URLS: Record<string, string> = {
|
||||
"azure-openai": "https://example-resource.openai.azure.com",
|
||||
"azure-ai": "https://example-resource.services.ai.azure.com/openai/v1",
|
||||
"bailian-coding-plan": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1",
|
||||
"xiaomi-mimo": "https://token-plan-sgp.xiaomimimo.com/v1",
|
||||
siliconflow: "https://api.siliconflow.com/v1",
|
||||
"searxng-search": "http://localhost:8888/search",
|
||||
petals: "https://chat.petals.dev/api/v1/generate",
|
||||
};
|
||||
|
||||
function getLocalProviderMetadata(providerId?: string | null) {
|
||||
if (!providerId || !isSelfHostedChatProvider(providerId)) return null;
|
||||
return (LOCAL_PROVIDERS as Record<string, LocalProviderMetadata>)[providerId] || null;
|
||||
}
|
||||
|
||||
function isBaseUrlConfigurableProvider(providerId?: string | null) {
|
||||
return Boolean(
|
||||
providerId &&
|
||||
(CONFIGURABLE_BASE_URL_PROVIDERS.has(providerId) || isSelfHostedChatProvider(providerId))
|
||||
);
|
||||
}
|
||||
|
||||
function getProviderBaseUrlDefault(providerId?: string | null) {
|
||||
const localProvider = getLocalProviderMetadata(providerId);
|
||||
if (typeof localProvider?.localDefault === "string" && localProvider.localDefault.trim()) {
|
||||
return localProvider.localDefault;
|
||||
}
|
||||
return providerId ? DEFAULT_PROVIDER_BASE_URLS[providerId] || "" : "";
|
||||
}
|
||||
|
||||
function getProviderBaseUrlHint(
|
||||
providerId?: string | null,
|
||||
t?: ((key: string, values?: Record<string, unknown>) => string) | null
|
||||
) {
|
||||
const localProvider = getLocalProviderMetadata(providerId);
|
||||
if (localProvider && t) {
|
||||
return t("localProviderBaseUrlHint", {
|
||||
provider: localProvider.name || providerId,
|
||||
baseUrl: getProviderBaseUrlDefault(providerId),
|
||||
});
|
||||
}
|
||||
switch (providerId) {
|
||||
case "azure-openai":
|
||||
return t ? t("azureOpenAiBaseUrlHint") : undefined;
|
||||
case "bailian-coding-plan":
|
||||
return t ? t("bailianBaseUrlHint") : undefined;
|
||||
case "xiaomi-mimo":
|
||||
return t ? t("xiaomiMimoBaseUrlHint") : undefined;
|
||||
case "heroku":
|
||||
return t ? t("herokuBaseUrlHint") : undefined;
|
||||
case "databricks":
|
||||
return t ? t("databricksBaseUrlHint") : undefined;
|
||||
case "snowflake":
|
||||
return t ? t("snowflakeBaseUrlHint") : undefined;
|
||||
case "searxng-search":
|
||||
return t ? t("searxngBaseUrlHint") : undefined;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getProviderBaseUrlPlaceholder(providerId?: string | null) {
|
||||
if (isSelfHostedChatProvider(providerId || "")) {
|
||||
return getProviderBaseUrlDefault(providerId);
|
||||
}
|
||||
switch (providerId) {
|
||||
case "azure-openai":
|
||||
return "https://my-resource.openai.azure.com";
|
||||
case "bailian-coding-plan":
|
||||
case "xiaomi-mimo":
|
||||
return getProviderBaseUrlDefault(providerId);
|
||||
case "siliconflow":
|
||||
return "https://api.siliconflow.cn/v1";
|
||||
case "heroku":
|
||||
return "https://us.inference.heroku.com";
|
||||
case "databricks":
|
||||
return "https://adb-1234567890123456.7.azuredatabricks.net/serving-endpoints";
|
||||
case "snowflake":
|
||||
return "https://example-account.snowflakecomputing.com";
|
||||
case "searxng-search":
|
||||
return "http://localhost:8888/search";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function isGlmProvider(providerId?: string | null) {
|
||||
return providerId === "glm" || providerId === "glm-cn" || providerId === "glmt";
|
||||
}
|
||||
|
||||
function parseRoutingTagsInput(value: string): string[] | undefined {
|
||||
const tags = Array.from(
|
||||
new Set(
|
||||
value
|
||||
.split(",")
|
||||
.map((tag) => tag.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
return tags.length > 0 ? tags : undefined;
|
||||
}
|
||||
|
||||
function parseExcludedModelsInput(value: string): string[] | undefined {
|
||||
const patterns = Array.from(
|
||||
new Set(
|
||||
value
|
||||
.split(",")
|
||||
.map((pattern) => pattern.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
return patterns.length > 0 ? patterns : undefined;
|
||||
}
|
||||
|
||||
function formatRoutingTagsInput(value: unknown): string {
|
||||
if (!Array.isArray(value)) return "";
|
||||
return value
|
||||
.filter((tag): tag is string => typeof tag === "string" && tag.trim().length > 0)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function formatExcludedModelsInput(value: unknown): string {
|
||||
if (!Array.isArray(value)) return "";
|
||||
return value
|
||||
.filter(
|
||||
(pattern): pattern is string => typeof pattern === "string" && pattern.trim().length > 0
|
||||
)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function extractCommandCodeCredentialInput(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "";
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
// 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
|
||||
// (AddApiKeyModal / EditConnectionModal) out of the god-component in later phases.
|
||||
import { LOCAL_PROVIDERS, isSelfHostedChatProvider } from "@/shared/constants/providers";
|
||||
|
||||
export type ProviderMessageTranslator = ((
|
||||
key: string,
|
||||
values?: Record<string, unknown>
|
||||
) => string) & {
|
||||
has?: (key: string) => boolean;
|
||||
};
|
||||
|
||||
export type LocalProviderMetadata = {
|
||||
name?: string;
|
||||
localDefault?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export function providerText(
|
||||
t: ProviderMessageTranslator,
|
||||
key: string,
|
||||
fallback: string,
|
||||
values?: Record<string, unknown>
|
||||
): string {
|
||||
if (typeof t.has === "function" && t.has(key)) {
|
||||
return t(key, values);
|
||||
}
|
||||
if (values) {
|
||||
return Object.entries(values).reduce(
|
||||
(acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)),
|
||||
fallback
|
||||
);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function providerCountText(
|
||||
t: ProviderMessageTranslator,
|
||||
key: string,
|
||||
count: number,
|
||||
singularFallback: string,
|
||||
pluralFallback: string
|
||||
): string {
|
||||
return providerText(t, key, count === 1 ? singularFallback : pluralFallback, { count });
|
||||
}
|
||||
|
||||
export function readBooleanToggle(value: unknown, fallback: boolean): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return value === 1;
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "1" || normalized === "true") return true;
|
||||
if (normalized === "0" || normalized === "false") return false;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export const CONFIGURABLE_BASE_URL_PROVIDERS = new Set([
|
||||
"azure-openai",
|
||||
"azure-ai",
|
||||
"bailian-coding-plan",
|
||||
"xiaomi-mimo",
|
||||
"siliconflow",
|
||||
"heroku",
|
||||
"databricks",
|
||||
"snowflake",
|
||||
"searxng-search",
|
||||
"petals",
|
||||
]);
|
||||
|
||||
export const DEFAULT_PROVIDER_BASE_URLS: Record<string, string> = {
|
||||
"azure-openai": "https://example-resource.openai.azure.com",
|
||||
"azure-ai": "https://example-resource.services.ai.azure.com/openai/v1",
|
||||
"bailian-coding-plan": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1",
|
||||
"xiaomi-mimo": "https://token-plan-sgp.xiaomimimo.com/v1",
|
||||
siliconflow: "https://api.siliconflow.com/v1",
|
||||
"searxng-search": "http://localhost:8888/search",
|
||||
petals: "https://chat.petals.dev/api/v1/generate",
|
||||
};
|
||||
|
||||
export function getLocalProviderMetadata(providerId?: string | null) {
|
||||
if (!providerId || !isSelfHostedChatProvider(providerId)) return null;
|
||||
return (LOCAL_PROVIDERS as Record<string, LocalProviderMetadata>)[providerId] || null;
|
||||
}
|
||||
|
||||
export function isBaseUrlConfigurableProvider(providerId?: string | null) {
|
||||
return Boolean(
|
||||
providerId &&
|
||||
(CONFIGURABLE_BASE_URL_PROVIDERS.has(providerId) || isSelfHostedChatProvider(providerId))
|
||||
);
|
||||
}
|
||||
|
||||
export function getProviderBaseUrlDefault(providerId?: string | null) {
|
||||
const localProvider = getLocalProviderMetadata(providerId);
|
||||
if (typeof localProvider?.localDefault === "string" && localProvider.localDefault.trim()) {
|
||||
return localProvider.localDefault;
|
||||
}
|
||||
return providerId ? DEFAULT_PROVIDER_BASE_URLS[providerId] || "" : "";
|
||||
}
|
||||
|
||||
export function getProviderBaseUrlHint(
|
||||
providerId?: string | null,
|
||||
t?: ((key: string, values?: Record<string, unknown>) => string) | null
|
||||
) {
|
||||
const localProvider = getLocalProviderMetadata(providerId);
|
||||
if (localProvider && t) {
|
||||
return t("localProviderBaseUrlHint", {
|
||||
provider: localProvider.name || providerId,
|
||||
baseUrl: getProviderBaseUrlDefault(providerId),
|
||||
});
|
||||
}
|
||||
switch (providerId) {
|
||||
case "azure-openai":
|
||||
return t ? t("azureOpenAiBaseUrlHint") : undefined;
|
||||
case "bailian-coding-plan":
|
||||
return t ? t("bailianBaseUrlHint") : undefined;
|
||||
case "xiaomi-mimo":
|
||||
return t ? t("xiaomiMimoBaseUrlHint") : undefined;
|
||||
case "heroku":
|
||||
return t ? t("herokuBaseUrlHint") : undefined;
|
||||
case "databricks":
|
||||
return t ? t("databricksBaseUrlHint") : undefined;
|
||||
case "snowflake":
|
||||
return t ? t("snowflakeBaseUrlHint") : undefined;
|
||||
case "searxng-search":
|
||||
return t ? t("searxngBaseUrlHint") : undefined;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function getProviderBaseUrlPlaceholder(providerId?: string | null) {
|
||||
if (isSelfHostedChatProvider(providerId || "")) {
|
||||
return getProviderBaseUrlDefault(providerId);
|
||||
}
|
||||
switch (providerId) {
|
||||
case "azure-openai":
|
||||
return "https://my-resource.openai.azure.com";
|
||||
case "bailian-coding-plan":
|
||||
case "xiaomi-mimo":
|
||||
return getProviderBaseUrlDefault(providerId);
|
||||
case "siliconflow":
|
||||
return "https://api.siliconflow.cn/v1";
|
||||
case "heroku":
|
||||
return "https://us.inference.heroku.com";
|
||||
case "databricks":
|
||||
return "https://adb-1234567890123456.7.azuredatabricks.net/serving-endpoints";
|
||||
case "snowflake":
|
||||
return "https://example-account.snowflakecomputing.com";
|
||||
case "searxng-search":
|
||||
return "http://localhost:8888/search";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function isGlmProvider(providerId?: string | null) {
|
||||
return providerId === "glm" || providerId === "glm-cn" || providerId === "glmt";
|
||||
}
|
||||
|
||||
export function parseRoutingTagsInput(value: string): string[] | undefined {
|
||||
const tags = Array.from(
|
||||
new Set(
|
||||
value
|
||||
.split(",")
|
||||
.map((tag) => tag.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
return tags.length > 0 ? tags : undefined;
|
||||
}
|
||||
|
||||
export function parseExcludedModelsInput(value: string): string[] | undefined {
|
||||
const patterns = Array.from(
|
||||
new Set(
|
||||
value
|
||||
.split(",")
|
||||
.map((pattern) => pattern.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
return patterns.length > 0 ? patterns : undefined;
|
||||
}
|
||||
|
||||
export function formatRoutingTagsInput(value: unknown): string {
|
||||
if (!Array.isArray(value)) return "";
|
||||
return value
|
||||
.filter((tag): tag is string => typeof tag === "string" && tag.trim().length > 0)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
export function formatExcludedModelsInput(value: unknown): string {
|
||||
if (!Array.isArray(value)) return "";
|
||||
return value
|
||||
.filter(
|
||||
(pattern): pattern is string => typeof pattern === "string" && pattern.trim().length > 0
|
||||
)
|
||||
.join(", ");
|
||||
}
|
||||
64
tests/unit/provider-page-helpers-3501.test.ts
Normal file
64
tests/unit/provider-page-helpers-3501.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// Phase 2 unit tests for Issue #3501: the pure helpers extracted from the
|
||||
// provider-detail god-component into providerPageHelpers.ts. Beyond asserting
|
||||
// behaviour, exercising EVERY exported function guards against a missing
|
||||
// transitive import in the extracted module (the Phase 0 smoke test caught one
|
||||
// such gap — isSelfHostedChatProvider — at mount time; this locks it down).
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
providerText,
|
||||
providerCountText,
|
||||
readBooleanToggle,
|
||||
getLocalProviderMetadata,
|
||||
isBaseUrlConfigurableProvider,
|
||||
getProviderBaseUrlDefault,
|
||||
getProviderBaseUrlHint,
|
||||
getProviderBaseUrlPlaceholder,
|
||||
isGlmProvider,
|
||||
parseRoutingTagsInput,
|
||||
parseExcludedModelsInput,
|
||||
formatRoutingTagsInput,
|
||||
formatExcludedModelsInput,
|
||||
} from "../../src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts";
|
||||
|
||||
const tStub = Object.assign((key: string) => key, { has: (_k: string) => false });
|
||||
|
||||
test("providerText falls back when key missing and interpolates values", () => {
|
||||
assert.equal(providerText(tStub, "missing.key", "Hello {name}", { name: "X" }), "Hello X");
|
||||
const tHas = Object.assign((key: string) => `T:${key}`, { has: (_k: string) => true });
|
||||
assert.equal(providerText(tHas, "present.key", "fallback"), "T:present.key");
|
||||
});
|
||||
|
||||
test("providerCountText picks singular/plural by count", () => {
|
||||
assert.equal(providerCountText(tStub, "k", 1, "{count} item", "{count} items"), "1 item");
|
||||
assert.equal(providerCountText(tStub, "k", 3, "{count} item", "{count} items"), "3 items");
|
||||
});
|
||||
|
||||
test("readBooleanToggle coerces booleans/numbers/strings with fallback", () => {
|
||||
assert.equal(readBooleanToggle(true, false), true);
|
||||
assert.equal(readBooleanToggle(1, false), true);
|
||||
assert.equal(readBooleanToggle("false", true), false);
|
||||
assert.equal(readBooleanToggle(undefined, true), true);
|
||||
});
|
||||
|
||||
test("base-url helpers run without throwing (transitive imports present)", () => {
|
||||
// The key regression guard: isBaseUrlConfigurableProvider internally calls
|
||||
// isSelfHostedChatProvider — a transitive import that must be wired up.
|
||||
assert.doesNotThrow(() => isBaseUrlConfigurableProvider("openai"));
|
||||
assert.equal(typeof isBaseUrlConfigurableProvider("openai"), "boolean");
|
||||
assert.doesNotThrow(() => getLocalProviderMetadata("openai"));
|
||||
assert.doesNotThrow(() => getProviderBaseUrlDefault("openai"));
|
||||
assert.doesNotThrow(() => getProviderBaseUrlHint("openai", null));
|
||||
assert.doesNotThrow(() => getProviderBaseUrlPlaceholder("openai"));
|
||||
assert.equal(typeof isGlmProvider("glm"), "boolean");
|
||||
assert.doesNotThrow(() => isBaseUrlConfigurableProvider(null));
|
||||
});
|
||||
|
||||
test("routing-tags / excluded-models parse + format round-trip", () => {
|
||||
assert.deepEqual(parseRoutingTagsInput("a, b ,c"), ["a", "b", "c"]);
|
||||
assert.equal(parseRoutingTagsInput(" "), undefined);
|
||||
assert.deepEqual(parseExcludedModelsInput("m1, m2"), ["m1", "m2"]);
|
||||
assert.equal(formatRoutingTagsInput(["x", "y"]), "x, y");
|
||||
assert.equal(formatExcludedModelsInput(["a", "b"]), "a, b");
|
||||
assert.equal(formatRoutingTagsInput(undefined), "");
|
||||
});
|
||||
Reference in New Issue
Block a user