fix: improve provider routing and assignments
This commit is contained in:
1 parent
d00205511a
commit
df8c4ccb81
17 files changed
+471
-51
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user