mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 01:03:50 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e94a7d472b | ||
|
|
5f3a92a1f1 | ||
|
|
36eb5c5e89 | ||
|
|
bfc5e6236e | ||
|
|
d2e7e3a8a7 | ||
|
|
23446606a4 | ||
|
|
b9b137dc91 | ||
|
|
bd0ba56c82 | ||
|
|
600007873c |
No files matched your search
+21
-6
@@ -250,14 +250,30 @@ func (c Config) Provider() string {
|
||||
return "anthropic"
|
||||
}
|
||||
|
||||
// NewProvider builds an llm.Provider from the config. When a rate is set, the
|
||||
// limiter lives on the single provider instance — so planner + all workers +
|
||||
// main agent (which share this provider) are bounded by one shared rate limit.
|
||||
// NewProvider builds a provider with its own HTTP request limiter. Every HTTP
|
||||
// attempt, including SDK retries, counts toward both rolling rate windows.
|
||||
func (c Config) NewProvider() (llm.Provider, error) {
|
||||
return c.NewProviderWithLimiter(&RequestLimiter{})
|
||||
}
|
||||
|
||||
// NewProviderWithLimiter reuses quota history across provider rebuilds. Callers
|
||||
// sharing a profile must retain the same limiter outside their provider cache.
|
||||
func (c Config) NewProviderWithLimiter(limiter *RequestLimiter) (llm.Provider, error) {
|
||||
client, err := quotaAwareHTTPClient(c.Proxy, c.SessionHeaderKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.newProvider(client, limiter)
|
||||
}
|
||||
|
||||
func (c Config) newProvider(client *http.Client, limiter *RequestLimiter) (llm.Provider, error) {
|
||||
if limiter == nil {
|
||||
limiter = &RequestLimiter{}
|
||||
}
|
||||
if err := limiter.configure(c.RatePerSecond, c.RatePerMinute); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client.Transport = rateLimitedTransport{base: client.Transport, limiter: limiter}
|
||||
lc := llm.Config{
|
||||
Format: c.Format,
|
||||
BaseURL: c.BaseURL,
|
||||
@@ -277,9 +293,8 @@ func (c Config) NewProvider() (llm.Provider, error) {
|
||||
lc.RetryInterval = c.Retry.ConnectInterval
|
||||
lc.EmptyResponseRetries = c.Retry.EmptyAttempts
|
||||
lc.EmptyResponseInterval = c.Retry.EmptyInterval
|
||||
if c.RatePerSecond > 0 || c.RatePerMinute > 0 {
|
||||
lc.RateLimit = &llm.RateLimit{PerSecond: c.RatePerSecond, PerMinute: c.RatePerMinute}
|
||||
}
|
||||
// Norma's limiter allows a full-window burst and excludes establishment
|
||||
// retries. Enforce limits on HTTP attempts instead; do not also enable it.
|
||||
return llm.NewProvider(lc)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RequestLimiter counts HTTP attempts in rolling second/minute windows. Keep it
|
||||
// outside provider caches so rebuilding a provider does not reset its quota.
|
||||
// Its zero value is ready for use; limits are set by NewProviderWithLimiter.
|
||||
type RequestLimiter struct {
|
||||
mu sync.Mutex
|
||||
windows [2]requestWindow
|
||||
sent []time.Time
|
||||
changed chan struct{}
|
||||
}
|
||||
|
||||
type requestWindow struct {
|
||||
period time.Duration
|
||||
limit float64
|
||||
}
|
||||
|
||||
func (l *RequestLimiter) configure(perSecond, perMinute float64) error {
|
||||
var windows [2]requestWindow
|
||||
for i, rate := range [2]float64{perSecond, perMinute} {
|
||||
if math.IsNaN(rate) || math.IsInf(rate, 0) {
|
||||
return fmt.Errorf("LLM request rate must be finite")
|
||||
}
|
||||
if rate <= 0 {
|
||||
continue
|
||||
}
|
||||
period := [2]time.Duration{time.Second, time.Minute}[i]
|
||||
// Whole HTTP attempts cannot exceed a fractional window capacity.
|
||||
limit := math.Floor(rate)
|
||||
if rate < 1 {
|
||||
// Preserve fractional rates such as 0.5/s as one attempt per 2s.
|
||||
d := math.Ceil(float64(period) / rate)
|
||||
if d >= float64(math.MaxInt64) {
|
||||
return fmt.Errorf("LLM request rate is too small")
|
||||
}
|
||||
period, limit = time.Duration(d), 1
|
||||
}
|
||||
windows[i] = requestWindow{period: period, limit: limit}
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.windows = windows
|
||||
if l.changed != nil {
|
||||
close(l.changed)
|
||||
}
|
||||
l.changed = make(chan struct{})
|
||||
return nil
|
||||
}
|
||||
|
||||
// wait counts a request only when it may actually proceed. Waiters recheck both
|
||||
// windows under the same lock, so concurrency and cancellation cannot overbook
|
||||
// capacity. Requests in (now-period, now] count toward each rolling window.
|
||||
func (l *RequestLimiter) wait(ctx context.Context) error {
|
||||
if l == nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
for {
|
||||
l.mu.Lock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
l.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
// Retain at least a minute across limit changes, including while limits
|
||||
// are disabled. Fractional rates may need a longer history.
|
||||
retention := time.Minute
|
||||
for _, w := range l.windows {
|
||||
if w.period > retention {
|
||||
retention = w.period
|
||||
}
|
||||
}
|
||||
first := 0
|
||||
// Keep the last attempt even when older than retention: a subsequent
|
||||
// switch to a fractional rate may need to space requests further apart.
|
||||
for first+1 < len(l.sent) && !l.sent[first].After(now.Add(-retention)) {
|
||||
first++
|
||||
}
|
||||
l.sent = l.sent[first:]
|
||||
var delay time.Duration
|
||||
for _, w := range l.windows {
|
||||
if w.limit == 0 {
|
||||
continue
|
||||
}
|
||||
start := len(l.sent)
|
||||
for start > 0 && l.sent[start-1].After(now.Add(-w.period)) {
|
||||
start--
|
||||
}
|
||||
count := len(l.sent) - start
|
||||
if float64(count) >= w.limit {
|
||||
// If a limit was lowered, enough old requests must expire to
|
||||
// leave room for this attempt, not just the oldest request.
|
||||
d := l.sent[len(l.sent)-int(w.limit)].Add(w.period).Sub(now)
|
||||
if d > delay {
|
||||
delay = d
|
||||
}
|
||||
}
|
||||
}
|
||||
if delay <= 0 {
|
||||
l.sent = append(l.sent, now)
|
||||
l.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
changed := l.changed
|
||||
l.mu.Unlock()
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return ctx.Err()
|
||||
case <-changed:
|
||||
timer.Stop()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type rateLimitedTransport struct {
|
||||
base http.RoundTripper
|
||||
limiter *RequestLimiter
|
||||
}
|
||||
|
||||
func (t rateLimitedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if err := t.limiter.wait(req.Context()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t.base.RoundTrip(req)
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
)
|
||||
|
||||
func TestRequestLimiterRollingWindows(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
rps, rpm float64
|
||||
capacity int
|
||||
window time.Duration
|
||||
}{
|
||||
{"second", 10, 0, 10, time.Second},
|
||||
{"minute_45", 0, 45, 45, time.Minute},
|
||||
{"fractional_second", .5, 0, 1, 2 * time.Second},
|
||||
{"fractional_minute", 0, .5, 1, 2 * time.Minute},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
l := &RequestLimiter{}
|
||||
if err := l.configure(tc.rps, tc.rpm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := time.Now()
|
||||
for i := 0; i < tc.capacity; i++ {
|
||||
if err := l.wait(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), tc.window-time.Nanosecond)
|
||||
defer cancel()
|
||||
if err := l.wait(ctx); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("extra attempt before window expired: %v", err)
|
||||
}
|
||||
if err := l.wait(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed != tc.window {
|
||||
t.Fatalf("next attempt at %s, want %s; cancelled wait must not consume quota", elapsed, tc.window)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestLimiterBothWindowsAndConcurrentCallers(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
l := &RequestLimiter{}
|
||||
if err := l.configure(3, 5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var mu sync.Mutex
|
||||
var sent []time.Time
|
||||
var wg sync.WaitGroup
|
||||
start := time.Now()
|
||||
for i := 0; i < 12; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := l.wait(context.Background()); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
sent = append(sent, time.Now())
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if elapsed := time.Since(start); elapsed != 2*time.Minute {
|
||||
t.Fatalf("12 requests under 3/s and 5/min took %s, want 2m", elapsed)
|
||||
}
|
||||
assertRollingRequestCounts(t, sent, time.Second, 3)
|
||||
assertRollingRequestCounts(t, sent, time.Minute, 5)
|
||||
})
|
||||
}
|
||||
|
||||
func assertRollingRequestCounts(t *testing.T, sent []time.Time, window time.Duration, limit int) {
|
||||
t.Helper()
|
||||
for _, end := range sent {
|
||||
count := 0
|
||||
for _, ts := range sent {
|
||||
if ts.After(end.Add(-window)) && !ts.After(end) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count > limit {
|
||||
t.Fatalf("%d attempts in rolling %s, limit %d", count, window, limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestLimiterReconfiguration(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
l := &RequestLimiter{}
|
||||
if err := l.configure(0, 5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := l.wait(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
start := time.Now()
|
||||
if err := l.configure(0, 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- l.wait(context.Background()) }()
|
||||
synctest.Wait()
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("lowering limit reset history: %v", err)
|
||||
default:
|
||||
}
|
||||
// A change must wake waiting requests rather than leave them sleeping
|
||||
// until the old minute window expires.
|
||||
if err := l.configure(0, 6); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if time.Since(start) != 0 {
|
||||
t.Fatal("waiter did not wake on limit increase")
|
||||
}
|
||||
if err := l.configure(0, 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := l.wait(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if time.Since(start) != time.Minute {
|
||||
t.Fatal("lower limit did not retain previous attempts")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRequestLimiterDisabledAndCancelled(t *testing.T) {
|
||||
l := &RequestLimiter{}
|
||||
if err := l.configure(0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 100; i++ {
|
||||
if err := l.wait(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := l.wait(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
if len(l.sent) != 100 {
|
||||
t.Fatal("cancelled request consumed quota")
|
||||
}
|
||||
for _, rate := range []float64{math.NaN(), math.Inf(1), math.Inf(-1), math.SmallestNonzeroFloat64} {
|
||||
if err := l.configure(rate, 0); err == nil {
|
||||
t.Errorf("accepted invalid rate %v", rate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exercise the actual Norma retry loops for all formats and both completion
|
||||
// modes. In-memory HTTP responses let synctest advance a full minute instantly.
|
||||
func TestProviderRateLimitCountsHTTPRetries(t *testing.T) {
|
||||
for _, format := range []string{"openai", "anthropic", "openai-responses"} {
|
||||
for _, streaming := range []bool{false, true} {
|
||||
mode := "complete"
|
||||
if streaming {
|
||||
mode = "stream"
|
||||
}
|
||||
t.Run(format+"/"+mode, func(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
var sent []time.Time
|
||||
client := &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
sent = append(sent, time.Now())
|
||||
return &http.Response{StatusCode: 500, Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"temporary error"}}`))}, nil
|
||||
})}
|
||||
cfg := ConfigFrom(format, "test-model", "http://example.invalid", "test-key", "")
|
||||
cfg.RatePerSecond, cfg.RatePerMinute = 1, 2
|
||||
cfg.Retry.ConnectInterval = time.Millisecond
|
||||
prov, err := cfg.newProvider(client, &RequestLimiter{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := time.Now()
|
||||
req := llm.CompletionRequest{Messages: []llm.Message{llm.UserText("ping")}}
|
||||
if streaming {
|
||||
for _, streamErr := range prov.Stream(context.Background(), req) {
|
||||
err = streamErr
|
||||
}
|
||||
} else {
|
||||
_, _, _, err = prov.Complete(context.Background(), req)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected upstream 500")
|
||||
}
|
||||
if len(sent) != 4 {
|
||||
t.Fatalf("HTTP attempts=%d, want initial+3 retries", len(sent))
|
||||
}
|
||||
assertRollingRequestCounts(t, sent, time.Second, 1)
|
||||
assertRollingRequestCounts(t, sent, time.Minute, 2)
|
||||
if elapsed := time.Since(start); elapsed != time.Minute+time.Second {
|
||||
t.Fatalf("retry attempts took %s, want 1m1s", elapsed)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderRebuildPreservesRateLimit(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
l := &RequestLimiter{}
|
||||
var sent []time.Time
|
||||
cfg := ConfigFrom("openai", "test-model", "http://example.invalid", "test-key", "")
|
||||
cfg.RatePerMinute = 1
|
||||
cfg.Retry.ConnectAttempts = -1
|
||||
for i := 0; i < 2; i++ {
|
||||
client := &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
sent = append(sent, time.Now())
|
||||
return &http.Response{StatusCode: 500, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("error"))}, nil
|
||||
})}
|
||||
prov, err := cfg.newProvider(client, l)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prov.Complete(context.Background(), llm.CompletionRequest{Messages: []llm.Message{llm.UserText("ping")}})
|
||||
}
|
||||
if len(sent) != 2 || sent[1].Sub(sent[0]) != time.Minute {
|
||||
t.Fatalf("provider rebuild reset quota: %v", sent)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProviderRateLimitSuccessfulRequests(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
rps, rpm float64
|
||||
calls int
|
||||
window time.Duration
|
||||
limit int
|
||||
}{
|
||||
{"minute_45_requests_60", 0, 45, 60, time.Minute, 45},
|
||||
{"second_10_requests_15", 10, 0, 15, time.Second, 10},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
var sent []time.Time
|
||||
client := &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
sent = append(sent, time.Now())
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(
|
||||
`{"choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`))}, nil
|
||||
})}
|
||||
cfg := ConfigFrom("openai", "test-model", "http://example.invalid", "test-key", "")
|
||||
cfg.RatePerSecond, cfg.RatePerMinute = tc.rps, tc.rpm
|
||||
prov, err := cfg.newProvider(client, &RequestLimiter{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := time.Now()
|
||||
for i := 0; i < tc.calls; i++ {
|
||||
_, _, _, err := prov.Complete(context.Background(), llm.CompletionRequest{Messages: []llm.Message{llm.UserText("ping")}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if len(sent) != tc.calls || time.Since(start) != tc.window {
|
||||
t.Fatalf("sent %d attempts in %s, want %d in %s", len(sent), time.Since(start), tc.calls, tc.window)
|
||||
}
|
||||
assertRollingRequestCounts(t, sent, tc.window, tc.limit)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderRateLimitCountsEmptyResponseRetries(t *testing.T) {
|
||||
for _, streaming := range []bool{false, true} {
|
||||
name := "complete"
|
||||
if streaming {
|
||||
name = "stream"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
var sent []time.Time
|
||||
client := &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
sent = append(sent, time.Now())
|
||||
body := `{"choices":[{"message":{"role":"assistant","content":""},"finish_reason":"stop"}]}`
|
||||
if streaming {
|
||||
body = "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}, nil
|
||||
})}
|
||||
cfg := ConfigFrom("openai", "test-model", "http://example.invalid", "test-key", "")
|
||||
cfg.RatePerMinute = 1
|
||||
cfg.Retry.EmptyInterval = time.Millisecond
|
||||
prov, err := cfg.newProvider(client, &RequestLimiter{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := llm.CompletionRequest{Messages: []llm.Message{llm.UserText("ping")}}
|
||||
if streaming {
|
||||
for range prov.Stream(context.Background(), req) {
|
||||
}
|
||||
} else {
|
||||
prov.Complete(context.Background(), req)
|
||||
}
|
||||
if len(sent) != 3 {
|
||||
t.Fatalf("attempts=%d, want initial + 2 empty retries", len(sent))
|
||||
}
|
||||
assertRollingRequestCounts(t, sent, time.Minute, 1)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestLimiterUsesRollingHistory(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
l := &RequestLimiter{}
|
||||
if err := l.configure(3, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := time.Now()
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := l.wait(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
time.Sleep(750 * time.Millisecond)
|
||||
if err := l.wait(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(350 * time.Millisecond)
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := l.wait(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Only the two t=0 attempts expired. The one at t=.75 still counts.
|
||||
if err := l.wait(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if time.Since(start) != 1750*time.Millisecond {
|
||||
t.Fatal("window reset lost an unexpired attempt")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRateLimitedTransportCancellationDoesNotSend(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
l := &RequestLimiter{}
|
||||
if err := l.configure(0, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls := 0
|
||||
tr := rateLimitedTransport{limiter: l, base: roundTripperFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
return &http.Response{StatusCode: 200, Body: http.NoBody}, nil
|
||||
})}
|
||||
req, err := http.NewRequest("POST", "http://example.invalid", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tr.RoundTrip(req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if _, err := tr.RoundTrip(req.WithContext(ctx)); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("cancelled waiter sent HTTP request: %d", calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProviderRateLimitPublicFactory(t *testing.T) {
|
||||
for _, format := range []string{"openai", "anthropic", "openai-responses"} {
|
||||
for _, streaming := range []bool{false, true} {
|
||||
mode := "complete"
|
||||
if streaming {
|
||||
mode = "stream"
|
||||
}
|
||||
t.Run(format+"/"+mode, func(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests.Add(1)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
io.WriteString(w, `{"error":{"message":"temporary error"}}`)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
cfg := ConfigFrom(format, "test-model", upstream.URL, "test-key", "")
|
||||
cfg.RatePerSecond, cfg.RatePerMinute = 1, 1
|
||||
cfg.Retry.ConnectAttempts = -1
|
||||
limiter := &RequestLimiter{}
|
||||
req := llm.CompletionRequest{Messages: []llm.Message{llm.UserText("ping")}}
|
||||
for i := 0; i < 2; i++ {
|
||||
// A new public provider factory call must keep the old quota.
|
||||
prov, err := cfg.NewProviderWithLimiter(limiter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
if i == 1 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
defer cancel()
|
||||
}
|
||||
if streaming {
|
||||
for _, streamErr := range prov.Stream(ctx, req) {
|
||||
err = streamErr
|
||||
}
|
||||
} else {
|
||||
_, _, _, err = prov.Complete(ctx, req)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected upstream error or quota wait cancellation")
|
||||
}
|
||||
if i == 1 && !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("got %v, want quota wait deadline", err)
|
||||
}
|
||||
}
|
||||
if got := requests.Load(); got != 1 {
|
||||
t.Fatalf("provider rebuild sent %d HTTP requests under 1/min", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-5
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package server
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestProfileRequestLimiterSurvivesCacheInvalidation(t *testing.T) {
|
||||
s := &Server{}
|
||||
limiter := s.requestLimiterForProfile(1)
|
||||
if limiter == s.requestLimiterForProfile(2) || limiter == s.requestLimiterForProfile(0) {
|
||||
t.Fatal("different profiles must have independent quotas")
|
||||
}
|
||||
s.invalidateProfileAgents()
|
||||
if got := s.requestLimiterForProfile(1); got != limiter {
|
||||
t.Fatal("clearing providers reset the profile's rate limiter")
|
||||
}
|
||||
}
|
||||
+38
-15
@@ -103,6 +103,9 @@ type Server struct {
|
||||
provCacheMu sync.Mutex
|
||||
provByProfile map[int64]*provEntry
|
||||
provCacheGen uint64
|
||||
// Quota histories survive provider/agent cache invalidation. Key 0 belongs
|
||||
// to the non-persisted global config; persisted profiles use their DB IDs.
|
||||
requestLimiters map[int64]*agent.RequestLimiter
|
||||
|
||||
// llmHealth is the process-wide circuit-breaker state for LLM failover (轮询).
|
||||
// It deliberately lives OUTSIDE the provider caches: rebuilding the chain
|
||||
@@ -446,7 +449,7 @@ func (s *Server) applyLLM(cfg agent.Config) error {
|
||||
}
|
||||
if prov == nil {
|
||||
var err error
|
||||
prov, err = cfg.NewProvider()
|
||||
prov, err = cfg.NewProviderWithLimiter(s.requestLimiterForProfile(0))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -581,31 +584,51 @@ func (s *Server) providerForProfile(id int64) (llm.Provider, agent.Config, bool)
|
||||
if !ok {
|
||||
return nil, agent.Config{}, false
|
||||
}
|
||||
prov, err := cfg.NewProvider()
|
||||
if err != nil {
|
||||
log.Printf("[engine] build provider for LLM profile %d failed: %v", id, err)
|
||||
return nil, agent.Config{}, false
|
||||
}
|
||||
// Wrap with recorder, tagged with this profile's name.
|
||||
if p, _ := s.m.pg.ProfileByID(id); p != nil {
|
||||
prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, p.Name, cfg.ThinkingType, cfg.ReasoningEffort, s.m.LLMRecordEnabled)
|
||||
prov = bindSideProvider(prov, cfg, id, p.Name)
|
||||
}
|
||||
profile, _ := s.m.pg.ProfileByID(id)
|
||||
s.provCacheMu.Lock()
|
||||
if generation != s.provCacheGen {
|
||||
s.provCacheMu.Unlock()
|
||||
return s.providerForProfile(id)
|
||||
}
|
||||
if e := s.provByProfile[id]; e != nil { // lost the race → keep the winner
|
||||
prov, cfg = e.prov, e.cfg
|
||||
} else {
|
||||
s.provByProfile[id] = &provEntry{prov: prov, cfg: cfg}
|
||||
log.Printf("[engine] built provider for LLM profile %d (%s / %s)", id, cfg.Provider(), cfg.Model)
|
||||
s.provCacheMu.Unlock()
|
||||
return e.prov, e.cfg, true
|
||||
}
|
||||
// Building the HTTP client does not perform network I/O. Keep this step
|
||||
// under the generation lock so a stale build cannot overwrite the retained
|
||||
// limiter's settings after a newer configuration has already been installed.
|
||||
prov, err := cfg.NewProviderWithLimiter(s.requestLimiterForProfileLocked(id))
|
||||
if err != nil {
|
||||
s.provCacheMu.Unlock()
|
||||
log.Printf("[engine] build provider for LLM profile %d failed: %v", id, err)
|
||||
return nil, agent.Config{}, false
|
||||
}
|
||||
if profile != nil {
|
||||
prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, profile.Name, cfg.ThinkingType, cfg.ReasoningEffort, s.m.LLMRecordEnabled)
|
||||
prov = bindSideProvider(prov, cfg, id, profile.Name)
|
||||
}
|
||||
s.provByProfile[id] = &provEntry{prov: prov, cfg: cfg}
|
||||
log.Printf("[engine] built provider for LLM profile %d (%s / %s)", id, cfg.Provider(), cfg.Model)
|
||||
s.provCacheMu.Unlock()
|
||||
return prov, cfg, true
|
||||
}
|
||||
|
||||
func (s *Server) requestLimiterForProfile(id int64) *agent.RequestLimiter {
|
||||
s.provCacheMu.Lock()
|
||||
defer s.provCacheMu.Unlock()
|
||||
return s.requestLimiterForProfileLocked(id)
|
||||
}
|
||||
|
||||
func (s *Server) requestLimiterForProfileLocked(id int64) *agent.RequestLimiter {
|
||||
if s.requestLimiters == nil {
|
||||
s.requestLimiters = make(map[int64]*agent.RequestLimiter)
|
||||
}
|
||||
if s.requestLimiters[id] == nil {
|
||||
s.requestLimiters[id] = &agent.RequestLimiter{}
|
||||
}
|
||||
return s.requestLimiters[id]
|
||||
}
|
||||
|
||||
// chatAgentForProfile returns a ChatAgent built from a specific LLM profile, cached
|
||||
// per profile id. Returns nil if the profile is missing or has no API key.
|
||||
func (s *Server) chatAgentForProfile(id int64) *agent.ChatAgent {
|
||||
|
||||
@@ -1222,7 +1222,8 @@ const TaskRow = React.memo(function TaskRow({
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs tabular-nums">
|
||||
{(() => {
|
||||
const f = task.distinct_findings ?? task.findings;
|
||||
// Keep report severity counts visible while a merged folder awaits its own assessment.
|
||||
const f = task.findings ?? task.distinct_findings;
|
||||
const total = f ? f.critical + f.high + f.medium + f.low : 0;
|
||||
if (!f || (total === 0 && !task.distinct_findings?.total))
|
||||
return <span className="text-muted-foreground">0</span>;
|
||||
@@ -1230,7 +1231,7 @@ const TaskRow = React.memo(function TaskRow({
|
||||
return (
|
||||
<span
|
||||
className="font-medium whitespace-nowrap"
|
||||
title={`独立漏洞:严重 / 高 / 中 / 低;上报 ${task.distinct_findings?.reports ?? total} 条;待评估 ${task.distinct_findings?.unassessed ?? 0} 个`}
|
||||
title={`${task.findings ? "原始上报" : "独立漏洞"}:严重 / 高 / 中 / 低;上报 ${task.distinct_findings?.reports ?? total} 条;待评估 ${task.distinct_findings?.unassessed ?? 0} 个`}
|
||||
>
|
||||
{seg(f.critical, "text-rose-600 dark:text-rose-400")}
|
||||
<span className="text-muted-foreground">/</span>
|
||||
|
||||
@@ -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);
|
||||
@@ -337,7 +340,7 @@ function JudgeCard() {
|
||||
<div className="grid gap-4 lg:grid-cols-5">
|
||||
{/* 左:提示词编辑器(直接展开,主区域) */}
|
||||
<Card className="lg:col-span-3">
|
||||
<CardContent className="flex h-full flex-col gap-2 p-4">
|
||||
<CardContent className="flex min-h-0 flex-1 flex-col gap-2 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">审批提示词</p>
|
||||
@@ -348,7 +351,7 @@ function JudgeCard() {
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
className="min-h-[22rem] flex-1 resize-none font-mono text-xs leading-relaxed"
|
||||
className="field-sizing-fixed min-h-[22rem] max-h-[none] flex-1 resize-none font-mono text-xs leading-relaxed"
|
||||
value={cfg.prompt}
|
||||
onChange={(e) => patch({ prompt: e.target.value })}
|
||||
placeholder="留空使用内置模板"
|
||||
@@ -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>
|
||||
|
||||
@@ -111,6 +111,40 @@ function HighlightedBody({ body, format }: { body: string; format: BodyFormat })
|
||||
return body;
|
||||
}
|
||||
|
||||
// Empty grid rows have no text, so native selection copying drops their newlines.
|
||||
// Map the selected DOM boundaries back to the raw packet instead.
|
||||
function selectedPacketText(container: HTMLElement, value: string, lines: string[]): string | null {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed || selection.rangeCount !== 1) return null;
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!container.contains(range.startContainer) || !container.contains(range.endContainer)) return null;
|
||||
|
||||
let start: number | null = null;
|
||||
let end: number | null = null;
|
||||
let offset = 0;
|
||||
container.querySelectorAll("[data-line] code").forEach((code, index) => {
|
||||
if (range.intersectsNode(code)) {
|
||||
const selected = document.createRange();
|
||||
selected.selectNodeContents(code);
|
||||
if (range.compareBoundaryPoints(Range.START_TO_START, selected) > 0) {
|
||||
selected.setStart(range.startContainer, range.startOffset);
|
||||
}
|
||||
if (range.compareBoundaryPoints(Range.END_TO_END, selected) < 0) {
|
||||
selected.setEnd(range.endContainer, range.endOffset);
|
||||
}
|
||||
const prefix = document.createRange();
|
||||
prefix.selectNodeContents(code);
|
||||
prefix.setEnd(selected.startContainer, selected.startOffset);
|
||||
start ??= offset + prefix.toString().length;
|
||||
prefix.setEnd(selected.endContainer, selected.endOffset);
|
||||
end = offset + prefix.toString().length;
|
||||
}
|
||||
const lineEnd = offset + lines[index].length;
|
||||
offset = lineEnd + (value[lineEnd] === "\r" ? 2 : 1);
|
||||
});
|
||||
return start !== null && end !== null ? value.slice(start, end) : null;
|
||||
}
|
||||
|
||||
export function HttpCodeBlock({ raw }: { raw: string }) {
|
||||
const [wrapLines, setWrapLines] = React.useState(true);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
@@ -183,6 +217,12 @@ export function HttpCodeBlock({ raw }: { raw: string }) {
|
||||
aria-readonly="true"
|
||||
tabIndex={0}
|
||||
className="max-h-[calc(100vh-15rem)] min-w-0 overflow-auto bg-background py-3 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
onCopy={(event) => {
|
||||
const text = selectedPacketText(event.currentTarget, value, lines);
|
||||
if (text === null) return;
|
||||
event.preventDefault();
|
||||
event.clipboardData.setData("text/plain", text);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "a") return;
|
||||
event.preventDefault();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"; // 审批超时后的默认动作
|
||||
|
||||
Reference in new issue
Block a user