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
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,17 +363,21 @@ identity automatically:
```yaml
qualityGuard:
enabled: true
model: "grok-4.5"
# Optional: withhold thinking-model streams that have no reasoning.
model: "grok-4.6"
# Withhold thinking-model streams that have no streamed reasoning.
# Observe for up to 30s. An open stream with a reasoning start and visible
# output is released at the deadline; empty/terminal failures still retry.
requestRetry:
enabled: false
enabled: true
maxAttempts: 6
holdTimeout: 3s
minOutputTokens: 32
holdTimeout: 30s
minOutputTokens: 8
onExhausted: fail_closed # fail_open | fail_closed
accountCooldown: 12h
idleAccountCooldown: 15m
```

`requestRetry` runs on the gateway request path and is independent of the sidecar. It is off by default. When enabled, a thinking-model stream with enough visible output and no reasoning is **not delivered**; another account is tried. If every attempt still has no reasoning, `onExhausted` either returns `503 quality_degraded` or delivers the last body. Image, video, tool, stored-response, and ForcedEgress probe requests are unchanged.
`requestRetry` runs on the gateway request path and is independent of the sidecar. The example enables it. When enabled, a thinking-model stream with enough visible output and no streamed reasoning is **not delivered**; another account is tried. If every attempt still has no reasoning, `onExhausted` either returns `503 quality_degraded` or delivers the last body. Image, video, stored-response, and ForcedEgress probe requests are unchanged. Grok TUI tool turns stay held so 0-thinking dumps cannot skip the gate.

```bash
docker compose --profile quality-guard up -d --build
Expand Down
16 changes: 10 additions & 6 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,17 +359,21 @@ Hysteria 与 TUIC 暂未支持。FlareSolverr 仅接受 HTTP/SOCKS 代理地址
```yaml
qualityGuard:
enabled: true
model: "grok-4.5"
# 可选:思考模型缺 reasoning 时先扣住响应,换号再打,不把降智正文发给用户。
model: "grok-4.6"
# 思考模型缺流式 reasoning 时先扣住响应,换号再打,不把降智正文发给用户。
# 最多观察 30 秒;已有 reasoning 起始信号和可见输出的进行中流会在超时后放行,
# 空流和终态仍无 thinking 的响应继续换号。
requestRetry:
enabled: false
enabled: true
maxAttempts: 6
holdTimeout: 3s
minOutputTokens: 32
holdTimeout: 30s
minOutputTokens: 8
onExhausted: fail_closed # fail_open | fail_closed
accountCooldown: 12h
idleAccountCooldown: 15m
```

`requestRetry` 在网关请求路径上生效,与 sidecar 探测/隔离相互独立。默认关闭。开启后,可见输出达到 `minOutputTokens` 且全程无 reasoning 时**不发给用户**,排除该账号再试;全部仍无推理则按 `onExhausted` 返回 `503 quality_degraded` 或放出最后一枪。不处理图/视频/工具、stored response 钉账号和 ForcedEgress 探针。
`requestRetry` 在网关请求路径上生效,与 sidecar 探测/隔离相互独立。示例配置默认开启。开启后,可见输出达到 `minOutputTokens` 且全程无流式 reasoning 时**不发给用户**,排除该账号再试;全部仍无推理则按 `onExhausted` 返回 `503 quality_degraded` 或放出最后一枪。不处理图/视频、stored response 钉账号和 ForcedEgress 探针。Grok TUI 带 tools 的回合仍会 hold,避免 0-thinking 降智流跳过闸门

```bash
docker compose --profile quality-guard up -d --build
Expand Down
22 changes: 14 additions & 8 deletions backend/internal/application/gateway/quality_retry.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@ const (
qualityRetryFailOpen = "fail_open"
qualityRetryFailClosed = "fail_closed"
defaultQualityMaxAttempts = 6
defaultQualityHoldTimeout = 3 * time.Second
defaultQualityMinOutput = int64(32)
defaultMissingThinkingCooldown = 24 * time.Hour
defaultQualityHoldTimeout = 30 * time.Second
defaultQualityMinOutput = int64(8)
defaultMissingThinkingCooldown = 12 * time.Hour
lastErrorMissingThinking = accountdomain.LastErrorMissingThinking
lastErrorMissingThinkingDisabled = accountdomain.LastErrorMissingThinkingDisabled
// An empty stream that idles while held is treated as an account-quality
// failure: the request can still rotate before any bytes reach the client.
qualityIdleAccountCooldown = 24 * time.Hour
qualityIdleAccountCooldown = 15 * time.Minute
)

var (
Expand Down Expand Up @@ -129,9 +129,12 @@ func (s *Service) qualityRetryConfig() QualityRetryRuntime {
// A hold timeout with no visible output is not fail-open: keep waiting for
// more bytes or a stream abort so an empty hang is not flushed as HTTP 200.
//
// An empty reasoning stub is not thinking. Wait for usage/terminal so
// encrypted thinking (tokens arrive at the end) is not withheld, and so
// 200 + 推理·高 + reasoning=0 is not delivered the moment the stub appears.
// An empty reasoning stub is not thinking. Before the hold deadline, wait for
// real evidence or a terminal event. If the deadline expires while the stream
// is still open and already has visible output, the result is inconclusive:
// release it without penalizing the account. A stub-only empty stream keeps
// waiting for idle/terminal handling. This keeps HoldTimeout a real latency
// bound without reopening the empty-stream 200 response path.
func ClassifyQualityHold(sig QualityStreamSignals, minOutput int64) QualityVerdict {
if minOutput <= 0 {
minOutput = defaultQualityMinOutput
Expand All @@ -148,7 +151,10 @@ func ClassifyQualityHold(sig QualityStreamSignals, minOutput int64) QualityVerdi
output = sig.OutputTokens
}
enough := output >= minOutput
if sig.ReasoningStarted && !sig.Terminal && !sig.HoldExpired {
if sig.ReasoningStarted && !sig.Terminal {
if sig.HoldExpired && output > 0 {
return QualityDeliver
}
return QualityWait
}
if sig.Terminal {
Expand Down
112 changes: 107 additions & 5 deletions backend/internal/application/gateway/quality_retry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ func TestClassifyQualityHold(t *testing.T) {
{name: "empty terminal waits for transport handling", sig: QualityStreamSignals{Terminal: true}, want: QualityWait},
{name: "midstream enough content withhold", sig: QualityStreamSignals{VisibleTokens: 64}, want: QualityWithhold},
{name: "stub midstream waits even with enough visible", sig: QualityStreamSignals{ReasoningStarted: true, VisibleTokens: 64}, want: QualityWait},
{name: "stub hold expiry is inconclusive and delivers", sig: QualityStreamSignals{ReasoningStarted: true, VisibleTokens: 64, HoldExpired: true}, want: QualityDeliver},
{name: "stub-only hold expiry keeps waiting", sig: QualityStreamSignals{ReasoningStarted: true, HoldExpired: true}, want: QualityWait},
{name: "stub terminal enough withhold", sig: QualityStreamSignals{ReasoningStarted: true, VisibleTokens: 64, Terminal: true}, want: QualityWithhold},
{name: "wait for more", sig: QualityStreamSignals{VisibleTokens: 8}, want: QualityWait},
{name: "hold expired short delivers", sig: QualityStreamSignals{VisibleTokens: 8, HoldExpired: true}, want: QualityDeliver},
Expand Down Expand Up @@ -606,6 +608,64 @@ func TestPeekQualityStreamHoldTimeoutInterruptsBlockedReadAndPreservesRemainder(
}
}

func TestPeekQualityStreamHoldTimeoutDeliversStartedReasoningAndPreservesLateEvidence(t *testing.T) {
t.Parallel()
reader, writer := io.Pipe()
content := strings.Repeat("abcd", 40)
first := sse(
`data: {"type":"response.output_item.added","item":{"id":"rs_1","type":"reasoning"}}`,
`data: {"type":"response.output_text.delta","delta":"`+content+`"}`,
)
second := sse(
`data: {"type":"response.output_item.done","item":{"id":"rs_1","type":"reasoning","encrypted_content":"late-proof"}}`,
`data: {"type":"response.completed","response":{"id":"resp_1","usage":{"output_tokens":40,"output_tokens_details":{"reasoning_tokens":20}}}}`,
)
writeErr := make(chan error, 1)
continueWrite := make(chan struct{})
go func() {
if _, err := io.WriteString(writer, first); err != nil {
writeErr <- err
return
}
select {
case <-continueWrite:
case <-time.After(500 * time.Millisecond):
}
if _, err := io.WriteString(writer, second); err != nil {
writeErr <- err
return
}
writeErr <- writer.Close()
}()

started := time.Now()
replay, verdict, _, _, err := peekQualityStream(context.Background(), reader, qualityProtocolResponses, QualityRetryRuntime{
MinOutputTokens: 8,
HoldTimeout: 30 * time.Millisecond,
})
if err != nil {
t.Fatal(err)
}
defer replay.Close()
if elapsed := time.Since(started); elapsed < 20*time.Millisecond || elapsed > 200*time.Millisecond {
t.Fatalf("peek returned after %s, want the 30ms hold timeout", elapsed)
}
if verdict != QualityDeliver {
t.Fatalf("started reasoning at hold timeout verdict = %s, want deliver", verdict)
}
close(continueWrite)
body, err := io.ReadAll(replay)
if err != nil {
t.Fatal(err)
}
if err := <-writeErr; err != nil {
t.Fatal(err)
}
if got := string(body); !strings.Contains(got, content) || !strings.Contains(got, `"encrypted_content":"late-proof"`) {
t.Fatalf("replay lost late reasoning evidence: %q", got)
}
}

func TestPeekQualityStreamHoldTimeoutEmptyDoesNotFailOpen(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancelCause(context.Background())
Expand Down Expand Up @@ -640,6 +700,48 @@ func TestPeekQualityStreamHoldTimeoutEmptyDoesNotFailOpen(t *testing.T) {
}
}

func TestPeekQualityStreamHoldTimeoutStubOnlyDoesNotFailOpen(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancelCause(context.Background())
reader, writer := io.Pipe()
defer writer.Close()
writeDone := make(chan error, 1)
go func() {
_, err := io.WriteString(writer, sse(": grok2api-reasoning-start"))
writeDone <- err
}()
done := make(chan struct{})
var verdict QualityVerdict
var peekErr error
go func() {
defer close(done)
_, verdict, _, _, peekErr = peekQualityStream(ctx, reader, qualityProtocolChat, QualityRetryRuntime{
MinOutputTokens: 8,
HoldTimeout: 20 * time.Millisecond,
})
}()
if err := <-writeDone; err != nil {
t.Fatal(err)
}
select {
case <-done:
t.Fatal("stub-only hold timeout must keep reading, not release an empty stream")
case <-time.After(50 * time.Millisecond):
}
cancel(neterrorpkg.ErrUpstreamStreamIdleTimeout)
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("peekQualityStream did not return after stub-only idle cancel")
}
if !neterrorpkg.IsUpstreamStreamIdleTimeout(peekErr) {
t.Fatalf("peekErr = %v, want idle timeout", peekErr)
}
if verdict != QualityWait {
t.Fatalf("verdict=%s, want wait so the loop retries as transport", verdict)
}
}

type qualityOpenPeekResult struct {
replay io.ReadCloser
verdict QualityVerdict
Expand Down Expand Up @@ -1089,8 +1191,8 @@ func TestAttemptLoopQualityHold(t *testing.T) {
if emptyAccount.FailureCount != 1 || emptyAccount.CooldownUntil == nil {
t.Fatalf("empty stream account was not cooled: %#v", emptyAccount)
}
if remaining := time.Until(*emptyAccount.CooldownUntil); remaining < 23*time.Hour || remaining > 24*time.Hour+time.Minute {
t.Fatalf("empty stream cooldown = %s, want about 24h", remaining)
if remaining := time.Until(*emptyAccount.CooldownUntil); remaining < 14*time.Minute || remaining > 15*time.Minute+time.Minute {
t.Fatalf("empty stream cooldown = %s, want about 15m", remaining)
}
noThinkingAccount, err := accountRepo.Get(ctx, credentials[1].ID)
if err != nil {
Expand All @@ -1099,8 +1201,8 @@ func TestAttemptLoopQualityHold(t *testing.T) {
if !noThinkingAccount.Enabled || noThinkingAccount.LastError != lastErrorMissingThinking || noThinkingAccount.CooldownUntil == nil {
t.Fatalf("missing-thinking account was not cooled: %#v", noThinkingAccount)
}
if remaining := time.Until(*noThinkingAccount.CooldownUntil); remaining < 23*time.Hour || remaining > 24*time.Hour+time.Minute {
t.Fatalf("missing-thinking cooldown = %s, want about 24h", remaining)
if remaining := time.Until(*noThinkingAccount.CooldownUntil); remaining < 11*time.Hour || remaining > 12*time.Hour+time.Minute {
t.Fatalf("missing-thinking cooldown = %s, want about 12h", remaining)
}
logs, total, err := auditRepo.List(ctx, 0, 20)
if err != nil {
Expand Down Expand Up @@ -1307,7 +1409,7 @@ func TestAttemptLoopQualityFailOpenFallbackAndTotalAttemptCap(t *testing.T) {
func TestNormalizeQualityRetryDefaults(t *testing.T) {
t.Parallel()
got := normalizeQualityRetry(QualityRetryRuntime{Enabled: true})
if !got.Enabled || got.MaxAttempts != 6 || got.MinOutputTokens != 32 || got.OnExhausted != qualityRetryFailClosed || got.HoldTimeout != 3*time.Second || got.AccountCooldown != 24*time.Hour || got.IdleAccountCooldown != 24*time.Hour {
if !got.Enabled || got.MaxAttempts != 6 || got.MinOutputTokens != 8 || got.OnExhausted != qualityRetryFailClosed || got.HoldTimeout != 30*time.Second || got.AccountCooldown != 12*time.Hour || got.IdleAccountCooldown != 15*time.Minute {
t.Fatalf("defaults = %#v", got)
}
}
4 changes: 2 additions & 2 deletions backend/internal/application/gateway/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -443,8 +443,8 @@ func TestGatewayFailsOverBeforeReturningBody(t *testing.T) {
t.Fatalf("interrupted account health = %#v, err=%v", interruptedAccount, err)
}
remaining := time.Until(*interruptedAccount.CooldownUntil)
if remaining < 23*time.Hour || remaining > 24*time.Hour+time.Minute {
t.Fatalf("idle stream cooldown = %s, want about 24h", remaining)
if remaining < 14*time.Minute || remaining > 15*time.Minute+time.Minute {
t.Fatalf("idle stream cooldown = %s, want about 15m", remaining)
}
}

Expand Down
8 changes: 4 additions & 4 deletions backend/internal/infra/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ type QualityGuardRequestRetryConfig struct {
OnExhausted string `yaml:"onExhausted"`
AccountCooldown Duration `yaml:"accountCooldown"`
// IdleAccountCooldown cools an account after a truly empty upstream
// stream. Independent of accountCooldown (missing-thinking). Zero uses 24h.
// stream. Independent of accountCooldown (missing-thinking). Zero uses 15m.
IdleAccountCooldown Duration `yaml:"idleAccountCooldown"`
}

Expand Down Expand Up @@ -935,15 +935,15 @@ func defaultConfig() Config {
LedgerUnhealthyGrace: Duration(10 * time.Second), LedgerQueueHighWatermarkPct: 90,
},
QualityGuard: QualityGuardConfig{
Model: "grok-4.5", Mode: "hybrid",
Model: "grok-4.6", Mode: "hybrid",
ActiveInterval: Duration(30 * time.Minute), PassivePollInterval: Duration(5 * time.Second),
SoftTPS: 500, HardTPS: 1000, ConsecutiveSoft: 2, ConsecutiveErrors: 2,
QuarantineDuration: Duration(5 * time.Minute), NoAccountBackoff: Duration(5 * time.Minute),
MinimumHealthyNodes: 3, MaxOutputTokens: 384,
MinimumGenerationWindow: Duration(time.Second), RotationTimeout: Duration(45 * time.Second),
RequestRetry: QualityGuardRequestRetryConfig{
MaxAttempts: 6, HoldTimeout: Duration(3 * time.Second), MinOutputTokens: 32, OnExhausted: "fail_closed",
AccountCooldown: Duration(24 * time.Hour), IdleAccountCooldown: Duration(24 * time.Hour),
MaxAttempts: 6, HoldTimeout: Duration(30 * time.Second), MinOutputTokens: 8, OnExhausted: "fail_closed",
AccountCooldown: Duration(12 * time.Hour), IdleAccountCooldown: Duration(15 * time.Minute),
},
},
ClientKeyDefaults: ClientKeyDefaultsConfig{RPMLimit: clientkeydomain.DefaultRPMLimit, MaxConcurrent: clientkeydomain.DefaultMaxConcurrent},
Expand Down
4 changes: 2 additions & 2 deletions backend/internal/infra/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,15 +215,15 @@ qualityGuard:
t.Fatalf("qualityGuard = %#v", value.QualityGuard)
}
retry := value.QualityGuard.RequestRetry
if retry.Enabled || retry.MaxAttempts != 6 || retry.HoldTimeout.Value() != 3*time.Second || retry.MinOutputTokens != 32 || retry.OnExhausted != "fail_closed" || retry.AccountCooldown.Value() != 24*time.Hour {
if retry.Enabled || retry.MaxAttempts != 6 || retry.HoldTimeout.Value() != 30*time.Second || retry.MinOutputTokens != 8 || retry.OnExhausted != "fail_closed" || retry.AccountCooldown.Value() != 12*time.Hour {
t.Fatalf("loaded requestRetry defaults = %#v", retry)
}
}

func TestDefaultQualityGuardRequestRetryContract(t *testing.T) {
t.Parallel()
got := defaultConfig().QualityGuard.RequestRetry
if got.Enabled || got.MaxAttempts != 6 || got.HoldTimeout.Value() != 3*time.Second || got.MinOutputTokens != 32 || got.OnExhausted != "fail_closed" || got.AccountCooldown.Value() != 24*time.Hour || got.IdleAccountCooldown.Value() != 24*time.Hour {
if got.Enabled || got.MaxAttempts != 6 || got.HoldTimeout.Value() != 30*time.Second || got.MinOutputTokens != 8 || got.OnExhausted != "fail_closed" || got.AccountCooldown.Value() != 12*time.Hour || got.IdleAccountCooldown.Value() != 15*time.Minute {
t.Fatalf("requestRetry defaults = %#v", got)
}
}
Expand Down
24 changes: 15 additions & 9 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ qualityGuard:
enabled: false
# 主程序会自动创建并复用不可导出的内部探测身份,无需配置 Client Key。
# 使用已验证会输出推理 Token 的 Build 模型时,内置恢复探针还会校验 reasoning Token。
model: "grok-4.5"
model: "grok-4.6"
mode: hybrid # passive | active | hybrid
activeInterval: 30m
passivePollInterval: 5s
Expand All @@ -149,19 +149,25 @@ qualityGuard:
rotationTimeout: 45s
nodeIDs: []
rotatableNodeIDs: []
# Optional request-path withhold/retry for thinking models. Disabled by default.
# Missing thinking with enough visible output is not delivered; another account
# is tried (up to maxAttempts). fail_closed returns 503 instead of the last body.
# Optional request-path withhold/retry for thinking models.
# Observe up to 30s for real reasoning evidence. If upstream has announced
# a reasoning item and visible output but proof arrives later, the still-open
# stream is released at the deadline without penalizing the account. Empty or
# terminal no-thinking responses are still retried.
# Missing thinking with enough visible output is not delivered; another
# account is tried (up to maxAttempts). fail_closed returns 503 instead
# of the last body. Sidecar qualityGuard.enabled stays false until you
# start the quality-guard profile.
requestRetry:
enabled: false
enabled: true
maxAttempts: 6
holdTimeout: 3s
minOutputTokens: 32
holdTimeout: 30s
minOutputTokens: 8
onExhausted: fail_closed # fail_open | fail_closed
# First missing-thinking hit cools the account; a later hit after the
# cooldown expires disables it.
accountCooldown: 24h
accountCooldown: 12h
# Empty SSE / idle-timeout penalty. Independent of accountCooldown.
# Single-account pools should set this lower; toggling enabled does not
# clear cooldown — use POST /api/admin/v1/accounts/:id/clear-cooldown.
idleAccountCooldown: 24h
idleAccountCooldown: 15m
4 changes: 2 additions & 2 deletions tools/egress-quality-guard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ your own traffic before allowing automatic quarantine.

- Supports Grok Build streaming requests after egress nodes and request audits are configured in grok2api.
- At least one schedulable Grok Build account must be able to serve the probe model. The account does not have to be bound to every managed node.
- The built-in thinking guard is enforced only when the backend recognizes the configured Build model as reasoning-capable. Keep the default `grok-4.5` or another verified reasoning model when missing-thinking detection is required; unknown and non-reasoning models retain marker/TPS checks without this signal.
- The built-in thinking guard is enforced only when the backend recognizes the configured Build model as reasoning-capable. Keep the default `grok-4.6` or another verified reasoning model when missing-thinking detection is required; unknown and non-reasoning models retain marker/TPS checks without this signal.
- The main service automatically provisions a non-exportable system probe identity. The sidecar reaches only a scoped internal API over the Compose network.
- Classification is heuristic evidence. It cannot prove that upstream model capability changed and does not replace application-level regression tests.

Expand Down Expand Up @@ -147,7 +147,7 @@ copy, select, or configure a Client Key for the guard:
```yaml
qualityGuard:
enabled: true
model: "grok-4.5"
model: "grok-4.6"
mode: hybrid
activeInterval: 30m
passivePollInterval: 5s
Expand Down
2 changes: 1 addition & 1 deletion tools/egress-quality-guard/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ Webhook,确认出口发生变化,再执行一次真实模型质量检测;
```yaml
qualityGuard:
enabled: true
model: "grok-4.5"
model: "grok-4.6"
mode: hybrid
activeInterval: 30m
passivePollInterval: 5s
Expand Down
Loading