diff --git a/CHANGELOG.md b/CHANGELOG.md index 21bc85eae7..12ff5708ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - **feat(providers):** add **Yuanbao (web)** as a cookie-session provider ([#6196](https://github.com/diegosouzapw/OmniRoute/issues/6196)) — `yuanbao-web` (Tencent Yuanbao, `yuanbao.tencent.com`) with cookie-only auth (`hy_user`/`hy_token` + public agent id), SSE→OpenAI translation incl. `reasoning_content`, exposing DeepSeek V3/R1 + Hunyuan / Hunyuan-T1. Regression guard: `tests/unit/providers-yuanbao-web.test.ts`. `together-web` was **deferred** (no verifiable web-session endpoint — needs a captured request) and `huggingchat-web` **dropped** (the existing `huggingchat` already is a web-cookie provider). (thanks @chirag127) - **feat(providers):** route the built-in **agentrouter** through the dynamic Claude-Code wire image ([#6056](https://github.com/diegosouzapw/OmniRoute/issues/6056)) — a small static allow-set (`CC_WIRE_IMAGE_BUILTINS` in `open-sse/services/ccWireImageBuiltins.ts`), consulted by `isClaudeCodeCompatible` / `isClaudeCodeCompatibleProvider` / `applyFingerprint`, makes agentrouter adopt the CC wire-image headers + fingerprint **while guarding the CC baseUrl/auth branches** so it keeps its own registry `baseUrl` and `x-api-key` auth. Regression guard: `tests/unit/agentrouter-cc-wire-image.test.ts` (asserts the wire image is applied AND agentrouter's baseUrl/auth are preserved). Live WAF-acceptance against agentrouter.org is a VPS validation follow-up (Hard Rule #18). - **feat(providers):** **bulk-add API keys for Cloudflare Workers AI** ([#6174](https://github.com/diegosouzapw/OmniRoute/issues/6174)) — `cloudflare-ai` is removed from the bulk-add exclusion list and the bulk parser gains a 3-field `name|accountId|apiKey` mode; the bulk route now builds a **per-entry** `providerSpecificData` so each key carries its own `accountId` (fixing the previous shared-object reuse), and both the create + key-validation paths receive it. Regression guard: `tests/unit/bulk-api-key-parser-cloudflare.test.ts`. (thanks @muflifadla38) +- **feat(dashboard):** routing/settings UX clarity ([#6147](https://github.com/diegosouzapw/OmniRoute/issues/6147)) — (1) weighted combos show the **effective routing share %** next to each weight when weights don't sum to 100 (`WeightTotalBar.tsx`); (2) the status widget's user-facing **"Cloud Sync" label is renamed** to "Remote Settings Sync" (`CloudSyncStatus.tsx`; internal ids/state untouched); (3) built-in providers gain an **opt-in advanced base-URL override** (`isBaseUrlOverrideEligibleProvider`, hidden behind an "Advanced" toggle, reusing the existing `providerSpecificData.baseUrl` persistence — not globally widened). Regression guard: `tests/unit/routing-settings-ux-6147.test.ts`. ### 🐛 Bug Fixes diff --git a/src/app/(dashboard)/dashboard/combos/WeightTotalBar.tsx b/src/app/(dashboard)/dashboard/combos/WeightTotalBar.tsx index 3d0a6cbe65..bacb890994 100644 --- a/src/app/(dashboard)/dashboard/combos/WeightTotalBar.tsx +++ b/src/app/(dashboard)/dashboard/combos/WeightTotalBar.tsx @@ -20,6 +20,20 @@ const WEIGHT_COLORS = [ "bg-indigo-500", ]; +/** + * #6147 — effective routing share of a weighted target. + * + * The per-target `weight` values do not have to sum to 100; at routing time each + * target is picked with probability `weight / Σweights`. So a raw weight of 30 + * with a total of 60 is an *effective* 50% share. This pure helper computes that + * share (0-100) and guards the `total === 0` case so the UI never renders NaN. + */ +export function effectiveSharePercent(weight: number, total: number): number { + if (!weight || weight <= 0) return 0; + if (!total || total <= 0) return 0; + return (weight / total) * 100; +} + export default function WeightTotalBar({ models }: WeightTotalBarProps) { const total = models.reduce((sum, m) => sum + (m.weight || 0), 0); const isValid = total === 100; @@ -49,6 +63,16 @@ export default function WeightTotalBar({ models }: WeightTotalBarProps) { className={`inline-block w-1.5 h-1.5 rounded-full ${WEIGHT_COLORS[i % WEIGHT_COLORS.length]}`} /> {m.weight}% + {/* #6147 — show the *effective* routing share when weights don't sum to 100 */} + {total > 0 && total !== 100 && ( + + {" → "} + {Math.round(effectiveSharePercent(m.weight, total))}% + + )} ) )} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx index d4a7d3ea6c..5e409ba84f 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx @@ -23,6 +23,7 @@ import { isClaudeExtraUsageBlockEnabled } from "@/lib/providers/claudeExtraUsage import { resolveDashboardProviderInfo } from "../../../providerPageUtils"; import { isBaseUrlConfigurableProvider, + isBaseUrlOverrideEligibleProvider, getProviderBaseUrlDefault, getProviderBaseUrlHint, getProviderBaseUrlPlaceholder, @@ -153,7 +154,19 @@ export default function EditConnectionModal({ const [showAdvanced, setShowAdvanced] = useState(false); const showEmail = useEmailPrivacyStore((state) => state.emailsVisible); - const usesBaseUrl = isBaseUrlConfigurableProvider(provider); + // #6147 — built-in providers can opt in to an advanced base-URL override. + // OAuth connections are excluded: their save path does not persist + // providerSpecificData.baseUrl. + const isConfigurableBaseUrl = isBaseUrlConfigurableProvider(provider); + const isBaseUrlOverrideEligible = + connection.authType !== "oauth" && isBaseUrlOverrideEligibleProvider(provider); + const [showBaseUrlOverride, setShowBaseUrlOverride] = useState( + () => + typeof connection.providerSpecificData?.baseUrl === "string" && + connection.providerSpecificData.baseUrl.trim().length > 0 + ); + const usesBaseUrl = + isConfigurableBaseUrl || (isBaseUrlOverrideEligible && showBaseUrlOverride); const defaultBaseUrl = getProviderBaseUrlDefault(provider); const isVertex = provider === "vertex" || provider === "vertex-partner"; const isBedrock = provider === "bedrock"; @@ -441,12 +454,18 @@ export default function EditConnectionModal({ let validatedBaseUrl = null; if (usesBaseUrl) { - const checked = normalizeAndValidateHttpBaseUrl(formData.baseUrl, defaultBaseUrl); - if (checked.error) { - setSaveError(checked.error); - return; + // #6147 — an opt-in override left blank clears it (no default to fall + // back to). Configurable providers keep their existing default-fallback. + if (!isConfigurableBaseUrl && !formData.baseUrl.trim()) { + validatedBaseUrl = null; + } else { + const checked = normalizeAndValidateHttpBaseUrl(formData.baseUrl, defaultBaseUrl); + if (checked.error) { + setSaveError(checked.error); + return; + } + validatedBaseUrl = checked.value; } - validatedBaseUrl = checked.value; } if (!isOAuth && formData.apiKey) { @@ -937,13 +956,33 @@ export default function EditConnectionModal({ )} + {/* #6147 — opt-in "Advanced → override base URL" for eligible built-ins */} + {!usesBaseUrl && isBaseUrlOverrideEligible && ( + + )} + {usesBaseUrl && ( setFormData({ ...formData, baseUrl: e.target.value })} placeholder={getProviderBaseUrlPlaceholder(provider)} - hint={getProviderBaseUrlHint(provider, t)} + hint={ + getProviderBaseUrlHint(provider, t) || + (isBaseUrlOverrideEligible + ? providerText( + t, + "overrideBaseUrlHint", + "Advanced: point this built-in provider at a custom endpoint. Leave blank to use the default." + ) + : undefined) + } /> )} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts index c943720d12..aa8499e839 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts @@ -257,6 +257,25 @@ export function isBaseUrlConfigurableProvider(providerId?: string | null) { ); } +/** + * #6147 — whether a built-in provider is eligible for an OPT-IN "Advanced → + * override base URL" affordance in the edit-connection modal. + * + * This does NOT widen the always-on base-URL field: providers already covered by + * `isBaseUrlConfigurableProvider` (the configurable set + self-hosted) keep their + * existing dedicated field and return `false` here. Every *other* provider id is + * eligible to opt in per-connection so an operator can hot-fix a broken built-in + * preset by pointing it at a custom endpoint. The field stays hidden until the + * user explicitly reveals it (or an override was already saved), so nothing is + * exposed by default. OAuth connections are excluded at the call site, since + * their save path does not persist `providerSpecificData.baseUrl`. + */ +export function isBaseUrlOverrideEligibleProvider(providerId?: string | null): boolean { + if (!providerId) return false; + if (isBaseUrlConfigurableProvider(providerId)) return false; + return true; +} + export function getProviderBaseUrlDefault(providerId?: string | null) { const localProvider = getLocalProviderMetadata(providerId); if (typeof localProvider?.localDefault === "string" && localProvider.localDefault.trim()) { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index c676c7834c..98d387f1a8 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4849,6 +4849,8 @@ "kimiWebDesc": "Moonshot AI consumer chat via www.kimi.com (international, Connect-RPC API)", "doubaoWebLabel": "Dola Web", "doubaoWebDesc": "ByteDance AI chat via dola.com", + "overrideBaseUrlAdvanced": "Advanced: override base URL", + "overrideBaseUrlHint": "Advanced: point this built-in provider at a custom endpoint. Leave blank to use the default.", "bulkAddFormatHintCloudflare": "One key per line. Format: name|accountId|apiKey (Cloudflare account ID + API token)." }, "settings": { diff --git a/src/shared/components/CloudSyncStatus.tsx b/src/shared/components/CloudSyncStatus.tsx index f01c7d9dab..aae7fa1b09 100644 --- a/src/shared/components/CloudSyncStatus.tsx +++ b/src/shared/components/CloudSyncStatus.tsx @@ -13,11 +13,15 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { useRouter } from "next/navigation"; +// #6147 — user-facing labels renamed from "Cloud …" to "Remote Settings Sync" +// wording (this feature syncs the operator's own settings to their own remote +// store — it is not a cloud/telemetry service). Internal state keys, the +// `cloud_*` material icons and the cloudSync.* wiring are intentionally kept. const STATUS_CONFIG = { - connected: { icon: "cloud_done", color: "text-green-500", label: "Cloud" }, + connected: { icon: "cloud_done", color: "text-green-500", label: "Synced" }, syncing: { icon: "cloud_sync", color: "text-blue-400 animate-pulse", label: "Syncing..." }, - disconnected: { icon: "cloud_off", color: "text-amber-500", label: "Cloud Off" }, - error: { icon: "cloud_off", color: "text-red-400", label: "Cloud Error" }, + disconnected: { icon: "cloud_off", color: "text-amber-500", label: "Sync Off" }, + error: { icon: "cloud_off", color: "text-red-400", label: "Sync Error" }, disabled: { icon: "cloud_off", color: "text-text-muted/50", label: "Disabled" }, }; @@ -80,10 +84,10 @@ export default function CloudSyncStatus({ collapsed = false }) { className="flex items-center gap-2 px-3 py-1.5 text-xs rounded-lg hover:bg-white/5 transition-colors cursor-pointer w-full" title={ lastSync - ? `Cloud ${status === "connected" ? "connected" : "disconnected"} — Last sync: ${lastSync.toLocaleTimeString()}` + ? `Remote settings sync ${status === "connected" ? "connected" : "disconnected"} — Last sync: ${lastSync.toLocaleTimeString()}` : config.label } - aria-label={`Cloud sync status: ${config.label}`} + aria-label={`Remote settings sync status: ${config.label}`} >