fix: add configurable model approval retries

This commit is contained in:
ji ruo committed 2026-10-15 14:45:08 +08:00
1 parent 23446606a4
commit d2e7e3a8a7
8 files changed
+464 -11

No files matched your search

+21 -5
View File
@@ -305,6 +305,8 @@ const (
settingJudgeProfileID = "llm_judge_profile_id"
settingJudgePrompt = "llm_judge_prompt"
settingJudgeTimeoutSecs = "llm_judge_timeout_seconds"
settingJudgeRetryCount = "llm_judge_retry_count"
settingJudgeRetryIntervalMS = "llm_judge_retry_interval_ms"
settingJudgeFailAction = "llm_judge_fail_action"
settingJudgeAskTimeoutSecs = "llm_judge_ask_timeout_seconds"
settingJudgeAskTimeoutAction = "llm_judge_ask_timeout_action"
@@ -327,7 +329,9 @@ type JudgeConfig struct {
Enabled bool `json:"enabled"`
ProfileID int64 `json:"profile_id"` // 0 = follow active/default
Prompt string `json:"prompt"`
TimeoutSeconds int `json:"timeout_seconds"`
TimeoutSeconds int `json:"timeout_seconds"` // per attempt
RetryCount int `json:"retry_count"` // additional attempts; 0 = disabled
RetryIntervalMS int `json:"retry_interval_ms"`
FailAction string `json:"fail_action"` // allow|ask|deny
AskTimeoutSeconds int `json:"ask_timeout_seconds"`
AskTimeoutAction string `json:"ask_timeout_action"` // allow|deny
@@ -342,6 +346,8 @@ func (i *Interceptor) judgeConfig() JudgeConfig {
Enabled: i.db.GetBool(settingJudgeEnabled, false),
ProfileID: int64(i.getSettingInt(settingJudgeProfileID, 0)),
TimeoutSeconds: i.getSettingInt(settingJudgeTimeoutSecs, defaultJudgeTimeoutSecs),
RetryCount: min(max(i.getSettingInt(settingJudgeRetryCount, 0), 0), MaxJudgeRetryCount),
RetryIntervalMS: min(max(i.getSettingInt(settingJudgeRetryIntervalMS, defaultJudgeRetryIntervalMS), 0), MaxJudgeRetryIntervalMS),
FailAction: i.getSettingChoice(settingJudgeFailAction, defaultJudgeFailAction, "allow", "ask", "deny"),
AskTimeoutSeconds: i.getSettingInt(settingJudgeAskTimeoutSecs, defaultJudgeAskTimeoutSecs),
AskTimeoutAction: i.getSettingChoice(settingJudgeAskTimeoutAction, defaultJudgeAskTimeoutAction, "allow", "deny"),
@@ -395,6 +401,9 @@ func (i *Interceptor) GetJudgeConfig() JudgeConfig { return i.judgeConfig() }
// SetJudgeConfig persists the judge configuration. An empty Prompt clears the
// override (the built-in template is used again).
func (i *Interceptor) SetJudgeConfig(c JudgeConfig) error {
if err := c.ValidateRetry(); err != nil {
return err
}
if err := i.db.SetBool(settingJudgeEnabled, c.Enabled); err != nil {
return err
}
@@ -413,6 +422,12 @@ func (i *Interceptor) SetJudgeConfig(c JudgeConfig) error {
if err := i.db.SetSetting(settingJudgeTimeoutSecs, strconv.Itoa(c.TimeoutSeconds)); err != nil {
return err
}
if err := i.db.SetSetting(settingJudgeRetryCount, strconv.Itoa(c.RetryCount)); err != nil {
return err
}
if err := i.db.SetSetting(settingJudgeRetryIntervalMS, strconv.Itoa(c.RetryIntervalMS)); err != nil {
return err
}
if err := i.db.SetSetting(settingJudgeFailAction, c.FailAction); err != nil {
return err
}
@@ -432,7 +447,8 @@ func (i *Interceptor) SetJudgeConfig(c JudgeConfig) error {
// returns (Decision, true) when the judge produced a terminal verdict, or
// (Decision{}, false) when the fallback is disabled or not wired (caller then
// keeps the current behavior: allow). On model error or an unparseable reply it
// falls back to the configured FailAction. Ask verdicts carry the human-approval
// retries up to RetryCount times, then falls back to the configured FailAction.
// Ask verdicts carry the human-approval
// timeout so the existing HandleAsk consumes them unchanged.
func (i *Interceptor) Judge(ctx context.Context, tool string, arguments json.RawMessage) (Decision, bool) {
cfg := i.judgeConfig()
@@ -444,13 +460,13 @@ func (i *Interceptor) Judge(ctx context.Context, tool string, arguments json.Raw
}
cctx := ctx
cancel := func() {}
if cfg.TimeoutSeconds > 0 {
var cancel context.CancelFunc
cctx, cancel = context.WithTimeout(ctx, time.Duration(cfg.TimeoutSeconds)*time.Second)
defer cancel()
}
input, contextErr := BuildReviewInput(cctx, tool, arguments)
cancel() // Capture once; each model attempt gets its own timeout below.
cfg.Prompt = EffectiveJudgePrompt(cfg.Prompt)
var out Decision
var err error
@@ -461,7 +477,7 @@ func (i *Interceptor) Judge(ctx context.Context, tool string, arguments json.Raw
out = Decision{Action: "ask", ModelFallback: true, Message: "审查上下文不完整,需要人工确认:" + contextErr.Error()}
} else {
modelInput, _ = json.Marshal(input)
out, err = rv(cctx, cfg.ProfileID, cfg.Prompt, input)
out, err = reviewWithRetry(ctx, cfg, rv, input)
}
if err != nil {
out = Decision{ProfileID: out.ProfileID, ModelFallback: true, Action: cfg.FailAction, Message: "模型审批失败,按失败策略处理: " + err.Error()}
+67
View File
@@ -0,0 +1,67 @@
package intercept
import (
"context"
"fmt"
"time"
)
const (
MaxJudgeRetryCount = 20
MaxJudgeRetryIntervalMS = 3600_000
defaultJudgeRetryIntervalMS = 1000
)
// ValidateRetry rejects invalid settings before any judge configuration is saved.
func (c JudgeConfig) ValidateRetry() error {
if c.RetryCount < 0 || c.RetryCount > MaxJudgeRetryCount {
return fmt.Errorf("retry_count 必须在 0 到 %d 之间", MaxJudgeRetryCount)
}
if c.RetryIntervalMS < 0 || c.RetryIntervalMS > MaxJudgeRetryIntervalMS {
return fmt.Errorf("retry_interval_ms 必须在 0 到 %d 之间", MaxJudgeRetryIntervalMS)
}
return nil
}
// reviewWithRetry retries failed classifications, including invalid verdicts.
// The captured input stays identical across attempts. A valid deny/ask verdict
// is final, and parent cancellation interrupts both requests and the retry wait.
// Provider-level connection/empty-response retries still run within each attempt.
func reviewWithRetry(ctx context.Context, cfg JudgeConfig, rv Reviewer, input ReviewInput) (out Decision, err error) {
for attempt := 0; attempt <= cfg.RetryCount; attempt++ {
if ctx.Err() != nil {
return out, ctx.Err()
}
if attempt > 0 && cfg.RetryIntervalMS > 0 {
timer := time.NewTimer(time.Duration(cfg.RetryIntervalMS) * time.Millisecond)
select {
case <-ctx.Done():
timer.Stop()
return out, ctx.Err()
case <-timer.C:
}
}
if ctx.Err() != nil {
return out, ctx.Err()
}
attemptCtx := ctx
cancel := func() {}
if cfg.TimeoutSeconds > 0 {
attemptCtx, cancel = context.WithTimeout(ctx, time.Duration(cfg.TimeoutSeconds)*time.Second)
}
out, err = rv(attemptCtx, cfg.ProfileID, cfg.Prompt, input)
if attemptCtx.Err() != nil {
err = attemptCtx.Err()
}
cancel()
if err == nil {
switch out.Action {
case "allow", "ask", "deny":
return out, nil
default:
err = fmt.Errorf("模型输出无法解析")
}
}
}
return out, err
}
+247
View File
@@ -0,0 +1,247 @@
package intercept
import (
"context"
"database/sql"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"time"
"github.com/Autumn-27/artex/db"
)
func retryTestInterceptor(t *testing.T, settings map[string]string) *Interceptor {
t.Helper()
d, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = d.Close() })
if _, err := d.Exec(`CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)`); err != nil {
t.Fatal(err)
}
for key, value := range settings {
if _, err := d.Exec(`INSERT INTO settings(key,value) VALUES (?,?)`, key, value); err != nil {
t.Fatal(err)
}
}
return New(&db.DB{DB: d})
}
func TestJudgeRetryConfigDefaultsAndBounds(t *testing.T) {
for _, tc := range []struct {
name string
settings map[string]string
count, interval int
}{
{"legacy", nil, 0, 1000},
{"configured", map[string]string{settingJudgeRetryCount: "3", settingJudgeRetryIntervalMS: "25"}, 3, 25},
{"immediate", map[string]string{settingJudgeRetryCount: "1", settingJudgeRetryIntervalMS: "0"}, 1, 0},
{"malformed", map[string]string{settingJudgeRetryCount: "bad", settingJudgeRetryIntervalMS: "bad"}, 0, 1000},
{"negative", map[string]string{settingJudgeRetryCount: "-1", settingJudgeRetryIntervalMS: "-1"}, 0, 0},
{"excessive", map[string]string{settingJudgeRetryCount: "999", settingJudgeRetryIntervalMS: "999999999"}, 20, 3600000},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := retryTestInterceptor(t, tc.settings).GetJudgeConfig()
if cfg.RetryCount != tc.count || cfg.RetryIntervalMS != tc.interval {
t.Fatalf("config=%+v", cfg)
}
})
}
}
func TestJudgeRetriesBeforeApplyingFailurePolicy(t *testing.T) {
for _, tc := range []struct {
name, failAction, verdict string
failures, wantCalls int
wantAction string
wantFallback bool
}{
{"recover", "deny", "allow", 2, 3, "allow", false},
{"valid_deny_is_final", "allow", "deny", 0, 1, "deny", false},
{"valid_ask_is_final", "allow", "ask", 0, 1, "ask", false},
{"exhaust_allow", "allow", "allow", 9, 3, "allow", true},
{"exhaust_ask", "ask", "allow", 9, 3, "ask", true},
{"exhaust_deny", "deny", "allow", 9, 3, "deny", true},
} {
t.Run(tc.name, func(t *testing.T) {
i := retryTestInterceptor(t, map[string]string{
settingJudgeEnabled: "true", settingJudgeRetryCount: "2", settingJudgeRetryIntervalMS: "0", settingJudgeFailAction: tc.failAction,
})
calls := 0
var first ReviewInput
i.SetReviewer(func(ctx context.Context, id int64, prompt string, input ReviewInput) (Decision, error) {
calls++
if ctx.Err() != nil || prompt == "" {
t.Fatal("invalid request context")
}
if calls == 1 {
first = input
} else if !reflect.DeepEqual(input, first) {
t.Fatal("review input changed on retry")
}
out := Decision{Action: tc.verdict, ProfileID: 42, Message: "review result"}
if calls <= tc.failures {
return out, errors.New("temporary model failure")
}
return out, nil
})
got, ok := i.Judge(t.Context(), "Bash", json.RawMessage(`{"command":"echo ok"}`))
if !ok || calls != tc.wantCalls || got.Action != tc.wantAction || got.ModelFallback != tc.wantFallback || got.ProfileID != 42 {
t.Fatalf("calls=%d decision=%+v ok=%v", calls, got, ok)
}
if got.RuleID != 0 || len(got.ModelInput) == 0 || got.ModelInputDigest == "" || got.ConfigDigest == "" {
t.Fatalf("missing audit data: %+v", got)
}
if got.Action == "ask" && (!got.TimeoutEnabled || got.TimeoutSeconds != 300 || got.TimeoutAction != "deny") {
t.Fatal("human approval timeout lost")
}
if tc.wantFallback && !strings.Contains(got.Message, "temporary model failure") {
t.Fatal("last failure lost")
}
})
}
}
func TestJudgeRetriesInvalidVerdict(t *testing.T) {
for _, invalid := range []string{"", "invalid"} {
calls := 0
out, err := reviewWithRetry(t.Context(), JudgeConfig{RetryCount: 1}, func(context.Context, int64, string, ReviewInput) (Decision, error) {
calls++
if calls == 1 {
return Decision{Action: invalid}, nil
}
return Decision{Action: "deny"}, nil
}, ReviewInput{})
if err != nil || out.Action != "deny" || calls != 2 {
t.Fatalf("calls=%d out=%+v err=%v", calls, out, err)
}
}
}
func TestJudgeLegacyConfigDoesNotRetry(t *testing.T) {
i := retryTestInterceptor(t, map[string]string{settingJudgeEnabled: "true", settingJudgeFailAction: "deny"})
calls := 0
i.SetReviewer(func(context.Context, int64, string, ReviewInput) (Decision, error) {
calls++
return Decision{}, errors.New("failed")
})
got, _ := i.Judge(t.Context(), "Bash", json.RawMessage(`{"command":"echo ok"}`))
if calls != 1 || got.Action != "deny" || !got.ModelFallback {
t.Fatalf("calls=%d out=%+v", calls, got)
}
}
func TestJudgeRetryTimeoutGetsFreshContext(t *testing.T) {
calls := 0
var previous context.Context
out, err := reviewWithRetry(t.Context(), JudgeConfig{RetryCount: 1, TimeoutSeconds: 1}, func(ctx context.Context, _ int64, _ string, _ ReviewInput) (Decision, error) {
calls++
if calls == 1 {
previous = ctx
<-ctx.Done()
return Decision{}, ctx.Err()
}
if previous.Err() != context.DeadlineExceeded || ctx.Err() != nil {
t.Fatal("retry reused expired context")
}
if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) < 500*time.Millisecond {
t.Fatal("retry has no fresh timeout")
}
return Decision{Action: "allow"}, nil
}, ReviewInput{})
if calls != 2 || err != nil || out.Action != "allow" {
t.Fatalf("calls=%d out=%+v err=%v", calls, out, err)
}
if previous.Err() == nil {
t.Fatal("attempt context was not released")
}
}
func TestJudgeRetryCancellationInterruptsWait(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
started := make(chan struct{})
done := make(chan error, 1)
calls := 0
go func() {
_, err := reviewWithRetry(ctx, JudgeConfig{RetryCount: 20, RetryIntervalMS: 3600000}, func(context.Context, int64, string, ReviewInput) (Decision, error) {
calls++
close(started)
return Decision{}, errors.New("failed")
}, ReviewInput{})
done <- err
}()
<-started
cancel()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) || calls != 1 {
t.Fatalf("calls=%d err=%v", calls, err)
}
case <-time.After(time.Second):
t.Fatal("cancellation did not interrupt retry wait")
}
}
func TestJudgeRetryDoesNotCallCanceledOrDisabledReviewer(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
cancel()
_, err := reviewWithRetry(ctx, JudgeConfig{RetryCount: 2}, func(context.Context, int64, string, ReviewInput) (Decision, error) {
t.Fatal("called after cancellation")
return Decision{}, nil
}, ReviewInput{})
if !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
i := retryTestInterceptor(t, nil)
i.SetReviewer(func(context.Context, int64, string, ReviewInput) (Decision, error) {
t.Fatal("called disabled reviewer")
return Decision{}, nil
})
if _, ok := i.Judge(t.Context(), "Bash", json.RawMessage(`{}`)); ok {
t.Fatal("disabled judge returned verdict")
}
}
func TestJudgeRetryWaitsBetweenFailures(t *testing.T) {
var first time.Time
calls := 0
_, err := reviewWithRetry(t.Context(), JudgeConfig{RetryCount: 1, RetryIntervalMS: 20}, func(context.Context, int64, string, ReviewInput) (Decision, error) {
calls++
if calls == 1 {
first = time.Now()
return Decision{}, errors.New("failed")
}
if time.Since(first) < 20*time.Millisecond {
t.Fatal("retry skipped configured interval")
}
return Decision{Action: "allow"}, nil
}, ReviewInput{})
if err != nil || calls != 2 {
t.Fatalf("calls=%d err=%v", calls, err)
}
}
func TestJudgeInvalidArgumentsDoNotCallReviewer(t *testing.T) {
i := retryTestInterceptor(t, map[string]string{settingJudgeEnabled: "true", settingJudgeRetryCount: "2"})
i.SetReviewer(func(context.Context, int64, string, ReviewInput) (Decision, error) {
t.Fatal("invalid input must go straight to human review")
return Decision{}, nil
})
got, ok := i.Judge(t.Context(), "Bash", json.RawMessage(`{broken`))
if !ok || got.Action != "ask" || !got.ModelFallback {
t.Fatalf("out=%+v ok=%v", got, ok)
}
}
func TestJudgeRejectsInvalidRetryBeforeWritingSettings(t *testing.T) {
for _, cfg := range []JudgeConfig{{RetryCount: -1}, {RetryCount: 21}, {RetryIntervalMS: -1}, {RetryIntervalMS: 3600001}} {
if err := New(nil).SetJudgeConfig(cfg); err == nil {
t.Fatalf("accepted invalid retry: %+v", cfg)
}
}
}
+4
View File
@@ -474,6 +474,10 @@ func (s *Server) interceptSetJudgeConfig(w http.ResponseWriter, r *http.Request)
writeErr(w, 400, "incomplete_script_action 必须是 ask 或 model")
return
}
if err := req.ValidateRetry(); err != nil {
writeErr(w, 400, err.Error())
return
}
if err := s.m.interceptor.SetJudgeConfig(req); err != nil {
writeErr(w, 500, err.Error())
return
+73 -2
View File
@@ -1,13 +1,18 @@
package server
import (
"database/sql"
"database/sql/driver"
"encoding/json"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/intercept"
"modernc.org/sqlite"
)
func TestJudgeConfigIncompleteScriptDefaultAndOverride(t *testing.T) {
@@ -20,7 +25,7 @@ func TestJudgeConfigIncompleteScriptDefaultAndOverride(t *testing.T) {
t.Fatal(err)
}
t.Cleanup(func() { _ = pg.Close() })
keys := []string{"llm_judge_enabled", "llm_judge_profile_id", "llm_judge_prompt", "llm_judge_timeout_seconds", "llm_judge_fail_action", "llm_judge_ask_timeout_seconds", "llm_judge_ask_timeout_action", "llm_judge_incomplete_script_action"}
keys := []string{"llm_judge_enabled", "llm_judge_profile_id", "llm_judge_prompt", "llm_judge_timeout_seconds", "llm_judge_retry_count", "llm_judge_retry_interval_ms", "llm_judge_fail_action", "llm_judge_ask_timeout_seconds", "llm_judge_ask_timeout_action", "llm_judge_incomplete_script_action"}
for _, key := range keys {
value, exists, err := pg.GetSetting(key)
if err != nil {
@@ -47,14 +52,80 @@ func TestJudgeConfigIncompleteScriptDefaultAndOverride(t *testing.T) {
{`,"incomplete_script_action":"model"`, "model", 200},
{`,"incomplete_script_action":"invalid"`, "model", 400},
} {
req := httptest.NewRequest("PUT", "/api/intercept/judge-config", strings.NewReader(`{"fail_action":"allow","ask_timeout_action":"deny"`+tc.option+`}`))
req := httptest.NewRequest("PUT", "/api/intercept/judge-config", strings.NewReader(`{"fail_action":"allow","ask_timeout_action":"deny","retry_count":2,"retry_interval_ms":17`+tc.option+`}`))
w := httptest.NewRecorder()
s.interceptSetJudgeConfig(w, req)
if w.Code != tc.code {
t.Fatalf("option=%s status=%d body=%s", tc.option, w.Code, w.Body.String())
}
if cfg := s.m.interceptor.GetJudgeConfig(); cfg.RetryCount != 2 || cfg.RetryIntervalMS != 17 {
t.Fatalf("retry configuration not persisted: %+v", cfg)
}
if got := s.m.interceptor.GetJudgeConfig().IncompleteScriptAction; got != tc.want {
t.Fatalf("option=%s action=%s want=%s", tc.option, got, tc.want)
}
}
}
func TestJudgeConfigRejectsInvalidRetryBeforeSaving(t *testing.T) {
// No manager is needed: invalid input must be rejected before database writes.
s := &Server{}
for _, fields := range []string{
`"retry_count":-1`, `"retry_count":21`, `"retry_count":1.5`,
`"retry_interval_ms":-1`, `"retry_interval_ms":3600001`, `"retry_interval_ms":1.5`,
} {
t.Run(fields, func(t *testing.T) {
req := httptest.NewRequest("PUT", "/api/intercept/judge", strings.NewReader(`{"fail_action":"allow","ask_timeout_action":"deny",`+fields+`}`))
w := httptest.NewRecorder()
s.interceptSetJudgeConfig(w, req)
if w.Code != 400 {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
})
}
}
var judgeConfigSQLiteNow sync.Once
func TestJudgeConfigRetryRoundTrip(t *testing.T) {
// Exercise production settings SQL without requiring an external PG service.
judgeConfigSQLiteNow.Do(func() {
sqlite.MustRegisterDeterministicScalarFunction("now", 0, func(*sqlite.FunctionContext, []driver.Value) (driver.Value, error) {
return "2026-01-01 00:00:00", nil
})
})
sqldb, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqldb.Close() })
if _, err := sqldb.Exec(`CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT, updated_at TEXT)`); err != nil {
t.Fatal(err)
}
pg := &db.DB{DB: sqldb}
s := &Server{m: &Manager{pg: pg, interceptor: intercept.New(pg)}}
for _, tc := range []struct{ count, interval int }{{2, 17}, {20, 3600000}, {0, 0}} {
cfg := s.m.interceptor.GetJudgeConfig()
cfg.RetryCount, cfg.RetryIntervalMS = tc.count, tc.interval
body, err := json.Marshal(cfg)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
s.interceptSetJudgeConfig(w, httptest.NewRequest("PUT", "/api/intercept/judge", strings.NewReader(string(body))))
if w.Code != 200 {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
// Construct a fresh interceptor to prove persistence, rather than cache reuse.
s.m.interceptor = intercept.New(pg)
w = httptest.NewRecorder()
s.interceptGetJudgeConfig(w, httptest.NewRequest("GET", "/api/intercept/judge", nil))
var got intercept.JudgeConfig
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.RetryCount != tc.count || got.RetryIntervalMS != tc.interval {
t.Fatalf("saved=%+v got=%+v", cfg, got)
}
}
}
+47 -3
View File
@@ -21,6 +21,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Field as FormField, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
@@ -200,6 +201,8 @@ const defaultJudge = (): JudgeConfig => ({
profile_id: 0,
prompt: "",
timeout_seconds: 15,
retry_count: 0,
retry_interval_ms: 1000,
fail_action: "allow",
ask_timeout_seconds: 300,
ask_timeout_action: "deny",
@@ -207,7 +210,7 @@ const defaultJudge = (): JudgeConfig => ({
});
function JudgeCard() {
const [cfg, setCfg] = React.useState<JudgeConfig>(defaultJudge());
const [cfg, setCfg] = React.useState<JudgeConfig>(defaultJudge);
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
@@ -376,7 +379,7 @@ function JudgeCard() {
</SelectContent>
</Select>
</Field>
<Field label="模型判定超时(秒)">
<Field label="每次模型判定超时(秒)">
<Input
type="number"
min={1}
@@ -387,7 +390,48 @@ function JudgeCard() {
}}
/>
</Field>
<Field label="模型失败时(出错 / 超时 / 无法解析)">
<FieldGroup>
<FormField>
<FieldLabel htmlFor="judge-retry-count">失败重试次数</FieldLabel>
<Input
id="judge-retry-count"
type="number"
min={0}
max={20}
step={1}
value={cfg.retry_count}
onChange={(e) => {
const n = Number(e.target.value);
if (Number.isInteger(n) && n >= 0 && n <= 20) patch({ retry_count: n });
}}
aria-describedby="judge-retry-count-hint"
/>
<FieldDescription id="judge-retry-count-hint">
首次审批失败后额外重试 0–20 次,0 表示不重试。出错、超时或裁决无法解析时重试;正常放行、转人工或拦截后立即结束。
</FieldDescription>
</FormField>
<FormField data-disabled={cfg.retry_count === 0}>
<FieldLabel htmlFor="judge-retry-interval">重试间隔(毫秒)</FieldLabel>
<Input
id="judge-retry-interval"
type="number"
min={0}
max={3600000}
step={1}
value={cfg.retry_interval_ms}
disabled={cfg.retry_count === 0}
onChange={(e) => {
const n = Number(e.target.value);
if (Number.isInteger(n) && n >= 0 && n <= 3600000) patch({ retry_interval_ms: n });
}}
aria-describedby="judge-retry-interval-hint"
/>
<FieldDescription id="judge-retry-interval-hint">
默认 1000 毫秒,0 表示立即重试。每次审批均有独立超时,全部尝试失败后执行下方失败策略。模型配置中的建连和空响应重试仍在每次审批内生效。
</FieldDescription>
</FormField>
</FieldGroup>
<Field label="模型失败时(重试耗尽后)">
<Select value={cfg.fail_action} onValueChange={(v) => patch({ fail_action: v as JudgeConfig["fail_action"] })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
+2
View File
@@ -2511,6 +2511,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
profile_id: 0,
prompt: "",
timeout_seconds: 15,
retry_count: 0,
retry_interval_ms: 1000,
fail_action: "allow",
ask_timeout_seconds: 300,
ask_timeout_action: "deny",
+3 -1
View File
@@ -1361,7 +1361,9 @@ export interface JudgeConfig {
enabled: boolean;
profile_id: number; // 0 = 跟随激活/默认配置
prompt: string; // 判定提示词;GET 未设置时后端回填内置模板全文
timeout_seconds: number; // 模型调用超时
timeout_seconds: number; // 每次模型审批调用的超时
retry_count: number; // 审批失败后的额外重试次数,0 = 不重试
retry_interval_ms: number; // 审批重试间隔(毫秒),0 = 立即重试
fail_action: "allow" | "ask" | "deny"; // 模型出错/超时/不可解析时的回退
ask_timeout_seconds: number; // 模型判 ask 转人工后的审批等待超时
ask_timeout_action: "allow" | "deny"; // 审批超时后的默认动作