fix: improve provider routing and assignments

This commit is contained in:
MUH. IQRAM BAHRING committed 2026-08-15 03:47:18 +08:00
1 parent d00205511a
commit df8c4ccb81
17 files changed
+471 -51

No files matched your search

@@ -6,7 +6,7 @@ import PropTypes from "prop-types";
import { Badge, Toggle, Tooltip } from "@/shared/components";
import CooldownTimer from "./CooldownTimer";
export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete, oneByOneStatus = null, autoPing = null }) {
export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete, oneByOneStatus = null, autoPing = null, modelAssignmentOptions = null, onModelAssignmentChange = null, strictModelAssignment = false }) {
const [showProxyDropdown, setShowProxyDropdown] = useState(false);
const [updatingProxy, setUpdatingProxy] = useState(false);
const [selectedProxyIds, setSelectedProxyIds] = useState([]);
@@ -19,13 +19,14 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
const legacyProxyPoolId = connection.providerSpecificData?.proxyPoolId;
// Migrate legacy single proxy to array format
if (legacyProxyPoolId && proxyPoolIds.length === 0) {
setSelectedProxyIds([legacyProxyPoolId]);
} else {
setSelectedProxyIds(proxyPoolIds);
}
setRotationStrategy(connection.providerSpecificData?.proxyRotationStrategy || "none");
queueMicrotask(() => {
if (legacyProxyPoolId && proxyPoolIds.length === 0) {
setSelectedProxyIds([legacyProxyPoolId]);
} else {
setSelectedProxyIds(proxyPoolIds);
}
setRotationStrategy(connection.providerSpecificData?.proxyRotationStrategy || "none");
});
}, [connection]);
const proxyPoolMap = new Map((proxyPools || []).map((pool) => [pool.id, pool]));
@@ -201,7 +202,7 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
return () => {
if (interval) clearInterval(interval);
};
}, [modelLockUntil]);
}, [connection, modelLockUntil]);
// Determine effective status (override unavailable if cooldown expired)
const effectiveStatus = (connection.testStatus === "unavailable" && !isCooldown)
@@ -283,6 +284,20 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
</Badge>
)}
</div>
{modelAssignmentOptions && onModelAssignmentChange && (
<select
value={connection.providerSpecificData?.assignedModel || connection.providerSpecificData?.freebuffModel || ""}
onChange={(e) => onModelAssignmentChange(e.target.value)}
disabled={!strictModelAssignment}
className="mt-2 max-w-full rounded-md border border-border bg-background px-2 py-1 text-[11px] text-text-main"
title="Model assignment"
>
<option value="">Unassigned</option>
{modelAssignmentOptions.map((model) => (
<option key={model.id} value={model.id}>{model.name || model.id}</option>
))}
</select>
)}
{hasAnyProxy && (
<div className="mt-1 flex items-center gap-2 flex-wrap">
<span className="max-w-full truncate text-[11px] text-text-muted sm:max-w-[420px]" title={proxyDisplayText}>
@@ -495,6 +510,9 @@ ConnectionRow.propTypes = {
onUpdateProxy: PropTypes.func,
onEdit: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
modelAssignmentOptions: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string.isRequired, name: PropTypes.string })),
onModelAssignmentChange: PropTypes.func,
strictModelAssignment: PropTypes.bool,
oneByOneStatus: PropTypes.shape({
state: PropTypes.string,
error: PropTypes.string,
@@ -64,6 +64,7 @@ export default function ProviderDetailPage() {
const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false);
const [providerStrategy, setProviderStrategy] = useState(null);
const [providerStickyLimit, setProviderStickyLimit] = useState("");
const [strictModelAssignment, setStrictModelAssignment] = useState(false);
const [thinkingMode, setThinkingMode] = useState("auto");
const [autoPing, setAutoPing] = useState({ enabled: false, connections: {} });
const [suggestedModels, setSuggestedModels] = useState([]);
@@ -170,6 +171,21 @@ export default function ProviderDetailPage() {
return levels && levels.includes(thinkingMode) ? thinkingMode : null;
};
const providerStorageAlias = isCompatible ? providerId : providerAlias;
const assignmentModels = (() => {
const byId = new Map();
const add = (model) => {
if (model?.id && !byId.has(model.id)) byId.set(model.id, model);
};
models.forEach(add);
kiloFreeModels.forEach(add);
customModels.forEach((model) => {
if (model.providerAlias === providerStorageAlias && (model.kind || model.type || "llm") === "llm") {
add(model);
}
});
const disabled = new Set(disabledModelIds);
return [...byId.values()].filter((model) => !disabled.has(model.id));
})();
// Union of levels across this provider's reasoning models — drives the level picker options.
// Include custom models too (e.g. manually added gpt-5.6-sol → max).
const providerThinkingLevels = (() => {
@@ -313,6 +329,7 @@ export default function ProviderDetailPage() {
const override = (settingsData.providerStrategies || {})[providerId] || {};
setProviderStrategy(override.fallbackStrategy || null);
setProviderStickyLimit(override.stickyRoundRobinLimit != null ? String(override.stickyRoundRobinLimit) : "1");
setStrictModelAssignment(override.strictModelAssignment === true);
// Load per-provider thinking config
const thinkingCfg = (settingsData.providerThinking || {})[providerId] || {};
setThinkingMode(thinkingCfg.mode || "auto");
@@ -368,9 +385,13 @@ export default function ProviderDetailPage() {
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
const current = settingsData.providerStrategies || {};
// Build override: null strategy means remove override, use global
const override = {};
// Preserve Freebuff-only settings while changing the shared strategy.
const override = { ...(current[providerId] || {}) };
if (strategy) override.fallbackStrategy = strategy;
else {
delete override.fallbackStrategy;
delete override.stickyRoundRobinLimit;
}
if (strategy === "round-robin" && stickyLimit !== "") {
override.stickyRoundRobinLimit = Number(stickyLimit) || 3;
}
@@ -392,6 +413,44 @@ export default function ProviderDetailPage() {
}
};
const handleStrictAssignmentToggle = async (enabled) => {
setStrictModelAssignment(enabled);
try {
const settingsRes = await fetch("/api/settings", { cache: "no-store" });
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
const current = settingsData.providerStrategies || {};
await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
providerStrategies: {
...current,
[providerId]: { ...(current[providerId] || {}), strictModelAssignment: enabled },
},
}),
});
} catch (error) {
console.log("Error saving Freebuff strict assignment:", error);
}
};
const handleModelAssignment = async (connectionId, assignedModel) => {
try {
const res = await fetch(`/api/providers/${connectionId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ providerSpecificData: { assignedModel: assignedModel || null } }),
});
if (res.ok) {
setConnections((prev) => prev.map((c) => c.id === connectionId
? { ...c, providerSpecificData: { ...(c.providerSpecificData || {}), assignedModel: assignedModel || null } }
: c));
}
} catch (error) {
console.log("Error saving Freebuff model assignment:", error);
}
};
const handleRoundRobinToggle = (enabled) => {
const strategy = enabled ? "round-robin" : null;
const sticky = enabled ? (providerStickyLimit || "1") : providerStickyLimit;
@@ -452,10 +511,12 @@ export default function ProviderDetailPage() {
};
useEffect(() => {
fetchConnections();
fetchAliases();
fetchCustomModels();
fetchDisabledModels();
Promise.resolve().then(() => {
fetchConnections();
fetchAliases();
fetchCustomModels();
fetchDisabledModels();
});
}, [fetchConnections, fetchAliases, fetchCustomModels, fetchDisabledModels]);
// Cursor's model availability is account-specific and changes frequently.
@@ -463,13 +524,13 @@ export default function ProviderDetailPage() {
// registry remains the fallback while the request is pending or unavailable.
useEffect(() => {
if (providerId !== "cursor") {
setLiveModels([]);
queueMicrotask(() => setLiveModels([]));
return;
}
const connection = connections.find((item) => item.isActive !== false);
if (!connection?.id) {
setLiveModels([]);
queueMicrotask(() => setLiveModels([]));
return;
}
@@ -867,7 +928,9 @@ export default function ProviderDetailPage() {
};
useEffect(() => {
setSelectedConnectionIds((prev) => prev.filter((id) => connections.some((conn) => conn.id === id)));
queueMicrotask(() => {
setSelectedConnectionIds((prev) => prev.filter((id) => connections.some((conn) => conn.id === id)));
});
}, [connections]);
const selectedProxySummary = (() => {
@@ -1014,6 +1077,9 @@ export default function ProviderDetailPage() {
}}
onDelete={() => handleDelete(conn.id)}
oneByOneStatus={oneByOneResults[conn.id] || null}
modelAssignmentOptions={assignmentModels}
onModelAssignmentChange={(model) => handleModelAssignment(conn.id, model)}
strictModelAssignment={strictModelAssignment}
/>
</div>
</div>
@@ -1511,6 +1577,13 @@ export default function ProviderDetailPage() {
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2 border-t border-black/[0.03] pt-2 dark:border-white/[0.03]">
<div>
<span className="text-xs text-text-muted font-medium">Strict Model Assignment</span>
<p className="text-[10px] text-text-muted">Only assigned accounts can serve each model for this provider.</p>
</div>
<Toggle checked={strictModelAssignment} onChange={handleStrictAssignmentToggle} />
</div>
</div>
</div>
@@ -522,6 +522,23 @@ export function parseQuotaData(provider, data) {
}
break;
case "freebuff":
// Session quotas keyed by model id — label rows with the friendly
// displayName (from the registry) and keep modelKey for ordering.
if (data.quotas) {
Object.entries(data.quotas).forEach(([modelKey, quota]) => {
normalizedQuotas.push({
name: quota.displayName || modelKey,
modelKey,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
recurring: quota.recurring !== false,
});
});
}
break;
case "ollama":
// Session (5h) / Weekly (7d) usage % from ollama.com/api/usage.
// remainingPercentage only — no absolute remaining (UI treats remaining as %).
+17 -2
View File
@@ -102,9 +102,24 @@ const OAUTH_TEST_CONFIG = {
},
refreshable: false,
},
freebuff: {
// The session endpoint doubles as the auth probe: GET never claims a
// session (POST would burn 1.0 unit of the daily quota). Mirrors the usage
// handler: 401 = bad token, 403 = region/account gate (token still valid),
// 404 = no session row yet (pre-join, token valid). No refresh path —
// when the authToken dies the user re-logs in.
url: "https://www.codebuff.com/api/v1/freebuff/session",
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
extraHeaders: { Accept: "application/json", "User-Agent": "codebuff-cli/0.0.138" },
acceptStatuses: [403, 404],
softFailMessage: {
403: "Connected, but Freebuff is gated (403) — country blocked or account banned.",
},
},
// Grok CLI / Grok Build — probe /v1/user (no inference quota). Headers mirror official CLI.
"grok-cli": {
url: PROVIDERS["grok-cli"]?.userUrl || "https://cli-chat-proxy.grok.com/v1/user",
"grok-cli": { url: PROVIDERS["grok-cli"]?.userUrl || "https://cli-chat-proxy.grok.com/v1/user",
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
@@ -1,10 +1,11 @@
import { NextResponse } from "next/server";
import { clearPoolUnfit } from "open-sse/services/proxyPoolFitness.js";
import { clearPoolUnfit, ensurePoolFitnessHydrated } from "open-sse/services/proxyPoolFitness.js";
// POST /api/proxy-pools/[id]/fitness/clear
// Body: { scope: "provider::model" } — clears the mark for this pool + scope.
export async function POST(request, { params }) {
try {
await ensurePoolFitnessHydrated();
const { id } = await params;
let body = {};
try {
@@ -22,4 +23,4 @@ export async function POST(request, { params }) {
console.log("Error clearing pool fitness:", error);
return NextResponse.json({ error: "Failed to clear pool fitness" }, { status: 500 });
}
}
}
@@ -1,11 +1,12 @@
import { NextResponse } from "next/server";
import { clearAllPoolUnfit } from "open-sse/services/proxyPoolFitness.js";
import { clearAllPoolUnfit, ensurePoolFitnessHydrated } from "open-sse/services/proxyPoolFitness.js";
// POST /api/proxy-pools/fitness/clear-all
// Body: { provider?: string } — clears every mark, or only marks scoped to the
// given provider ("provider::*") when provided.
export async function POST(request) {
try {
await ensurePoolFitnessHydrated();
let body = {};
try {
body = await request.json();
@@ -19,4 +20,4 @@ export async function POST(request) {
console.log("Error clearing proxy fitness:", error);
return NextResponse.json({ error: "Failed to clear proxy fitness" }, { status: 500 });
}
}
}
+3 -2
View File
@@ -1,13 +1,14 @@
import { NextResponse } from "next/server";
import { poolFitnessSnapshot } from "open-sse/services/proxyPoolFitness.js";
import { ensurePoolFitnessHydrated, poolFitnessSnapshot } from "open-sse/services/proxyPoolFitness.js";
// GET /api/proxy-pools/fitness — in-memory snapshot of pool fitness marks.
// Returns { pools: { [poolId]: { [scope]: { until, reason } } } }.
export async function GET() {
try {
await ensurePoolFitnessHydrated();
return NextResponse.json({ pools: poolFitnessSnapshot() });
} catch (error) {
console.log("Error reading proxy fitness:", error);
return NextResponse.json({ error: "Failed to read proxy fitness" }, { status: 500 });
}
}
}
+26 -6
View File
@@ -1,5 +1,5 @@
import { getProxyPoolById } from "@/models";
import { fitPoolIds } from "open-sse/services/proxyPoolFitness.js";
import { ensurePoolFitnessHydrated, fitPoolIds } from "open-sse/services/proxyPoolFitness.js";
// Safely normalize any value into a trimmed string.
function normalizeString(value) {
@@ -30,8 +30,10 @@ export function pickProxyPoolId(poolIds, strategy, providerId, opts = {}) {
if (strategy === "smart" && scope) eligible = fitPoolIds(eligible, scope);
if (eligible.length === 0) {
// Every candidate is unfit/excluded — fall back to the first non-excluded
// pool rather than deadlocking; its egress may have recovered.
// Freebuff must never reuse a limited-IP egress. Other providers retain
// the previous fail-open behavior when every smart candidate is marked
// unfit; their executors may have their own pool fallback semantics.
if (providerId === "freebuff" && strategy === "smart") return null;
eligible = poolIds.filter((id) => !(excludeIds || []).includes(id));
if (eligible.length === 0) return null;
}
@@ -88,6 +90,7 @@ export async function resolveConnectionProxyConfig(
excludePoolIds = null
) {
try {
await ensurePoolFitnessHydrated();
// Handle new multi-proxy format
const proxyPoolIds = providerSpecificData?.proxyPoolIds || [];
const proxyRotationStrategy = providerSpecificData?.proxyRotationStrategy || "none";
@@ -97,6 +100,8 @@ export async function resolveConnectionProxyConfig(
const proxyPoolIdRaw = legacyProxyPoolId === "__none__" ? "" : legacyProxyPoolId;
const legacy = normalizeLegacyProxy(providerSpecificData);
const multiPoolScope = providerSpecificData?.proxyPoolScope || null;
let selectedPoolId = null;
/**
* -----------------------------
@@ -104,10 +109,9 @@ export async function resolveConnectionProxyConfig(
* -----------------------------
*/
if (proxyPoolIds.length > 0) {
const scope = providerSpecificData?.proxyPoolScope || null;
const selectedPoolId = pickProxyPoolId(proxyPoolIds, proxyRotationStrategy, connectionId, { scope, excludeIds: excludePoolIds });
selectedPoolId = pickProxyPoolId(proxyPoolIds, proxyRotationStrategy, connectionId, { scope: multiPoolScope, excludeIds: excludePoolIds });
if (selectedPoolId) {
if (selectedPoolId) {
const proxyPool = await getProxyPoolById(selectedPoolId);
const proxyUrl = normalizeString(proxyPool?.proxyUrl);
const noProxy = normalizeString(proxyPool?.noProxy);
@@ -147,6 +151,22 @@ export async function resolveConnectionProxyConfig(
}
}
}
if (
!selectedPoolId &&
proxyRotationStrategy === "smart" &&
multiPoolScope?.startsWith("freebuff::")
) {
return {
source: "pool",
proxyPoolId: null,
proxyPool: null,
noFitPool: true,
connectionProxyEnabled: false,
connectionProxyUrl: "",
connectionNoProxy: "",
strictProxy: true,
};
}
/**
* -----------------------------
+17 -3
View File
@@ -8,6 +8,18 @@ import * as log from "../utils/logger.js";
// Mutex to prevent race conditions during account selection
let selectionMutex = Promise.resolve();
export function filterConnectionsForModel(providerId, connections, model, settings = {}) {
const override = (settings.providerStrategies || {})[providerId] || {};
if (override.strictModelAssignment !== true || !model) {
return connections;
}
return connections.filter((connection) => {
const assignedModel = connection.providerSpecificData?.assignedModel
|| (providerId === "freebuff" ? connection.providerSpecificData?.freebuffModel : null);
return assignedModel === model;
});
}
const GITHUB_MONTHLY_USAGE_LIMIT = "you've reached your additional usage limit for your plan";
function githubMonthlyResetMs(status, errorText, provider) {
@@ -81,7 +93,10 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
};
}
const connections = await getProviderConnections({ provider: providerId, isActive: true });
let connections = await getProviderConnections({ provider: providerId, isActive: true });
const settings = await getSettings();
const providerOverride = (settings.providerStrategies || {})[providerId] || {};
connections = filterConnectionsForModel(providerId, connections, model, settings);
log.debug("AUTH", `${provider} | total connections: ${connections.length}, excludeIds: ${excludeSet.size > 0 ? [...excludeSet].join(",") : "none"}, model: ${model || "any"}`);
if (connections.length === 0) {
@@ -126,9 +141,7 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
return null;
}
const settings = await getSettings();
// Per-provider strategy overrides global setting
const providerOverride = (settings.providerStrategies || {})[providerId] || {};
const strategy = providerOverride.fallbackStrategy || settings.fallbackStrategy || "fill-first";
let connection;
@@ -211,6 +224,7 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
connectionProxyPoolId: resolvedProxy.proxyPoolId || null,
vercelRelayUrl: resolvedProxy.vercelRelayUrl || "",
proxyPoolId: resolvedProxy.proxyPoolId || null,
noFitPool: resolvedProxy.noFitPool === true,
strictProxy: resolvedProxy.strictProxy === true,
},
connectionId: connection.id,