Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .downstream/gpt6-models-and-prompts.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Claude OAuth 伪装系统块只由上游设置决定:设置 → 网关的 `ena

侧栏“扩展功能 → 推理恢复”(`/admin/reasoning-recovery`):一个全局开关和保存按钮,默认开启,没有账号级选项;页面打开时读取一次,读取失败时显示错误、不显示开关。存为 `settings.reasoning_recovery_config` 一行 JSON(`{"enabled": <bool>}`),没有该行即开启;`GET/PUT /api/v1/admin/reasoning-recovery` 以标准响应封装读写 `{"enabled": <bool>}`,管理员认证、无二次验证,保存记入操作审计,请求体不是严格的 `{"enabled": <bool>}` 时返回 400 且不保存。请求路径只读内存中的布尔值(保存即替换,60 秒后台刷新以同步多实例),不查询数据库,每次转发开始时读取一次。启动时读取失败或存储值不是严格的 `{"enabled": <bool>}` 则停止启动;运行中刷新失败保留当前值。

开启时,OpenAI 平台的 API Key、OAuth 和 Setup Token 账号(含自动透传)在原生 Responses(含 compact)与 Chat Completions 转 Responses 的 HTTP 请求上生效:上游以结构化错误码 `invalid_encrypted_content` 或 `thinking_signature_invalid` 拒绝、且尚无语义输出时,去掉推理项的 `encrypted_content`,在同一账号重发一次:上游用 `input[N]` 指明条目时只去掉该项,否则去掉请求里全部推理项的密文。上游未用 `input[N]` 指明条目、而请求里还有其他密文载体(压缩项、多智能体 `agent_message` 的正文段)时,只有上游错误消息按条目 id 点名的是带密文的推理项才恢复,否则不恢复;这条点名规则只在推理项 id 随请求发出的路径上成立(API Key 账号与自动透传),OAuth / Setup Token 的非透传请求发出前会去掉推理项 id,遇到其他密文载体仍不恢复。其他载体的密文任何情况下都不改动。被去掉的密文按来源在 Redis 记忆 24 小时,期间同源请求发送前即去掉;记忆按每批 64 条读写,读与写共用每个请求 100 毫秒的 I/O 预算。本账号修不好时换号:在 `POST /v1/responses`(含 compact 与自动透传)上,上游报告请求里的密文无法解密或校验(结构化错误码、同类错误消息,或 `could not be decrypted` 的措辞),而本账号无法修复(不能恢复,或去掉推理密文后的重发、发送前已去掉密文的请求仍得到这类拒绝),且客户端尚未收到本次尝试的任何输出时,请求原样交给分组里的下一个账号——密文只能由签发它的上游组织解开,别的账号可能直接接受。每个请求最多因此换号 3 次(同时计入正常换号预算),下一个账号各自按上述规则恢复一次;被换下的账号不计健康惩罚,也不做同账号重试。换号上限或可选账号用尽时,客户端收到第一个报告密文问题的账号的那个错误;途中某个账号给出自己的终态(例如读得懂密文但请求本身无效)或网关侧容量限制时,返回该结果。没有账号给出回答时,被这次换号挪到后续账号上的粘性绑定回到第一个报告密文问题的账号,调度器保留在别处的绑定不动;有账号接受或已开始输出时,会话留在该账号。普通的请求校验错误、带 `previous_response_id` 或 `conversation` 的请求、上游状态为 401/402/403/407/429 时不换号;先试过透传账号再换到非透传账号时,沿用既有规则整块去掉加密推理项。经原生 Responses 路径转发的其他平台账号同样适用;Chat Completions 转 Responses 不换号。Ops 续接诊断用 `recovery.account_mismatch` 标出因密文读不了而交给下一个账号的尝试。恢复重发因账号自身的原因失败时也换号(同样只在 `POST /v1/responses`,含 compact 与自动透传;Chat Completions 转 Responses 不换号):重发已发出、失败属于该路径首次发送时就会换号的那一类,且客户端尚未收到本次尝试的任何输出时,请求按普通账号失败交给下一个账号——下一个账号收到原样请求,本账号不再重试,换号预算同普通换号。哪些失败算这一类以各路径首次发送的换号规则为准:非透传包括额度用尽、限流、鉴权失败、5xx 与流内的上游失败;自动透传的规则更窄,OAuth / Setup Token 透传的普通 5xx、不带账号状态信息的 401/403 等不在其内,仍在本账号结束。Ops 续接诊断用 `recovery.account_failure` 标出这样交给下一个账号的尝试。账号健康:失败的分类与首次发送相同(请求预算被拒 402/403 `balance_insufficient` 只算本次请求,结构化的 `model_not_supported` 只算该账号与模型,`budget_exceeded` 429 算账号,其余池模式可重试的状态不计入 API Key 健康熔断),调度失败照常上报,流内失败照常写入账号状态;重发的 HTTP 错误应答则不执行转发层在首次发送时的账号状态写入(标记限流、置为错误、停用、预算用尽标记、API Key 的瞬时失败计数),也不开启 OAuth 429 的同账号重试窗口,留给之后的首次发送处理,其中首次发送因同时停用账号而把池模式可重试状态计入账号的情形按不计入处理。处理器侧同账号重试用尽后的冷却照常调用,它只对 OAuth / Setup Token 账号起作用(硬额度 429 会写入额度状态);OpenAI API Key 账号在 HTTP 错误应答下不被冷却,下一个请求仍可能先发给它,由那次首次发送写入状态。重发遇到请求校验错误、拒绝类结果、传输错误、响应头超时或未归类为可换号的错误时,仍在本账号结束;重发再次被报告密文无法解密时,按上文的密文规则换号。Messages 桥接与 WebSocket 不恢复。关闭时不重发也不预先去除,签名拒绝仍作为请求级终态返回,不切换账号。
开启时,OpenAI 平台的 API Key、OAuth 和 Setup Token 账号(含自动透传)在原生 Responses(含 compact)与 Chat Completions 转 Responses 的 HTTP 请求上生效:上游以结构化错误码 `invalid_encrypted_content` 或 `thinking_signature_invalid` 拒绝、且尚无语义输出时,去掉推理项的 `encrypted_content`,在同一账号重发一次:上游用 `input[N]` 指明条目时只去掉该项,否则去掉请求里全部推理项的密文。上游未用 `input[N]` 指明条目、而请求里还有其他密文载体(压缩项、多智能体 `agent_message` 的正文段)时,只有上游错误消息按条目 id 点名的是带密文的推理项才恢复,否则不恢复;这条点名规则只在推理项 id 随请求发出的路径上成立(API Key 账号与自动透传),OAuth / Setup Token 的非透传请求发出前会去掉推理项 id,遇到其他密文载体仍不恢复。其他载体的密文任何情况下都不改动。被去掉的密文按来源在 Redis 记忆 24 小时,期间同源请求发送前即去掉;记忆按每批 64 条读写,读与写共用每个请求 100 毫秒的 I/O 预算。去掉密文后留下的推理项仍带着 `id`,官方上游可能把它当作对已存储条目的引用去查,查不到时返回 `Item with id '<id>' not found`(官方为 HTTP 404;生产上只见于 `rs_` 加 32 位十六进制的 id,推断由中转签发,OpenAI 自己签发的 id 留着被接受)。上游的错误消息这样点名、且被点名的正是本次尝试去掉过密文的推理项时,把本次尝试去掉过密文的推理项的 `id` 一并去掉(条目和其余字段保留;没有 `summary` 的条目去掉 `id` 后会被上游拒收,所以它保留 `id`,被点名的是它时不修),在同一账号再发一次。这一次与上面的去密文重发各算各的、各自最多一次,所以一次转发尝试里推理恢复最多让同一账号多收两次(去密文、去 id);转发层其他的同账号重发(被拒字段的兼容重试、compact 模型回退、Agent Identity 任务恢复)另计,且只发生在去密文重发之前;处理器对同一账号做同账号重试时,新的一次尝试从原样请求重新开始,这两次也重新计;先去 id 再遇到密文被拒时,仍可去密文重发一次,届时被去密文的条目同时去掉 `id`。状态为 401/402/403/407/429 时不修;其他 4xx/5xx 只要消息点名了这样的条目就修(中转可能改写状态码)。流内失败不触发。这一步不写任何记忆:同一会话之后的每次转发尝试仍先按原样发出、被拒后再修一次,即每次受影响的尝试多一次很快被拒的上传。被修掉的那个错误应答不影响账号健康;修正后的请求若仍失败,按它所处的位置处理(未做过去密文重发时按首次发送,做过时按去密文重发的规则)。原生 Responses、自动透传以及 Chat Completions 端点上的 Responses 形状请求都适用。Ops 记为 `retry_without_item_ids`,续接诊断的 `recovery.item_ids_removed` 是被去掉 `id` 的条目数,`disposition` 仍只描述去密文的那一次重发。官方 API 是否接受不带 `id` 的无密文推理项未用真实上游验证过(Codex OAuth 后端已验证接受);不接受时修正后的请求以新的错误结束,结果不比修之前差。本账号修不好时换号:在 `POST /v1/responses`(含 compact 与自动透传)上,上游报告请求里的密文无法解密或校验(结构化错误码、同类错误消息,或 `could not be decrypted` 的措辞),而本账号无法修复(不能恢复,或去掉推理密文后的重发、发送前已去掉密文的请求仍得到这类拒绝),且客户端尚未收到本次尝试的任何输出时,请求原样交给分组里的下一个账号——密文只能由签发它的上游组织解开,别的账号可能直接接受。每个请求最多因此换号 3 次(同时计入正常换号预算),下一个账号各自按上述规则恢复一次;被换下的账号不计健康惩罚,也不做同账号重试。换号上限或可选账号用尽时,客户端收到第一个报告密文问题的账号的那个错误;途中某个账号给出自己的终态(例如读得懂密文但请求本身无效)或网关侧容量限制时,返回该结果。没有账号给出回答时,被这次换号挪到后续账号上的粘性绑定回到第一个报告密文问题的账号,调度器保留在别处的绑定不动;有账号接受或已开始输出时,会话留在该账号。普通的请求校验错误、带 `previous_response_id` 或 `conversation` 的请求、上游状态为 401/402/403/407/429 时不换号;先试过透传账号再换到非透传账号时,沿用既有规则整块去掉加密推理项。经原生 Responses 路径转发的其他平台账号同样适用;Chat Completions 转 Responses 不换号。Ops 续接诊断用 `recovery.account_mismatch` 标出因密文读不了而交给下一个账号的尝试。恢复重发因账号自身的原因失败时也换号(同样只在 `POST /v1/responses`,含 compact 与自动透传;Chat Completions 转 Responses 不换号):重发已发出、失败属于该路径首次发送时就会换号的那一类,且客户端尚未收到本次尝试的任何输出时,请求按普通账号失败交给下一个账号——下一个账号收到原样请求,本账号不再重试,换号预算同普通换号。哪些失败算这一类以各路径首次发送的换号规则为准:非透传包括额度用尽、限流、鉴权失败、5xx 与流内的上游失败;自动透传的规则更窄,OAuth / Setup Token 透传的普通 5xx、不带账号状态信息的 401/403 等不在其内,仍在本账号结束。Ops 续接诊断用 `recovery.account_failure` 标出这样交给下一个账号的尝试。账号健康:失败的分类与首次发送相同(请求预算被拒 402/403 `balance_insufficient` 只算本次请求,结构化的 `model_not_supported` 只算该账号与模型,`budget_exceeded` 429 算账号,其余池模式可重试的状态不计入 API Key 健康熔断),调度失败照常上报,流内失败照常写入账号状态;重发的 HTTP 错误应答则不执行转发层在首次发送时的账号状态写入(标记限流、置为错误、停用、预算用尽标记、API Key 的瞬时失败计数),也不开启 OAuth 429 的同账号重试窗口,留给之后的首次发送处理,其中首次发送因同时停用账号而把池模式可重试状态计入账号的情形按不计入处理。处理器侧同账号重试用尽后的冷却照常调用,它只对 OAuth / Setup Token 账号起作用(硬额度 429 会写入额度状态);OpenAI API Key 账号在 HTTP 错误应答下不被冷却,下一个请求仍可能先发给它,由那次首次发送写入状态。重发遇到请求校验错误、拒绝类结果、传输错误、响应头超时或未归类为可换号的错误时,仍在本账号结束;重发再次被报告密文无法解密时,按上文的密文规则换号。Messages 桥接与 WebSocket 不恢复。关闭时不重发也不预先去除,签名拒绝仍作为请求级终态返回,不切换账号。

迁移 `267_purge_account_reasoning_options.sql` 一次性删除各账号 `extra` 中原有的两个账号级推理选项键(键名见该文件);此后没有代码读写它们,已存的账号级取值不带入全局开关。Chat Completions 转 Responses 的请求只由客户端消息转换而来,网关不保存响应中的推理项。

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -387,3 +387,28 @@ func TestOpenAIGatewayHandler_ContinuationStrippedRetryAccountFailureCoolsTheAcc
serveContinuationMismatchSession(t, handler, body, "another-conversation")
require.Equal(t, []int64{1, 1, 2, 2}, upstream.accountIDs, "account 1 would accept again, but is cooling")
}

// The upstream cannot find a reasoning item that the stripped retry left behind
// without ciphertext. The same account is asked once more, without the id, and
// answers; no other account is involved and the session stays where it was.
func TestOpenAIGatewayHandler_ContinuationStrippedItemTheUpstreamCannotFindIsRepairedOnTheSameAccount(t *testing.T) {
gin.SetMode(gin.TestMode)
const relayID = "rs_0123456789abcdef0123456789abcdef"
body := strings.Replace(continuationMismatchReasoningOnlyBody, `"rs_a"`, `"`+relayID+`"`, 1)
unfound := continuationMismatchReply{status: http.StatusNotFound,
payload: `{"error":{"message":"Item with id '` + relayID + `' not found. Items are not persisted when ` + "`store`" + ` is set to false.","type":"invalid_request_error","param":"input","code":null}}`}
upstream := &continuationMismatchUpstream{
accepting: map[int64]bool{1: true, 2: true},
sequence: map[int64][]continuationMismatchReply{1: {continuationMismatchUnrecoverable, unfound}},
}
handler, cache := newContinuationMismatchHandler(t, upstream, continuationMismatchAccounts(2, service.AccountTypeAPIKey))
response := serveContinuationMismatch(t, handler, body)

require.Equal(t, http.StatusOK, response.code, response.body)
require.Equal(t, []int64{1, 1, 1}, upstream.accountIDs, "first send, stripped retry, then the repair")
require.Equal(t, relayID, gjson.GetBytes(upstream.bodies[1], "input.0.id").String())
require.False(t, gjson.GetBytes(upstream.bodies[1], "input.0.encrypted_content").Exists())
require.Equal(t, "reasoning", gjson.GetBytes(upstream.bodies[2], "input.0.type").String(), "the item is kept")
require.False(t, gjson.GetBytes(upstream.bodies[2], "input.0.id").Exists(), "without the id the upstream could not find")
require.Equal(t, int64(1), continuationMismatchBoundAccount(t, cache))
}
8 changes: 7 additions & 1 deletion backend/internal/service/openai_continuation_diagnostic.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ type OpenAIContinuationDiagnostic struct {
}

type openAIContinuationRecoveryShape struct {
CacheSkippedItems int `json:"cache_skipped_items"`
CacheSkippedItems int `json:"cache_skipped_items"`
// ItemIDsRemoved counts stripped reasoning items sent without their id,
// because the upstream did not find such an id.
ItemIDsRemoved int `json:"item_ids_removed,omitempty"`
RetryAttempted bool `json:"retry_attempted"`
Disposition string `json:"disposition"`
NotAttemptedReason string `json:"not_attempted_reason,omitempty"`
Expand Down Expand Up @@ -462,6 +465,9 @@ func sanitizeOpenAIContinuationDiagnosticValues(in *OpenAIContinuationDiagnostic
if recovery.CacheSkippedItems < 0 {
recovery.CacheSkippedItems = 0
}
if recovery.ItemIDsRemoved < 0 {
recovery.ItemIDsRemoved = 0
}
switch recovery.Disposition {
case "not_attempted", "retry_prepared", "retry_attempted", "budget_exhausted":
default:
Expand Down
12 changes: 10 additions & 2 deletions backend/internal/service/openai_gateway_chat_completions.go
Original file line number Diff line number Diff line change
Expand Up @@ -453,9 +453,17 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions(
}
if resp.StatusCode >= 400 {
respBody, upstreamMsg := s.readOpenAIUpstreamError(resp, c)
if repaired, repair := recovery.TryRepairUnfoundItemIDs(resp.StatusCode, resp.Header, respBody); repair {
closeUpstreamResponse()
responsesBody, err = projectReasoningRecoveryEdits(responsesBody, wireBody, repaired)
if err != nil {
return nil, err
}
continue
}
if retryBody, retry := recovery.TryRecover(resp.StatusCode, resp.Header, respBody, false); retry {
closeUpstreamResponse()
responsesBody, err = projectReasoningCipherEdits(responsesBody, wireBody, retryBody)
responsesBody, err = projectReasoningRecoveryEdits(responsesBody, wireBody, retryBody)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -495,7 +503,7 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions(
}
closeUpstreamResponse()
if retryBody, retry := recovery.TryRecoverError(handleErr); retry {
responsesBody, err = projectReasoningCipherEdits(responsesBody, wireBody, retryBody)
responsesBody, err = projectReasoningRecoveryEdits(responsesBody, wireBody, retryBody)
if err != nil {
return nil, err
}
Expand Down
15 changes: 12 additions & 3 deletions backend/internal/service/openai_gateway_forward.go
Original file line number Diff line number Diff line change
Expand Up @@ -1076,8 +1076,17 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
respBody := s.readUpstreamErrorBody(resp)
_ = resp.Body.Close()
resp.Body = io.NopCloser(bytes.NewReader(respBody))
if repaired, repair := reasoningRecovery.TryRepairUnfoundItemIDs(resp.StatusCode, resp.Header, respBody); repair {
body, err = projectReasoningRecoveryEdits(body, wireBody, repaired)
if err != nil {
return nil, err
}
requestView = newOpenAIRequestView(body)
reqBody = nil
continue
}
if retryBody, retry := reasoningRecovery.TryRecover(resp.StatusCode, resp.Header, respBody, false); retry {
body, err = projectReasoningCipherEdits(body, wireBody, retryBody)
body, err = projectReasoningRecoveryEdits(body, wireBody, retryBody)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -1223,7 +1232,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
if err != nil {
if retryBody, retry := reasoningRecovery.TryRecoverError(err); retry {
_ = resp.Body.Close()
body, err = projectReasoningCipherEdits(body, wireBody, retryBody)
body, err = projectReasoningRecoveryEdits(body, wireBody, retryBody)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -1285,7 +1294,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
if err != nil {
if retryBody, retry := reasoningRecovery.TryRecoverError(err); retry {
_ = resp.Body.Close()
body, err = projectReasoningCipherEdits(body, wireBody, retryBody)
body, err = projectReasoningRecoveryEdits(body, wireBody, retryBody)
if err != nil {
return nil, err
}
Expand Down
Loading
Loading