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
62 changes: 62 additions & 0 deletions backend/internal/application/egress/probe_match.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package egress

import (
"regexp"
"strings"
"unicode"
)

const (
MatchContains = "contains"
MatchLastLine = "last_line"
MatchRegex = "regex"
)

func NormalizeMatchMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case MatchLastLine, "last-line", "lastline":
return MatchLastLine
case MatchRegex, "regexp":
return MatchRegex
default:
return MatchContains
}
}

func lastNonEmptyLine(text string) string {
lines := strings.Split(text, "\n")
for i := len(lines) - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
if line != "" {
return line
}
}
return ""
}

// MatchExpected reports whether the probe body satisfies the expected marker.
// An empty expected string always matches so throughput-only profiles can skip
// content checks.
func MatchExpected(text, expected, mode string) bool {
expected = strings.TrimSpace(expected)
if expected == "" {
return true
}
text = strings.TrimRightFunc(text, unicode.IsSpace)
switch NormalizeMatchMode(mode) {
case MatchLastLine:
line := lastNonEmptyLine(text)
if line == "" {
return false
}
return strings.EqualFold(line, expected)
case MatchRegex:
re, err := regexp.Compile(expected)
if err != nil {
return false
}
return re.MatchString(text)
default:
return strings.Contains(text, expected)
}
}
31 changes: 31 additions & 0 deletions backend/internal/application/egress/probe_match_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package egress

import "testing"

func TestMatchExpectedModes(t *testing.T) {
text := "天空是蓝的,因为瑞利散射。\nQUALITY_OK\n"
if !MatchExpected(text, "QUALITY_OK", MatchLastLine) {
t.Fatal("last line QUALITY_OK should match")
}
if MatchExpected("hello\nNOT_OK", "QUALITY_OK", MatchLastLine) {
t.Fatal("wrong last line must not match")
}
if !MatchExpected("prefix QUALITY_OK suffix", "QUALITY_OK", MatchContains) {
t.Fatal("contains should match")
}
if MatchExpected("done\nstatus=QUALITY_OK", "QUALITY_OK", MatchLastLine) {
t.Fatal("last-line mode must require the complete marker line")
}
if MatchExpected("done\nNOT_QUALITY_OK", "QUALITY_OK", MatchLastLine) {
t.Fatal("last-line mode must not accept a marker substring")
}
if !MatchExpected("alpha\nbeta QUALITY_OK", `QUALITY_OK$`, MatchRegex) {
t.Fatal("regex should match")
}
if MatchExpected("nope", "[", MatchRegex) {
t.Fatal("invalid regex must not match")
}
if !MatchExpected("anything", "", MatchContains) {
t.Fatal("empty expected is always a match")
}
}
29 changes: 29 additions & 0 deletions backend/internal/application/egress/quality_probe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,32 @@ func TestProbeQualityRejectsUnsupportedNodeAndMissingProber(t *testing.T) {
t.Fatalf("missing prober error = %v", err)
}
}

func TestProbeQualityScopesThinkingGuardToReasoningBuildModels(t *testing.T) {
repository := &qualityProbeRepository{node: domain.Node{
ID: 7, Scope: domain.ScopeBuild, Enabled: true, EncryptedProxyURL: "encrypted",
}}
prober := &qualityProberStub{}
service := NewService(repository, nil, "")
service.SetQualityProber(prober)

result, err := service.ProbeQuality(context.Background(), 7, QualityProbeInput{
ClientKeyID: 3, Model: "grok-4.5", RequireThinking: true,
})
if err != nil {
t.Fatal(err)
}
if !prober.input.RequireThinking || !result.ThinkingRequired {
t.Fatalf("reasoning model probe=%#v result=%#v", prober.input, result)
}

result, err = service.ProbeQuality(context.Background(), 7, QualityProbeInput{
ClientKeyID: 3, Model: "grok-composer-2.5-fast", RequireThinking: true,
})
if err != nil {
t.Fatal(err)
}
if prober.input.RequireThinking || result.ThinkingRequired {
t.Fatalf("non-reasoning model probe=%#v result=%#v", prober.input, result)
}
}
19 changes: 17 additions & 2 deletions backend/internal/application/egress/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account"
domain "github.com/chenyme/grok2api/backend/internal/domain/egress"
modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model"
"github.com/chenyme/grok2api/backend/internal/infra/security"
"github.com/chenyme/grok2api/backend/internal/pkg/tunnelproxy"
"github.com/chenyme/grok2api/backend/internal/repository"
Expand Down Expand Up @@ -41,6 +42,8 @@ type QualityProbeInput struct {
Model string
Prompt string
Expected string
MatchMode string
RequireThinking bool
MaxOutputTokens int
}

Expand All @@ -59,6 +62,7 @@ type QualityProbeResult struct {
VisibleCharacters int
OutputTokensPerSecond float64
ExpectedMatched bool
ThinkingRequired bool
ResponseSHA256 string
}

Expand Down Expand Up @@ -128,13 +132,15 @@ func (s *Service) ProbeQuality(ctx context.Context, nodeID uint64, input Quality
input.Model = strings.TrimSpace(input.Model)
input.Prompt = strings.TrimSpace(input.Prompt)
input.Expected = strings.TrimSpace(input.Expected)
rawMatchMode := strings.TrimSpace(input.MatchMode)
input.MatchMode = NormalizeMatchMode(input.MatchMode)
if input.Model == "" {
return QualityProbeResult{}, fmt.Errorf("%w: model 必填", ErrInvalidInput)
}
if input.Prompt == "" {
input.Prompt = DefaultQualityProbePrompt
}
if input.Expected == "" {
if input.Expected == "" && rawMatchMode == "" {
input.Expected = DefaultQualityProbeExpected
}
if len(input.Prompt) > MaxQualityProbePromptBytes || len(input.Expected) > MaxQualityProbeExpectedBytes {
Expand Down Expand Up @@ -162,7 +168,16 @@ func (s *Service) ProbeQuality(ctx context.Context, nodeID uint64, input Quality
if prober == nil {
return QualityProbeResult{}, ErrQualityProbeUnavailable
}
return prober.ProbeEgressQuality(ctx, nodeID, input)
// A profile may request the thinking guard, but only a known reasoning-capable
// Build model can make zero reasoning tokens meaningful. Unknown/custom and
// non-reasoning models stay observable without being falsely quarantined.
input.RequireThinking = input.RequireThinking && modeldomain.SupportsReasoningForProvider(accountdomain.ProviderBuild, input.Model)
result, err := prober.ProbeEgressQuality(ctx, nodeID, input)
if err != nil {
return QualityProbeResult{}, err
}
result.ThinkingRequired = input.RequireThinking
return result, nil
}

// AccountBindingRepository is intentionally narrow so existing account
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/application/gateway/quality_probe.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ func (s *Service) ProbeEgressQuality(ctx context.Context, nodeID uint64, input e
FirstTokenMS: firstTokenMS, DurationMS: durationMS, GenerationMS: generationMS,
ChunkCount: chunkCount, OutputTokens: usage.OutputTokens, ReasoningTokens: usage.ReasoningTokens,
VisibleTokens: visibleTokens, VisibleCharacters: visibleCharacters, OutputTokensPerSecond: outputTokensPerSecond,
ExpectedMatched: strings.Contains(text, input.Expected), ResponseSHA256: hex.EncodeToString(digest[:]),
ExpectedMatched: egressapp.MatchExpected(text, input.Expected, input.MatchMode), ResponseSHA256: hex.EncodeToString(digest[:]),
}, nil
}

Expand Down
33 changes: 29 additions & 4 deletions backend/internal/transport/http/egress/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"time"

egressapp "github.com/chenyme/grok2api/backend/internal/application/egress"
Expand All @@ -26,6 +27,7 @@ type Handler struct {
guardStatePath string
guardConfigPath string
guardProbe egressapp.QualityProbeInput
profilesMu sync.Mutex
}

func NewHandler(service *egressapp.Service, guardStatePath ...string) *Handler {
Expand Down Expand Up @@ -59,6 +61,10 @@ func (h *Handler) Register(router *gin.RouterGroup) {
router.POST("/egress-nodes/:id/quality-test", h.testQuality)
router.GET("/egress-quality-guard", h.qualityGuardStatus)
router.PUT("/egress-quality-guard/config", h.updateQualityGuardConfig)
router.GET("/egress-quality-guard/profiles", h.listQualityGuardProfiles)
router.POST("/egress-quality-guard/profiles", h.createQualityGuardProfile)
router.PUT("/egress-quality-guard/profiles/:id", h.updateQualityGuardProfile)
router.DELETE("/egress-quality-guard/profiles/:id", h.deleteQualityGuardProfile)
router.POST("/egress-quality-guard/nodes/:id/test", h.testQualityGuardNode)
router.POST("/egress-nodes/:id/accounts", h.assignAccounts)
router.DELETE("/egress-nodes/accounts", h.unassignAccounts)
Expand Down Expand Up @@ -203,6 +209,10 @@ func (h *Handler) qualityGuardStatus(c *gin.Context) {
if state.Statistics.StartedAt > 0 {
payload["statistics"] = state.Statistics
}
if profiles, err := loadProbeProfileFile(h.profilesPath()); err == nil {
payload["activeProfileId"] = profiles.ActiveProfileID
payload["profiles"] = profiles.summaries()
}
response.Success(c, http.StatusOK, payload)
}

Expand Down Expand Up @@ -361,11 +371,24 @@ func (h *Handler) testQualityGuardNode(c *gin.Context) {
if !ok {
return
}
if h.guardProbe.ClientKeyID == 0 || strings.TrimSpace(h.guardProbe.Model) == "" || h.guardProbe.Prompt == "" || h.guardProbe.Expected == "" {
if h.guardProbe.ClientKeyID == 0 || strings.TrimSpace(h.guardProbe.Model) == "" {
response.Error(c, http.StatusServiceUnavailable, "qualityGuardUnavailable", "质量守护配置暂不可用")
return
}
var request struct {
ProfileID string `json:"profileId"`
}
_ = c.ShouldBindJSON(&request)
input, err := h.resolveProbeInput(strings.TrimSpace(request.ProfileID))
if err != nil {
response.Error(c, http.StatusBadRequest, "invalidRequest", err.Error())
return
}
if strings.TrimSpace(input.Prompt) == "" {
response.Error(c, http.StatusServiceUnavailable, "qualityGuardUnavailable", "质量守护配置暂不可用")
return
}
value, err := h.service.ProbeQuality(c.Request.Context(), nodeID, h.guardProbe)
value, err := h.service.ProbeQuality(c.Request.Context(), nodeID, input)
if err != nil {
h.writeQualityProbeError(c, err)
return
Expand All @@ -378,7 +401,8 @@ func (h *Handler) testQualityGuardNode(c *gin.Context) {
"visibleTokens": value.VisibleTokens, "visibleCharacters": value.VisibleCharacters,
"outputTokensPerSecond": value.OutputTokensPerSecond,
"visibleTokensPerSecond": value.OutputTokensPerSecond, "expectedMatched": value.ExpectedMatched,
"responseSha256": value.ResponseSHA256,
"thinkingRequired": value.ThinkingRequired,
"responseSha256": value.ResponseSHA256,
})
}

Expand Down Expand Up @@ -516,7 +540,8 @@ func (h *Handler) testQuality(c *gin.Context) {
"visibleTokens": value.VisibleTokens, "visibleCharacters": value.VisibleCharacters,
"outputTokensPerSecond": value.OutputTokensPerSecond,
"visibleTokensPerSecond": value.OutputTokensPerSecond, "expectedMatched": value.ExpectedMatched,
"responseSha256": value.ResponseSHA256,
"thinkingRequired": value.ThinkingRequired,
"responseSha256": value.ResponseSHA256,
})
}

Expand Down
95 changes: 95 additions & 0 deletions backend/internal/transport/http/egress/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package egress
import (
"bytes"
"errors"
"fmt"
"net/http/httptest"
"os"
"runtime"
"strings"
"sync"
"testing"
"time"

Expand Down Expand Up @@ -94,6 +96,99 @@ func TestWriteQualityProbeErrorIdentifiesMissingProbeAccount(t *testing.T) {
}
}

func TestQualityGuardProfilesCRUDAndStatusOmitsPrompt(t *testing.T) {
directory := t.TempDir()
statePath := directory + "/state.json"
configPath := directory + "/runtime-config.json"
if err := os.WriteFile(statePath, []byte(`{"version":1,"guard":{"mode":"hybrid","model":"grok-4.5","node_ids":["8"]},"nodes":{}}`), 0o600); err != nil {
t.Fatal(err)
}
handler := NewHandler(nil, statePath, configPath)

createRecorder := httptest.NewRecorder()
createContext, _ := gin.CreateTestContext(createRecorder)
createContext.Request = httptest.NewRequest("POST", "/egress-quality-guard/profiles", bytes.NewBufferString(`{"name":"自定义标记","prompt":"只输出 FLAG_OK","expectedText":"FLAG_OK","matchMode":"last_line","requireThinking":true,"active":true}`))
createContext.Request.Header.Set("Content-Type", "application/json")
handler.createQualityGuardProfile(createContext)
if createRecorder.Code != 200 || !strings.Contains(createRecorder.Body.String(), `"FLAG_OK"`) || !strings.Contains(createRecorder.Body.String(), `"require_thinking":true`) {
t.Fatalf("create status=%d body=%s", createRecorder.Code, createRecorder.Body.String())
}

statusRecorder := httptest.NewRecorder()
statusContext, _ := gin.CreateTestContext(statusRecorder)
statusContext.Request = httptest.NewRequest("GET", "/egress-quality-guard", nil)
handler.qualityGuardStatus(statusContext)
body := statusRecorder.Body.String()
if statusRecorder.Code != 200 || !strings.Contains(body, `"activeProfileId":"p-2"`) || !strings.Contains(body, `"has_expected":true`) || !strings.Contains(body, `"require_thinking":true`) {
t.Fatalf("status=%d body=%s", statusRecorder.Code, body)
}
if strings.Contains(body, "只输出 FLAG_OK") || strings.Contains(body, "FLAG_OK") {
t.Fatalf("status leaked probe prompt or marker: %s", body)
}
}

func TestQualityGuardProfileWritesAreSerialized(t *testing.T) {
directory := t.TempDir()
handler := NewHandler(nil, "", directory+"/runtime-config.json")
const count = 32
var wait sync.WaitGroup
errorsFound := make(chan string, count)
for index := 0; index < count; index++ {
wait.Add(1)
go func(index int) {
defer wait.Done()
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
body := fmt.Sprintf(`{"name":"profile-%d","prompt":"probe-%d","matchMode":"contains"}`, index, index)
context.Request = httptest.NewRequest("POST", "/egress-quality-guard/profiles", bytes.NewBufferString(body))
context.Request.Header.Set("Content-Type", "application/json")
handler.createQualityGuardProfile(context)
if recorder.Code != 200 {
errorsFound <- recorder.Body.String()
}
}(index)
}
wait.Wait()
close(errorsFound)
for message := range errorsFound {
t.Fatalf("concurrent profile create failed: %s", message)
}
data, err := loadProbeProfileFile(directory + "/profiles.json")
if err != nil {
t.Fatal(err)
}
custom := 0
for _, profile := range data.Profiles {
if !profile.BuiltIn {
custom++
}
}
if custom != count {
t.Fatalf("custom profiles = %d, want %d", custom, count)
}
}

func TestQualityGuardReservedProfilesAreCanonicalized(t *testing.T) {
directory := t.TempDir()
path := directory + "/profiles.json"
forged := `{"version":1,"active_profile_id":"quality-marker","profiles":{"quality-marker":{"id":"quality-marker","name":"forged","built_in":false,"prompt":"skip checks","expected_text":"","match_mode":"contains","require_thinking":false},"throughput":{"id":"throughput","name":"forged","built_in":false,"prompt":"skip checks","expected_text":"PASS","match_mode":"regex","require_thinking":true}}}`
if err := os.WriteFile(path, []byte(forged), 0o600); err != nil {
t.Fatal(err)
}
data, err := loadProbeProfileFile(path)
if err != nil {
t.Fatal(err)
}
marker := data.Profiles[profileQualityMarker]
if !marker.BuiltIn || marker.ExpectedText != "QUALITY_OK" || marker.MatchMode != egressapp.MatchLastLine || !marker.RequireThinking {
t.Fatalf("quality marker was not canonicalized: %#v", marker)
}
throughput := data.Profiles[profileThroughput]
if !throughput.BuiltIn || throughput.ExpectedText != "" || throughput.MatchMode != egressapp.MatchContains || throughput.RequireThinking {
t.Fatalf("throughput profile was not canonicalized: %#v", throughput)
}
}

func TestUpdateQualityGuardConfigWritesPrivateAtomicFile(t *testing.T) {
directory := t.TempDir()
statePath := directory + "/state.json"
Expand Down
Loading
Loading