Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d70aa3d
fix(failures): sendChatRequest returns the verdict it already computed
Oct 9, 2026
cb227ae
fix(openai): the failure says why it failed on /v1/chat/completions
Oct 9, 2026
ca134f1
fix(anthropic): the failure says why it failed on /v1/messages
Oct 9, 2026
7f5d5fc
fix(failures): apply two-axis review findings
Oct 9, 2026
576be12
refactor(openai): delete the tool turn path production never reached
Oct 9, 2026
656b456
fix(openai): the tools-less retry no longer hands out phantom tool calls
Oct 9, 2026
6d0ea3b
test(anthropic): measure the non-stream retry frame filter — no fix n…
Oct 9, 2026
139856c
refactor(openai): finish the deletion — drop the dead finish-reason p…
Oct 9, 2026
aff4e53
test(agent): freeze the turn-acceptance corpus before the gate refactor
Oct 9, 2026
e944043
fix(agent): the corpus can no longer claim coverage it does not have
Oct 9, 2026
d5f8ee5
fix(agent): one meaning for "max attempts" across both surfaces
Oct 9, 2026
114868e
fix(agent): declare what the budget resolver changes besides the floor
Oct 9, 2026
f75ef0f
refactor(anthropic): one gate decides the streaming turn
Oct 9, 2026
c4d3c43
fix(agent): the gate's paper trail now matches the gate
Oct 9, 2026
0436121
refactor(anthropic): the non-streaming loop decides through the same …
Oct 9, 2026
aa12ec7
refactor(openai): the OpenAI runtime decides through the same gate
Oct 9, 2026
b736df3
fix(agent): one home per hint, and two comments that named things tha…
Oct 9, 2026
bf492e9
fix(openai): the spent recovery allowance stops retries, not suppression
Oct 9, 2026
a8f2a1c
docs(adr): record the turn gate, and where it stops
Oct 9, 2026
022affa
chore: keep the fork's decision records out of the upstream change
Oct 9, 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
432 changes: 189 additions & 243 deletions src/controllers/anthropic.js

Large diffs are not rendered by default.

375 changes: 54 additions & 321 deletions src/controllers/chat.js

Large diffs are not rendered by default.

458 changes: 458 additions & 0 deletions src/utils/agent-turn-gate.js

Large diffs are not rendered by default.

311 changes: 161 additions & 150 deletions src/utils/openai-agent-runtime.js

Large diffs are not rendered by default.

35 changes: 29 additions & 6 deletions src/utils/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ const { uploadAgentContextFile, buildChatFileDescriptor } = require('./upload.js
const { buildRequestHeaders } = require('./header-profile')
const {
ContextExternalizationError, isTransportInterruption, assertChatChallengeBreakerClosed,
bindChatChallengeContext, chatChallengeFrom, isWafChallengeError, releaseChatProbe
bindChatChallengeContext, chatChallengeFrom, isWafChallengeError, releaseChatProbe,
describeUpstreamFailure, unclassifiedFailure
} = require('./upstream-error.js')
const { contextPrefixCache, prefixMatches, canonicalHistoryHash } = require('./context-prefix-cache.js')
const {
Expand Down Expand Up @@ -695,7 +696,9 @@ const sendChatRequest = async (body, options = {}) => {
return {
status: false,
response: null,
message: reason
message: reason,
// Fallo local de configuracion, no del cliente: reintentar mas tarde es lo correcto.
failure: unclassifiedFailure(503)
}
}

Expand Down Expand Up @@ -743,7 +746,10 @@ const postChatRequest = async (body, options, currentAccount, currentToken, brea
return {
status: false,
response: null,
message: '无法创建或续接 Qwen 会话'
message: '无法创建或续接 Qwen 会话',
// Upstream opaco: los challenges ya se lanzan antes de llegar aqui, asi que esto
// no es "vuelve en un rato" sino "el upstream contesto algo que no entendemos".
failure: unclassifiedFailure(502)
}
}
// 浏览器 referer 为 /c/<chat_id>(在 chat_id 生成后动态设置)
Expand Down Expand Up @@ -840,10 +846,23 @@ const postChatRequest = async (body, options, currentAccount, currentToken, brea
}
}

// El veredicto sale con el fallo: los llamadores no tienen que volver a deducir el
// significado de un booleano. Sin respuesta HTTP no hay veredicto que leer — el
// transporte se cayo, y eso es reintentable (503). Con respuesta, decide el clasificador
// (429 del upstream o 502 opaco).
const failure = lastError?.response
? describeUpstreamFailure(lastError, 502, 503)
: unclassifiedFailure(503)

// 所有尝试失败 — 分类错误
if (lastError && currentAccount?.email) {
const hadHttpResponse = !!lastError.response
if (!hadHttpResponse && isRetryableNetworkError(lastError)) {
if (failure.rateLimited) {
// La misma pared que el canal de payload, por el otro canal: la cuenta queda en
// cuota agotada (cooldown por defecto si el upstream no dijo cuanto esperar).
logger.error(`发送聊天请求失败: 账户额度已耗尽`, 'REQUEST', '', lastError.message)
accountManager.recordAccountQuotaExhausted(currentAccount.email, failure.retryAfter)
} else if (!hadHttpResponse && isRetryableNetworkError(lastError)) {
// 传输层失败耗尽重试——记 failure,累计可触发 cooldown(PR #112 语义)
logger.error(
`聊天请求传输失败 (已尝试 ${totalAttempts} 次): ${lastError.message}`,
Expand All @@ -856,7 +875,10 @@ const postChatRequest = async (body, options, currentAccount, currentToken, brea
)
accountManager.recordAccountFailure(currentAccount.email, lastError.code)
} else {
// HTTP 4xx/5xx (上游主动拒绝, 账户有效) — 仅刷新 warn 指示, 不影响 cooldown
// HTTP 4xx/5xx (上游主动拒绝, 账户有效) — 仅刷新 warn 指示, 不影响 cooldown。
// Excepción deliberada y registrada: el 429, que cae en la rama de cuota agotada
// de arriba y sí enfría la cuenta — con el cooldown por defecto cuando el cuerpo
// no trae espera.
const status = lastError.response?.status
logger.error('发送聊天请求失败', 'REQUEST', '', lastError.message)
accountManager.recordAccountError(currentAccount.email, status)
Expand All @@ -871,7 +893,8 @@ const postChatRequest = async (body, options, currentAccount, currentToken, brea

return {
status: false,
response: null
response: null,
failure
}
}

Expand Down
51 changes: 49 additions & 2 deletions src/utils/upstream-error.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ const RATE_LIMIT_MESSAGE_RE = /upper limit for today|reached the upper limit|已
const isRateLimitError = (error) => {
if (!error || typeof error !== 'object') return false;
if (isWafChallengeError(error)) return false;
// Un 429 del upstream es la cuota dicha por el otro canal: la misma pared, sin cuerpo que
// leer. Clasificarla aqui hace que TODOS los consumidores de este predicado la vean igual
// (el failover dentro de la peticion, la marca de cuenta agotada, el registro de fallos).
if (Number(error.response?.status) === 429) return true;
const code = String(error.code || '').toLowerCase();
if (code === RATE_LIMIT_CODE.toLowerCase() || code === QUOTA_LIMIT_CODE) return true;
return RATE_LIMIT_MESSAGE_RE.test(String(error.publicMessage || error.message || ''));
Expand Down Expand Up @@ -192,8 +196,11 @@ const isContextAttachmentError = (error) => String(error?.code || '') === CONTEX
*/
const rateLimitRetryAfterSeconds = (error) => {
const hours = Number(error?.details?.waitHours);
if (!Number.isFinite(hours) || hours <= 0) return null;
return Math.ceil(hours * 3600);
if (Number.isFinite(hours) && hours > 0) return Math.ceil(hours * 3600);
// Canal HTTP: un 429 puede traer la espera como cabecera. Solo segundos — una fecha HTTP
// no se interpreta, porque no hay caso medido que la produzca.
const header = Number(error?.response?.headers?.['retry-after']);
return Number.isFinite(header) && header > 0 ? Math.ceil(header) : null;
};

/**
Expand Down Expand Up @@ -257,6 +264,44 @@ const describeUpstreamFailure = (error, fallbackStatus = 502, overloadedStatus =
return { rateLimited: true, overloaded: false, status: 429, retryAfter: rateLimitRetryAfterSeconds(error) };
};

/**
* El veredicto cuando no hay causa de upstream que leer: nadie clasifico, y el status lo
* elige quien conoce el caso (503 configuracion local, 502 upstream opaco). Existe para que
* la forma del veredicto se defina una sola vez, aqui, y no en cada sitio que la construye.
* @param {number} status - Status que corresponde al caso
* @returns {{rateLimited: boolean, overloaded: boolean, status: number, retryAfter: null}}
*/
const unclassifiedFailure = (status) => ({
rateLimited: false,
overloaded: false,
status,
retryAfter: null
});

/**
* Un veredicto de upstream en la forma de error de cable OpenAI. Vive aqui, junto a los
* constantes de los dos vocabularios, porque la usan tres sitios que no pueden depender
* unos de otros: el controlador de chat (via de excepcion y via de retorno) y el runtime
* de agente, que ya devolvia un error con status y code de este cable.
* @param {{rateLimited: boolean, overloaded: boolean, status: number, retryAfter: number|null}} failure
* @param {string} message - Mensaje para el cliente
* @param {string} [fallbackCode] - `code` cuando el veredicto no trae uno propio
* @returns {{status: number, message: string, code: string, type?: string, retry_after?: number}}
*/
const openAIErrorShape = (failure, message, fallbackCode = 'upstream_error') => {
const shape = {
status: failure.status,
message,
code: failure.rateLimited
? RATE_LIMIT_OPENAI_TYPE
: (failure.overloaded ? 'upstream_unavailable' : fallbackCode)
};
if (failure.rateLimited) shape.type = RATE_LIMIT_OPENAI_TYPE;
else if (failure.overloaded) shape.type = 'server_error';
if (failure.retryAfter !== null) shape.retry_after = failure.retryAfter;
return shape;
};

/**
* Denuncia la cuenta que se quedo sin cuota, para que la rotacion deje de elegirla.
*
Expand Down Expand Up @@ -482,6 +527,8 @@ module.exports = {
setChatChallengeClockForTests,
rateLimitRetryAfterSeconds,
describeUpstreamFailure,
unclassifiedFailure,
openAIErrorShape,
noteRateLimitedAccount,
ContextExternalizationError,
isContextAttachmentError,
Expand Down
Loading