fix: improve provider routing and assignments
This commit is contained in:
+2
-1
@@ -26,6 +26,7 @@ product
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
.vscode
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
@@ -84,4 +85,4 @@ graphify-out/*
|
||||
.script/
|
||||
.codegraph/
|
||||
.PR/
|
||||
.next-analyze/*
|
||||
.next-analyze/*
|
||||
@@ -75,13 +75,40 @@ function injectFreebuffMarker(body) {
|
||||
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 = {
|
||||
"deepseek/deepseek-v4-flash": "base2-free-deepseek-flash",
|
||||
"deepseek/deepseek-v4-pro": "base2-free-deepseek",
|
||||
"mimo/mimo-v2.5": "base2-free-mimo",
|
||||
"minimax/minimax-m3": "base2-free-minimax-m3",
|
||||
"openai/gpt-5.6-luna": "base2-free-luna",
|
||||
"deepseek/deepseek-v4-flash": "base3-free-deepseek-flash",
|
||||
"deepseek/deepseek-v4-pro": "base3-free-deepseek",
|
||||
"mimo/mimo-v2.5": "base3-free-mimo",
|
||||
"minimax/minimax-m3": "base3-free-minimax-m3",
|
||||
"openai/gpt-5.6-luna": "base3-free-luna",
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Top-level wire shape — see header comment. `run_id` and
|
||||
// `freebuff_instance_id` are attached by execute() (they need the async
|
||||
@@ -425,14 +469,16 @@ export class FreebuffExecutor extends BaseExecutor {
|
||||
cost_mode: "free",
|
||||
};
|
||||
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
|
||||
// reasoning_effort / reasoning.effort collides with that default →
|
||||
// 400 "both provided with conflicting values". Mirror the CLI: send none.
|
||||
delete body.reasoning_effort;
|
||||
delete body.reasoning;
|
||||
// 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 }) {
|
||||
@@ -638,6 +684,7 @@ export const __test__ = {
|
||||
resetSessionCache,
|
||||
rootAgentIdForModel,
|
||||
injectFreebuffMarker,
|
||||
injectEndTurnTool,
|
||||
fetchWithNetworkRetry,
|
||||
FREEBUFF_SYSTEM_MARKER,
|
||||
SESSION_STALE_CODES,
|
||||
|
||||
@@ -309,8 +309,29 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
proxyPoolId: psd?.proxyPoolId || psd?.connectionProxyPoolId || null,
|
||||
});
|
||||
|
||||
const proxyScope = `${provider}::${model}`;
|
||||
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) {
|
||||
const connectionName = credentials?.connectionName || credentials?.connectionId || "unknown";
|
||||
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
|
||||
// (executor.execute) and non-ok responses declared poolScoped via
|
||||
// parseError — poolId/scope are completed here from proxyOptions.
|
||||
const proxyScope = `${provider}::${model}`;
|
||||
let parsedNonOk = null;
|
||||
|
||||
const tryNextPool = async (poolScoped, reasonMsg) => {
|
||||
|
||||
@@ -17,14 +17,49 @@
|
||||
|
||||
const FITNESS_STATE_KEY = "__9routerPoolFitness__";
|
||||
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;
|
||||
|
||||
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 = "") {
|
||||
if (!poolId || !scope) return;
|
||||
const byScope = fitness.get(poolId) || new Map();
|
||||
byScope.set(scope, { until, reason });
|
||||
fitness.set(poolId, byScope);
|
||||
schedulePersist();
|
||||
}
|
||||
|
||||
export function clearPoolUnfit(poolId, scope) {
|
||||
@@ -32,6 +67,7 @@ export function clearPoolUnfit(poolId, scope) {
|
||||
if (!byScope) return;
|
||||
byScope.delete(scope);
|
||||
if (byScope.size === 0) fitness.delete(poolId);
|
||||
schedulePersist();
|
||||
}
|
||||
|
||||
// "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);
|
||||
}
|
||||
schedulePersist();
|
||||
return;
|
||||
}
|
||||
fitness.clear();
|
||||
schedulePersist();
|
||||
}
|
||||
|
||||
// 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).
|
||||
export function resetPoolFitness() {
|
||||
fitness.clear();
|
||||
hydratePromise = Promise.resolve();
|
||||
schedulePersist();
|
||||
}
|
||||
|
||||
// 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 (removed) schedulePersist();
|
||||
return removed;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 %).
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* -----------------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -207,6 +207,43 @@ describe("freebuff executor wire shape", () => {
|
||||
const ex = new FreebuffExecutor();
|
||||
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", () => {
|
||||
@@ -301,11 +338,11 @@ describe("freebuff free-tier system marker", () => {
|
||||
|
||||
describe("freebuff run registration", () => {
|
||||
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-pro")).toBe("base2-free-deepseek");
|
||||
expect(rootAgentIdForModel("mimo/mimo-v2.5")).toBe("base2-free-mimo");
|
||||
expect(rootAgentIdForModel("minimax/minimax-m3")).toBe("base2-free-minimax-m3");
|
||||
expect(rootAgentIdForModel("openai/gpt-5.6-luna")).toBe("base2-free-luna");
|
||||
expect(rootAgentIdForModel("deepseek/deepseek-v4-flash")).toBe("base3-free-deepseek-flash");
|
||||
expect(rootAgentIdForModel("deepseek/deepseek-v4-pro")).toBe("base3-free-deepseek");
|
||||
expect(rootAgentIdForModel("mimo/mimo-v2.5")).toBe("base3-free-mimo");
|
||||
expect(rootAgentIdForModel("minimax/minimax-m3")).toBe("base3-free-minimax-m3");
|
||||
expect(rootAgentIdForModel("openai/gpt-5.6-luna")).toBe("base3-free-luna");
|
||||
expect(rootAgentIdForModel("some/unknown-model")).toBe("base2-free");
|
||||
});
|
||||
|
||||
@@ -320,7 +357,7 @@ describe("freebuff run registration", () => {
|
||||
expect(opts.headers.Authorization).toBe("Bearer tok-1");
|
||||
const payload = JSON.parse(opts.body);
|
||||
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([]);
|
||||
});
|
||||
|
||||
@@ -586,3 +623,28 @@ describe("freebuff executor execute", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,3 +48,31 @@ describe("classifyOAuthProbeResult (grok-cli)", () => {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
pruneExpired,
|
||||
resetPoolFitness,
|
||||
} from "open-sse/services/proxyPoolFitness.js";
|
||||
import { pickProxyPoolId } from "../../src/lib/network/connectionProxy.js";
|
||||
|
||||
describe("proxy pool fitness registry", () => {
|
||||
beforeEach(() => resetPoolFitness());
|
||||
@@ -39,6 +40,30 @@ describe("proxy pool fitness registry", () => {
|
||||
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", () => {
|
||||
markPoolUnfit("p1", "freebuff::m1", Date.now() + 60_000);
|
||||
markPoolUnfit("p1", "kiro::m3", Date.now() + 60_000);
|
||||
|
||||
Reference in New Issue
Block a user