Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
6266b8f
fix(images): deliver tool_result images and stop the externalized-con…
Sep 7, 2026
a783f92
fix(images): harvest current-turn images from non-last messages
Sep 8, 2026
e3bd245
fix(openai): harvest current-turn media so a trailing text message ca…
Sep 8, 2026
5019f04
fix(openai): turn boundary is the last final answer, not any assistan…
Sep 8, 2026
526fd49
fix(anthropic): a single tool call no longer erases the image from th…
Sep 8, 2026
1c50b43
fix(openai): dedupe after folding, attach to null content, gate t2i, …
Sep 8, 2026
c6855d5
fix: surface unsupported Anthropic blocks; stop a truncated closer ea…
Sep 8, 2026
c3188d9
fix(agent-context): spend the context budget, and tell the client whe…
Sep 8, 2026
264d562
perf(images): reuse one upload across a turn instead of re-uploading …
Sep 8, 2026
46106c5
merge: image delivery works on both API paths, plus the context budge…
Sep 8, 2026
309da59
test(probes): add a live agent-loop protocol probe
Sep 8, 2026
a5ea559
fix(tool-protocol): number folded tool calls so results are addressable
Sep 8, 2026
46eca74
feat(agent-turn): give the model a ledger of what it already ran
Sep 8, 2026
7c97771
feat: inject the executed-call ledger on both API paths
Sep 8, 2026
e2eb41c
feat(agent-turn): seed the tool ledger from history and warn on repeats
Sep 8, 2026
127ae21
fix(anthropic): a truncated turn reports max_tokens even when a tool …
Sep 8, 2026
ee18c78
fix(anthropic): tool_use ids use the toolu_ prefix
Sep 8, 2026
4d87e70
fix(openai): strip tool-call residue from delivered text
Sep 8, 2026
62ba427
fix(anthropic): keep tool history readable when the request sends no …
Sep 8, 2026
6d9d15c
fix(anthropic): keep inbound thinking blocks in history
Sep 8, 2026
b479529
fix(tool-protocol): recognise the numbered closer the folded history …
Sep 9, 2026
0108eac
fix(agent-turn): the ledger can no longer forge a line or misattribut…
Sep 9, 2026
f2b5a6f
fix(agent-turn): the ledger tells the truth, identically on both paths
Sep 9, 2026
ab89650
fix(openai): a turn that is only protocol residue fails instead of de…
Sep 9, 2026
23c5b2c
fix(T8 repair): land the OpenAI twin and stop forged ordinals colliding
Sep 9, 2026
8a68433
fix(T9 repair): the delimiter stops being forgeable and the budget st…
Sep 9, 2026
4238d0c
chore(probes): drop the dead initializer eslint flags in the agent-lo…
Sep 9, 2026
f0407ce
test(probes): add the duplicate-onset replay harness
Sep 9, 2026
785486d
fix(probes): make the replay harness measure what it claims to measure
Sep 9, 2026
8e45452
test(probes): record that the replay design cannot reproduce the bug
Sep 9, 2026
03b0587
fix(images): stop the folded tool result claiming an image read retur…
Sep 9, 2026
5e62b00
test(probes): make the tool_result image probe score what it claims t…
Sep 9, 2026
3f0f48b
fix(images): bound the upload cache by the URL's own signed expiry, n…
Sep 9, 2026
a9c9f57
fix(openai): stop the turn gate turning a correct answer into HTTP 429
Sep 9, 2026
08e504a
fix(images): say an image is here only when it is, and only when we s…
Sep 9, 2026
e80b54b
fix(images): a cache entry that vanished mid-read must re-upload, not…
Sep 9, 2026
8411eb4
test(probes): give the tool_result image probe a cell that can fail
Sep 9, 2026
aedd124
fix(openai): close the residue leak the tolerant unwrap opened, and s…
Sep 9, 2026
2575b71
fix(images): one HARVEST_MEDIA_CAP, and a test that fails when the tw…
Sep 9, 2026
1c31c6f
test(agent-turn): pin the ledger's default entry capacity at 40
Sep 9, 2026
ec0de7e
test(probes): make the A/B driver's tool simulator honest, and pin it
Sep 9, 2026
08c724f
fix: map Qwen's daily-quota refusal to 429 on both API paths
Sep 9, 2026
e5babf7
fix(agent-turn): raise the ledger byte cap to 12000, and pin the knob…
Sep 9, 2026
5ca8e60
test: make a short run fail instead of exiting 0
Sep 9, 2026
41b2e4d
fix(quota): stop re-picking the dead account, and tell the truth abou…
Sep 9, 2026
258a658
fix(agent-turn): give the ledger its own inline budget, so 12000 reac…
Sep 9, 2026
a493ada
fix(test-gate): a skipped test is not a pass, and bless can go down
Sep 9, 2026
40df21e
revert(agent-turn): the ledger goes back to 6000 B, its benefit was n…
Sep 10, 2026
fb904db
test(agent-turn): pin what the model READS of the ledger when the pre…
Sep 10, 2026
ef1b6b2
test(agent-turn): medir la ceguera del brazo por defecto en vez de co…
Sep 10, 2026
b24335f
chore(probes): the replay harness stops shipping an operator's transc…
Sep 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,29 @@ SERVICE_PORT=3000
# prose/thinking after it also cuts the turn, regardless of this cap.
# AGENT_TURN_MAX_TOOL_CALLS=24

# /v1/chat/completions 的 Agent 回合门禁。三个变量此前只存在于 src/config/index.js,
# 没有出现在本文件里 —— 而它们正是决定门禁何时把一次协议分歧变成 HTTP 错误的开关。
# AGENT_TURN_MAX_ATTEMPTS:同一回合最多重生几次(默认 3,clamp 到 2..6)。调高不会
# 提高成功率:重试复用同一个 chat_id 且 parentId 指向刚被拒的那条回复,三次抽样高度
# 相关,只会成倍放大对同一个账号的上游压力(实测这正是触发 WAF/RGV587 的原因)。
# AGENT_TURN_ALLOW_PROSE_WITH_TOOLS:允许正文与工具调用共存(默认 false)。Anthropic
# 路径无条件允许(anthropic.js#decideRetryReason),所以打开它就是两条路径对齐。
# AGENT_TURN_ACCEPT_BARE_FINAL:接受没有完成包装的裸正文(默认 false)。默认关闭是
# 刻意的:不能把"计划/进度汇报"当成任务完成交付给 agentic 客户端。
# Agent turn gate on /v1/chat/completions. These three lived only in src/config/index.js.
# AGENT_TURN_MAX_ATTEMPTS: regenerations per turn (default 3, clamped 2..6). Raising it
# does not raise the success rate — retries reuse the same chat_id with parentId pointing
# at the just-rejected response, so the draws are correlated; it mostly multiplies upstream
# load on one account (measured: that is what trips the WAF/RGV587 challenge).
# AGENT_TURN_ALLOW_PROSE_WITH_TOOLS: let prose coexist with tool calls (default false).
# The Anthropic path allows it unconditionally, so enabling it aligns both paths.
# AGENT_TURN_ACCEPT_BARE_FINAL: accept bare prose with no completion wrapper (default
# false). Off by default on purpose: a plan or a progress update must not be delivered to
# an agentic client as a finished task.
# AGENT_TURN_MAX_ATTEMPTS=3
# AGENT_TURN_ALLOW_PROSE_WITH_TOOLS=false
# AGENT_TURN_ACCEPT_BARE_FINAL=false

# 监听地址(非必填)
# Listen address (optional)
LISTEN_ADDRESS=
Expand Down
12 changes: 11 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ concurrency:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
# Must exceed the gate's own worst case (TEST_GATE_TIMEOUT_MS x
# TEST_GATE_ATTEMPTS, pinned below to 3 x 150s = 7.5 min) plus npm ci and
# lint. With the old 10 here and the gate's 10-minute default watchdog, the
# job died at 10 min and the watchdog — the thing that makes the gate unable
# to hang — could never fire.
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
Expand All @@ -34,3 +39,8 @@ jobs:
# unset, killing the five suites that load controllers. The value
# itself is never read by any test.
API_KEY: ci-test-key
# The whole suite runs in ~5s locally. 150s per attempt is ~30x
# headroom for a cold shared runner, and 3 attempts still fit inside
# timeout-minutes above, so a hung runner is reported by the gate as
# TIMEOUT instead of looking like CI infrastructure flake.
TEST_GATE_TIMEOUT_MS: 150000
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"scripts": {
"start": "node src/start.js",
"dev": "nodemon src/server.js",
"test": "node --test --test-force-exit tests/*.test.js",
"test": "node tools/test-gate.js",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"pm2": "pm2 start ecosystem.config.js",
Expand All @@ -18,7 +18,9 @@
"pm2:delete": "pm2 delete qwen2api",
"pm2:logs": "pm2 logs qwen2api",
"pm2:status": "pm2 status",
"pm2:monit": "pm2 monit"
"pm2:monit": "pm2 monit",
"test:raw": "node --test --test-force-exit tests/*.test.js",
"test:bless": "node tools/test-gate.js --bless"
},
"keywords": [],
"author": "",
Expand Down
672 changes: 624 additions & 48 deletions src/controllers/anthropic.js

Large diffs are not rendered by default.

223 changes: 189 additions & 34 deletions src/controllers/chat.js

Large diffs are not rendered by default.

102 changes: 95 additions & 7 deletions src/middlewares/chat-middleware.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const { generateUUID } = require('../utils/tools.js')
const { isChatType, isThinkingEnabled, parserModel, parserMessages } = require('../utils/chat-helpers.js')
const { isChatType, isThinkingEnabled, parserModel, parserMessages, extractMediaToFiles, harvestCurrentTurnMedia, attachMediaToLastMessage, willBeFolded } = require('../utils/chat-helpers.js')
const { buildToolSystemPrompt, foldToolMessages } = require('../utils/tool-prompt.js')
const { buildAgentTurnDirective } = require('../utils/agent-turn.js')
const { buildAgentTurnDirective, buildToolHistoryLedger, extractHistoryToolCalls } = require('../utils/agent-turn.js')
const { logger } = require('../utils/logger')
const { mapIncomingModel } = require('../utils/model-map.js')

Expand All @@ -12,6 +12,8 @@ const shouldEnableToolRuntime = (tools, chatType, toolChoice) => (
toolChoice !== 'none'
)

const HARVEST_CHAT_TYPES = new Set(['t2t', 'search', 'image_edit'])

const AGENT_CURRENT_MESSAGE_MARKER = '# Current message'

const ensureAgentCurrentEnvelope = (content, role = 'user') => {
Expand Down Expand Up @@ -117,11 +119,45 @@ const processRequestBody = async (req, res, next) => {
const hasTools = shouldEnableToolRuntime(tools, chatType, tool_choice)
const originalLastMessage = Array.isArray(messages) ? messages[messages.length - 1] : null
const afterToolResult = ['tool', 'function'].includes(String(originalLastMessage?.role || '').toLowerCase())
// 当前回合的图片几乎从来不在最后一条消息上:真实客户端会在图片后面再补一条纯文本
// 消息(OpenClaw 的 OPENCLAW_INTERNAL_CONTEXT,Claude Code 的 `[Image: source: …]`),
// 而 parserMessages 只上传最后一条的媒体。先收上来,折叠完再挂回去。
// 详见 chat-helpers.js#harvestCurrentTurnMedia(含 2026-09-08 的真实抓包证据)。
// 白名单,不是黑名单:routes/chat.js 的分发表把 deep_research 和**所有未知类型**都
// 交给 handleImageVideoCompletion,而那个控制器只在 t2i/t2v/image_edit 里给 content
// 赋值。用黑名单的话,任何新增/未知类型都会默默落进收割区。
//
// image_edit 必须留在名单里:那条路正是靠收割把输入图放进 files[]
// (chat.image.video.js:1290-1313)。t2i/t2v 排除掉:那里 content 是纯文本提示词,
// 收割会把它变成数组、塞进空的 '\n\n' 分隔符,还会为一张控制器根本不看的图付一次上传,
// 顺带打断 '@16:9' 这类尺寸嗅探。
// El ledger se arma AQUI, antes de la cosecha: harvestCurrentTurnMedia reescribe
// `candidate.content` quitando los items de imagen, y un tool_result que solo traia la
// imagen (la forma exacta del Read de Claude Code) queda como `[]`. Construido
// despues, el digest sale `-> []` — le dice al modelo que el Read no devolvio nada,
// justo bajo la leyenda que le pide reusar el resultado en vez de repetir la llamada;
// Read es la herramienta mas repetida de la medicion (802 de 1.451). Gemelo de
// anthropic.js#buildInternalRequest, que por la misma razon lo arma antes de su
// barrido de medios (alli la imagen esta en el bypass `media`, no en `content`).
//
// Sigue siendo PRE-FOLD, que es el otro requisito: despues de foldToolMessages la
// llamada ya es texto (`[TOOL CALL #1]`) sin tool_calls ni tool_call_id que recorrer,
// y el ledger saldria vacio sin que nada lo delate.
const toolHistoryLedger = hasTools ? buildToolHistoryLedger(messages || []) : ''

const currentTurnMedia = HARVEST_CHAT_TYPES.has(chatType)
? harvestCurrentTurnMedia(messages)
: []

let preparedMessages = messages
let toolSystemPrompt = ''
if (hasTools) {
toolSystemPrompt = buildToolSystemPrompt(tools, { tool_choice })
preparedMessages = foldToolMessages(messages || [])
// Semilla del ledger de deduplicacion del runtime (openai-agent-runtime.js), del
// mismo recorrido pre-fold y con los mismos ordinales que ve el modelo. No suprime:
// marca la llamada como ya ejecutada para poder registrarla. Gemelo de
// anthropic.js#buildInternalRequest -> built.historyToolCalls.
req.tool_history_calls = extractHistoryToolCalls(messages || [])
req.has_tools = true
req.tool_choice = tool_choice || 'auto'
req.allowed_tool_names = tools
Expand Down Expand Up @@ -163,16 +199,57 @@ const processRequestBody = async (req, res, next) => {
req.has_tools = false
req.allowed_tool_names = []
req.tool_schemas = null
req.tool_history_calls = []
}

// La historia se pliega segun lo que CONTIENE, no segun lo que esta peticion declara.
// Gemelo exacto de anthropic.js#buildInternalRequest. Con el fold dentro de
// `if (hasTools)`, una peticion sin `tools` (o con `tool_choice: 'none'`) dejaba
// intacto al assistant que solo lleva `tool_calls`: su `content` es null/'' y
// formatSingleMessage (chat-helpers.js) descarta todo mensaje cuyo texto queda
// vacio, asi que EL TURNO ENTERO desaparecia de la historia mientras su resultado
// sobrevivia como una linea JSONL con el rol inexistente "tool" — el modelo veia
// una respuesta sin la pregunta. La compactacion y el resumen de Claude Code tienen
// justo esa forma y llegan sin `tools`.
//
// Es RENDERIZADO, no protocolo: toolSystemPrompt, el ledger y req.has_tools siguen
// atados a `hasTools` (arriba), asi que una peticion sin herramientas recupera su
// historia legible sin aprender a llamarlas.
//
// Posicion obligatoria: DESPUES de harvestCurrentTurnMedia (el fold convierte el
// array de contenido en texto y se llevaria la imagen por delante) y ANTES de
// attachMediaToLastMessage (el fold devuelve objetos nuevos; colgar antes seria
// colgar sobre el objeto que se descarta).
if (hasTools || (Array.isArray(messages) && messages.some(willBeFolded))) {
preparedMessages = foldToolMessages(messages || [])
}

// 必须在 foldToolMessages 之后再挂:折叠会把 role=tool/assistant 的消息换成新对象,
// 挂早了那份就被丢掉了。挂到最后一条,parserMessages 才会去上传它。
attachMediaToLastMessage(preparedMessages, currentTurnMedia)

// 处理 messages 参数 : 消息历史(返回 OpenAI 格式消息数组)
const parsedMessages = await parserMessages(preparedMessages, thinkingConfig, chatType)

// 将解析后的消息填充到 React UI 格式的消息对象中
// 取最后一条用户消息作为主消息内容,历史消息通过 content 传递
const lastMessage = parsedMessages[parsedMessages.length - 1] || { role: 'user', content: '' }
// 图片从 content[] 换到 files[]:content[] 带图 + files[] 带外置上下文文档的组合
// 会让上游 500(详见 chat-helpers.js#extractMediaToFiles)。
//
// 只对走文本控制器的 chat_type 生效。routes/chat.js 的分发表把 t2t / search 交给
// handleChatCompletion,其余(t2i / t2v / image_edit,以及未知类型的兜底)全部交给
// handleImageVideoCompletion —— 后者拿的就是这个 req.body,并且直接读
// messages[0].content,期待原始的 content 数组。换成字符串会让 image_edit 走进
// `!Array.isArray(userPrompt)` 分支退化成 t2i,把输入图片整个丢掉。
const splitsMediaToFiles = chatType === 't2t' || chatType === 'search'
const { content: envelopeContent, files: envelopeFiles } = splitsMediaToFiles
? extractMediaToFiles(lastMessage.content || '')
: { content: lastMessage.content || '', files: [] }
body.messages[0].role = lastMessage.role || 'user'
body.messages[0].content = lastMessage.content || ''
body.messages[0].content = envelopeContent
// files 的键位在上面的信封字面量里(对齐 React UI 的键顺序,别挪),这里只填内容。
body.messages[0].files.push(...envelopeFiles)
body.messages[0].chat_type = chatType
body.messages[0].sub_chat_type = chatType
body.messages[0].feature_config.thinking_enabled = thinkingConfig.thinking_enabled
Expand All @@ -183,15 +260,26 @@ const processRequestBody = async (req, res, next) => {
body.messages[0].content,
lastMessage.role || 'user'
)
// Orden fijo en ambos caminos: toolPrompt -> ledger -> envelope -> directive. El
// ledger va pegado al protocolo porque es parte del contrato de herramientas (sin el
// protocolo delante seria una lista de ordinales sueltos), y delante de la historia
// que documenta. Vive en el prefijo, fuera del bloque de historia, donde se recortaria
// justo en las conversaciones largas, que son las que repiten llamadas.
//
// Estar en el prefijo NO lo pone a salvo: en una peticion externalizada el prefijo se
// retiene inline recortado por cabeza y cola, y el bloque perdia ahi sus entradas mas
// NUEVAS. buildBudgetedAgentPrompt (utils/request.js) lo separa y lo recorta aparte,
// reconociendolo por sus dos primeras lineas; tiene que ir AL FINAL del prefijo.
const toolPrefix = [toolSystemPrompt, toolHistoryLedger].filter(Boolean).join('\n\n')
const msgContent = body.messages[0].content
if (typeof msgContent === 'string') {
body.messages[0].content = `${toolSystemPrompt}\n\n${msgContent}`
body.messages[0].content = `${toolPrefix}\n\n${msgContent}`
} else if (Array.isArray(msgContent)) {
const textIdx = msgContent.findIndex(c => c?.type === 'text')
if (textIdx >= 0) {
msgContent[textIdx].text = `${toolSystemPrompt}\n\n${msgContent[textIdx].text || ''}`
msgContent[textIdx].text = `${toolPrefix}\n\n${msgContent[textIdx].text || ''}`
} else {
msgContent.unshift({ type: 'text', text: toolSystemPrompt })
msgContent.unshift({ type: 'text', text: toolPrefix })
}
}

Expand Down
55 changes: 53 additions & 2 deletions src/utils/account-rotator.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,14 @@ class AccountRotator {
this.lastErrorAt = new Map() // 最近一次错误的时间戳(用于 UI warn 指示,含 HTTP 4xx/5xx)
this.lastErrorCode = new Map() // 最近一次错误码(HTTP status 或 transport err.code)
this.cooldownStartedAt = new Map() // 进入 cooldown 的起始时间戳(failureCounts 达阈值时刻)
this.quotaCooldownUntil = new Map() // 日额度耗尽的账户 -> 解禁时间戳(见 recordQuotaExhausted)
this.maxFailures = 3 // 最大失败次数
this.cooldownPeriod = 5 * 60 * 1000 // 5分钟冷却期
// 额度耗尽的默认静默期。上游给了 `data.num`(小时)时用那个,这是没给时的回退。
// 1 小时是刻意保守:额度按天重置,所以更久也“正确”,但分类若误判(文本回退可能
// 命中别的东西),一天的流放会白白扔掉一个好账户;1 小时足以掐断热循环,
// 又能自己愈合。
this.quotaCooldownPeriod = 60 * 60 * 1000
}

/**
Expand Down Expand Up @@ -134,9 +140,42 @@ class AccountRotator {
}
}

/**
* 记录“日额度已耗尽”(RateLimited),把账户暂时移出轮询。
*
* 这是 recordError / recordFailure 之外的第三类,因为两者都不对:
* - recordError(HTTP 4xx/5xx 走的那条)刻意不冷却——上游主动拒绝、账户本身有效。
* 额度耗尽的账户**不是**有效的:在 Qwen 那边重置之前,它对每个请求都会再拒一次。
* - recordFailure 是传输层故障的计数器,要攒够 maxFailures 才冷却;额度耗尽不需要
* 证据积累,一次就是确定的。
*
* 不做这件事时的代价正是这次改动的理由:客户端看到 429 不再重试了,可服务端还在
* 把同一个死账户发回轮询,每一轮再烧一次。
* @param {string} email - 邮箱地址
* @param {number|null} [retryAfterSeconds] - 上游给的真实等待(秒),没有则用默认静默期
*/
recordQuotaExhausted(email, retryAfterSeconds = null) {
if (!email) return
const seconds = Number(retryAfterSeconds)
const waitMs = Number.isFinite(seconds) && seconds > 0
? seconds * 1000
: this.quotaCooldownPeriod
this.quotaCooldownUntil.set(email, Date.now() + waitMs)
this.lastErrorAt.set(email, Date.now())
this.lastErrorCode.set(email, 'RateLimited')
logger.warn(
`账户 ${email} 日额度已耗尽,暂停轮询 ${Math.round(waitMs / 60000)} 分钟`,
'ACCOUNT'
)
}

/**
* 重置账户失败计数(清除 cooldown)
* 注意:不清理 lastErrorAt/lastErrorCode——它们由 endpoint 的 15 分钟窗口管理
*
* 也**不**清理 quotaCooldownUntil:account.js:516 在每次令牌刷新成功后对所有账户
* 调用本方法,而刷新是定时器驱动的。若在这里解禁,额度流放只能活到下一个 tick,
* 烧账户的循环会自己回来。额度只由时间解除(_isAccountAvailable)。
* @param {string} email - 邮箱地址
*/
resetFailures(email) {
Expand All @@ -163,7 +202,8 @@ class AccountRotator {
available: this._isAccountAvailable(account),
lastErrorAt: this.lastErrorAt.get(email) || null,
lastErrorCode: this.lastErrorCode.get(email) || null,
cooldownEndsAt: cooldownStart ? cooldownStart + this.cooldownPeriod : null
cooldownEndsAt: cooldownStart ? cooldownStart + this.cooldownPeriod : null,
quotaCooldownEndsAt: this.quotaCooldownUntil.get(email) || null
}
})

Expand Down Expand Up @@ -195,6 +235,15 @@ class AccountRotator {
return false
}

// 额度流放优先于一切:这个账户对上游来说今天已经没有配额,再选它就是白烧一轮。
const quotaUntil = this.quotaCooldownUntil.get(account.email)
if (quotaUntil) {
if (Date.now() < quotaUntil) {
return false
}
this.quotaCooldownUntil.delete(account.email)
}

// 基于 cooldownStartedAt(显式标记)而非 lastUsedTimes——
// 后者对 CLI-only 失败不更新,导致 cooldown 计算不准
const cooldownStart = this.cooldownStartedAt.get(account.email)
Expand Down Expand Up @@ -277,7 +326,8 @@ class AccountRotator {
this.lastUsedTimes,
this.lastErrorAt,
this.lastErrorCode,
this.cooldownStartedAt
this.cooldownStartedAt,
this.quotaCooldownUntil
]
for (const map of maps) {
for (const email of map.keys()) {
Expand All @@ -298,6 +348,7 @@ class AccountRotator {
this.lastErrorAt.clear()
this.lastErrorCode.clear()
this.cooldownStartedAt.clear()
this.quotaCooldownUntil.clear()
}
}

Expand Down
11 changes: 11 additions & 0 deletions src/utils/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,17 @@ class Account {
this.accountRotator.recordError(email, code)
}

/**
* 记录“该账户今天的额度已耗尽”,把它移出轮询直到额度恢复。
* 调用方:anthropic.js / chat.js 的 catch,经 upstream-error#noteRateLimitedAccount。
* 额度耗尽藏在 HTTP 200 的 SSE 包体里,request.js 的状态码分支永远看不到它。
* @param {string} email - 邮箱地址
* @param {number|null} [retryAfterSeconds] - 上游给的真实等待(秒)
*/
recordAccountQuotaExhausted(email, retryAfterSeconds = null) {
this.accountRotator.recordQuotaExhausted(email, retryAfterSeconds)
}

/**
* 累计 daily stats(per-account)
* 调用方:chat.js / anthropic.js / cli.chat.js 在成功消费完上游 usage 后
Expand Down
Loading