fix: improve provider routing and assignments

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

No files matched your search

+55 -8
View File
@@ -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,
+21 -1
View File
@@ -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) => {
+41
View File
@@ -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;
}