fix: improve provider routing and assignments

This commit is contained in:
MUH. IQRAM BAHRING
2026-08-15 03:47:18 +08:00
parent d00205511a
commit df8c4ccb81
17 changed files with 471 additions and 51 deletions
+2 -1
View File
@@ -26,6 +26,7 @@ product
# misc # misc
.DS_Store .DS_Store
*.pem *.pem
.vscode
# debug # debug
npm-debug.log* npm-debug.log*
@@ -84,4 +85,4 @@ graphify-out/*
.script/ .script/
.codegraph/ .codegraph/
.PR/ .PR/
.next-analyze/* .next-analyze/*
+55 -8
View File
@@ -75,13 +75,40 @@ function injectFreebuffMarker(body) {
return { ...body, messages: [{ role: "system", content: FREEBUFF_SYSTEM_MARKER }, ...messages] }; return { ...body, messages: [{ role: "system", content: FREEBUFF_SYSTEM_MARKER }, ...messages] };
} }
// Freebuff root agent id per model (mirrors the CLI's FREEBUFF_ROOT_AGENT_ID_BY_MODEL). // The backend's foreign_toolset gate rejects any tool-calling request whose
// toolset lacks the CLI's `end_turn` tool with a misleading 404 "No endpoints
// found for {model}" (verified live 2026-08-14; the freebuff-proxy bridge
// works around the same gate by injecting this definition). Every request that
// declares tools must carry it or the router finds no serving endpoint.
const END_TURN_TOOL = {
type: "function",
function: {
name: "end_turn",
description: "Signal the end of the current task.",
parameters: { type: "object", properties: {} },
},
};
function injectEndTurnTool(body) {
const tools = body?.tools;
if (!Array.isArray(tools) || tools.length === 0) return body;
const hasEndTurn = tools.some(
(t) => t?.function?.name === "end_turn",
);
if (hasEndTurn) return body;
return { ...body, tools: [...tools, END_TURN_TOOL] };
}
// Freebuff root agent id per model (mirrors the CLI's
// FREEBUFF_CLI_BASE3_AGENT_ID_BY_MODEL — the CLI harness moved from base2 to
// base3, and the backend can return 404 "No endpoints found" for the old
// base2 roots during the transition).
const FREE_ROOT_AGENT_BY_MODEL = { const FREE_ROOT_AGENT_BY_MODEL = {
"deepseek/deepseek-v4-flash": "base2-free-deepseek-flash", "deepseek/deepseek-v4-flash": "base3-free-deepseek-flash",
"deepseek/deepseek-v4-pro": "base2-free-deepseek", "deepseek/deepseek-v4-pro": "base3-free-deepseek",
"mimo/mimo-v2.5": "base2-free-mimo", "mimo/mimo-v2.5": "base3-free-mimo",
"minimax/minimax-m3": "base2-free-minimax-m3", "minimax/minimax-m3": "base3-free-minimax-m3",
"openai/gpt-5.6-luna": "base2-free-luna", "openai/gpt-5.6-luna": "base3-free-luna",
}; };
// Per-token+model session cache (in-memory; keyed so multi-account setups // Per-token+model session cache (in-memory; keyed so multi-account setups
@@ -414,6 +441,23 @@ export class FreebuffExecutor extends BaseExecutor {
return this.config.baseUrl; return this.config.baseUrl;
} }
// The backend's model router answers 404 "No endpoints found for {model}"
// when a tool-calling request's toolset fails its foreign_toolset gate —
// normally prevented by injecting the CLI's `end_turn` tool (see
// injectEndTurnTool). If one still slips through, surface a helpful message
// instead of a bare 404, and let the standard cooldown pace retries.
async parseError(response, bodyText) {
const text = String(bodyText || "");
if (response?.status === 404 && /No endpoints found/i.test(text)) {
return {
status: 404,
message: `Freebuff upstream rejected the request (404: "${text.trim().slice(0, 90)}"). Tool-calling requests need the CLI's end_turn tool — retry; if it persists the Codebuff backend may be having trouble.`,
resetsAtMs: Date.now() + 120_000,
};
}
return super.parseError(response, bodyText);
}
transformRequest(model, body, stream, credentials) { transformRequest(model, body, stream, credentials) {
// Top-level wire shape — see header comment. `run_id` and // Top-level wire shape — see header comment. `run_id` and
// `freebuff_instance_id` are attached by execute() (they need the async // `freebuff_instance_id` are attached by execute() (they need the async
@@ -425,14 +469,16 @@ export class FreebuffExecutor extends BaseExecutor {
cost_mode: "free", cost_mode: "free",
}; };
body.provider = { allow_fallbacks: false }; body.provider = { allow_fallbacks: false };
// Freebuff agents (base2-free-*) own reasoning: the backend applies the // Freebuff agents (base3-free-*) own reasoning: the backend applies the
// agent's reasoningOptions.effort server-side, so a client-sent // agent's reasoningOptions.effort server-side, so a client-sent
// reasoning_effort / reasoning.effort collides with that default → // reasoning_effort / reasoning.effort collides with that default →
// 400 "both provided with conflicting values". Mirror the CLI: send none. // 400 "both provided with conflicting values". Mirror the CLI: send none.
delete body.reasoning_effort; delete body.reasoning_effort;
delete body.reasoning; delete body.reasoning;
// Free-tier gate: first system message must open with the CLI marker. // Free-tier gate: first system message must open with the CLI marker.
return injectFreebuffMarker(body); body = injectFreebuffMarker(body);
// Foreign-toolset gate: tool-calling requests must declare `end_turn`.
return injectEndTurnTool(body);
} }
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
@@ -638,6 +684,7 @@ export const __test__ = {
resetSessionCache, resetSessionCache,
rootAgentIdForModel, rootAgentIdForModel,
injectFreebuffMarker, injectFreebuffMarker,
injectEndTurnTool,
fetchWithNetworkRetry, fetchWithNetworkRetry,
FREEBUFF_SYSTEM_MARKER, FREEBUFF_SYSTEM_MARKER,
SESSION_STALE_CODES, SESSION_STALE_CODES,
+21 -1
View File
@@ -309,8 +309,29 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
proxyPoolId: psd?.proxyPoolId || psd?.connectionProxyPoolId || null, proxyPoolId: psd?.proxyPoolId || psd?.connectionProxyPoolId || null,
}); });
const proxyScope = `${provider}::${model}`;
let proxyOptions = buildProxyOptions(credentials?.providerSpecificData || {}); let proxyOptions = buildProxyOptions(credentials?.providerSpecificData || {});
if (provider === "freebuff" && credentials?.providerSpecificData?.noFitPool === true) {
const error = new Error(`Freebuff has no healthy proxy pool for ${model}; all assigned pools are cooling down after limited-IP errors.`);
error.status = 503;
error.poolScoped = { poolId: null, scope: proxyScope, reason: "no_fit_pool" };
trackPendingRequest(model, provider, connectionId, false, true);
return createErrorResult(503, error.message);
}
if (
provider === "freebuff" &&
!proxyOptions.proxyPoolId &&
!proxyOptions.vercelRelayUrl &&
!(proxyOptions.connectionProxyEnabled && proxyOptions.connectionProxyUrl)
) {
const error = new Error(`Freebuff requires a configured proxy pool for ${model}; direct egress is disabled to prevent limited-IP rate limits.`);
error.status = 503;
trackPendingRequest(model, provider, connectionId, false, true);
return createErrorResult(503, error.message);
}
if (proxyOptions.vercelRelayUrl) { if (proxyOptions.vercelRelayUrl) {
const connectionName = credentials?.connectionName || credentials?.connectionId || "unknown"; const connectionName = credentials?.connectionName || credentials?.connectionId || "unknown";
const poolId = proxyOptions.proxyPoolId || "none"; const poolId = proxyOptions.proxyPoolId || "none";
@@ -342,7 +363,6 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
// another pool instead of failing the account. Covers both thrown errors // another pool instead of failing the account. Covers both thrown errors
// (executor.execute) and non-ok responses declared poolScoped via // (executor.execute) and non-ok responses declared poolScoped via
// parseError — poolId/scope are completed here from proxyOptions. // parseError — poolId/scope are completed here from proxyOptions.
const proxyScope = `${provider}::${model}`;
let parsedNonOk = null; let parsedNonOk = null;
const tryNextPool = async (poolScoped, reasonMsg) => { const tryNextPool = async (poolScoped, reasonMsg) => {
+41
View File
@@ -17,14 +17,49 @@
const FITNESS_STATE_KEY = "__9routerPoolFitness__"; const FITNESS_STATE_KEY = "__9routerPoolFitness__";
const fitness = (globalThis[FITNESS_STATE_KEY] ??= new Map()); // poolId -> Map<scope, { until, reason }> const fitness = (globalThis[FITNESS_STATE_KEY] ??= new Map()); // poolId -> Map<scope, { until, reason }>
let persistTimer = null;
let hydratePromise = null;
export const POOL_UNFIT_MS = 5 * 60 * 1000; export const POOL_UNFIT_MS = 5 * 60 * 1000;
function schedulePersist() {
if (persistTimer) return;
persistTimer = setTimeout(async () => {
persistTimer = null;
try {
const { updateSettings } = await import("@/lib/db/repos/settingsRepo.js");
await updateSettings({ proxyPoolFitness: poolFitnessSnapshot() });
} catch {
// Fitness is advisory; persistence failures must not block requests.
}
}, 25);
if (persistTimer.unref) persistTimer.unref();
}
export function hydratePoolFitness(snapshot = {}) {
for (const [poolId, byScope] of Object.entries(snapshot || {})) {
const entries = Object.entries(byScope || {}).filter(([, entry]) => Number(entry?.until) > Date.now());
if (entries.length) fitness.set(poolId, new Map(entries));
}
}
export async function ensurePoolFitnessHydrated() {
if (!hydratePromise) {
hydratePromise = import("@/lib/db/repos/settingsRepo.js")
.then(({ getSettings }) => getSettings())
.then((settings) => hydratePoolFitness(settings.proxyPoolFitness || {}))
.catch(() => {})
.then(() => undefined);
}
return hydratePromise;
}
export function markPoolUnfit(poolId, scope, until = Date.now() + POOL_UNFIT_MS, reason = "") { export function markPoolUnfit(poolId, scope, until = Date.now() + POOL_UNFIT_MS, reason = "") {
if (!poolId || !scope) return; if (!poolId || !scope) return;
const byScope = fitness.get(poolId) || new Map(); const byScope = fitness.get(poolId) || new Map();
byScope.set(scope, { until, reason }); byScope.set(scope, { until, reason });
fitness.set(poolId, byScope); fitness.set(poolId, byScope);
schedulePersist();
} }
export function clearPoolUnfit(poolId, scope) { export function clearPoolUnfit(poolId, scope) {
@@ -32,6 +67,7 @@ export function clearPoolUnfit(poolId, scope) {
if (!byScope) return; if (!byScope) return;
byScope.delete(scope); byScope.delete(scope);
if (byScope.size === 0) fitness.delete(poolId); if (byScope.size === 0) fitness.delete(poolId);
schedulePersist();
} }
// "provider::model" -> "provider::*" (null when the scope has no provider part) // "provider::model" -> "provider::*" (null when the scope has no provider part)
@@ -77,9 +113,11 @@ export function clearAllPoolUnfit(provider = null) {
} }
if (byScope.size === 0) fitness.delete(poolId); if (byScope.size === 0) fitness.delete(poolId);
} }
schedulePersist();
return; return;
} }
fitness.clear(); fitness.clear();
schedulePersist();
} }
// Snapshot of live (non-expired) marks — expired entries are pruned here so // Snapshot of live (non-expired) marks — expired entries are pruned here so
@@ -87,6 +125,8 @@ export function clearAllPoolUnfit(provider = null) {
// Test helper: drop all marks (module state is globalThis-backed). // Test helper: drop all marks (module state is globalThis-backed).
export function resetPoolFitness() { export function resetPoolFitness() {
fitness.clear(); fitness.clear();
hydratePromise = Promise.resolve();
schedulePersist();
} }
// Sweep all expired marks. Returns how many scope entries were removed. // Sweep all expired marks. Returns how many scope entries were removed.
@@ -101,6 +141,7 @@ export function pruneExpired(now = Date.now()) {
} }
if (byScope.size === 0) fitness.delete(poolId); if (byScope.size === 0) fitness.delete(poolId);
} }
if (removed) schedulePersist();
return removed; return removed;
} }
@@ -6,7 +6,7 @@ import PropTypes from "prop-types";
import { Badge, Toggle, Tooltip } from "@/shared/components"; import { Badge, Toggle, Tooltip } from "@/shared/components";
import CooldownTimer from "./CooldownTimer"; 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 [showProxyDropdown, setShowProxyDropdown] = useState(false);
const [updatingProxy, setUpdatingProxy] = useState(false); const [updatingProxy, setUpdatingProxy] = useState(false);
const [selectedProxyIds, setSelectedProxyIds] = useState([]); const [selectedProxyIds, setSelectedProxyIds] = useState([]);
@@ -19,13 +19,14 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
const legacyProxyPoolId = connection.providerSpecificData?.proxyPoolId; const legacyProxyPoolId = connection.providerSpecificData?.proxyPoolId;
// Migrate legacy single proxy to array format // Migrate legacy single proxy to array format
if (legacyProxyPoolId && proxyPoolIds.length === 0) { queueMicrotask(() => {
setSelectedProxyIds([legacyProxyPoolId]); if (legacyProxyPoolId && proxyPoolIds.length === 0) {
} else { setSelectedProxyIds([legacyProxyPoolId]);
setSelectedProxyIds(proxyPoolIds); } else {
} setSelectedProxyIds(proxyPoolIds);
}
setRotationStrategy(connection.providerSpecificData?.proxyRotationStrategy || "none"); setRotationStrategy(connection.providerSpecificData?.proxyRotationStrategy || "none");
});
}, [connection]); }, [connection]);
const proxyPoolMap = new Map((proxyPools || []).map((pool) => [pool.id, pool])); const proxyPoolMap = new Map((proxyPools || []).map((pool) => [pool.id, pool]));
@@ -201,7 +202,7 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
return () => { return () => {
if (interval) clearInterval(interval); if (interval) clearInterval(interval);
}; };
}, [modelLockUntil]); }, [connection, modelLockUntil]);
// Determine effective status (override unavailable if cooldown expired) // Determine effective status (override unavailable if cooldown expired)
const effectiveStatus = (connection.testStatus === "unavailable" && !isCooldown) const effectiveStatus = (connection.testStatus === "unavailable" && !isCooldown)
@@ -283,6 +284,20 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
</Badge> </Badge>
)} )}
</div> </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 && ( {hasAnyProxy && (
<div className="mt-1 flex items-center gap-2 flex-wrap"> <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}> <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, onUpdateProxy: PropTypes.func,
onEdit: PropTypes.func.isRequired, onEdit: PropTypes.func.isRequired,
onDelete: 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({ oneByOneStatus: PropTypes.shape({
state: PropTypes.string, state: PropTypes.string,
error: PropTypes.string, error: PropTypes.string,
@@ -64,6 +64,7 @@ export default function ProviderDetailPage() {
const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false); const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false);
const [providerStrategy, setProviderStrategy] = useState(null); const [providerStrategy, setProviderStrategy] = useState(null);
const [providerStickyLimit, setProviderStickyLimit] = useState(""); const [providerStickyLimit, setProviderStickyLimit] = useState("");
const [strictModelAssignment, setStrictModelAssignment] = useState(false);
const [thinkingMode, setThinkingMode] = useState("auto"); const [thinkingMode, setThinkingMode] = useState("auto");
const [autoPing, setAutoPing] = useState({ enabled: false, connections: {} }); const [autoPing, setAutoPing] = useState({ enabled: false, connections: {} });
const [suggestedModels, setSuggestedModels] = useState([]); const [suggestedModels, setSuggestedModels] = useState([]);
@@ -170,6 +171,21 @@ export default function ProviderDetailPage() {
return levels && levels.includes(thinkingMode) ? thinkingMode : null; return levels && levels.includes(thinkingMode) ? thinkingMode : null;
}; };
const providerStorageAlias = isCompatible ? providerId : providerAlias; 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. // 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). // Include custom models too (e.g. manually added gpt-5.6-sol → max).
const providerThinkingLevels = (() => { const providerThinkingLevels = (() => {
@@ -313,6 +329,7 @@ export default function ProviderDetailPage() {
const override = (settingsData.providerStrategies || {})[providerId] || {}; const override = (settingsData.providerStrategies || {})[providerId] || {};
setProviderStrategy(override.fallbackStrategy || null); setProviderStrategy(override.fallbackStrategy || null);
setProviderStickyLimit(override.stickyRoundRobinLimit != null ? String(override.stickyRoundRobinLimit) : "1"); setProviderStickyLimit(override.stickyRoundRobinLimit != null ? String(override.stickyRoundRobinLimit) : "1");
setStrictModelAssignment(override.strictModelAssignment === true);
// Load per-provider thinking config // Load per-provider thinking config
const thinkingCfg = (settingsData.providerThinking || {})[providerId] || {}; const thinkingCfg = (settingsData.providerThinking || {})[providerId] || {};
setThinkingMode(thinkingCfg.mode || "auto"); setThinkingMode(thinkingCfg.mode || "auto");
@@ -368,9 +385,13 @@ export default function ProviderDetailPage() {
const settingsData = settingsRes.ok ? await settingsRes.json() : {}; const settingsData = settingsRes.ok ? await settingsRes.json() : {};
const current = settingsData.providerStrategies || {}; const current = settingsData.providerStrategies || {};
// Build override: null strategy means remove override, use global // Preserve Freebuff-only settings while changing the shared strategy.
const override = {}; const override = { ...(current[providerId] || {}) };
if (strategy) override.fallbackStrategy = strategy; if (strategy) override.fallbackStrategy = strategy;
else {
delete override.fallbackStrategy;
delete override.stickyRoundRobinLimit;
}
if (strategy === "round-robin" && stickyLimit !== "") { if (strategy === "round-robin" && stickyLimit !== "") {
override.stickyRoundRobinLimit = Number(stickyLimit) || 3; 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 handleRoundRobinToggle = (enabled) => {
const strategy = enabled ? "round-robin" : null; const strategy = enabled ? "round-robin" : null;
const sticky = enabled ? (providerStickyLimit || "1") : providerStickyLimit; const sticky = enabled ? (providerStickyLimit || "1") : providerStickyLimit;
@@ -452,10 +511,12 @@ export default function ProviderDetailPage() {
}; };
useEffect(() => { useEffect(() => {
fetchConnections(); Promise.resolve().then(() => {
fetchAliases(); fetchConnections();
fetchCustomModels(); fetchAliases();
fetchDisabledModels(); fetchCustomModels();
fetchDisabledModels();
});
}, [fetchConnections, fetchAliases, fetchCustomModels, fetchDisabledModels]); }, [fetchConnections, fetchAliases, fetchCustomModels, fetchDisabledModels]);
// Cursor's model availability is account-specific and changes frequently. // 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. // registry remains the fallback while the request is pending or unavailable.
useEffect(() => { useEffect(() => {
if (providerId !== "cursor") { if (providerId !== "cursor") {
setLiveModels([]); queueMicrotask(() => setLiveModels([]));
return; return;
} }
const connection = connections.find((item) => item.isActive !== false); const connection = connections.find((item) => item.isActive !== false);
if (!connection?.id) { if (!connection?.id) {
setLiveModels([]); queueMicrotask(() => setLiveModels([]));
return; return;
} }
@@ -867,7 +928,9 @@ export default function ProviderDetailPage() {
}; };
useEffect(() => { 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]); }, [connections]);
const selectedProxySummary = (() => { const selectedProxySummary = (() => {
@@ -1014,6 +1077,9 @@ export default function ProviderDetailPage() {
}} }}
onDelete={() => handleDelete(conn.id)} onDelete={() => handleDelete(conn.id)}
oneByOneStatus={oneByOneResults[conn.id] || null} oneByOneStatus={oneByOneResults[conn.id] || null}
modelAssignmentOptions={assignmentModels}
onModelAssignmentChange={(model) => handleModelAssignment(conn.id, model)}
strictModelAssignment={strictModelAssignment}
/> />
</div> </div>
</div> </div>
@@ -1511,6 +1577,13 @@ export default function ProviderDetailPage() {
</div> </div>
)} )}
</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>
</div> </div>
@@ -522,6 +522,23 @@ export function parseQuotaData(provider, data) {
} }
break; 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": case "ollama":
// Session (5h) / Weekly (7d) usage % from ollama.com/api/usage. // Session (5h) / Weekly (7d) usage % from ollama.com/api/usage.
// remainingPercentage only — no absolute remaining (UI treats remaining as %). // remainingPercentage only — no absolute remaining (UI treats remaining as %).
+17 -2
View File
@@ -102,9 +102,24 @@ const OAUTH_TEST_CONFIG = {
}, },
refreshable: false, 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 / Grok Build — probe /v1/user (no inference quota). Headers mirror official CLI.
"grok-cli": { "grok-cli": { url: PROVIDERS["grok-cli"]?.userUrl || "https://cli-chat-proxy.grok.com/v1/user",
url: PROVIDERS["grok-cli"]?.userUrl || "https://cli-chat-proxy.grok.com/v1/user",
method: "GET", method: "GET",
authHeader: "Authorization", authHeader: "Authorization",
authPrefix: "Bearer ", authPrefix: "Bearer ",
@@ -1,10 +1,11 @@
import { NextResponse } from "next/server"; 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 // POST /api/proxy-pools/[id]/fitness/clear
// Body: { scope: "provider::model" } — clears the mark for this pool + scope. // Body: { scope: "provider::model" } — clears the mark for this pool + scope.
export async function POST(request, { params }) { export async function POST(request, { params }) {
try { try {
await ensurePoolFitnessHydrated();
const { id } = await params; const { id } = await params;
let body = {}; let body = {};
try { try {
@@ -22,4 +23,4 @@ export async function POST(request, { params }) {
console.log("Error clearing pool fitness:", error); console.log("Error clearing pool fitness:", error);
return NextResponse.json({ error: "Failed to clear pool fitness" }, { status: 500 }); return NextResponse.json({ error: "Failed to clear pool fitness" }, { status: 500 });
} }
} }
@@ -1,11 +1,12 @@
import { NextResponse } from "next/server"; 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 // POST /api/proxy-pools/fitness/clear-all
// Body: { provider?: string } — clears every mark, or only marks scoped to the // Body: { provider?: string } — clears every mark, or only marks scoped to the
// given provider ("provider::*") when provided. // given provider ("provider::*") when provided.
export async function POST(request) { export async function POST(request) {
try { try {
await ensurePoolFitnessHydrated();
let body = {}; let body = {};
try { try {
body = await request.json(); body = await request.json();
@@ -19,4 +20,4 @@ export async function POST(request) {
console.log("Error clearing proxy fitness:", error); console.log("Error clearing proxy fitness:", error);
return NextResponse.json({ error: "Failed to clear proxy fitness" }, { status: 500 }); 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 { 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. // GET /api/proxy-pools/fitness — in-memory snapshot of pool fitness marks.
// Returns { pools: { [poolId]: { [scope]: { until, reason } } } }. // Returns { pools: { [poolId]: { [scope]: { until, reason } } } }.
export async function GET() { export async function GET() {
try { try {
await ensurePoolFitnessHydrated();
return NextResponse.json({ pools: poolFitnessSnapshot() }); return NextResponse.json({ pools: poolFitnessSnapshot() });
} catch (error) { } catch (error) {
console.log("Error reading proxy fitness:", error); console.log("Error reading proxy fitness:", error);
return NextResponse.json({ error: "Failed to read proxy fitness" }, { status: 500 }); return NextResponse.json({ error: "Failed to read proxy fitness" }, { status: 500 });
} }
} }
+26 -6
View File
@@ -1,5 +1,5 @@
import { getProxyPoolById } from "@/models"; 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. // Safely normalize any value into a trimmed string.
function normalizeString(value) { function normalizeString(value) {
@@ -30,8 +30,10 @@ export function pickProxyPoolId(poolIds, strategy, providerId, opts = {}) {
if (strategy === "smart" && scope) eligible = fitPoolIds(eligible, scope); if (strategy === "smart" && scope) eligible = fitPoolIds(eligible, scope);
if (eligible.length === 0) { if (eligible.length === 0) {
// Every candidate is unfit/excluded — fall back to the first non-excluded // Freebuff must never reuse a limited-IP egress. Other providers retain
// pool rather than deadlocking; its egress may have recovered. // 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)); eligible = poolIds.filter((id) => !(excludeIds || []).includes(id));
if (eligible.length === 0) return null; if (eligible.length === 0) return null;
} }
@@ -88,6 +90,7 @@ export async function resolveConnectionProxyConfig(
excludePoolIds = null excludePoolIds = null
) { ) {
try { try {
await ensurePoolFitnessHydrated();
// Handle new multi-proxy format // Handle new multi-proxy format
const proxyPoolIds = providerSpecificData?.proxyPoolIds || []; const proxyPoolIds = providerSpecificData?.proxyPoolIds || [];
const proxyRotationStrategy = providerSpecificData?.proxyRotationStrategy || "none"; const proxyRotationStrategy = providerSpecificData?.proxyRotationStrategy || "none";
@@ -97,6 +100,8 @@ export async function resolveConnectionProxyConfig(
const proxyPoolIdRaw = legacyProxyPoolId === "__none__" ? "" : legacyProxyPoolId; const proxyPoolIdRaw = legacyProxyPoolId === "__none__" ? "" : legacyProxyPoolId;
const legacy = normalizeLegacyProxy(providerSpecificData); const legacy = normalizeLegacyProxy(providerSpecificData);
const multiPoolScope = providerSpecificData?.proxyPoolScope || null;
let selectedPoolId = null;
/** /**
* ----------------------------- * -----------------------------
@@ -104,10 +109,9 @@ export async function resolveConnectionProxyConfig(
* ----------------------------- * -----------------------------
*/ */
if (proxyPoolIds.length > 0) { if (proxyPoolIds.length > 0) {
const scope = providerSpecificData?.proxyPoolScope || null; selectedPoolId = pickProxyPoolId(proxyPoolIds, proxyRotationStrategy, connectionId, { scope: multiPoolScope, excludeIds: excludePoolIds });
const selectedPoolId = pickProxyPoolId(proxyPoolIds, proxyRotationStrategy, connectionId, { scope, excludeIds: excludePoolIds });
if (selectedPoolId) { if (selectedPoolId) {
const proxyPool = await getProxyPoolById(selectedPoolId); const proxyPool = await getProxyPoolById(selectedPoolId);
const proxyUrl = normalizeString(proxyPool?.proxyUrl); const proxyUrl = normalizeString(proxyPool?.proxyUrl);
const noProxy = normalizeString(proxyPool?.noProxy); 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 // Mutex to prevent race conditions during account selection
let selectionMutex = Promise.resolve(); 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"; const GITHUB_MONTHLY_USAGE_LIMIT = "you've reached your additional usage limit for your plan";
function githubMonthlyResetMs(status, errorText, provider) { 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"}`); log.debug("AUTH", `${provider} | total connections: ${connections.length}, excludeIds: ${excludeSet.size > 0 ? [...excludeSet].join(",") : "none"}, model: ${model || "any"}`);
if (connections.length === 0) { if (connections.length === 0) {
@@ -126,9 +141,7 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
return null; return null;
} }
const settings = await getSettings();
// Per-provider strategy overrides global setting // Per-provider strategy overrides global setting
const providerOverride = (settings.providerStrategies || {})[providerId] || {};
const strategy = providerOverride.fallbackStrategy || settings.fallbackStrategy || "fill-first"; const strategy = providerOverride.fallbackStrategy || settings.fallbackStrategy || "fill-first";
let connection; let connection;
@@ -211,6 +224,7 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
connectionProxyPoolId: resolvedProxy.proxyPoolId || null, connectionProxyPoolId: resolvedProxy.proxyPoolId || null,
vercelRelayUrl: resolvedProxy.vercelRelayUrl || "", vercelRelayUrl: resolvedProxy.vercelRelayUrl || "",
proxyPoolId: resolvedProxy.proxyPoolId || null, proxyPoolId: resolvedProxy.proxyPoolId || null,
noFitPool: resolvedProxy.noFitPool === true,
strictProxy: resolvedProxy.strictProxy === true, strictProxy: resolvedProxy.strictProxy === true,
}, },
connectionId: connection.id, connectionId: connection.id,
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { filterConnectionsForModel } from "../../src/sse/services/auth.js";
const connections = [
{ id: "flash-1", providerSpecificData: { freebuffModel: "deepseek/deepseek-v4-flash" } },
{ id: "mimo-1", providerSpecificData: { freebuffModel: "mimo/mimo-v2.5" } },
{ id: "unassigned", providerSpecificData: {} },
];
describe("Freebuff strict model assignment", () => {
it("keeps only accounts assigned to the requested model", () => {
const result = filterConnectionsForModel("freebuff", connections, "mimo/mimo-v2.5", {
providerStrategies: { freebuff: { strictModelAssignment: true } },
});
expect(result.map((connection) => connection.id)).toEqual(["mimo-1"]);
});
it("excludes unassigned accounts when strict mode is enabled", () => {
const result = filterConnectionsForModel("freebuff", connections, "deepseek/deepseek-v4-flash", {
providerStrategies: { freebuff: { strictModelAssignment: true } },
});
expect(result.map((connection) => connection.id)).toEqual(["flash-1"]);
});
it("preserves the existing pool when strict mode is disabled", () => {
expect(filterConnectionsForModel("freebuff", connections, "mimo/mimo-v2.5", {})).toBe(connections);
});
it("does not affect other providers when their toggle is off", () => {
expect(filterConnectionsForModel("codex", connections, "mimo/mimo-v2.5", {
providerStrategies: {},
})).toBe(connections);
});
});
+68 -6
View File
@@ -207,6 +207,43 @@ describe("freebuff executor wire shape", () => {
const ex = new FreebuffExecutor(); const ex = new FreebuffExecutor();
expect(ex.buildUrl()).toBe("https://www.codebuff.com/api/v1/chat/completions"); expect(ex.buildUrl()).toBe("https://www.codebuff.com/api/v1/chat/completions");
}); });
it("injects the end_turn tool into any tool-calling request (backend foreign_toolset gate)", () => {
const ex = new FreebuffExecutor();
const body = {
model: "deepseek/deepseek-v4-flash",
messages: [{ role: "user", content: "hi" }],
tools: [{ type: "function", function: { name: "read_file", description: "read" } }],
};
const out = ex.transformRequest(body.model, body, true, { providerSpecificData: { fingerprintId: "fp-1" } });
const names = out.tools.map((t) => t.function.name);
expect(names).toContain("read_file");
expect(names).toContain("end_turn");
expect(out.tools[out.tools.length - 1].function).toMatchObject({
name: "end_turn",
description: "Signal the end of the current task.",
});
});
it("does not inject end_turn when the request has no tools", () => {
const ex = new FreebuffExecutor();
const body = { model: "deepseek/deepseek-v4-flash", messages: [{ role: "user", content: "hi" }] };
const out = ex.transformRequest(body.model, body, true, { providerSpecificData: { fingerprintId: "fp-1" } });
expect(out.tools).toBeUndefined();
});
it("does not duplicate end_turn when the caller already declared it", () => {
const ex = new FreebuffExecutor();
const endTurn = { type: "function", function: { name: "end_turn", description: "Signal the end of the current task.", parameters: { type: "object", properties: {} } } };
const body = {
model: "deepseek/deepseek-v4-flash",
messages: [{ role: "user", content: "hi" }],
tools: [endTurn],
};
const out = ex.transformRequest(body.model, body, true, { providerSpecificData: { fingerprintId: "fp-1" } });
expect(out.tools).toHaveLength(1);
expect(out.tools[0].function.name).toBe("end_turn");
});
}); });
describe("freebuff session pre-flight", () => { describe("freebuff session pre-flight", () => {
@@ -301,11 +338,11 @@ describe("freebuff free-tier system marker", () => {
describe("freebuff run registration", () => { describe("freebuff run registration", () => {
it("maps freebuff models to their root free agent ids", () => { it("maps freebuff models to their root free agent ids", () => {
expect(rootAgentIdForModel("deepseek/deepseek-v4-flash")).toBe("base2-free-deepseek-flash"); expect(rootAgentIdForModel("deepseek/deepseek-v4-flash")).toBe("base3-free-deepseek-flash");
expect(rootAgentIdForModel("deepseek/deepseek-v4-pro")).toBe("base2-free-deepseek"); expect(rootAgentIdForModel("deepseek/deepseek-v4-pro")).toBe("base3-free-deepseek");
expect(rootAgentIdForModel("mimo/mimo-v2.5")).toBe("base2-free-mimo"); expect(rootAgentIdForModel("mimo/mimo-v2.5")).toBe("base3-free-mimo");
expect(rootAgentIdForModel("minimax/minimax-m3")).toBe("base2-free-minimax-m3"); expect(rootAgentIdForModel("minimax/minimax-m3")).toBe("base3-free-minimax-m3");
expect(rootAgentIdForModel("openai/gpt-5.6-luna")).toBe("base2-free-luna"); expect(rootAgentIdForModel("openai/gpt-5.6-luna")).toBe("base3-free-luna");
expect(rootAgentIdForModel("some/unknown-model")).toBe("base2-free"); expect(rootAgentIdForModel("some/unknown-model")).toBe("base2-free");
}); });
@@ -320,7 +357,7 @@ describe("freebuff run registration", () => {
expect(opts.headers.Authorization).toBe("Bearer tok-1"); expect(opts.headers.Authorization).toBe("Bearer tok-1");
const payload = JSON.parse(opts.body); const payload = JSON.parse(opts.body);
expect(payload.action).toBe("START"); expect(payload.action).toBe("START");
expect(payload.agentId).toBe("base2-free-deepseek-flash"); expect(payload.agentId).toBe("base3-free-deepseek-flash");
expect(payload.ancestorRunIds).toEqual([]); expect(payload.ancestorRunIds).toEqual([]);
}); });
@@ -586,3 +623,28 @@ describe("freebuff executor execute", () => {
expect(JSON.parse(finishes[0][1].body).status).toBe("cancelled"); expect(JSON.parse(finishes[0][1].body).status).toBe("cancelled");
}); });
}); });
describe("freebuff executor parseError", () => {
it("explains a 404 'No endpoints found' as the toolset gate, not a credential problem", async () => {
const ex = new FreebuffExecutor();
const res = jsonResponse(
{ error: { message: "No endpoints found for deepseek/deepseek-v4-flash.", code: 404, type: null, param: null } },
{ status: 404, ok: false },
);
const parsed = await ex.parseError(res, JSON.stringify({ error: { message: "No endpoints found for deepseek/deepseek-v4-flash.", code: 404 } }));
expect(parsed.status).toBe(404);
expect(parsed.message).toMatch(/end_turn/i);
expect(parsed.message).not.toMatch(/credential/i);
expect(parsed.resetsAtMs).toBeGreaterThan(Date.now());
});
it("passes other statuses through untouched", async () => {
const ex = new FreebuffExecutor();
const res = jsonResponse({ error: "bad" }, { status: 500, ok: false });
const parsed = await ex.parseError(res, JSON.stringify({ error: "bad" }));
expect(parsed.status).toBe(500);
expect(parsed.message).toContain("bad");
expect(parsed.resetsAtMs).toBeUndefined();
});
});
+28
View File
@@ -48,3 +48,31 @@ describe("classifyOAuthProbeResult (grok-cli)", () => {
expect(r).toEqual({ valid: true, error: null, soft: false }); expect(r).toEqual({ valid: true, error: null, soft: false });
}); });
}); });
describe("classifyOAuthProbeResult (freebuff)", () => {
const FREEBUFF_PROBE = {
acceptStatuses: [403, 404],
softFailMessage: { 403: "gated" },
};
it("treats 404 (no session row) as silent success", () => {
const r = classifyOAuthProbeResult({ ok: false, status: 404 }, FREEBUFF_PROBE, "");
expect(r).toEqual({ valid: true, error: null, soft: false });
});
it("treats 403 (region/account gate) as soft success", () => {
const r = classifyOAuthProbeResult(
{ ok: false, status: 403 },
FREEBUFF_PROBE,
JSON.stringify({ status: "country_blocked", countryCode: "XX" }),
);
expect(r.valid).toBe(true);
expect(r.soft).toBe(true);
expect(r.error).toMatch(/gated/);
});
it("treats 401 as hard auth failure", () => {
const r = classifyOAuthProbeResult({ ok: false, status: 401 }, FREEBUFF_PROBE, "unauthorized");
expect(r).toEqual({ valid: false, error: "Token invalid or revoked", soft: false });
});
});
+25
View File
@@ -9,6 +9,7 @@ import {
pruneExpired, pruneExpired,
resetPoolFitness, resetPoolFitness,
} from "open-sse/services/proxyPoolFitness.js"; } from "open-sse/services/proxyPoolFitness.js";
import { pickProxyPoolId } from "../../src/lib/network/connectionProxy.js";
describe("proxy pool fitness registry", () => { describe("proxy pool fitness registry", () => {
beforeEach(() => resetPoolFitness()); beforeEach(() => resetPoolFitness());
@@ -39,6 +40,30 @@ describe("proxy pool fitness registry", () => {
expect(snap.p1["fb::m2"]).toBeUndefined(); expect(snap.p1["fb::m2"]).toBeUndefined();
}); });
it("does not reuse a pool when every smart candidate is unfit", () => {
markPoolUnfit("p1", "freebuff::openai/gpt-5.6-luna", Date.now() + 60_000, "limited_ip");
markPoolUnfit("p2", "freebuff::openai/gpt-5.6-luna", Date.now() + 60_000, "limited_ip");
expect(pickProxyPoolId(
["p1", "p2"],
"smart",
"freebuff",
{ scope: "freebuff::openai/gpt-5.6-luna" },
)).toBeNull();
});
it("preserves fail-open smart fallback for non-Freebuff providers", () => {
markPoolUnfit("p1", "opencode::sonnet-4.6", Date.now() + 60_000, "ip-limit");
markPoolUnfit("p2", "opencode::sonnet-4.6", Date.now() + 60_000, "ip-limit");
expect(pickProxyPoolId(
["p1", "p2"],
"smart",
"opencode",
{ scope: "opencode::sonnet-4.6" },
)).toBe("p1");
});
it("clear per scope, clear-all per provider, clear-all global, pruneExpired", () => { it("clear per scope, clear-all per provider, clear-all global, pruneExpired", () => {
markPoolUnfit("p1", "freebuff::m1", Date.now() + 60_000); markPoolUnfit("p1", "freebuff::m1", Date.now() + 60_000);
markPoolUnfit("p1", "kiro::m3", Date.now() + 60_000); markPoolUnfit("p1", "kiro::m3", Date.now() + 60_000);