feat(llm): LLM 配置轮询(故障转移),某个配置没额度/失效时自动切下一个

默认关闭。开启后「未指定模型」的 agent 在当前配置不可用(402 余额不足 /
401 key 失效 / 429 限流 / 5xx)时自动切到下一个配置;agent 绑定或任务 pin
了配置的默认不轮询(绑定即独占,可由 llm_pool_bind_fallback 打开兜底)。

链序 = 激活配置 + 其余按 priority DESC,排除 pool_exclude 的。只给
llm_profiles 加两列,不引入关联表,所以 agents/tasks/conversations 对
llm_profile_id 的四处引用一行未改。

核心约束:只在本次调用尚未 yield 出任何事件时切换成员。已经吐出
text_delta / tool_use_start 后中途断流一律原样上抛(交给 agentcore 的
resume),否则重发同一 request 会重复输出、污染会话历史。SDK 的 doStream
保证 4xx/5xx 在读 body 前返回,所以目标场景都落在这个安全窗口内。

context.Canceled / DeadlineExceeded 绝不触发切换——那是用户取消或任务
超时,误判会白烧备用 key 并污染 a6bd180 的终止原因诊断。400 也不切
(请求本身有问题,换模型结果一样)。

熔断:402/401/403/404 一次即熔断,429/5xx/网络错误连续 3 次;冷却
60s→5min→30min 退避,到期半开试探,成功清零。Registry 是进程级的,独立
于 provider 缓存,重建链路不丢状态,且全进程共享(A 任务撞见的 402,B 任务
立刻知道)。状态旁路落库,重启只恢复尚未到期的冷却窗口。

上下文窗口装不下当前请求的成员直接跳过(必然 400),不把主力模型的窗口
压到全链最小值。全员熔断时仍探链首,不让引擎停摆。

Pool 包在 llmrec 外面、每个成员自带 recorder,失败调用会以 status=error
+ 正确的 profile 名进 llm_records;切换/熔断走 log.Printf,自动出现在
系统日志页。

顺带修两个既有问题:
- DecomposeGoals 原本自己 NewProvider(),绕过限速器/录制/轮询,且每个
  任务新建一个实例——改为由 server 传入共享 provider
- saveLLMConfig 会把 priority/pool_exclude 重置为零值

前端:LLM 页新增轮询卡片(总开关/兜底开关/实际链路顺序/健康徽章/冷却
倒计时/立即恢复),配置编辑区加优先级与「不参与轮询」。

只有一个配置时链长度为 1,poolChain 返回 nil 走裸 provider,行为与开关
打开前完全一致。设计见 docs/LLM轮询设计.md。
This commit is contained in:
Autumn-27 committed 2026-08-19 11:07:53 -04:00
1 parent 89c5e9b26f
commit 0c653a2135
19 files changed
+1750 -50

No files matched your search

+294 -12
View File
@@ -2,7 +2,17 @@
import * as React from "react";
import { Loader2Icon, PlugZapIcon, PlusIcon, RefreshCwIcon, SaveIcon, StarIcon, Trash2Icon } from "lucide-react";
import {
Loader2Icon,
PlugZapIcon,
PlusIcon,
RefreshCwIcon,
RotateCcwIcon,
SaveIcon,
StarIcon,
Trash2Icon,
ZapIcon,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@@ -23,8 +33,9 @@ import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { api } from "@/lib/api";
import type { LLMProfile } from "@/lib/types";
import type { LLMPoolStatus, LLMProfile } from "@/lib/types";
import { cn } from "@/lib/utils";
// 思考强度档位(仅在开关打开时生效)。
@@ -48,6 +59,203 @@ const toStore = (mode: ThinkMode, effort: string) => (mode === "on" ? effort : m
const modeFromStore = (v?: string): ThinkMode => (!v ? "none" : v === "off" ? "off" : "on");
const effortFromStore = (v?: string) => (v && v !== "off" ? v : "high");
// 熔断状态 → 徽章样式。degraded 是「在失败但还没到熔断阈值」的中间态。
const STATE_BADGE: Record<string, { label: string; cls: string }> = {
ok: { label: "正常", cls: "border-emerald-500/50 text-emerald-600 dark:text-emerald-400" },
degraded: { label: "异常", cls: "border-amber-500/50 text-amber-600 dark:text-amber-400" },
tripped: { label: "已熔断", cls: "border-destructive/50 text-destructive" },
};
function cooldownText(secs: number) {
if (secs <= 0) return "";
if (secs < 60) return `${secs}s`;
return `${Math.ceil(secs / 60)}min`;
}
// PoolCard 是轮询功能的总控:主开关、绑定兜底开关,以及后端算出来的**实际**链路顺序。
// 顺序预览很关键——「激活 + 优先级 + 不参与轮询」三个因素叠加后,光看单张配置卡片
// 是猜不出真实顺序的。
function PoolCard({ refreshKey }: { refreshKey: number }) {
const [pool, setPool] = React.useState<LLMPoolStatus | null>(null);
const [busy, setBusy] = React.useState(false);
const load = React.useCallback(async () => {
try {
setPool(await api.llmPool());
} catch {
/* ignore */
}
}, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey is a prop — bumping it is exactly how the parent asks for a refetch after a profile edit
React.useEffect(() => {
void load();
}, [load, refreshKey]);
// 冷却倒计时是后端算的剩余秒数,定时拉一次让它走起来(只在有熔断时才轮询)。
React.useEffect(() => {
if (!pool?.enabled || !pool.chain.some((m) => m.state !== "ok")) return;
const t = setInterval(() => void load(), 10_000);
return () => clearInterval(t);
}, [pool, load]);
async function toggle(patch: { llm_pool_enabled?: boolean; llm_pool_bind_fallback?: boolean }) {
if (busy) return;
setBusy(true);
try {
await api.setSettings(patch);
await load();
if (patch.llm_pool_enabled !== undefined) {
toast.success(patch.llm_pool_enabled ? "已开启 LLM 轮询" : "已关闭 LLM 轮询");
} else {
toast.success("已更新绑定兜底设置");
}
} catch (e) {
toast.error(`设置失败:${(e as Error).message}`);
} finally {
setBusy(false);
}
}
async function recover(id?: string) {
try {
setPool(await api.resetLLMPool(id));
toast.success(id ? "已恢复该配置" : "已恢复全部配置");
} catch (e) {
toast.error(`恢复失败:${(e as Error).message}`);
}
}
const enabled = pool?.enabled ?? false;
// 链路顺序 = 后端返回的顺序;不参与轮询的配置仍会列出(灰显并标注),
// 这样用户能看见「它为什么不在队列里」,而不是凭空消失。
const chain = pool?.chain ?? [];
const inChain = chain.filter((m) => m.active || !m.excluded);
const tripped = chain.filter((m) => m.state === "tripped");
return (
<Card>
<CardHeader className="has-data-[slot=card-action]:grid-cols-[1fr_auto]">
<CardTitle className="flex items-center gap-2">
<ZapIcon className="size-4" /> LLM 轮询 · 故障转移
</CardTitle>
<CardDescription>
开启后,<b>未指定模型</b>的 Agent 在当前配置不可用(余额不足 / Key 失效 / 限流 /
服务异常)时自动切换到下一个配置。 指定了模型的 Agent 与任务默认不参与轮询。
</CardDescription>
<CardAction className="self-center">
<Switch
checked={enabled}
disabled={busy}
onCheckedChange={(v) => void toggle({ llm_pool_enabled: v })}
aria-label="LLM 轮询开关"
/>
</CardAction>
</CardHeader>
{enabled && (
<CardContent className="grid gap-4">
<div className="flex items-center justify-between gap-4 rounded-lg border p-3">
<div className="grid gap-0.5">
<Label className="text-sm">指定模型失败时也兜底</Label>
<p className="text-muted-foreground text-xs">
默认关闭:Agent 或任务指定了某个配置就只用它,失败即失败(不会悄悄换成别的模型)。
开启后,指定的配置失败时也会回落到下面的轮询链。
</p>
</div>
<Switch
checked={pool?.bind_fallback ?? false}
disabled={busy}
onCheckedChange={(v) => void toggle({ llm_pool_bind_fallback: v })}
aria-label="绑定配置失败兜底开关"
/>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label className="text-sm">轮询顺序</Label>
{tripped.length > 0 && (
<Button size="sm" variant="ghost" onClick={() => void recover()}>
<RotateCcwIcon /> 全部恢复
</Button>
)}
</div>
{inChain.length < 2 && (
<p className="text-muted-foreground text-xs">
当前只有 {inChain.length} 个可用配置,轮询不会生效——至少需要 2 个已填 API Key 且参与轮询的配置。
</p>
)}
<div className="grid gap-2">
{chain.map((m) => {
const badge = STATE_BADGE[m.state] ?? STATE_BADGE.ok;
const order =
m.excluded && !m.active ? null : inChain.findIndex((x) => x.profile_id === m.profile_id) + 1;
return (
<div
key={m.profile_id}
className={cn(
"flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border p-2.5 text-sm",
m.excluded && !m.active && "opacity-55",
m.state === "tripped" && "border-destructive/40",
)}
>
<span className="w-6 shrink-0 text-center font-mono text-muted-foreground text-xs">
{order ?? "—"}
</span>
<span className="font-medium">{m.name}</span>
<code className="truncate font-mono text-muted-foreground text-xs">{m.model}</code>
{m.active && (
<Badge variant="outline" className="border-amber-400/50 text-amber-500">
激活
</Badge>
)}
{m.excluded && !m.active && <Badge variant="outline">不参与轮询</Badge>}
{!m.active && <span className="text-muted-foreground text-xs">优先级 {m.priority}</span>}
<div className="ml-auto flex items-center gap-2">
{m.state === "tripped" && m.cooldown_secs > 0 && (
<span className="text-muted-foreground text-xs">冷却 {cooldownText(m.cooldown_secs)}</span>
)}
{m.state === "degraded" && (
<span className="text-muted-foreground text-xs">连续失败 {m.fails} 次</span>
)}
<Badge variant="outline" className={badge.cls}>
{badge.label}
</Badge>
{m.state !== "ok" && (
<Button
size="icon"
variant="ghost"
className="size-7"
aria-label="立即恢复"
title="立即恢复:清除熔断,下次调用重试该配置"
onClick={() => void recover(m.profile_id)}
>
<RotateCcwIcon className="size-3.5" />
</Button>
)}
</div>
{m.last_error && (
<p className="w-full truncate font-mono text-muted-foreground text-xs" title={m.last_error}>
{m.last_error}
</p>
)}
</div>
);
})}
{chain.length === 0 && (
<div className="rounded-lg border border-dashed p-4 text-center text-muted-foreground text-sm">
暂无配置
</div>
)}
</div>
<p className="text-muted-foreground text-xs">
激活配置恒为第 1 顺位,其余按优先级从高到低。某个配置失败后进入冷却(60s → 5min → 30min),
冷却期内被跳过;恢复后自动切回。上下文窗口装不下当前请求的配置会被跳过。
</p>
</div>
</CardContent>
)}
</Card>
);
}
function NewProfileDialog({ onCreated }: { onCreated: (id: string) => void }) {
const [open, setOpen] = React.useState(false);
const [name, setName] = React.useState("");
@@ -61,6 +269,8 @@ function NewProfileDialog({ onCreated }: { onCreated: (id: string) => void }) {
const [cw, setCw] = React.useState("0");
const [thinkMode, setThinkMode] = React.useState<ThinkMode>("none");
const [effort, setEffort] = React.useState("high");
const [priority, setPriority] = React.useState("0");
const [poolExclude, setPoolExclude] = React.useState(false);
const [models, setModels] = React.useState<string[]>([]);
const [loadingModels, setLoadingModels] = React.useState(false);
const [modelsOpen, setModelsOpen] = React.useState(false);
@@ -77,6 +287,8 @@ function NewProfileDialog({ onCreated }: { onCreated: (id: string) => void }) {
setCw("0");
setThinkMode("none");
setEffort("high");
setPriority("0");
setPoolExclude(false);
setModels([]);
setModelsOpen(false);
}
@@ -118,6 +330,8 @@ function NewProfileDialog({ onCreated }: { onCreated: (id: string) => void }) {
rate_per_minute: Number(rpm) || 0,
context_window_k: Number(cw) || 0,
reasoning_effort: toStore(thinkMode, effort),
priority: Number(priority) || 0,
pool_exclude: poolExclude,
});
toast.success(`已新建 Profile:${name.trim()}(在列表中「设为激活」以启用)`);
reset();
@@ -275,12 +489,39 @@ function NewProfileDialog({ onCreated }: { onCreated: (id: string) => void }) {
/>
</div>
</div>
<div className="grid gap-3 rounded-lg border p-3">
<div className="flex items-center justify-between gap-4">
<div className="grid gap-0.5">
<Label htmlFor="np-priority" className="text-sm">
轮询优先级
</Label>
<p className="text-muted-foreground text-xs">数字越大越先被选中;激活配置恒为第 1 顺位。</p>
</div>
<Input
id="np-priority"
type="number"
className="w-24 shrink-0"
value={priority}
onChange={(e) => setPriority(e.target.value)}
/>
</div>
<div className="flex items-center justify-between gap-4 border-t pt-3">
<div className="grid gap-0.5">
<Label className="text-sm">不参与轮询</Label>
<p className="text-muted-foreground text-xs">
开启后不会被当作故障转移目标(仍可被 Agent / 任务显式指定使用)。
</p>
</div>
<Switch checked={poolExclude} onCheckedChange={setPoolExclude} aria-label="不参与轮询" />
</div>
</div>
<div className="grid gap-3 rounded-lg border p-3">
<div className="flex items-center justify-between gap-4">
<div className="grid gap-0.5">
<Label className="text-sm">思考模式 · Extended Thinking</Label>
<p className="text-muted-foreground text-xs">
不发送=不带思考字段(兼容 MiniMax 等不支持该字段的模型);显式关闭=发 disabled(默认开思考的模型才需要);开启=先推理再作答。
不发送=不带思考字段(兼容 MiniMax 等不支持该字段的模型);显式关闭=发
disabled(默认开思考的模型才需要);开启=先推理再作答。
</p>
</div>
<Select value={thinkMode} onValueChange={(v) => setThinkMode(v as ThinkMode)}>
@@ -344,6 +585,10 @@ export default function LLMPage() {
const [cw, setCw] = React.useState("0"); // 上下文窗口(K tokens);0=默认200K
const [thinkMode, setThinkMode] = React.useState<ThinkMode>("none");
const [effort, setEffort] = React.useState("high");
const [priority, setPriority] = React.useState("0"); // 轮询顺位;越大越先
const [poolExclude, setPoolExclude] = React.useState(false); // true=不作为故障转移目标
// 任何 profile 的改动都可能改变轮询链的形状,用它触发 PoolCard 重新拉取。
const [poolKey, setPoolKey] = React.useState(0);
const [testing, setTesting] = React.useState(false);
const [models, setModels] = React.useState<string[]>([]);
const [loadingModels, setLoadingModels] = React.useState(false);
@@ -353,6 +598,7 @@ export default function LLMPage() {
try {
const ps = await api.llmProfiles();
setProfiles(ps);
setPoolKey((k) => k + 1); // 链路顺序可能变了,让 PoolCard 重拉
setSelectedId((prev) => {
if (prev && ps.some((p) => p.id === prev)) return prev;
return ps.find((p) => p.is_default)?.id ?? ps[0]?.id ?? null;
@@ -380,6 +626,8 @@ export default function LLMPage() {
setCw(String(selected.context_window_k ?? 0));
setThinkMode(modeFromStore(selected.reasoning_effort));
setEffort(effortFromStore(selected.reasoning_effort));
setPriority(String(selected.priority ?? 0));
setPoolExclude(selected.pool_exclude ?? false);
setApiKey("");
setKeyHint(selected.api_key_hint ?? "");
}, [selected]);
@@ -389,13 +637,7 @@ export default function LLMPage() {
setLoadingModels(true);
setModels([]);
try {
const r = await api.fetchLLMModels(
format,
baseUrl,
apiKey,
proxy,
selectedId ? Number(selectedId) : undefined,
);
const r = await api.fetchLLMModels(format, baseUrl, apiKey, proxy, selectedId ? Number(selectedId) : undefined);
if (r.ok && r.models && r.models.length > 0) {
setModels(r.models);
setModelsOpen(true);
@@ -457,6 +699,8 @@ export default function LLMPage() {
rate_per_minute: Number(rpm) || 0,
context_window_k: Number(cw) || 0,
reasoning_effort: toStore(thinkMode, effort),
priority: Number(priority) || 0,
pool_exclude: poolExclude,
});
toast.success(selected?.is_default ? "已保存,激活配置即时生效,无需重启" : "已保存");
setApiKey("");
@@ -498,6 +742,7 @@ export default function LLMPage() {
<p className="text-muted-foreground text-sm">全 Agent 共享的格式 / 模型 / 限速配置</p>
</div>
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<PoolCard refreshKey={poolKey} />
<div className="grid grid-cols-1 gap-4 md:gap-6 lg:grid-cols-3">
<Card className="lg:order-2 lg:col-span-2">
<CardHeader>
@@ -572,7 +817,10 @@ export default function LLMPage() {
</Button>
</PopoverTrigger>
{models.length > 0 && (
<PopoverContent className="max-h-72 w-72 gap-0 overflow-y-auto overscroll-contain p-1" align="end">
<PopoverContent
className="max-h-72 w-72 gap-0 overflow-y-auto overscroll-contain p-1"
align="end"
>
{models.map((m) => (
<button
key={m}
@@ -657,12 +905,44 @@ export default function LLMPage() {
</p>
</div>
<div className="grid gap-3 rounded-lg border p-3">
<div className="flex items-center justify-between gap-4">
<div className="grid gap-0.5">
<Label htmlFor="priority" className="text-sm">
轮询优先级
</Label>
<p className="text-muted-foreground text-xs">
数字越大越先被选中;激活配置恒为第 1
顺位,与本值无关。相同优先级的配置会轮流打头,天然分摊额度。
</p>
</div>
<Input
id="priority"
type="number"
className="w-24 shrink-0"
value={priority}
onChange={(e) => setPriority(e.target.value)}
/>
</div>
<div className="flex items-center justify-between gap-4 border-t pt-3">
<div className="grid gap-0.5">
<Label className="text-sm">不参与轮询</Label>
<p className="text-muted-foreground text-xs">
开启后不会被当作故障转移目标(仍可被 Agent / 任务显式指定使用)。 适合「只给某个 Agent
专用、不希望别人失败时烧掉」的昂贵配置。
</p>
</div>
<Switch checked={poolExclude} onCheckedChange={setPoolExclude} aria-label="不参与轮询" />
</div>
</div>
<div className="grid gap-3 rounded-lg border p-3">
<div className="flex items-center justify-between gap-4">
<div className="grid gap-0.5">
<Label className="text-sm">思考模式 · Extended Thinking</Label>
<p className="text-muted-foreground text-xs">
不发送=不带思考字段(兼容 MiniMax 等不支持该字段的模型);显式关闭=发 disabled(默认开思考的模型才需要);开启=先推理再作答。
不发送=不带思考字段(兼容 MiniMax 等不支持该字段的模型);显式关闭=发
disabled(默认开思考的模型才需要);开启=先推理再作答。
</p>
</div>
<Select value={thinkMode} onValueChange={(v) => setThinkMode(v as ThinkMode)}>
@@ -780,6 +1060,8 @@ export default function LLMPage() {
{p.reasoning_effort && (
<span>思考 {p.reasoning_effort === "off" ? "关" : p.reasoning_effort}</span>
)}
{!p.is_default &&
(p.pool_exclude ? <span>不参与轮询</span> : <span>优先级 {p.priority ?? 0}</span>)}
</div>
</div>
</div>
+7 -1
View File
@@ -14,7 +14,7 @@ import type {
InterceptRule, InterceptPending, InterceptApprovalRow,
TaskAssetView, SSProject, SSTask, ConvTokenSummary, CoverageGraphData, CoverageAssetRefs,
WorkspaceListing, WorkspaceFile,
CommandRecord, LLMRecordItem, LLMRecordDetail, LLMTask,
CommandRecord, LLMRecordItem, LLMRecordDetail, LLMTask, LLMPoolStatus,
} from "@/lib/types";
function getToken(): string | null {
@@ -409,9 +409,15 @@ export const api = {
rate_per_minute?: number;
context_window_k?: number;
reasoning_effort?: string; // ""|"off"|"low"|"medium"|"high"|"max"
priority?: number; // 轮询顺位,越大越先用
pool_exclude?: boolean; // true=不作为故障转移目标
}) => post<{ id: number }>("/llm/profiles", p),
deleteLLMProfile: (id: string) => del<{ deleted: number }>(`/llm/profiles/${id}`),
activateLLMProfile: (id: string) => post<{ ok: boolean }>("/llm/profiles/active", { id: Number(id) }),
// 轮询链的实际顺序 + 各配置的熔断状态。
llmPool: () => get<LLMPoolStatus>("/llm/pool"),
// 清除熔断,让下一次调用立刻重试该配置;不传 id = 全部清除。
resetLLMPool: (id?: string) => post<LLMPoolStatus>("/llm/pool/reset", { id: id ? Number(id) : 0 }),
fetchLLMModels: (provider: string, base_url: string, api_key: string, proxy = "", profile_id?: number) =>
post<{ ok: boolean; error?: string; models?: string[] }>("/llm/models", {
provider,
+16 -3
View File
@@ -6,7 +6,7 @@
import type {
Activity, Agent, AgentDetail, Asset, Audit, Company, Conversation,
ConvTokenSummary, DailyTokenBucket, Edge, Finding, InterceptApprovalRow,
InterceptPending, InterceptRule, LLMProfile, MCPServer, MCPTool, PromptVar,
InterceptPending, InterceptRule, LLMPoolStatus, LLMProfile, MCPServer, MCPTool, PromptVar,
PromptVersion, Settings, SkillItem, Stats, TaskNode, Task, TokenTotal,
TokenUsage, Tool, TrafficResp, TrafficDetail, TrafficHost, LLMTask,
} from "@/lib/types";
@@ -595,8 +595,8 @@ export const audit: Audit = {
// ── LLM profiles ─────────────────────────────────────────────────────────────
export const llmProfiles: LLMProfile[] = [
{ id: "1", name: "Claude Opus 4.8", format: "anthropic", model: "claude-opus-4-8", api_key_hint: "…a3f2", rate_per_second: 0, rate_per_minute: 0, context_window_k: 1000, reasoning_effort: "high", is_default: true },
{ id: "2", name: "DeepSeek V4", format: "openai", base_url: "https://api.deepseek.com", model: "deepseek-v4-flash", api_key_hint: "…9c11", rate_per_second: 0, rate_per_minute: 60, context_window_k: 128, reasoning_effort: "", is_default: false },
{ id: "1", name: "Claude Opus 4.8", format: "anthropic", model: "claude-opus-4-8", api_key_hint: "…a3f2", rate_per_second: 0, rate_per_minute: 0, context_window_k: 1000, reasoning_effort: "high", is_default: true, priority: 0, pool_exclude: false },
{ id: "2", name: "DeepSeek V4", format: "openai", base_url: "https://api.deepseek.com", model: "deepseek-v4-flash", api_key_hint: "…9c11", rate_per_second: 0, rate_per_minute: 60, context_window_k: 128, reasoning_effort: "", is_default: false, priority: 10, pool_exclude: false },
];
export const llmConfig = {
@@ -689,6 +689,19 @@ export const settings: Settings = {
web_search_proxy: "",
python_interpreter: "/usr/bin/python3",
workers: 3,
llm_pool_enabled: true,
llm_pool_bind_fallback: false,
};
// ── LLM 轮询(故障转移)──────────────────────────────────────────────────────
// demo:激活配置正常,备用配置刚因余额不足熔断,正在冷却。
export const llmPool: LLMPoolStatus = {
enabled: true,
bind_fallback: false,
chain: [
{ profile_id: "1", name: "Claude Opus 4.8", model: "claude-opus-4-8", format: "anthropic", priority: 0, active: true, excluded: false, state: "ok", fails: 0, trips: 0, cooldown_secs: 0 },
{ profile_id: "2", name: "DeepSeek V4", model: "deepseek-v4-flash", format: "openai", priority: 10, active: false, excluded: false, state: "tripped", fails: 0, trips: 1, cooldown_secs: 42, last_error: "openai: status 402: insufficient balance", last_at: T("2026-07-26T00:10:00Z") },
],
};
// ── Intercept ────────────────────────────────────────────────────────────────
+36
View File
@@ -501,6 +501,11 @@ export interface Settings {
// 任务并发上限:同时「运行中」的任务数上限。关闭=不限;开启后新建任务超限则排队,有空位自动启动。
task_concurrency_enabled?: boolean; // 默认 false
task_concurrency_limit?: number; // 开启后默认 5
// LLM 轮询(故障转移)。默认关;开启后「未指定模型」的 agent 在当前配置不可用
// (余额不足/key 失效/限流/服务异常)时自动切到下一个配置。
llm_pool_enabled?: boolean; // 默认 false
// 绑定了指定配置的 agent/任务失败时是否也回落到轮询链。默认 false = 绑定即独占。
llm_pool_bind_fallback?: boolean;
}
// ---- LLM config ----
@@ -518,6 +523,37 @@ export interface LLMProfile {
// 思考模式: ""=默认(不发送) | "off"=关闭 | "low"/"medium"/"high"/"max"=开启并设强度
reasoning_effort?: string;
is_default: boolean;
// 轮询顺位:越大越先被选中。激活配置恒为链首,与本值无关。
priority?: number;
// true = 不作为故障转移目标(仍可被 agent/任务显式绑定使用)。
pool_exclude?: boolean;
}
// ---- LLM 轮询(故障转移)----
// 一个配置在轮询链中的位置与健康状态。state:
// ok 正常
// degraded 有连续失败但未达熔断阈值
// tripped 已熔断,冷却期内被跳过(cooldown_secs 为剩余秒数)
export interface LLMPoolMember {
profile_id: string;
name: string;
model: string;
format: string;
priority: number;
active: boolean; // 是否为当前激活配置(恒为链首)
excluded: boolean; // pool_exclude:不参与轮询
state: "ok" | "degraded" | "tripped";
fails: number;
trips: number;
cooldown_secs: number;
last_error?: string;
last_at?: string;
}
export interface LLMPoolStatus {
enabled: boolean;
bind_fallback: boolean;
chain: LLMPoolMember[];
}
// ---- Agents ----