Skip to content

Commit 092fbf8

Browse files
committed
fix(channel-monitor): raise IQ token budget to stop reasoning truncation
gpt-6-astra often needs >=512 reasoning tokens for the candy quiz; with monitorIQMaxTokens=512, accounts that enforce the cap get truncated inside the reasoning phase and return 2xx with an empty message content, which the monitor reported as "iq: empty upstream text" test errors. Codex OAuth accounts strip max_output_tokens upstream, so rotation made the failure intermittent. applyMaxTokensOverride rewrites max_tokens after body_override merge, so the budget can only be changed here. Raise the budget to 2048 (observed reasoning peak 516 + ~500-token answer; request stays well inside the 3-minute IQ timeout) and enrich the empty-text branch with finish_reason/status, usage token counts, and a body snippet via iqEmptyTextMessage, still funneled through sanitizeErrorMessage and truncateMessage.
1 parent ceb2378 commit 092fbf8

5 files changed

Lines changed: 98 additions & 3 deletions

File tree

‎.spec/knowledge/features/channel-monitor-status-iq.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ metadata:
6262
- 不抽 SVG。
6363
- 附加模型:每个附加模型也是两枪(探活 + 糖果题)。
6464
- 智商请求独立使用 180 秒总超时与响应头等待上限;普通探活仍为总计 45 秒、响应头 30 秒。
65+
- 糖果题 max_tokens 预算为 `monitorIQMaxTokens = 2048`:推理模型(gpt-6-astra)reasoning 峰值可超 500 token,512 会在推理阶段被截断成 2xx 空正文、误判「测试异常」;且 `applyMaxTokensOverride` 会在 body_override merge 后强制覆写回该常量,调预算只能改代码,不能走 body_override。2xx 空正文时错误消息由 `iqEmptyTextMessage` 携带 finish/status 与 usage token 计数及 body 片段,便于定位截断。
6566
- 后台单轮预算为 243 秒,覆盖 ping、算术探活、完整智商请求与缓冲;手动检测前端等待 300 秒。
6667
- 超时保存为 `test_timeout`,不当作答错;DNS 失败仍为监控网络异常。新增迁移 `947_channel_monitor_iq_timeout.sql` 放宽历史表状态约束;既有历史不重判。
6768
- 检测间隔仍为 15–3600 秒,抖动/启用/分组/高级设置保留。

‎backend/internal/service/channel_monitor_const.go‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,9 @@ const (
122122
monitorAnthropicAPIVersion = "2023-06-01"
123123
// monitorChallengeMaxTokens 单次 challenge 请求的 max_tokens(足够回答个位数算术)。
124124
monitorChallengeMaxTokens = 50
125-
// monitorIQMaxTokens 智商糖果题需要更长输出(模型常先解释再给数字)。
126-
monitorIQMaxTokens = 512
125+
// monitorIQMaxTokens 智商糖果题的输出预算:推理模型(如 gpt-6-astra)的 reasoning
126+
// 峰值可超 500 token,预算过低会在推理阶段截断、正文为空,被误判为测试异常。
127+
monitorIQMaxTokens = 2048
127128

128129
// monitorRunOneBuffer runOne 的总超时缓冲(除请求超时与 ping 超时外的额外裕量)。
129130
monitorRunOneBuffer = 10 * time.Second

‎backend/internal/service/channel_monitor_iq.go‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010
"regexp"
1111
"strings"
1212
"unicode"
13+
14+
"github.com/tidwall/gjson"
1315
)
1416

1517
// kin Turn-State 默认糖果题(与 origin/main-kin TurnStateProbe 一致)。
@@ -149,6 +151,41 @@ type iqClassifyInput struct {
149151
fuzzy bool
150152
}
151153

154+
// iqEmptyTextMessage 组装 2xx 空正文分支的诊断消息:
155+
// 从 rawBody 提取 finish_reason / status 与 usage token 计数(推理截断的定位线索),
156+
// 再附 body 片段;rawBody 为空时保持原文案。
157+
func iqEmptyTextMessage(rawBody string) string {
158+
const prefix = "iq: empty upstream text"
159+
if strings.TrimSpace(rawBody) == "" {
160+
return prefix
161+
}
162+
var parts []string
163+
if finish := gjson.Get(rawBody, "choices.0.finish_reason").String(); finish != "" {
164+
parts = append(parts, "finish="+finish)
165+
} else if status := gjson.Get(rawBody, "status").String(); status != "" {
166+
parts = append(parts, "status="+status)
167+
}
168+
if usage := gjson.Get(rawBody, "usage"); usage.IsObject() {
169+
if v := usage.Get("completion_tokens"); v.Exists() {
170+
parts = append(parts, "completion="+v.String())
171+
}
172+
if v := usage.Get("completion_tokens_details.reasoning_tokens"); v.Exists() {
173+
parts = append(parts, "reasoning="+v.String())
174+
}
175+
if v := usage.Get("output_tokens"); v.Exists() {
176+
parts = append(parts, "output_tokens="+v.String())
177+
}
178+
if v := usage.Get("output_tokens_details.reasoning_tokens"); v.Exists() {
179+
parts = append(parts, "reasoning="+v.String())
180+
}
181+
}
182+
body := truncateForErrorBody(rawBody)
183+
if len(parts) == 0 {
184+
return fmt.Sprintf("%s (body: %s)", prefix, body)
185+
}
186+
return fmt.Sprintf("%s (%s body: %s)", prefix, strings.Join(parts, " "), body)
187+
}
188+
152189
func classifyIQOutcome(in iqClassifyInput) (iqStatus, message string) {
153190
if in.err != nil {
154191
if isMonitorNetworkError(in.err) {
@@ -170,7 +207,7 @@ func classifyIQOutcome(in iqClassifyInput) (iqStatus, message string) {
170207
truncateMessage(sanitizeErrorMessage(fmt.Sprintf("upstream HTTP %d: %s", in.statusCode, bodySnippet)))
171208
}
172209
if strings.TrimSpace(in.respText) == "" {
173-
return MonitorIQStatusTestErr, "iq: empty upstream text"
210+
return MonitorIQStatusTestErr, truncateMessage(sanitizeErrorMessage(iqEmptyTextMessage(in.rawBody)))
174211
}
175212
if monitorIQAnswerMatches(in.respText, in.expected, in.fuzzy) {
176213
return MonitorIQStatusOK, ""

‎backend/internal/service/channel_monitor_iq_mode_test.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ func TestRunCheck_IQSendsArithmeticThenCandyQuiz(t *testing.T) {
4343
require.Contains(t, fmtAny(h.bodies[1]["messages"]), "苹果味")
4444
require.NotContains(t, fmtAny(h.bodies[1]["messages"]), "Calculate and respond")
4545
require.EqualValues(t, monitorIQMaxTokens, h.bodies[1]["max_tokens"])
46+
// 防回归:512 会把推理模型(如 gpt-6-astra)截断在推理阶段,正文为空 → 误判测试异常。
47+
require.EqualValues(t, 2048, h.bodies[1]["max_tokens"])
4648

4749
require.Len(t, repo.history, 1)
4850
require.Equal(t, MonitorIQStatusOK, repo.history[0].IqStatus)

‎backend/internal/service/channel_monitor_iq_test.go‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,60 @@ func TestClassifyIQOutcome_FourStates(t *testing.T) {
119119
require.Equal(t, MonitorIQStatusTestErr, iq)
120120
}
121121

122+
func TestClassifyIQOutcome_EmptyTextCarriesTruncationDiagnostics(t *testing.T) {
123+
// 生产复现:推理模型 2xx + finish_reason=length,reasoning 吃满 max_tokens,
124+
// 正文只剩空白 → test_error,消息必须带 finish / usage 计数与 body 片段。
125+
iq, msg := classifyIQOutcome(iqClassifyInput{
126+
statusCode: 200,
127+
respText: " ",
128+
rawBody: `{"choices":[{"finish_reason":"length","message":{"content":" "}}],"usage":{"completion_tokens":512,"completion_tokens_details":{"reasoning_tokens":512}}}`,
129+
expected: "21",
130+
fuzzy: true,
131+
})
132+
require.Equal(t, MonitorIQStatusTestErr, iq)
133+
require.Contains(t, msg, "finish=length")
134+
require.Contains(t, msg, "completion=512")
135+
require.Contains(t, msg, "reasoning=512")
136+
require.Contains(t, msg, "body:")
137+
require.LessOrEqual(t, len(msg), monitorMessageMaxBytes)
138+
139+
// responses 形态:status + output_tokens。
140+
iq, msg = classifyIQOutcome(iqClassifyInput{
141+
statusCode: 200,
142+
respText: " ",
143+
rawBody: `{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"usage":{"output_tokens":512,"output_tokens_details":{"reasoning_tokens":480}}}`,
144+
expected: "21",
145+
fuzzy: true,
146+
})
147+
require.Equal(t, MonitorIQStatusTestErr, iq)
148+
require.Contains(t, msg, "status=incomplete")
149+
require.Contains(t, msg, "output_tokens=512")
150+
require.Contains(t, msg, "reasoning=480")
151+
require.LessOrEqual(t, len(msg), monitorMessageMaxBytes)
152+
153+
// body 里混入 key 片段时仍走脱敏。
154+
iq, msg = classifyIQOutcome(iqClassifyInput{
155+
statusCode: 200,
156+
respText: " ",
157+
rawBody: `{"choices":[{"finish_reason":"length","message":{"content":"sk-abcdefghijklmnopqrst123456"}}],"usage":{"completion_tokens":512}}`,
158+
expected: "21",
159+
fuzzy: true,
160+
})
161+
require.Equal(t, MonitorIQStatusTestErr, iq)
162+
require.NotContains(t, msg, "sk-abcdefghijklmnopqrst123456")
163+
require.Contains(t, msg, "sk-***REDACTED***")
164+
165+
// 无 body 时保持原消息。
166+
iq, msg = classifyIQOutcome(iqClassifyInput{
167+
statusCode: 200,
168+
respText: " ",
169+
expected: "21",
170+
fuzzy: true,
171+
})
172+
require.Equal(t, MonitorIQStatusTestErr, iq)
173+
require.Equal(t, "iq: empty upstream text", msg)
174+
}
175+
122176
func TestValidateCreateParams_IQKeepsOriginalInterval(t *testing.T) {
123177
err := validateCreateParams(ChannelMonitorCreateParams{
124178
Name: "iq",

0 commit comments

Comments
 (0)