From df8c4ccb8155dd82ef48f30172bcc2a0a777b364 Mon Sep 17 00:00:00 2001 From: "MUH. IQRAM BAHRING" Date: Sat, 15 Aug 2026 03:47:18 +0800 Subject: [PATCH] fix: improve provider routing and assignments --- .gitignore | 3 +- open-sse/executors/freebuff.js | 63 +++++++++++-- open-sse/handlers/chatCore.js | 22 ++++- open-sse/services/proxyPoolFitness.js | 41 +++++++++ .../dashboard/providers/[id]/ConnectionRow.js | 36 ++++++-- .../dashboard/providers/[id]/page.js | 91 +++++++++++++++++-- .../usage/components/ProviderLimits/utils.js | 17 ++++ src/app/api/providers/[id]/test/testUtils.js | 19 +++- .../proxy-pools/[id]/fitness/clear/route.js | 5 +- .../proxy-pools/fitness/clear-all/route.js | 5 +- src/app/api/proxy-pools/fitness/route.js | 5 +- src/lib/network/connectionProxy.js | 32 +++++-- src/sse/services/auth.js | 20 +++- tests/unit/freebuff-model-assignment.test.js | 36 ++++++++ tests/unit/freebuff-provider.test.js | 74 +++++++++++++-- tests/unit/grok-cli-oauth-probe.test.js | 28 ++++++ tests/unit/proxy-pool-fitness.test.js | 25 +++++ 17 files changed, 471 insertions(+), 51 deletions(-) create mode 100644 tests/unit/freebuff-model-assignment.test.js diff --git a/.gitignore b/.gitignore index c05d02cf..fe81d4fe 100644 --- a/.gitignore +++ b/.gitignore @@ -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/* \ No newline at end of file diff --git a/open-sse/executors/freebuff.js b/open-sse/executors/freebuff.js index 8bbf8d7b..8efe719f 100644 --- a/open-sse/executors/freebuff.js +++ b/open-sse/executors/freebuff.js @@ -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, diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index bca3b7c5..8a097a1f 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -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) => { diff --git a/open-sse/services/proxyPoolFitness.js b/open-sse/services/proxyPoolFitness.js index 72fc4644..cd43165e 100644 --- a/open-sse/services/proxyPoolFitness.js +++ b/open-sse/services/proxyPoolFitness.js @@ -17,14 +17,49 @@ const FITNESS_STATE_KEY = "__9routerPoolFitness__"; const fitness = (globalThis[FITNESS_STATE_KEY] ??= new Map()); // poolId -> Map +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; } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js index 2e4f92e4..af7f02f5 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js @@ -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 )} + {modelAssignmentOptions && onModelAssignmentChange && ( + + )} {hasAnyProxy && (
@@ -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, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 5eb92c34..9ee75185 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -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} />
@@ -1511,6 +1577,13 @@ export default function ProviderDetailPage() { )} +
+
+ Strict Model Assignment +

Only assigned accounts can serve each model for this provider.

+
+ +
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js index 9f185b83..378a98bd 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js @@ -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 %). diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index 96fffd2a..f45cd836 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -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 ", diff --git a/src/app/api/proxy-pools/[id]/fitness/clear/route.js b/src/app/api/proxy-pools/[id]/fitness/clear/route.js index ce587750..947258c6 100644 --- a/src/app/api/proxy-pools/[id]/fitness/clear/route.js +++ b/src/app/api/proxy-pools/[id]/fitness/clear/route.js @@ -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 }); } -} \ No newline at end of file +} diff --git a/src/app/api/proxy-pools/fitness/clear-all/route.js b/src/app/api/proxy-pools/fitness/clear-all/route.js index 26c29e3a..5d89bc9c 100644 --- a/src/app/api/proxy-pools/fitness/clear-all/route.js +++ b/src/app/api/proxy-pools/fitness/clear-all/route.js @@ -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 }); } -} \ No newline at end of file +} diff --git a/src/app/api/proxy-pools/fitness/route.js b/src/app/api/proxy-pools/fitness/route.js index 6929b8d3..4f178b1c 100644 --- a/src/app/api/proxy-pools/fitness/route.js +++ b/src/app/api/proxy-pools/fitness/route.js @@ -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 }); } -} \ No newline at end of file +} diff --git a/src/lib/network/connectionProxy.js b/src/lib/network/connectionProxy.js index af819be3..393382ec 100644 --- a/src/lib/network/connectionProxy.js +++ b/src/lib/network/connectionProxy.js @@ -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, + }; + } /** * ----------------------------- diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index c2e80572..eaf42614 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -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, diff --git a/tests/unit/freebuff-model-assignment.test.js b/tests/unit/freebuff-model-assignment.test.js new file mode 100644 index 00000000..11a76eee --- /dev/null +++ b/tests/unit/freebuff-model-assignment.test.js @@ -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); + }); +}); diff --git a/tests/unit/freebuff-provider.test.js b/tests/unit/freebuff-provider.test.js index a96c57f0..7db8274f 100644 --- a/tests/unit/freebuff-provider.test.js +++ b/tests/unit/freebuff-provider.test.js @@ -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(); + }); +}); diff --git a/tests/unit/grok-cli-oauth-probe.test.js b/tests/unit/grok-cli-oauth-probe.test.js index 5cda4cdb..24eefa55 100644 --- a/tests/unit/grok-cli-oauth-probe.test.js +++ b/tests/unit/grok-cli-oauth-probe.test.js @@ -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 }); + }); +}); diff --git a/tests/unit/proxy-pool-fitness.test.js b/tests/unit/proxy-pool-fitness.test.js index aae84a0d..431194e1 100644 --- a/tests/unit/proxy-pool-fitness.test.js +++ b/tests/unit/proxy-pool-fitness.test.js @@ -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);