mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #4238 from superman2003/feat/grok-monitor-auto-probe
feat(grok): add import probes and channel monitoring
This commit is contained in:
@@ -196,7 +196,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, grokQuotaService, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService)
|
||||
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService)
|
||||
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
|
||||
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator)
|
||||
accountHandler := admin.ProvideAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService)
|
||||
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
|
||||
dataManagementService := service.NewDataManagementService()
|
||||
dataManagementHandler := admin.NewDataManagementHandler(dataManagementService)
|
||||
|
||||
@@ -167,6 +167,7 @@ const (
|
||||
ProviderOpenai Provider = "openai"
|
||||
ProviderAnthropic Provider = "anthropic"
|
||||
ProviderGemini Provider = "gemini"
|
||||
ProviderGrok Provider = "grok"
|
||||
)
|
||||
|
||||
func (pr Provider) String() string {
|
||||
@@ -176,7 +177,7 @@ func (pr Provider) String() string {
|
||||
// ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save.
|
||||
func ProviderValidator(pr Provider) error {
|
||||
switch pr {
|
||||
case ProviderOpenai, ProviderAnthropic, ProviderGemini:
|
||||
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("channelmonitor: invalid enum value for provider field: %q", pr)
|
||||
|
||||
@@ -103,6 +103,7 @@ const (
|
||||
ProviderOpenai Provider = "openai"
|
||||
ProviderAnthropic Provider = "anthropic"
|
||||
ProviderGemini Provider = "gemini"
|
||||
ProviderGrok Provider = "grok"
|
||||
)
|
||||
|
||||
func (pr Provider) String() string {
|
||||
@@ -112,7 +113,7 @@ func (pr Provider) String() string {
|
||||
// ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save.
|
||||
func ProviderValidator(pr Provider) error {
|
||||
switch pr {
|
||||
case ProviderOpenai, ProviderAnthropic, ProviderGemini:
|
||||
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("channelmonitorrequesttemplate: invalid enum value for provider field: %q", pr)
|
||||
|
||||
@@ -623,7 +623,7 @@ var (
|
||||
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "name", Type: field.TypeString, Size: 100},
|
||||
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini"}},
|
||||
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok"}},
|
||||
{Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"},
|
||||
{Name: "endpoint", Type: field.TypeString, Size: 500},
|
||||
{Name: "api_key_encrypted", Type: field.TypeString},
|
||||
@@ -768,7 +768,7 @@ var (
|
||||
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "name", Type: field.TypeString, Size: 100},
|
||||
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini"}},
|
||||
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok"}},
|
||||
{Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"},
|
||||
{Name: "description", Type: field.TypeString, Nullable: true, Size: 500, Default: ""},
|
||||
{Name: "extra_headers", Type: field.TypeJSON},
|
||||
|
||||
@@ -35,7 +35,7 @@ func (ChannelMonitor) Fields() []ent.Field {
|
||||
NotEmpty().
|
||||
MaxLen(100),
|
||||
field.Enum("provider").
|
||||
Values("openai", "anthropic", "gemini"),
|
||||
Values("openai", "anthropic", "gemini", "grok"),
|
||||
field.String("api_mode").
|
||||
Default("chat_completions").
|
||||
MaxLen(32).
|
||||
|
||||
@@ -39,7 +39,7 @@ func (ChannelMonitorRequestTemplate) Fields() []ent.Field {
|
||||
NotEmpty().
|
||||
MaxLen(100),
|
||||
field.Enum("provider").
|
||||
Values("openai", "anthropic", "gemini"),
|
||||
Values("openai", "anthropic", "gemini", "grok"),
|
||||
field.String("api_mode").
|
||||
Default("chat_completions").
|
||||
MaxLen(32).
|
||||
|
||||
@@ -460,6 +460,7 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
|
||||
if created.Platform == service.PlatformAntigravity && created.Type == service.AccountTypeOAuth {
|
||||
privacyAccounts = append(privacyAccounts, created)
|
||||
}
|
||||
h.scheduleGrokImportProbe(created)
|
||||
result.AccountCreated++
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ type AccountHandler struct {
|
||||
sessionLimitCache service.SessionLimitCache
|
||||
rpmCache service.RPMCache
|
||||
tokenCacheInvalidator service.TokenCacheInvalidator
|
||||
grokImportProber grokUsageProber
|
||||
}
|
||||
|
||||
// NewAccountHandler creates a new admin account handler
|
||||
@@ -855,6 +856,7 @@ func (h *AccountHandler) Create(c *gin.Context) {
|
||||
// OpenAI APIKey 账号创建后异步探测上游 /v1/responses 能力。
|
||||
// 探测失败不影响账号创建响应。
|
||||
h.scheduleOpenAIResponsesProbe(createdAccount)
|
||||
h.scheduleGrokImportProbe(createdAccount)
|
||||
response.Success(c, result.Data)
|
||||
}
|
||||
|
||||
@@ -1667,6 +1669,7 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
|
||||
}
|
||||
// OpenAI APIKey 账号异步探测 /v1/responses 能力。
|
||||
h.scheduleOpenAIResponsesProbe(account)
|
||||
h.scheduleGrokImportProbe(account)
|
||||
success++
|
||||
results = append(results, gin.H{
|
||||
"name": item.Name,
|
||||
|
||||
@@ -37,11 +37,11 @@ func NewChannelMonitorHandler(monitorService *service.ChannelMonitorService) *Ch
|
||||
|
||||
type channelMonitorCreateRequest struct {
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini"`
|
||||
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok"`
|
||||
APIMode string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
|
||||
Endpoint string `json:"endpoint" binding:"required,max=500"`
|
||||
APIKey string `json:"api_key" binding:"required,max=2000"`
|
||||
PrimaryModel string `json:"primary_model" binding:"required,max=200"`
|
||||
PrimaryModel string `json:"primary_model" binding:"max=200"`
|
||||
ExtraModels []string `json:"extra_models"`
|
||||
GroupName string `json:"group_name" binding:"max=100"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
@@ -55,7 +55,7 @@ type channelMonitorCreateRequest struct {
|
||||
|
||||
type channelMonitorUpdateRequest struct {
|
||||
Name *string `json:"name" binding:"omitempty,max=100"`
|
||||
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini"`
|
||||
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok"`
|
||||
APIMode *string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
|
||||
Endpoint *string `json:"endpoint" binding:"omitempty,max=500"`
|
||||
APIKey *string `json:"api_key" binding:"omitempty,max=2000"`
|
||||
|
||||
@@ -26,7 +26,7 @@ func NewChannelMonitorRequestTemplateHandler(templateService *service.ChannelMon
|
||||
|
||||
type channelMonitorTemplateCreateRequest struct {
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini"`
|
||||
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok"`
|
||||
APIMode string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
|
||||
Description string `json:"description" binding:"max=500"`
|
||||
ExtraHeaders map[string]string `json:"extra_headers"`
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
const (
|
||||
grokImportProbeConcurrency = 3
|
||||
grokImportProbeTimeout = 25 * time.Second
|
||||
)
|
||||
|
||||
type grokUsageProber interface {
|
||||
ProbeUsage(ctx context.Context, accountID int64) (*service.GrokQuotaProbeResult, error)
|
||||
}
|
||||
|
||||
type grokImportProbeTask struct {
|
||||
prober grokUsageProber
|
||||
accountID int64
|
||||
}
|
||||
|
||||
type grokImportProbeScheduler struct {
|
||||
mu sync.Mutex
|
||||
queue []grokImportProbeTask
|
||||
concurrency int
|
||||
workers int
|
||||
maxWorkers int
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
var defaultGrokImportProbeScheduler = newGrokImportProbeScheduler(
|
||||
grokImportProbeConcurrency,
|
||||
grokImportProbeTimeout,
|
||||
)
|
||||
|
||||
func newGrokImportProbeScheduler(concurrency int, timeout time.Duration) *grokImportProbeScheduler {
|
||||
if concurrency <= 0 {
|
||||
concurrency = 1
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = grokImportProbeTimeout
|
||||
}
|
||||
return &grokImportProbeScheduler{
|
||||
concurrency: concurrency,
|
||||
timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *grokImportProbeScheduler) schedule(prober grokUsageProber, account *service.Account) {
|
||||
if s == nil || prober == nil || account == nil || account.ID <= 0 {
|
||||
return
|
||||
}
|
||||
if account.Platform != service.PlatformGrok || account.Type != service.AccountTypeOAuth {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.queue = append(s.queue, grokImportProbeTask{prober: prober, accountID: account.ID})
|
||||
if s.workers < s.concurrency {
|
||||
s.workers++
|
||||
if s.workers > s.maxWorkers {
|
||||
s.maxWorkers = s.workers
|
||||
}
|
||||
go s.worker()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *grokImportProbeScheduler) worker() {
|
||||
for {
|
||||
task, ok := s.nextTask()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.run(task.prober, task.accountID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *grokImportProbeScheduler) nextTask() (grokImportProbeTask, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.queue) == 0 {
|
||||
s.workers--
|
||||
return grokImportProbeTask{}, false
|
||||
}
|
||||
task := s.queue[0]
|
||||
s.queue[0] = grokImportProbeTask{}
|
||||
s.queue = s.queue[1:]
|
||||
if len(s.queue) == 0 {
|
||||
s.queue = nil
|
||||
}
|
||||
return task, true
|
||||
}
|
||||
|
||||
func (s *grokImportProbeScheduler) run(prober grokUsageProber, accountID int64) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
slog.Error(
|
||||
"grok_import_active_probe_panic",
|
||||
"account_id", accountID,
|
||||
"recovery_type", panicType(recovered),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
// Queue time is intentionally excluded: every imported account is probed,
|
||||
// while this timeout only bounds the actual upstream probe execution.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
|
||||
defer cancel()
|
||||
result, err := prober.ProbeUsage(ctx, accountID)
|
||||
if err != nil {
|
||||
slog.Warn(
|
||||
"grok_import_active_probe_failed",
|
||||
"account_id", accountID,
|
||||
"status", infraerrors.Code(err),
|
||||
"reason", infraerrors.Reason(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
if result == nil {
|
||||
slog.Warn(
|
||||
"grok_import_active_probe_failed",
|
||||
"account_id", accountID,
|
||||
"reason", "empty_result",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info(
|
||||
"grok_import_active_probe_completed",
|
||||
"account_id", accountID,
|
||||
"model", result.Model,
|
||||
"status", result.StatusCode,
|
||||
"headers_observed", result.HeadersObserved,
|
||||
)
|
||||
}
|
||||
|
||||
func panicType(value any) string {
|
||||
switch value.(type) {
|
||||
case string:
|
||||
return "string"
|
||||
case error:
|
||||
return "error"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AccountHandler) scheduleGrokImportProbe(account *service.Account) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
defaultGrokImportProbeScheduler.schedule(h.grokImportProber, account)
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) scheduleGrokImportProbe(account *service.Account) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
defaultGrokImportProbeScheduler.schedule(h.importProber, account)
|
||||
}
|
||||
|
||||
// ProvideAccountHandler injects the Grok active prober for production while
|
||||
// keeping NewAccountHandler convenient for focused unit tests.
|
||||
func ProvideAccountHandler(
|
||||
adminService service.AdminService,
|
||||
oauthService *service.OAuthService,
|
||||
openaiOAuthService *service.OpenAIOAuthService,
|
||||
geminiOAuthService *service.GeminiOAuthService,
|
||||
antigravityOAuthService *service.AntigravityOAuthService,
|
||||
rateLimitService *service.RateLimitService,
|
||||
accountUsageService *service.AccountUsageService,
|
||||
accountTestService *service.AccountTestService,
|
||||
concurrencyService *service.ConcurrencyService,
|
||||
crsSyncService *service.CRSSyncService,
|
||||
sessionLimitCache service.SessionLimitCache,
|
||||
rpmCache service.RPMCache,
|
||||
tokenCacheInvalidator service.TokenCacheInvalidator,
|
||||
grokQuotaService *service.GrokQuotaService,
|
||||
) *AccountHandler {
|
||||
handler := NewAccountHandler(
|
||||
adminService,
|
||||
oauthService,
|
||||
openaiOAuthService,
|
||||
geminiOAuthService,
|
||||
antigravityOAuthService,
|
||||
rateLimitService,
|
||||
accountUsageService,
|
||||
accountTestService,
|
||||
concurrencyService,
|
||||
crsSyncService,
|
||||
sessionLimitCache,
|
||||
rpmCache,
|
||||
tokenCacheInvalidator,
|
||||
)
|
||||
handler.grokImportProber = grokQuotaService
|
||||
return handler
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//go:build unit
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type grokImportAdminService struct {
|
||||
*stubAdminService
|
||||
mu sync.Mutex
|
||||
nextID int64
|
||||
}
|
||||
|
||||
func newGrokImportAdminService() *grokImportAdminService {
|
||||
return &grokImportAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
nextID: 500,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *grokImportAdminService) CreateAccount(_ context.Context, input *service.CreateAccountInput) (*service.Account, error) {
|
||||
s.mu.Lock()
|
||||
s.nextID++
|
||||
id := s.nextID
|
||||
s.mu.Unlock()
|
||||
return &service.Account{
|
||||
ID: id,
|
||||
Name: input.Name,
|
||||
Platform: input.Platform,
|
||||
Type: input.Type,
|
||||
Credentials: input.Credentials,
|
||||
Extra: input.Extra,
|
||||
ProxyID: input.ProxyID,
|
||||
Concurrency: input.Concurrency,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type grokImportOAuthClientStub struct{}
|
||||
|
||||
func (grokImportOAuthClientStub) ExchangeCode(context.Context, string, string, string, string, string) (*xai.TokenResponse, error) {
|
||||
return &xai.TokenResponse{AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresIn: 3600}, nil
|
||||
}
|
||||
|
||||
func (grokImportOAuthClientStub) RefreshToken(context.Context, string, string, string) (*xai.TokenResponse, error) {
|
||||
return &xai.TokenResponse{AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresIn: 3600}, nil
|
||||
}
|
||||
|
||||
func (grokImportOAuthClientStub) ConvertSSOToBuild(context.Context, string, string) (*xai.TokenResponse, error) {
|
||||
return &xai.TokenResponse{AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresIn: 3600}, nil
|
||||
}
|
||||
|
||||
func TestGrokSSOBatchImportKeepsCreatedAccountsWhenOneAutomaticProbeFails(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
adminService := newGrokImportAdminService()
|
||||
oauthService := service.NewGrokOAuthService(nil, grokImportOAuthClientStub{})
|
||||
defer oauthService.Stop()
|
||||
prober := newGrokImportProbeStub(3)
|
||||
prober.failures[502] = infraerrors.New(502, "GROK_TEST_PROBE_FAILED", "sensitive-upstream-body")
|
||||
handler := NewGrokOAuthHandler(oauthService, adminService, nil)
|
||||
handler.importProber = prober
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/grok/sso-to-oauth", handler.CreateAccountsFromSSO)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/admin/grok/sso-to-oauth",
|
||||
strings.NewReader(`{"sso_tokens":["sso-one","sso-two","sso-three"]}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), `"created"`)
|
||||
require.NotContains(t, recorder.Body.String(), `GROK_TEST_PROBE_FAILED`)
|
||||
for i := 0; i < 3; i++ {
|
||||
awaitGrokProbeSignal(t, prober.done)
|
||||
}
|
||||
calls, _, _ := prober.snapshot()
|
||||
require.Equal(t, map[int64]int{501: 1, 502: 1, 503: 1}, calls)
|
||||
}
|
||||
|
||||
func TestAccountCreateWithoutAutomaticGrokProbeServiceStillSucceeds(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := NewAccountHandler(
|
||||
newGrokImportAdminService(),
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/accounts", handler.Create)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/admin/accounts",
|
||||
strings.NewReader(`{"name":"grok-rt","platform":"grok","type":"oauth","credentials":{"refresh_token":"secret"}}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//go:build unit
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type grokImportProbeStub struct {
|
||||
mu sync.Mutex
|
||||
calls map[int64]int
|
||||
failures map[int64]error
|
||||
active int
|
||||
maxActive int
|
||||
deadlineSeen bool
|
||||
block <-chan struct{}
|
||||
started chan int64
|
||||
done chan int64
|
||||
}
|
||||
|
||||
func newGrokImportProbeStub(buffer int) *grokImportProbeStub {
|
||||
return &grokImportProbeStub{
|
||||
calls: make(map[int64]int),
|
||||
failures: make(map[int64]error),
|
||||
started: make(chan int64, buffer),
|
||||
done: make(chan int64, buffer),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *grokImportProbeStub) ProbeUsage(ctx context.Context, accountID int64) (*service.GrokQuotaProbeResult, error) {
|
||||
_, deadlineSeen := ctx.Deadline()
|
||||
s.mu.Lock()
|
||||
s.calls[accountID]++
|
||||
s.active++
|
||||
if s.active > s.maxActive {
|
||||
s.maxActive = s.active
|
||||
}
|
||||
s.deadlineSeen = s.deadlineSeen || deadlineSeen
|
||||
s.mu.Unlock()
|
||||
|
||||
s.started <- accountID
|
||||
var ctxErr error
|
||||
if s.block != nil {
|
||||
select {
|
||||
case <-s.block:
|
||||
case <-ctx.Done():
|
||||
ctxErr = ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.active--
|
||||
failure := s.failures[accountID]
|
||||
s.mu.Unlock()
|
||||
s.done <- accountID
|
||||
if ctxErr != nil {
|
||||
return nil, ctxErr
|
||||
}
|
||||
if failure != nil {
|
||||
return nil, failure
|
||||
}
|
||||
return &service.GrokQuotaProbeResult{
|
||||
Source: "active_probe",
|
||||
Model: "grok-4.5",
|
||||
StatusCode: 200,
|
||||
ResetSupported: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *grokImportProbeStub) snapshot() (map[int64]int, int, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
calls := make(map[int64]int, len(s.calls))
|
||||
for id, count := range s.calls {
|
||||
calls[id] = count
|
||||
}
|
||||
return calls, s.maxActive, s.deadlineSeen
|
||||
}
|
||||
|
||||
type grokImportProbeSchedulerTestSnapshot struct {
|
||||
queued int
|
||||
workers int
|
||||
maxWorkers int
|
||||
}
|
||||
|
||||
func snapshotGrokImportProbeScheduler(s *grokImportProbeScheduler) grokImportProbeSchedulerTestSnapshot {
|
||||
if s == nil {
|
||||
return grokImportProbeSchedulerTestSnapshot{}
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return grokImportProbeSchedulerTestSnapshot{
|
||||
queued: len(s.queue),
|
||||
workers: s.workers,
|
||||
maxWorkers: s.maxWorkers,
|
||||
}
|
||||
}
|
||||
|
||||
func newGrokOAuthImportAccount(id int64) *service.Account {
|
||||
return &service.Account{
|
||||
ID: id,
|
||||
Platform: service.PlatformGrok,
|
||||
Type: service.AccountTypeOAuth,
|
||||
}
|
||||
}
|
||||
|
||||
func awaitGrokProbeSignal(t *testing.T, signals <-chan int64) int64 {
|
||||
t.Helper()
|
||||
select {
|
||||
case id := <-signals:
|
||||
return id
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for Grok import probe")
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrokImportProbeSchedulerProbesSingleAccountOnce(t *testing.T) {
|
||||
scheduler := newGrokImportProbeScheduler(1, time.Second)
|
||||
prober := newGrokImportProbeStub(1)
|
||||
|
||||
scheduler.schedule(prober, newGrokOAuthImportAccount(101))
|
||||
require.Equal(t, int64(101), awaitGrokProbeSignal(t, prober.done))
|
||||
|
||||
calls, maxActive, deadlineSeen := prober.snapshot()
|
||||
require.Equal(t, map[int64]int{101: 1}, calls)
|
||||
require.Equal(t, 1, maxActive)
|
||||
require.True(t, deadlineSeen)
|
||||
require.Eventually(t, func() bool {
|
||||
snapshot := snapshotGrokImportProbeScheduler(scheduler)
|
||||
return snapshot.queued == 0 && snapshot.workers == 0
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestGrokImportProbeSchedulerQueuesBatchWithoutPerTaskGoroutines(t *testing.T) {
|
||||
const taskCount = 100
|
||||
release := make(chan struct{})
|
||||
scheduler := newGrokImportProbeScheduler(3, time.Second)
|
||||
prober := newGrokImportProbeStub(taskCount)
|
||||
prober.block = release
|
||||
prober.failures[150] = infraerrors.New(502, "GROK_TEST_PROBE_FAILED", "sensitive-upstream-body")
|
||||
|
||||
for id := int64(101); id < 101+taskCount; id++ {
|
||||
scheduler.schedule(prober, newGrokOAuthImportAccount(id))
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
awaitGrokProbeSignal(t, prober.started)
|
||||
}
|
||||
snapshot := snapshotGrokImportProbeScheduler(scheduler)
|
||||
require.Equal(t, 97, snapshot.queued)
|
||||
require.Equal(t, 3, snapshot.workers)
|
||||
require.Equal(t, 3, snapshot.maxWorkers)
|
||||
select {
|
||||
case id := <-prober.started:
|
||||
t.Fatalf("probe %d started before a concurrency slot was released", id)
|
||||
case <-time.After(75 * time.Millisecond):
|
||||
}
|
||||
close(release)
|
||||
for i := 0; i < taskCount; i++ {
|
||||
awaitGrokProbeSignal(t, prober.done)
|
||||
}
|
||||
|
||||
calls, maxActive, _ := prober.snapshot()
|
||||
require.Len(t, calls, taskCount)
|
||||
for id := int64(101); id < 101+taskCount; id++ {
|
||||
require.Equal(t, 1, calls[id])
|
||||
}
|
||||
require.Equal(t, 3, maxActive)
|
||||
require.Eventually(t, func() bool {
|
||||
snapshot = snapshotGrokImportProbeScheduler(scheduler)
|
||||
return snapshot.queued == 0 && snapshot.workers == 0
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
require.Equal(t, 3, snapshot.maxWorkers)
|
||||
}
|
||||
|
||||
func TestGrokImportProbeSchedulerTimeoutCancelsProbe(t *testing.T) {
|
||||
neverRelease := make(chan struct{})
|
||||
scheduler := newGrokImportProbeScheduler(1, 20*time.Millisecond)
|
||||
prober := newGrokImportProbeStub(1)
|
||||
prober.block = neverRelease
|
||||
|
||||
scheduler.schedule(prober, newGrokOAuthImportAccount(201))
|
||||
require.Equal(t, int64(201), awaitGrokProbeSignal(t, prober.done))
|
||||
|
||||
calls, _, _ := prober.snapshot()
|
||||
require.Equal(t, 1, calls[201])
|
||||
}
|
||||
|
||||
func TestGrokImportProbeSchedulerSkipsMissingServiceAndNonGrokAccounts(t *testing.T) {
|
||||
scheduler := newGrokImportProbeScheduler(1, time.Second)
|
||||
prober := newGrokImportProbeStub(1)
|
||||
|
||||
scheduler.schedule(nil, newGrokOAuthImportAccount(301))
|
||||
scheduler.schedule(prober, &service.Account{ID: 302, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth})
|
||||
scheduler.schedule(prober, &service.Account{ID: 303, Platform: service.PlatformGrok, Type: service.AccountTypeAPIKey})
|
||||
|
||||
select {
|
||||
case id := <-prober.started:
|
||||
t.Fatalf("unexpected probe for account %d", id)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
calls, _, _ := prober.snapshot()
|
||||
require.Empty(t, calls)
|
||||
}
|
||||
|
||||
func TestGrokImportProbeFailureLogDoesNotIncludeErrorMessage(t *testing.T) {
|
||||
var logs bytes.Buffer
|
||||
previousLogger := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil)))
|
||||
defer slog.SetDefault(previousLogger)
|
||||
|
||||
scheduler := newGrokImportProbeScheduler(1, time.Second)
|
||||
prober := newGrokImportProbeStub(1)
|
||||
prober.failures[401] = infraerrors.New(502, "GROK_TEST_PROBE_FAILED", "refresh-token-secret")
|
||||
scheduler.schedule(prober, newGrokOAuthImportAccount(401))
|
||||
awaitGrokProbeSignal(t, prober.done)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return bytes.Contains(logs.Bytes(), []byte("grok_import_active_probe_failed"))
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
require.Contains(t, logs.String(), "GROK_TEST_PROBE_FAILED")
|
||||
require.NotContains(t, logs.String(), "refresh-token-secret")
|
||||
}
|
||||
@@ -22,6 +22,7 @@ type GrokOAuthHandler struct {
|
||||
grokOAuthService *service.GrokOAuthService
|
||||
adminService service.AdminService
|
||||
quotaService *service.GrokQuotaService
|
||||
importProber grokUsageProber
|
||||
}
|
||||
|
||||
func NewGrokOAuthHandler(
|
||||
@@ -33,6 +34,7 @@ func NewGrokOAuthHandler(
|
||||
grokOAuthService: grokOAuthService,
|
||||
adminService: adminService,
|
||||
quotaService: quotaService,
|
||||
importProber: quotaService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,6 +211,7 @@ func (h *GrokOAuthHandler) CreateAccountFromOAuth(c *gin.Context) {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
h.scheduleGrokImportProbe(account)
|
||||
response.Success(c, dto.AccountFromService(account))
|
||||
}
|
||||
|
||||
@@ -345,6 +348,7 @@ func (h *GrokOAuthHandler) createAccountFromSSOToken(ctx context.Context, req Gr
|
||||
if err != nil {
|
||||
return grokSSOImportWorkerResult{item: GrokSSOToOAuthItemResult{Index: index, Name: name, Email: tokenInfo.Email, Error: grokSSOImportErrorMessage(err)}}
|
||||
}
|
||||
h.scheduleGrokImportProbe(account)
|
||||
return grokSSOImportWorkerResult{
|
||||
created: true,
|
||||
item: GrokSSOToOAuthItemResult{
|
||||
|
||||
@@ -164,7 +164,7 @@ var ProviderSet = wire.NewSet(
|
||||
admin.NewDashboardHandler,
|
||||
admin.NewUserHandler,
|
||||
admin.NewGroupHandler,
|
||||
admin.NewAccountHandler,
|
||||
admin.ProvideAccountHandler,
|
||||
admin.NewAnnouncementHandler,
|
||||
admin.NewDataManagementHandler,
|
||||
admin.NewBackupHandler,
|
||||
|
||||
@@ -168,6 +168,7 @@ type providerAdapter struct {
|
||||
//nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。
|
||||
var providerAdapters = map[string]providerAdapter{
|
||||
MonitorProviderOpenAI: providerOpenAIChatAdapter,
|
||||
MonitorProviderGrok: providerGrokChatAdapter,
|
||||
MonitorProviderAnthropic: {
|
||||
buildPath: func(string) string { return providerAnthropicPath },
|
||||
buildBody: func(model, prompt string) ([]byte, error) {
|
||||
@@ -205,20 +206,27 @@ var providerAdapters = map[string]providerAdapter{
|
||||
}
|
||||
|
||||
//nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。
|
||||
var providerOpenAIChatAdapter = providerAdapter{
|
||||
buildPath: func(string) string { return providerOpenAIPath },
|
||||
buildBody: func(model, prompt string) ([]byte, error) {
|
||||
return json.Marshal(map[string]any{
|
||||
"model": model,
|
||||
"messages": []map[string]string{{"role": "user", "content": prompt}},
|
||||
"max_tokens": monitorChallengeMaxTokens,
|
||||
"stream": false,
|
||||
})
|
||||
},
|
||||
buildHeaders: func(apiKey string) map[string]string {
|
||||
return map[string]string{"Authorization": "Bearer " + apiKey}
|
||||
},
|
||||
textPath: "choices.0.message.content",
|
||||
var providerOpenAIChatAdapter = newOpenAICompatibleChatAdapter(providerOpenAIPath)
|
||||
|
||||
//nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。
|
||||
var providerGrokChatAdapter = newOpenAICompatibleChatAdapter(providerGrokPath)
|
||||
|
||||
func newOpenAICompatibleChatAdapter(path string) providerAdapter {
|
||||
return providerAdapter{
|
||||
buildPath: func(string) string { return path },
|
||||
buildBody: func(model, prompt string) ([]byte, error) {
|
||||
return json.Marshal(map[string]any{
|
||||
"model": model,
|
||||
"messages": []map[string]string{{"role": "user", "content": prompt}},
|
||||
"max_tokens": monitorChallengeMaxTokens,
|
||||
"stream": false,
|
||||
})
|
||||
},
|
||||
buildHeaders: func(apiKey string) map[string]string {
|
||||
return map[string]string{"Authorization": "Bearer " + apiKey}
|
||||
},
|
||||
textPath: "choices.0.message.content",
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。
|
||||
@@ -408,8 +416,9 @@ func buildRequestBody(adapter providerAdapter, provider, apiMode, model, prompt
|
||||
var bodyMergeKeyDenyList = map[string]map[string]bool{
|
||||
MonitorProviderOpenAI + ":" + MonitorAPIModeChatCompletions: {"model": true, "messages": true, "stream": true},
|
||||
MonitorProviderOpenAI + ":" + MonitorAPIModeResponses: {"model": true, "instructions": true, "input": true, "stream": true},
|
||||
MonitorProviderAnthropic: {"model": true, "messages": true},
|
||||
MonitorProviderGemini: {"contents": true},
|
||||
MonitorProviderGrok: {"model": true, "messages": true, "stream": true},
|
||||
MonitorProviderAnthropic: {"model": true, "messages": true},
|
||||
MonitorProviderGemini: {"contents": true},
|
||||
}
|
||||
|
||||
func checkAPIMode(opts *CheckOptions) string {
|
||||
@@ -427,7 +436,7 @@ func bodyMergeDenyKey(provider, apiMode string) string {
|
||||
}
|
||||
|
||||
func validateReplaceRequestBody(provider, apiMode string, body map[string]any) error {
|
||||
if provider != MonitorProviderOpenAI {
|
||||
if provider != MonitorProviderOpenAI && provider != MonitorProviderGrok {
|
||||
return nil
|
||||
}
|
||||
switch defaultAPIMode(apiMode) {
|
||||
@@ -528,6 +537,8 @@ var monitorAPIKeyPatterns = []struct {
|
||||
{regexp.MustCompile(`sk-ant-[A-Za-z0-9_-]{20,}`), "sk-ant-***REDACTED***"},
|
||||
// OpenAI / Anthropic 通用 sk-: sk-xxxxxxx
|
||||
{regexp.MustCompile(`sk-[A-Za-z0-9-]{20,}`), "sk-***REDACTED***"},
|
||||
// xAI API Key:xai-xxxxxxx
|
||||
{regexp.MustCompile(`xai-[A-Za-z0-9_-]{6,}`), "xai-***REDACTED***"},
|
||||
// Gemini / Google API Key:固定前缀 + 35 位
|
||||
{regexp.MustCompile(`AIza[A-Za-z0-9_-]{35}`), "AIza***REDACTED***"},
|
||||
// JWT 三段式(Bearer 后常出现):eyJxxx.eyJxxx.signature
|
||||
@@ -537,7 +548,7 @@ var monitorAPIKeyPatterns = []struct {
|
||||
// sanitizeErrorMessage 擦除错误/响应文本中可能泄露的 API key。
|
||||
// 处理两类来源:
|
||||
// 1. URL query 中的 ?key= / ?api_key= 等(Go *url.Error 会回填完整 URL)
|
||||
// 2. 上游 HTTP body 文本里直接出现的 sk-* / AIza* / JWT 等密钥碎片
|
||||
// 2. 上游 HTTP body 文本里直接出现的 sk-* / xai-* / AIza* / JWT 等密钥碎片
|
||||
//
|
||||
// 注意:与 gemini_messages_compat_service.go 的 sanitizeUpstreamErrorMessage 关注点类似但参数集更广,
|
||||
// 监控模块独立维护,避免互相耦合。
|
||||
|
||||
@@ -64,6 +64,7 @@ type openAICaptureHandler struct {
|
||||
lastHeaders http.Header
|
||||
lastPath string
|
||||
status int
|
||||
rawResponse string
|
||||
responsesLeadingReasoning bool
|
||||
}
|
||||
|
||||
@@ -80,6 +81,10 @@ func (h *openAICaptureHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(h.status)
|
||||
if h.rawResponse != "" {
|
||||
_, _ = w.Write([]byte(h.rawResponse))
|
||||
return
|
||||
}
|
||||
|
||||
answer := answerFromOpenAIRequest(parsed)
|
||||
if h.lastPath == providerOpenAIResponsesPath {
|
||||
@@ -190,6 +195,90 @@ func TestRunCheckForModel_OpenAI_DefaultChatRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrokMonitorConfiguration(t *testing.T) {
|
||||
if err := validateProvider(MonitorProviderGrok); err != nil {
|
||||
t.Fatalf("grok provider should be supported: %v", err)
|
||||
}
|
||||
if got := normalizeMonitorPrimaryModel(MonitorProviderGrok, ""); got != MonitorDefaultGrokModel {
|
||||
t.Fatalf("expected default Grok model %q, got %q", MonitorDefaultGrokModel, got)
|
||||
}
|
||||
if err := validateAPIMode(MonitorProviderGrok, MonitorAPIModeChatCompletions); err != nil {
|
||||
t.Fatalf("grok chat_completions mode should be valid: %v", err)
|
||||
}
|
||||
if err := validateAPIMode(MonitorProviderGrok, MonitorAPIModeResponses); err == nil {
|
||||
t.Fatal("grok responses mode should be rejected by channel monitoring")
|
||||
}
|
||||
if err := validateReplaceRequestBody(MonitorProviderGrok, MonitorAPIModeChatCompletions, map[string]any{}); err == nil {
|
||||
t.Fatal("grok replace-mode body should require messages")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCheckForModel_Grok_DefaultChatRequest(t *testing.T) {
|
||||
h := &openAICaptureHandler{}
|
||||
endpoint := setupFakeOpenAI(t, h)
|
||||
|
||||
res := runCheckForModel(context.Background(), MonitorProviderGrok, endpoint, "xai-key", MonitorDefaultGrokModel, nil)
|
||||
|
||||
if res.Status != MonitorStatusOperational {
|
||||
t.Fatalf("Grok request should pass challenge, got status=%s message=%q", res.Status, res.Message)
|
||||
}
|
||||
if res.LatencyMs == nil {
|
||||
t.Fatal("Grok request should record latency")
|
||||
}
|
||||
if h.lastPath != providerGrokPath {
|
||||
t.Fatalf("expected Grok chat completions path %q, got %q", providerGrokPath, h.lastPath)
|
||||
}
|
||||
if h.lastBody["model"] != MonitorDefaultGrokModel {
|
||||
t.Errorf("Grok body should contain model=%s, got %v", MonitorDefaultGrokModel, h.lastBody["model"])
|
||||
}
|
||||
if _, ok := h.lastBody["messages"]; !ok {
|
||||
t.Error("Grok body should contain messages")
|
||||
}
|
||||
if h.lastBody["stream"] != false {
|
||||
t.Errorf("Grok body should set stream=false, got %v", h.lastBody["stream"])
|
||||
}
|
||||
if h.lastHeaders.Get("Authorization") != "Bearer xai-key" {
|
||||
t.Errorf("expected Grok bearer auth header, got %q", h.lastHeaders.Get("Authorization"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCheckForModel_Grok_UpstreamFailure(t *testing.T) {
|
||||
h := &openAICaptureHandler{status: http.StatusTooManyRequests}
|
||||
endpoint := setupFakeOpenAI(t, h)
|
||||
|
||||
res := runCheckForModel(context.Background(), MonitorProviderGrok, endpoint, "xai-key", MonitorDefaultGrokModel, nil)
|
||||
|
||||
if res.Status != MonitorStatusError {
|
||||
t.Fatalf("Grok 429 should be recorded as error, got status=%s message=%q", res.Status, res.Message)
|
||||
}
|
||||
if !strings.Contains(res.Message, "upstream HTTP 429") {
|
||||
t.Fatalf("Grok failure should preserve upstream status, got %q", res.Message)
|
||||
}
|
||||
if res.LatencyMs == nil {
|
||||
t.Fatal("Grok failure should still record latency")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCheckForModel_Grok_RedactsXAIKeyFromUpstreamBody(t *testing.T) {
|
||||
h := &openAICaptureHandler{
|
||||
status: http.StatusUnauthorized,
|
||||
rawResponse: `{"error":{"message":"invalid API key xai-secret"}}`,
|
||||
}
|
||||
endpoint := setupFakeOpenAI(t, h)
|
||||
|
||||
res := runCheckForModel(context.Background(), MonitorProviderGrok, endpoint, "request-key", MonitorDefaultGrokModel, nil)
|
||||
|
||||
if res.Status != MonitorStatusError {
|
||||
t.Fatalf("Grok upstream failure should be recorded as error, got %s", res.Status)
|
||||
}
|
||||
if strings.Contains(res.Message, "xai-secret") {
|
||||
t.Fatalf("Grok error message leaked xAI key: %q", res.Message)
|
||||
}
|
||||
if !strings.Contains(res.Message, "xai-***REDACTED***") {
|
||||
t.Fatalf("Grok error message should contain redaction marker, got %q", res.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCheckForModel_OpenAIResponses_DefaultRequest(t *testing.T) {
|
||||
h := &openAICaptureHandler{}
|
||||
endpoint := setupFakeOpenAI(t, h)
|
||||
|
||||
@@ -47,6 +47,8 @@ const (
|
||||
|
||||
// providerOpenAIPath OpenAI Chat Completions 路径。
|
||||
providerOpenAIPath = "/v1/chat/completions"
|
||||
// providerGrokPath Grok OpenAI-compatible Chat Completions 路径。
|
||||
providerGrokPath = "/v1/chat/completions"
|
||||
// providerOpenAIResponsesPath OpenAI Responses API 路径。
|
||||
providerOpenAIResponsesPath = "/v1/responses"
|
||||
// providerAnthropicPath Anthropic Messages 路径。
|
||||
@@ -54,10 +56,14 @@ const (
|
||||
// providerGeminiPathTemplate Gemini generateContent 路径模板(含 model 占位)。
|
||||
providerGeminiPathTemplate = "/v1beta/models/%s:generateContent"
|
||||
|
||||
// MonitorProviderOpenAI / Anthropic / Gemini provider 字符串常量(也是 ent enum 的实际值)。
|
||||
// MonitorProviderOpenAI / Anthropic / Gemini / Grok provider 字符串常量(也是 ent enum 的实际值)。
|
||||
MonitorProviderOpenAI = "openai"
|
||||
MonitorProviderAnthropic = "anthropic"
|
||||
MonitorProviderGemini = "gemini"
|
||||
MonitorProviderGrok = "grok"
|
||||
|
||||
// MonitorDefaultGrokModel 是新增 Grok 监控未显式指定模型时使用的轻量测活模型。
|
||||
MonitorDefaultGrokModel = "grok-4.5"
|
||||
|
||||
// MonitorStatusOperational 等监控状态字符串常量(与 ent enum 一致)。
|
||||
MonitorStatusOperational = "operational"
|
||||
@@ -112,13 +118,13 @@ var (
|
||||
"CHANNEL_MONITOR_NOT_FOUND", "channel monitor not found",
|
||||
)
|
||||
ErrChannelMonitorInvalidProvider = infraerrors.BadRequest(
|
||||
"CHANNEL_MONITOR_INVALID_PROVIDER", "provider must be one of openai/anthropic/gemini",
|
||||
"CHANNEL_MONITOR_INVALID_PROVIDER", "provider must be one of openai/anthropic/gemini/grok",
|
||||
)
|
||||
ErrChannelMonitorInvalidAPIMode = infraerrors.BadRequest(
|
||||
"CHANNEL_MONITOR_INVALID_API_MODE", "api_mode must be chat_completions or responses; responses is only supported for openai",
|
||||
)
|
||||
ErrChannelMonitorInvalidRequestBody = infraerrors.BadRequest(
|
||||
"CHANNEL_MONITOR_INVALID_REQUEST_BODY", "openai replace-mode body_override must include non-empty messages for chat_completions or non-empty instructions and input for responses",
|
||||
"CHANNEL_MONITOR_INVALID_REQUEST_BODY", "openai-compatible replace-mode body_override must include non-empty messages for chat_completions or non-empty instructions and input for responses",
|
||||
)
|
||||
ErrChannelMonitorInvalidInterval = infraerrors.BadRequest(
|
||||
"CHANNEL_MONITOR_INVALID_INTERVAL", "interval_seconds must be in [15, 3600]",
|
||||
|
||||
@@ -123,7 +123,7 @@ func (s *ChannelMonitorService) Create(ctx context.Context, p ChannelMonitorCrea
|
||||
APIMode: defaultAPIMode(p.APIMode),
|
||||
Endpoint: normalizeEndpoint(p.Endpoint),
|
||||
APIKey: encrypted, // 注意:传入 repository 时该字段为密文
|
||||
PrimaryModel: strings.TrimSpace(p.PrimaryModel),
|
||||
PrimaryModel: normalizeMonitorPrimaryModel(p.Provider, p.PrimaryModel),
|
||||
ExtraModels: normalizeModels(p.ExtraModels),
|
||||
GroupName: strings.TrimSpace(p.GroupName),
|
||||
Enabled: p.Enabled,
|
||||
@@ -167,7 +167,7 @@ func validateCreateParams(p ChannelMonitorCreateParams) error {
|
||||
if strings.TrimSpace(p.APIKey) == "" {
|
||||
return ErrChannelMonitorMissingAPIKey
|
||||
}
|
||||
if strings.TrimSpace(p.PrimaryModel) == "" {
|
||||
if normalizeMonitorPrimaryModel(p.Provider, p.PrimaryModel) == "" {
|
||||
return ErrChannelMonitorMissingPrimaryModel
|
||||
}
|
||||
return nil
|
||||
@@ -486,8 +486,8 @@ func applyMonitorUpdate(existing *ChannelMonitor, p ChannelMonitorUpdateParams)
|
||||
if err := validateProvider(*p.Provider); err != nil {
|
||||
return err
|
||||
}
|
||||
providerChanged = existing.Provider != *p.Provider
|
||||
existing.Provider = *p.Provider
|
||||
providerChanged = true
|
||||
}
|
||||
if p.Endpoint != nil {
|
||||
if err := validateEndpoint(*p.Endpoint); err != nil {
|
||||
@@ -496,7 +496,13 @@ func applyMonitorUpdate(existing *ChannelMonitor, p ChannelMonitorUpdateParams)
|
||||
existing.Endpoint = normalizeEndpoint(*p.Endpoint)
|
||||
}
|
||||
if p.PrimaryModel != nil {
|
||||
existing.PrimaryModel = strings.TrimSpace(*p.PrimaryModel)
|
||||
primaryModel := normalizeMonitorPrimaryModel(existing.Provider, *p.PrimaryModel)
|
||||
if primaryModel == "" {
|
||||
return ErrChannelMonitorMissingPrimaryModel
|
||||
}
|
||||
existing.PrimaryModel = primaryModel
|
||||
} else if providerChanged && existing.Provider == MonitorProviderGrok {
|
||||
existing.PrimaryModel = MonitorDefaultGrokModel
|
||||
}
|
||||
if p.ExtraModels != nil {
|
||||
existing.ExtraModels = normalizeModels(*p.ExtraModels)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestApplyMonitorUpdate_ProviderOnlySwitchToGrokUsesDefaultModel(t *testing.T) {
|
||||
grok := MonitorProviderGrok
|
||||
existing := &ChannelMonitor{
|
||||
Provider: MonitorProviderOpenAI,
|
||||
APIMode: MonitorAPIModeResponses,
|
||||
PrimaryModel: "gpt-5",
|
||||
IntervalSeconds: 60,
|
||||
}
|
||||
|
||||
err := applyMonitorUpdate(existing, ChannelMonitorUpdateParams{Provider: &grok})
|
||||
if err != nil {
|
||||
t.Fatalf("provider-only switch to Grok failed: %v", err)
|
||||
}
|
||||
if existing.PrimaryModel != MonitorDefaultGrokModel {
|
||||
t.Fatalf("expected Grok default model %q, got %q", MonitorDefaultGrokModel, existing.PrimaryModel)
|
||||
}
|
||||
if existing.APIMode != MonitorAPIModeChatCompletions {
|
||||
t.Fatalf("expected Grok API mode %q, got %q", MonitorAPIModeChatCompletions, existing.APIMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMonitorUpdate_SwitchToGrokPreservesExplicitModel(t *testing.T) {
|
||||
grok := MonitorProviderGrok
|
||||
explicitModel := "grok-4.3"
|
||||
existing := &ChannelMonitor{
|
||||
Provider: MonitorProviderOpenAI,
|
||||
APIMode: MonitorAPIModeChatCompletions,
|
||||
PrimaryModel: "gpt-5",
|
||||
IntervalSeconds: 60,
|
||||
}
|
||||
|
||||
err := applyMonitorUpdate(existing, ChannelMonitorUpdateParams{
|
||||
Provider: &grok,
|
||||
PrimaryModel: &explicitModel,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("switch to Grok with explicit model failed: %v", err)
|
||||
}
|
||||
if existing.PrimaryModel != explicitModel {
|
||||
t.Fatalf("expected explicit model %q, got %q", explicitModel, existing.PrimaryModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMonitorUpdate_SameGrokProviderDoesNotResetExistingModel(t *testing.T) {
|
||||
grok := MonitorProviderGrok
|
||||
existing := &ChannelMonitor{
|
||||
Provider: MonitorProviderGrok,
|
||||
APIMode: MonitorAPIModeChatCompletions,
|
||||
PrimaryModel: "grok-4.3",
|
||||
IntervalSeconds: 60,
|
||||
}
|
||||
|
||||
err := applyMonitorUpdate(existing, ChannelMonitorUpdateParams{Provider: &grok})
|
||||
if err != nil {
|
||||
t.Fatalf("same-provider Grok update failed: %v", err)
|
||||
}
|
||||
if existing.PrimaryModel != "grok-4.3" {
|
||||
t.Fatalf("same-provider update reset existing model to %q", existing.PrimaryModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMonitorUpdate_SwitchToGrokRejectsResponsesMode(t *testing.T) {
|
||||
grok := MonitorProviderGrok
|
||||
responses := MonitorAPIModeResponses
|
||||
existing := &ChannelMonitor{
|
||||
Provider: MonitorProviderOpenAI,
|
||||
APIMode: MonitorAPIModeChatCompletions,
|
||||
PrimaryModel: "gpt-5",
|
||||
IntervalSeconds: 60,
|
||||
}
|
||||
|
||||
err := applyMonitorUpdate(existing, ChannelMonitorUpdateParams{
|
||||
Provider: &grok,
|
||||
APIMode: &responses,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Grok responses mode should remain unsupported")
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ var (
|
||||
"CHANNEL_MONITOR_TEMPLATE_NOT_FOUND", "channel monitor request template not found",
|
||||
)
|
||||
ErrChannelMonitorTemplateInvalidProvider = infraerrors.BadRequest(
|
||||
"CHANNEL_MONITOR_TEMPLATE_INVALID_PROVIDER", "template provider must be one of openai/anthropic/gemini",
|
||||
"CHANNEL_MONITOR_TEMPLATE_INVALID_PROVIDER", "template provider must be one of openai/anthropic/gemini/grok",
|
||||
)
|
||||
ErrChannelMonitorTemplateInvalidAPIMode = infraerrors.BadRequest(
|
||||
"CHANNEL_MONITOR_TEMPLATE_INVALID_API_MODE", "template api_mode must be chat_completions or responses; responses is only supported for openai",
|
||||
|
||||
@@ -124,6 +124,16 @@ func normalizeModels(in []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeMonitorPrimaryModel applies the Grok health-check default while
|
||||
// preserving the existing required-model behavior for every other provider.
|
||||
func normalizeMonitorPrimaryModel(provider, model string) string {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" && provider == MonitorProviderGrok {
|
||||
return MonitorDefaultGrokModel
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
// defaultAPIMode 空串归一为 chat_completions,保证历史数据与旧客户端兼容。
|
||||
func defaultAPIMode(apiMode string) string {
|
||||
if strings.TrimSpace(apiMode) == "" {
|
||||
|
||||
@@ -183,10 +183,22 @@ func (s *GrokQuotaService) probeUsage(ctx context.Context, accountID int64) (*Gr
|
||||
return result, nil
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 240))
|
||||
bodyText := truncate(strings.TrimSpace(string(bodyBytes)), 240)
|
||||
slog.Warn("grok_quota_probe_failed", "account_id", account.ID, "model", probeModel, "status", resp.StatusCode, "body", bodyText)
|
||||
return nil, infraerrors.Newf(mapUpstreamStatus(resp.StatusCode), "GROK_QUOTA_PROBE_UPSTREAM_ERROR", "upstream returned %d for probe model %q: %s", resp.StatusCode, probeModel, bodyText)
|
||||
const reason = "GROK_QUOTA_PROBE_UPSTREAM_ERROR"
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
|
||||
slog.Warn(
|
||||
"grok_quota_probe_failed",
|
||||
"account_id", account.ID,
|
||||
"model", probeModel,
|
||||
"status", resp.StatusCode,
|
||||
"reason", reason,
|
||||
)
|
||||
return nil, infraerrors.Newf(
|
||||
mapUpstreamStatus(resp.StatusCode),
|
||||
reason,
|
||||
"upstream returned %d for probe model %q",
|
||||
resp.StatusCode,
|
||||
probeModel,
|
||||
)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -298,6 +300,54 @@ func TestGrokQuotaServiceProbeUsageReportsProbeModelOnUpstreamError(t *testing.T
|
||||
require.Contains(t, infraerrors.Message(err), `probe model "grok-4.5"`)
|
||||
}
|
||||
|
||||
func TestGrokQuotaServiceProbeUsageRedactsUpstreamErrorBodyFromErrorAndLogs(t *testing.T) {
|
||||
const upstreamSecret = "upstream-secret-refresh-token"
|
||||
account := &Account{
|
||||
ID: 49,
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "access-token",
|
||||
"expires_at": time.Now().Add(time.Hour).UTC().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
repo := &grokQuotaAccountRepo{
|
||||
mockAccountRepoForPlatform: &mockAccountRepoForPlatform{
|
||||
accountsByID: map[int64]*Account{49: account},
|
||||
},
|
||||
}
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"error":"` + upstreamSecret + `","detail":"credential rejected"}`,
|
||||
)),
|
||||
}}
|
||||
svc := NewGrokQuotaService(
|
||||
repo,
|
||||
nil,
|
||||
NewGrokTokenProvider(repo, nil),
|
||||
upstream,
|
||||
)
|
||||
|
||||
var logs bytes.Buffer
|
||||
previousLogger := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil)))
|
||||
defer slog.SetDefault(previousLogger)
|
||||
|
||||
_, err := svc.ProbeUsage(context.Background(), account.ID)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "GROK_QUOTA_PROBE_UPSTREAM_ERROR", infraerrors.Reason(err))
|
||||
require.Contains(t, infraerrors.Message(err), `probe model "grok-4.5"`)
|
||||
require.NotContains(t, err.Error(), upstreamSecret)
|
||||
require.NotContains(t, infraerrors.Message(err), upstreamSecret)
|
||||
require.Contains(t, logs.String(), "GROK_QUOTA_PROBE_UPSTREAM_ERROR")
|
||||
require.NotContains(t, logs.String(), upstreamSecret)
|
||||
require.NotContains(t, logs.String(), "credential rejected")
|
||||
require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String())
|
||||
}
|
||||
|
||||
func TestGrokQuotaServiceProbeUsageLoadsProxyWhenAccountEdgeMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ import (
|
||||
// and explicit session IDs (e.g. "sess-xxx" or "compat_cc_xxx").
|
||||
const contentSessionSeedPrefix = "compat_cs_"
|
||||
|
||||
// contentStablePrefixSessionSeedPrefix distinguishes cache identities derived
|
||||
// only from request fields that remain stable across independent prompts.
|
||||
const contentStablePrefixSessionSeedPrefix = "compat_csp_"
|
||||
|
||||
// deriveOpenAIContentSessionSeed builds a stable session seed from an
|
||||
// OpenAI-format request body. Only fields constant across conversation turns
|
||||
// are included: model, tools/functions definitions, system/developer prompts,
|
||||
@@ -105,3 +109,156 @@ func deriveOpenAIContentSessionSeed(body []byte) string {
|
||||
}
|
||||
return contentSessionSeedPrefix + b.String()
|
||||
}
|
||||
|
||||
// deriveOpenAIAnchoredContentSessionSeed returns the legacy content-derived
|
||||
// seed only when it contains a meaningful user/input anchor. This preserves
|
||||
// the existing session derivation while preventing model-only requests from
|
||||
// becoming a tenant-wide cache routing identity.
|
||||
func deriveOpenAIAnchoredContentSessionSeed(body []byte) string {
|
||||
if !hasOpenAIContentSessionUserAnchor(body) {
|
||||
return ""
|
||||
}
|
||||
return deriveOpenAIContentSessionSeed(body)
|
||||
}
|
||||
|
||||
func hasOpenAIContentSessionUserAnchor(body []byte) bool {
|
||||
if len(body) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if messages := gjson.GetBytes(body, "messages"); messages.Exists() && messages.IsArray() {
|
||||
anchored := false
|
||||
messages.ForEach(func(_, message gjson.Result) bool {
|
||||
if strings.TrimSpace(message.Get("role").String()) != "user" {
|
||||
return true
|
||||
}
|
||||
anchored = hasMeaningfulOpenAIContent(message.Get("content"))
|
||||
return false
|
||||
})
|
||||
return anchored
|
||||
}
|
||||
|
||||
input := gjson.GetBytes(body, "input")
|
||||
if !input.Exists() {
|
||||
return false
|
||||
}
|
||||
if input.Type == gjson.String {
|
||||
return strings.TrimSpace(input.String()) != ""
|
||||
}
|
||||
if !input.IsArray() {
|
||||
return false
|
||||
}
|
||||
|
||||
anchored := false
|
||||
input.ForEach(func(_, item gjson.Result) bool {
|
||||
if strings.TrimSpace(item.Get("role").String()) == "user" {
|
||||
anchored = hasMeaningfulOpenAIContent(item.Get("content"))
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(item.Get("type").String()) == "input_text" {
|
||||
anchored = strings.TrimSpace(item.Get("text").String()) != ""
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return anchored
|
||||
}
|
||||
|
||||
func hasMeaningfulOpenAIContent(content gjson.Result) bool {
|
||||
if !content.Exists() || content.Type == gjson.Null {
|
||||
return false
|
||||
}
|
||||
if content.Type == gjson.String {
|
||||
return strings.TrimSpace(content.String()) != ""
|
||||
}
|
||||
if !content.IsArray() {
|
||||
normalized, ok := normalizeNonEmptyCompatSeedJSON(content)
|
||||
return ok && strings.TrimSpace(normalized) != ""
|
||||
}
|
||||
|
||||
meaningful := false
|
||||
content.ForEach(func(_, item gjson.Result) bool {
|
||||
if item.Type == gjson.String {
|
||||
meaningful = strings.TrimSpace(item.String()) != ""
|
||||
} else if text := item.Get("text"); text.Exists() {
|
||||
meaningful = strings.TrimSpace(text.String()) != ""
|
||||
} else {
|
||||
_, meaningful = normalizeNonEmptyCompatSeedJSON(item)
|
||||
}
|
||||
return !meaningful
|
||||
})
|
||||
return meaningful
|
||||
}
|
||||
|
||||
// deriveOpenAIStablePrefixSessionSeed builds a seed from the reusable prefix
|
||||
// of an OpenAI-format request. User and assistant content are deliberately
|
||||
// excluded so independent prompts with the same system/tool prefix can share
|
||||
// an upstream prompt-cache routing identity.
|
||||
//
|
||||
// An empty result means the request has no meaningful stable prefix. Callers
|
||||
// must then use a narrower fallback instead of grouping all requests by tenant
|
||||
// and model alone.
|
||||
func deriveOpenAIStablePrefixSessionSeed(body []byte) string {
|
||||
if len(body) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
hasStablePrefix := false
|
||||
appendJSON := func(label string, value gjson.Result) {
|
||||
normalized, ok := normalizeNonEmptyCompatSeedJSON(value)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_, _ = b.WriteString("|")
|
||||
_, _ = b.WriteString(label)
|
||||
_, _ = b.WriteString("=")
|
||||
_, _ = b.WriteString(normalized)
|
||||
hasStablePrefix = true
|
||||
}
|
||||
|
||||
if tools := gjson.GetBytes(body, "tools"); tools.Exists() && tools.IsArray() {
|
||||
appendJSON("tools", tools)
|
||||
}
|
||||
if funcs := gjson.GetBytes(body, "functions"); funcs.Exists() && funcs.IsArray() {
|
||||
appendJSON("functions", funcs)
|
||||
}
|
||||
if instructions := gjson.GetBytes(body, "instructions"); strings.TrimSpace(instructions.String()) != "" {
|
||||
appendJSON("instructions", instructions)
|
||||
}
|
||||
|
||||
appendSystemMessages := func(items gjson.Result) {
|
||||
items.ForEach(func(_, item gjson.Result) bool {
|
||||
role := strings.TrimSpace(item.Get("role").String())
|
||||
switch role {
|
||||
case "system", "developer":
|
||||
appendJSON(role, item.Get("content"))
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
if messages := gjson.GetBytes(body, "messages"); messages.Exists() && messages.IsArray() {
|
||||
appendSystemMessages(messages)
|
||||
} else if input := gjson.GetBytes(body, "input"); input.Exists() && input.IsArray() {
|
||||
appendSystemMessages(input)
|
||||
}
|
||||
|
||||
if !hasStablePrefix {
|
||||
return ""
|
||||
}
|
||||
return contentStablePrefixSessionSeedPrefix + b.String()
|
||||
}
|
||||
|
||||
func normalizeNonEmptyCompatSeedJSON(value gjson.Result) (string, bool) {
|
||||
if !value.Exists() || value.Type == gjson.Null {
|
||||
return "", false
|
||||
}
|
||||
normalized := normalizeCompatSeedJSON(json.RawMessage(value.Raw))
|
||||
switch normalized {
|
||||
case "", `""`, "[]", "{}", "null":
|
||||
return "", false
|
||||
default:
|
||||
return normalized, true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,3 +216,154 @@ func TestDeriveOpenAIContentSessionSeed_ResponsesAPI_TypedMessageItem(t *testing
|
||||
require.Contains(t, seed, "|first_user=")
|
||||
require.Contains(t, seed, "Hello from typed message")
|
||||
}
|
||||
|
||||
func TestDeriveOpenAIStablePrefixSessionSeed_IgnoresUserContent(t *testing.T) {
|
||||
first := []byte(`{
|
||||
"model": "grok",
|
||||
"instructions": "Be concise.",
|
||||
"tools": [{"type":"function","name":"lookup","parameters":{"type":"object"}}],
|
||||
"input": [{"role":"user","content":"Question A"}]
|
||||
}`)
|
||||
second := []byte(`{
|
||||
"model": "grok",
|
||||
"instructions": "Be concise.",
|
||||
"tools": [{"parameters":{"type":"object"},"name":"lookup","type":"function"}],
|
||||
"input": [{"role":"user","content":"Question B"}]
|
||||
}`)
|
||||
|
||||
firstSeed := deriveOpenAIStablePrefixSessionSeed(first)
|
||||
secondSeed := deriveOpenAIStablePrefixSessionSeed(second)
|
||||
|
||||
require.NotEmpty(t, firstSeed)
|
||||
require.Equal(t, firstSeed, secondSeed)
|
||||
require.NotContains(t, firstSeed, "Question A")
|
||||
require.NotContains(t, firstSeed, "first_user")
|
||||
}
|
||||
|
||||
func TestDeriveOpenAIStablePrefixSessionSeed_IsolatesStablePrefixFields(t *testing.T) {
|
||||
base := []byte(`{
|
||||
"instructions":"Be concise.",
|
||||
"tools":[{"type":"function","name":"lookup"}],
|
||||
"input":[{"role":"system","content":"System A"},{"role":"user","content":"Question"}]
|
||||
}`)
|
||||
differentInstructions := []byte(`{
|
||||
"instructions":"Be detailed.",
|
||||
"tools":[{"type":"function","name":"lookup"}],
|
||||
"input":[{"role":"system","content":"System A"},{"role":"user","content":"Question"}]
|
||||
}`)
|
||||
differentTools := []byte(`{
|
||||
"instructions":"Be concise.",
|
||||
"tools":[{"type":"function","name":"search"}],
|
||||
"input":[{"role":"system","content":"System A"},{"role":"user","content":"Question"}]
|
||||
}`)
|
||||
differentSystem := []byte(`{
|
||||
"instructions":"Be concise.",
|
||||
"tools":[{"type":"function","name":"lookup"}],
|
||||
"input":[{"role":"system","content":"System B"},{"role":"user","content":"Question"}]
|
||||
}`)
|
||||
|
||||
baseSeed := deriveOpenAIStablePrefixSessionSeed(base)
|
||||
require.NotEqual(t, baseSeed, deriveOpenAIStablePrefixSessionSeed(differentInstructions))
|
||||
require.NotEqual(t, baseSeed, deriveOpenAIStablePrefixSessionSeed(differentTools))
|
||||
require.NotEqual(t, baseSeed, deriveOpenAIStablePrefixSessionSeed(differentSystem))
|
||||
}
|
||||
|
||||
func TestDeriveOpenAIStablePrefixSessionSeed_ChatSystemAndDeveloper(t *testing.T) {
|
||||
first := []byte(`{
|
||||
"messages":[
|
||||
{"role":"system","content":"System prompt"},
|
||||
{"role":"developer","content":[{"type":"text","text":"Developer prompt"}]},
|
||||
{"role":"user","content":"Question A"}
|
||||
]
|
||||
}`)
|
||||
second := []byte(`{
|
||||
"messages":[
|
||||
{"role":"system","content":"System prompt"},
|
||||
{"role":"developer","content":[{"text":"Developer prompt","type":"text"}]},
|
||||
{"role":"user","content":"Question B"}
|
||||
]
|
||||
}`)
|
||||
|
||||
firstSeed := deriveOpenAIStablePrefixSessionSeed(first)
|
||||
require.Equal(t, firstSeed, deriveOpenAIStablePrefixSessionSeed(second))
|
||||
require.Contains(t, firstSeed, "System prompt")
|
||||
require.Contains(t, firstSeed, "Developer prompt")
|
||||
}
|
||||
|
||||
func TestDeriveOpenAIStablePrefixSessionSeed_EncodesSystemAndDeveloperRoles(t *testing.T) {
|
||||
systemThenDeveloper := []byte(`{
|
||||
"messages":[
|
||||
{"role":"system","content":"Prompt A"},
|
||||
{"role":"developer","content":"Prompt B"}
|
||||
]
|
||||
}`)
|
||||
developerThenSystem := []byte(`{
|
||||
"messages":[
|
||||
{"role":"developer","content":"Prompt A"},
|
||||
{"role":"system","content":"Prompt B"}
|
||||
]
|
||||
}`)
|
||||
|
||||
firstSeed := deriveOpenAIStablePrefixSessionSeed(systemThenDeveloper)
|
||||
secondSeed := deriveOpenAIStablePrefixSessionSeed(developerThenSystem)
|
||||
|
||||
require.NotEqual(t, firstSeed, secondSeed)
|
||||
require.Contains(t, firstSeed, "|system=")
|
||||
require.Contains(t, firstSeed, "|developer=")
|
||||
}
|
||||
|
||||
func TestDeriveOpenAIStablePrefixSessionSeed_EncodesInstructionDelimiters(t *testing.T) {
|
||||
instructionOnly := []byte(`{
|
||||
"instructions":"foo|system=\"bar\""
|
||||
}`)
|
||||
instructionAndSystem := []byte(`{
|
||||
"instructions":"foo",
|
||||
"input":[{"role":"system","content":"bar"}]
|
||||
}`)
|
||||
|
||||
firstSeed := deriveOpenAIStablePrefixSessionSeed(instructionOnly)
|
||||
secondSeed := deriveOpenAIStablePrefixSessionSeed(instructionAndSystem)
|
||||
|
||||
require.NotEmpty(t, firstSeed)
|
||||
require.NotEmpty(t, secondSeed)
|
||||
require.NotEqual(t, firstSeed, secondSeed)
|
||||
}
|
||||
|
||||
func TestDeriveOpenAIAnchoredContentSessionSeed_RequiresMeaningfulAnchor(t *testing.T) {
|
||||
emptyAnchors := [][]byte{
|
||||
nil,
|
||||
[]byte(`{"model":"grok"}`),
|
||||
[]byte(`{"model":"grok","messages":[{"role":"assistant","content":"answer"}]}`),
|
||||
[]byte(`{"model":"grok","messages":[{"role":"user","content":" "}]}`),
|
||||
[]byte(`{"model":"grok","messages":[{"role":"user","content":[{"type":"text","text":""}]}]}`),
|
||||
[]byte(`{"model":"grok","input":" "}`),
|
||||
[]byte(`{"model":"grok","input":[{"type":"input_text","text":""}]}`),
|
||||
}
|
||||
for _, body := range emptyAnchors {
|
||||
require.Empty(t, deriveOpenAIAnchoredContentSessionSeed(body))
|
||||
}
|
||||
|
||||
meaningfulAnchors := [][]byte{
|
||||
[]byte(`{"model":"grok","messages":[{"role":"user","content":"question"}]}`),
|
||||
[]byte(`{"model":"grok","messages":[{"role":"user","content":[{"type":"text","text":"question"}]}]}`),
|
||||
[]byte(`{"model":"grok","input":"question"}`),
|
||||
[]byte(`{"model":"grok","input":[{"type":"input_text","text":"question"}]}`),
|
||||
}
|
||||
for _, body := range meaningfulAnchors {
|
||||
require.NotEmpty(t, deriveOpenAIAnchoredContentSessionSeed(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveOpenAIStablePrefixSessionSeed_RequiresMeaningfulPrefix(t *testing.T) {
|
||||
tests := [][]byte{
|
||||
nil,
|
||||
[]byte(`{}`),
|
||||
[]byte(`{"model":"grok","input":"Question A"}`),
|
||||
[]byte(`{"model":"grok","tools":[],"input":"Question A"}`),
|
||||
[]byte(`{"model":"grok","functions":[],"instructions":" ","messages":[{"role":"system","content":""},{"role":"user","content":"Question A"}]}`),
|
||||
}
|
||||
|
||||
for _, body := range tests {
|
||||
require.Empty(t, deriveOpenAIStablePrefixSessionSeed(body))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,13 @@ func resolveGrokCacheIdentity(c *gin.Context, body []byte, explicitKey, upstream
|
||||
|
||||
seed := explicitGrokCacheSeed(c, body, explicitKey)
|
||||
if seed == "" {
|
||||
seed = deriveOpenAIContentSessionSeed(body)
|
||||
seed = deriveOpenAIStablePrefixSessionSeed(body)
|
||||
if seed == "" {
|
||||
// A model alone is too broad for cache routing. Preserve the
|
||||
// existing first-user-derived identity when no reusable prefix is
|
||||
// available so unrelated prompts do not share one tenant-wide key.
|
||||
seed = deriveOpenAIAnchoredContentSessionSeed(body)
|
||||
}
|
||||
}
|
||||
if seed == "" {
|
||||
return ""
|
||||
|
||||
@@ -37,6 +37,63 @@ func TestResolveGrokCacheIdentityStableAcrossAppendOnlyTurns(t *testing.T) {
|
||||
require.Equal(t, first, second)
|
||||
}
|
||||
|
||||
func TestResolveGrokCacheIdentityStableAcrossIndependentPromptsWithSamePrefix(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c := newGrokCacheTestContext(102)
|
||||
firstBody := []byte(`{"model":"grok","instructions":"be concise","tools":[{"type":"function","name":"lookup"}],"input":[{"role":"user","content":"Question A"}]}`)
|
||||
secondBody := []byte(`{"model":"grok","instructions":"be concise","tools":[{"type":"function","name":"lookup"}],"input":[{"role":"user","content":"Question B"}]}`)
|
||||
|
||||
first := resolveGrokCacheIdentity(c, firstBody, "", "grok-4.5")
|
||||
second := resolveGrokCacheIdentity(c, secondBody, "", "grok-4.5")
|
||||
|
||||
require.NotEmpty(t, first)
|
||||
require.Equal(t, first, second)
|
||||
}
|
||||
|
||||
func TestResolveGrokCacheIdentityStablePrefixIsolation(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
baseBody := []byte(`{"model":"grok","instructions":"be concise","tools":[{"type":"function","name":"lookup"}],"input":[{"role":"system","content":"System A"},{"role":"user","content":"Question A"}]}`)
|
||||
differentInstructions := []byte(`{"model":"grok","instructions":"be detailed","tools":[{"type":"function","name":"lookup"}],"input":[{"role":"system","content":"System A"},{"role":"user","content":"Question B"}]}`)
|
||||
differentSystem := []byte(`{"model":"grok","instructions":"be concise","tools":[{"type":"function","name":"lookup"}],"input":[{"role":"system","content":"System B"},{"role":"user","content":"Question B"}]}`)
|
||||
differentTools := []byte(`{"model":"grok","instructions":"be concise","tools":[{"type":"function","name":"search"}],"input":[{"role":"system","content":"System A"},{"role":"user","content":"Question B"}]}`)
|
||||
|
||||
base := resolveGrokCacheIdentity(newGrokCacheTestContext(103), baseBody, "", "grok-4.5")
|
||||
require.NotEqual(t, base, resolveGrokCacheIdentity(newGrokCacheTestContext(104), baseBody, "", "grok-4.5"))
|
||||
require.NotEqual(t, base, resolveGrokCacheIdentity(newGrokCacheTestContext(103), baseBody, "", "grok-4.3"))
|
||||
require.NotEqual(t, base, resolveGrokCacheIdentity(newGrokCacheTestContext(103), differentInstructions, "", "grok-4.5"))
|
||||
require.NotEqual(t, base, resolveGrokCacheIdentity(newGrokCacheTestContext(103), differentSystem, "", "grok-4.5"))
|
||||
require.NotEqual(t, base, resolveGrokCacheIdentity(newGrokCacheTestContext(103), differentTools, "", "grok-4.5"))
|
||||
}
|
||||
|
||||
func TestResolveGrokCacheIdentityFallsBackWhenStablePrefixIsEmpty(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c := newGrokCacheTestContext(105)
|
||||
firstBody := []byte(`{"model":"grok","tools":[],"input":"Question A"}`)
|
||||
secondBody := []byte(`{"model":"grok","tools":[],"input":"Question B"}`)
|
||||
|
||||
first := resolveGrokCacheIdentity(c, firstBody, "", "grok-4.5")
|
||||
second := resolveGrokCacheIdentity(c, secondBody, "", "grok-4.5")
|
||||
|
||||
require.NotEmpty(t, first)
|
||||
require.NotEmpty(t, second)
|
||||
require.NotEqual(t, first, second)
|
||||
}
|
||||
|
||||
func TestResolveGrokCacheIdentitySkipsUnanchoredFallback(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c := newGrokCacheTestContext(106)
|
||||
tests := [][]byte{
|
||||
[]byte(`{"model":"grok"}`),
|
||||
[]byte(`{"model":"grok","messages":[{"role":"assistant","content":"answer"}]}`),
|
||||
[]byte(`{"model":"grok","messages":[{"role":"user","content":""}]}`),
|
||||
[]byte(`{"model":"grok","input":" "}`),
|
||||
}
|
||||
|
||||
for _, body := range tests {
|
||||
require.Empty(t, resolveGrokCacheIdentity(c, body, "", "grok-4.5"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGrokCacheIdentityIsolatesAPIKeyAndMappedModel(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"grok","input":"same prompt"}`)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Migration: 176_channel_monitor_grok_provider
|
||||
-- Allow Grok as a channel-monitor provider. Grok checks use the existing
|
||||
-- OpenAI-compatible chat completions protocol with model grok-4.5 by default.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
monitor_constraint_def TEXT;
|
||||
template_constraint_def TEXT;
|
||||
BEGIN
|
||||
SELECT pg_get_constraintdef(c.oid)
|
||||
INTO monitor_constraint_def
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
WHERE t.relname = 'channel_monitors'
|
||||
AND c.conname = 'channel_monitors_provider_check';
|
||||
|
||||
IF monitor_constraint_def IS NULL OR position('grok' IN monitor_constraint_def) = 0 THEN
|
||||
ALTER TABLE channel_monitors
|
||||
DROP CONSTRAINT IF EXISTS channel_monitors_provider_check;
|
||||
ALTER TABLE channel_monitors
|
||||
ADD CONSTRAINT channel_monitors_provider_check
|
||||
CHECK (provider IN ('openai', 'anthropic', 'gemini', 'grok'));
|
||||
END IF;
|
||||
|
||||
SELECT pg_get_constraintdef(c.oid)
|
||||
INTO template_constraint_def
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
WHERE t.relname = 'channel_monitor_request_templates'
|
||||
AND c.conname = 'channel_monitor_request_templates_provider_check';
|
||||
|
||||
IF template_constraint_def IS NULL OR position('grok' IN template_constraint_def) = 0 THEN
|
||||
ALTER TABLE channel_monitor_request_templates
|
||||
DROP CONSTRAINT IF EXISTS channel_monitor_request_templates_provider_check;
|
||||
ALTER TABLE channel_monitor_request_templates
|
||||
ADD CONSTRAINT channel_monitor_request_templates_provider_check
|
||||
CHECK (provider IN ('openai', 'anthropic', 'gemini', 'grok'));
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,20 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestChannelMonitorGrokProviderMigration(t *testing.T) {
|
||||
content, err := FS.ReadFile("176_channel_monitor_grok_provider.sql")
|
||||
require.NoError(t, err)
|
||||
|
||||
sql := strings.Join(strings.Fields(string(content)), " ")
|
||||
require.Contains(t, sql, "channel_monitors_provider_check")
|
||||
require.Contains(t, sql, "channel_monitor_request_templates_provider_check")
|
||||
require.Contains(t, sql, "CHECK (provider IN ('openai', 'anthropic', 'gemini', 'grok'))")
|
||||
require.Contains(t, sql, "position('grok' IN monitor_constraint_def) = 0")
|
||||
require.Contains(t, sql, "position('grok' IN template_constraint_def) = 0")
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { apiClient } from '../client'
|
||||
|
||||
export type Provider = 'openai' | 'anthropic' | 'gemini'
|
||||
export type Provider = 'openai' | 'anthropic' | 'gemini' | 'grok'
|
||||
export type MonitorStatus = 'operational' | 'degraded' | 'failed' | 'error'
|
||||
export type BodyOverrideMode = 'off' | 'merge' | 'replace'
|
||||
export type APIMode = 'chat_completions' | 'responses'
|
||||
|
||||
@@ -109,6 +109,8 @@ import { useI18n } from 'vue-i18n'
|
||||
import type { APIMode, BodyOverrideMode, Provider } from '@/api/admin/channelMonitor'
|
||||
import {
|
||||
API_MODE_RESPONSES,
|
||||
DEFAULT_GROK_MODEL,
|
||||
PROVIDER_GROK,
|
||||
PROVIDER_OPENAI,
|
||||
} from '@/constants/channelMonitor'
|
||||
|
||||
@@ -305,11 +307,12 @@ const bodyPlaceholder = computed(() => {
|
||||
}
|
||||
return '{\n "model": "gpt-4o-mini",\n "instructions": "You are a health check endpoint. Reply briefly.",\n "input": "Reply with exactly: ok",\n "max_output_tokens": 20,\n "stream": false\n}'
|
||||
}
|
||||
if (props.provider === PROVIDER_OPENAI) {
|
||||
if (props.provider === PROVIDER_OPENAI || props.provider === PROVIDER_GROK) {
|
||||
if (props.bodyOverrideMode === 'merge') {
|
||||
return '{\n "max_tokens": 20\n}'
|
||||
}
|
||||
return '{\n "model": "gpt-4o-mini",\n "messages": [{"role":"user","content":"Reply with exactly: ok"}],\n "max_tokens": 20,\n "stream": false\n}'
|
||||
const model = props.provider === PROVIDER_GROK ? DEFAULT_GROK_MODEL : 'gpt-4o-mini'
|
||||
return `{\n "model": "${model}",\n "messages": [{"role":"user","content":"Reply with exactly: ok"}],\n "max_tokens": 20,\n "stream": false\n}`
|
||||
}
|
||||
if (props.bodyOverrideMode === 'merge') {
|
||||
return '{\n "system": "You are Claude Code..."\n}'
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
PROVIDER_OPENAI,
|
||||
PROVIDER_ANTHROPIC,
|
||||
PROVIDER_GEMINI,
|
||||
PROVIDER_GROK,
|
||||
} from '@/constants/channelMonitor'
|
||||
|
||||
defineProps<{
|
||||
@@ -94,6 +95,7 @@ const providerFilterOptions = computed(() => [
|
||||
{ value: PROVIDER_OPENAI, label: t('monitorCommon.providers.openai') },
|
||||
{ value: PROVIDER_ANTHROPIC, label: t('monitorCommon.providers.anthropic') },
|
||||
{ value: PROVIDER_GEMINI, label: t('monitorCommon.providers.gemini') },
|
||||
{ value: PROVIDER_GROK, label: t('monitorCommon.providers.grok') },
|
||||
])
|
||||
|
||||
const enabledFilterOptions = computed(() => [
|
||||
|
||||
@@ -13,15 +13,16 @@
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.channelMonitor.form.provider') }} <span class="text-red-500">*</span></label>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<button
|
||||
v-for="opt in providerOptions"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
:data-testid="`monitor-provider-${opt.value}`"
|
||||
:aria-pressed="form.provider === opt.value"
|
||||
class="flex items-center justify-center gap-2 rounded-lg border-2 px-3 py-2.5 text-sm font-medium transition-colors"
|
||||
:class="providerPickerClass(opt.value, form.provider === opt.value)"
|
||||
@click="form.provider = opt.value"
|
||||
@click="selectProvider(opt.value)"
|
||||
>
|
||||
<ProviderIcon :provider="opt.value" :size="18" />
|
||||
<span>{{ opt.label }}</span>
|
||||
@@ -50,7 +51,7 @@
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.channelMonitor.form.endpoint') }} <span class="text-red-500">*</span></label>
|
||||
<div class="flex gap-2">
|
||||
<input v-model="form.endpoint" type="text" required class="input flex-1" :placeholder="t('admin.channelMonitor.form.endpointPlaceholder')" />
|
||||
<input v-model="form.endpoint" data-testid="monitor-endpoint" type="text" required class="input flex-1" :placeholder="t('admin.channelMonitor.form.endpointPlaceholder')" />
|
||||
<button type="button" @click="useCurrentDomain" class="btn btn-secondary whitespace-nowrap">
|
||||
{{ t('admin.channelMonitor.form.useCurrentDomain') }}
|
||||
</button>
|
||||
@@ -80,6 +81,7 @@
|
||||
<label class="input-label">{{ t('admin.channelMonitor.form.primaryModel') }} <span class="text-red-500">*</span></label>
|
||||
<input
|
||||
v-model="form.primary_model"
|
||||
data-testid="monitor-primary-model"
|
||||
type="text"
|
||||
required
|
||||
class="input font-medium"
|
||||
@@ -213,8 +215,11 @@ import {
|
||||
PROVIDER_OPENAI,
|
||||
PROVIDER_ANTHROPIC,
|
||||
PROVIDER_GEMINI,
|
||||
PROVIDER_GROK,
|
||||
API_MODE_CHAT_COMPLETIONS,
|
||||
API_MODE_RESPONSES,
|
||||
DEFAULT_GROK_ENDPOINT,
|
||||
DEFAULT_GROK_MODEL,
|
||||
DEFAULT_INTERVAL_SECONDS,
|
||||
} from '@/constants/channelMonitor'
|
||||
|
||||
@@ -396,8 +401,26 @@ const providerOptions = computed<ProviderOption[]>(() => [
|
||||
{ value: PROVIDER_ANTHROPIC, label: t('monitorCommon.providers.anthropic') },
|
||||
{ value: PROVIDER_OPENAI, label: t('monitorCommon.providers.openai') },
|
||||
{ value: PROVIDER_GEMINI, label: t('monitorCommon.providers.gemini') },
|
||||
{ value: PROVIDER_GROK, label: t('monitorCommon.providers.grok') },
|
||||
])
|
||||
|
||||
function selectProvider(provider: Provider) {
|
||||
if (form.provider === provider) return
|
||||
const previousProvider = form.provider
|
||||
const clearGrokEndpoint =
|
||||
previousProvider === PROVIDER_GROK && form.endpoint === DEFAULT_GROK_ENDPOINT
|
||||
const clearGrokModel =
|
||||
previousProvider === PROVIDER_GROK && form.primary_model === DEFAULT_GROK_MODEL
|
||||
form.provider = provider
|
||||
if (provider === PROVIDER_GROK) {
|
||||
if (!form.endpoint.trim()) form.endpoint = DEFAULT_GROK_ENDPOINT
|
||||
if (!form.primary_model.trim()) form.primary_model = DEFAULT_GROK_MODEL
|
||||
return
|
||||
}
|
||||
if (clearGrokEndpoint) form.endpoint = ''
|
||||
if (clearGrokModel) form.primary_model = ''
|
||||
}
|
||||
|
||||
// Clear api_key whenever provider changes to avoid cross-provider key mismatch.
|
||||
// Editing mode loads api_key='' via loadFromMonitor and only sets it on user
|
||||
// typing, so clearing on provider change is always a safe no-op until the user
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
>
|
||||
<!-- provider tabs -->
|
||||
<div class="mb-4 border-b border-gray-200 dark:border-dark-700">
|
||||
<div role="tablist" class="flex gap-1">
|
||||
<div role="tablist" class="flex flex-wrap gap-1">
|
||||
<button
|
||||
v-for="tab in providerTabs"
|
||||
:key="tab.value"
|
||||
@@ -130,7 +130,7 @@
|
||||
{{ t('admin.channelMonitor.form.provider') }}
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<button
|
||||
v-for="opt in providerTabs"
|
||||
:key="opt.value"
|
||||
@@ -248,6 +248,7 @@ import {
|
||||
PROVIDER_ANTHROPIC,
|
||||
PROVIDER_OPENAI,
|
||||
PROVIDER_GEMINI,
|
||||
PROVIDER_GROK,
|
||||
API_MODE_CHAT_COMPLETIONS,
|
||||
API_MODE_RESPONSES,
|
||||
} from '@/constants/channelMonitor'
|
||||
@@ -267,6 +268,7 @@ const providerTabs = computed<{ value: Provider; label: string }[]>(() => [
|
||||
{ value: PROVIDER_ANTHROPIC, label: t('monitorCommon.providers.anthropic') },
|
||||
{ value: PROVIDER_OPENAI, label: t('monitorCommon.providers.openai') },
|
||||
{ value: PROVIDER_GEMINI, label: t('monitorCommon.providers.gemini') },
|
||||
{ value: PROVIDER_GROK, label: t('monitorCommon.providers.grok') },
|
||||
])
|
||||
|
||||
const activeProvider = ref<Provider>(PROVIDER_ANTHROPIC)
|
||||
@@ -282,6 +284,7 @@ const countByProvider = computed<Record<Provider, number>>(() => {
|
||||
anthropic: 0,
|
||||
openai: 0,
|
||||
gemini: 0,
|
||||
grok: 0,
|
||||
}
|
||||
for (const t of templates.value) out[t.provider]++
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<svg
|
||||
class="h-3 w-3 flex-shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
transform="scale(.76)"
|
||||
d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"
|
||||
/>
|
||||
<path d="M18.75 14.5l.72 2.53 2.53.72-2.53.72-.72 2.53-.72-2.53-2.53-.72 2.53-.72.72-2.53z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -33,6 +33,17 @@
|
||||
<!-- Row 2: Plan type + Privacy mode (only if either exists) -->
|
||||
<div v-if="planLabel || privacyBadge" class="inline-flex items-center overflow-hidden rounded-md">
|
||||
<span v-if="planLabel" :class="['inline-flex items-center gap-1 px-1.5 py-1', planBadgeClass]">
|
||||
<GrokFreeIcon
|
||||
v-if="isGrokFreePlan"
|
||||
data-testid="grok-free-plan-icon"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="planIconName"
|
||||
:name="planIconName"
|
||||
size="xs"
|
||||
data-testid="grok-plan-icon"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{{ planLabel }}</span>
|
||||
</span>
|
||||
<span
|
||||
@@ -57,6 +68,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { AccountPlatform, AccountType } from '@/types'
|
||||
import GrokFreeIcon from './GrokFreeIcon.vue'
|
||||
import PlatformIcon from './PlatformIcon.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
@@ -97,10 +109,13 @@ const typeLabel = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const normalizedPlanType = computed(() =>
|
||||
(props.planType || '').trim().toLowerCase().replace(/[\s_-]+/g, '')
|
||||
)
|
||||
|
||||
const planLabel = computed(() => {
|
||||
if (!props.planType) return ''
|
||||
const lower = props.planType.toLowerCase()
|
||||
switch (lower) {
|
||||
if (!normalizedPlanType.value) return ''
|
||||
switch (normalizedPlanType.value) {
|
||||
case 'plus':
|
||||
return 'Plus'
|
||||
case 'team':
|
||||
@@ -109,7 +124,12 @@ const planLabel = computed(() => {
|
||||
case 'pro':
|
||||
return 'Pro'
|
||||
case 'free':
|
||||
return 'Free'
|
||||
case 'basic':
|
||||
return props.platform === 'grok' ? 'Grok Free' : 'Free'
|
||||
case 'supergrok':
|
||||
return 'SuperGrok'
|
||||
case 'supergrokheavy':
|
||||
return 'SuperGrok Heavy'
|
||||
case 'abnormal':
|
||||
return t('admin.accounts.subscriptionAbnormal')
|
||||
default:
|
||||
@@ -117,6 +137,22 @@ const planLabel = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const isGrokFreePlan = computed(() =>
|
||||
props.platform === 'grok' &&
|
||||
(normalizedPlanType.value === 'free' || normalizedPlanType.value === 'basic')
|
||||
)
|
||||
|
||||
const planIconName = computed<'bolt' | null>(() => {
|
||||
if (props.platform !== 'grok') return null
|
||||
if (
|
||||
normalizedPlanType.value === 'supergrok' ||
|
||||
normalizedPlanType.value === 'supergrokheavy'
|
||||
) {
|
||||
return 'bolt'
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const platformClass = computed(() => {
|
||||
if (props.platform === 'anthropic') {
|
||||
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
||||
@@ -150,7 +186,7 @@ const typeClass = computed(() => {
|
||||
})
|
||||
|
||||
const planBadgeClass = computed(() => {
|
||||
if (props.planType && props.planType.toLowerCase() === 'abnormal') {
|
||||
if (normalizedPlanType.value === 'abnormal') {
|
||||
return 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400'
|
||||
}
|
||||
return typeClass.value
|
||||
@@ -159,7 +195,7 @@ const planBadgeClass = computed(() => {
|
||||
// Subscription expiration label (non-free only)
|
||||
const expiresLabel = computed(() => {
|
||||
if (!props.subscriptionExpiresAt || !props.planType) return ''
|
||||
if (props.planType.toLowerCase() === 'free') return ''
|
||||
if (normalizedPlanType.value === 'free' || normalizedPlanType.value === 'basic') return ''
|
||||
try {
|
||||
const d = new Date(props.subscriptionExpiresAt)
|
||||
if (isNaN(d.getTime())) return ''
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import GrokFreeIcon from '../GrokFreeIcon.vue'
|
||||
import PlatformTypeBadge from '../PlatformTypeBadge.vue'
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}
|
||||
})
|
||||
|
||||
describe('PlatformTypeBadge Grok plans', () => {
|
||||
it('renders FREE and BASIC as Grok Free with a lightweight plan icon', async () => {
|
||||
const wrapper = mount(PlatformTypeBadge, {
|
||||
props: {
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
planType: 'BASIC',
|
||||
subscriptionExpiresAt: '2027-01-01T00:00:00Z',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Grok Free')
|
||||
expect(wrapper.findComponent(GrokFreeIcon).exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="grok-free-plan-icon"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="grok-plan-icon"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('2027-01-01')
|
||||
|
||||
await wrapper.setProps({ planType: 'FREE' })
|
||||
expect(wrapper.text()).toContain('Grok Free')
|
||||
expect(wrapper.findComponent(GrokFreeIcon).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps SuperGrok labels compatible and marks paid Grok plans', async () => {
|
||||
const wrapper = mount(PlatformTypeBadge, {
|
||||
props: {
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
planType: 'SuperGrok Heavy',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('SuperGrok Heavy')
|
||||
expect(wrapper.find('[data-testid="grok-plan-icon"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="grok-free-plan-icon"]').exists()).toBe(false)
|
||||
|
||||
await wrapper.setProps({ platform: 'openai', planType: 'free' })
|
||||
expect(wrapper.text()).toContain('Free')
|
||||
expect(wrapper.text()).not.toContain('Grok Free')
|
||||
expect(wrapper.find('[data-testid="grok-plan-icon"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('uses a dedicated 12px currentColor Grok mark with a Free sparkle', () => {
|
||||
const wrapper = mount(GrokFreeIcon)
|
||||
|
||||
expect(wrapper.element.tagName.toLowerCase()).toBe('svg')
|
||||
expect(wrapper.attributes('fill')).toBe('currentColor')
|
||||
expect(wrapper.classes()).toEqual(expect.arrayContaining(['h-3', 'w-3']))
|
||||
expect(wrapper.findAll('path')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -89,6 +89,7 @@ const PROVIDER_TINT: Record<string, string> = {
|
||||
openai: 'text-emerald-600 dark:text-emerald-300',
|
||||
anthropic: 'text-orange-600 dark:text-orange-300',
|
||||
gemini: 'text-sky-600 dark:text-sky-300',
|
||||
grok: 'text-zinc-700 dark:text-zinc-200',
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -51,6 +51,11 @@ const PROVIDER_ICONS: Record<Provider, IconData> = {
|
||||
'M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z',
|
||||
],
|
||||
},
|
||||
grok: {
|
||||
paths: [
|
||||
'M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
PROVIDER_OPENAI,
|
||||
PROVIDER_ANTHROPIC,
|
||||
PROVIDER_GEMINI,
|
||||
PROVIDER_GROK,
|
||||
STATUS_OPERATIONAL,
|
||||
STATUS_DEGRADED,
|
||||
STATUS_FAILED,
|
||||
@@ -57,7 +58,12 @@ export function useChannelMonitorFormat() {
|
||||
}
|
||||
|
||||
function providerLabel(p: Provider | string): string {
|
||||
if (p === PROVIDER_OPENAI || p === PROVIDER_ANTHROPIC || p === PROVIDER_GEMINI) {
|
||||
if (
|
||||
p === PROVIDER_OPENAI ||
|
||||
p === PROVIDER_ANTHROPIC ||
|
||||
p === PROVIDER_GEMINI ||
|
||||
p === PROVIDER_GROK
|
||||
) {
|
||||
return t(`monitorCommon.providers.${p}`)
|
||||
}
|
||||
return p || '-'
|
||||
@@ -71,6 +77,8 @@ export function useChannelMonitorFormat() {
|
||||
return 'bg-orange-100 text-orange-700 dark:bg-orange-500/15 dark:text-orange-300'
|
||||
case PROVIDER_GEMINI:
|
||||
return 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300'
|
||||
case PROVIDER_GROK:
|
||||
return 'bg-zinc-100 text-zinc-700 dark:bg-zinc-500/15 dark:text-zinc-300'
|
||||
default:
|
||||
return NEUTRAL_BADGE
|
||||
}
|
||||
@@ -95,6 +103,10 @@ export function useChannelMonitorFormat() {
|
||||
return active
|
||||
? 'border-sky-500 bg-sky-50 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300 dark:border-sky-400'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:border-sky-300 hover:text-sky-700 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400 dark:hover:border-sky-500/50'
|
||||
case PROVIDER_GROK:
|
||||
return active
|
||||
? 'border-zinc-500 bg-zinc-50 text-zinc-800 dark:bg-zinc-500/15 dark:text-zinc-200 dark:border-zinc-400'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:border-zinc-400 hover:text-zinc-800 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400 dark:hover:border-zinc-500/50'
|
||||
default:
|
||||
return active
|
||||
? 'border-gray-400 bg-gray-50 text-gray-700 dark:border-dark-500 dark:bg-dark-700 dark:text-gray-200'
|
||||
@@ -166,6 +178,8 @@ export function providerGradient(provider: string): string {
|
||||
return 'bg-gradient-to-br from-orange-50 to-amber-100 dark:from-orange-500/10 dark:to-amber-500/20'
|
||||
case PROVIDER_GEMINI:
|
||||
return 'bg-gradient-to-br from-sky-50 to-indigo-100 dark:from-sky-500/10 dark:to-indigo-500/20'
|
||||
case PROVIDER_GROK:
|
||||
return 'bg-gradient-to-br from-zinc-50 to-neutral-200 dark:from-zinc-500/10 dark:to-neutral-500/20'
|
||||
default:
|
||||
return 'bg-gradient-to-br from-gray-100 to-gray-200 dark:from-dark-700 dark:to-dark-600'
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ import type { APIMode, Provider, MonitorStatus } from '@/api/admin/channelMonito
|
||||
export const PROVIDER_OPENAI: Provider = 'openai'
|
||||
export const PROVIDER_ANTHROPIC: Provider = 'anthropic'
|
||||
export const PROVIDER_GEMINI: Provider = 'gemini'
|
||||
export const PROVIDER_GROK: Provider = 'grok'
|
||||
|
||||
export const DEFAULT_GROK_ENDPOINT = 'https://api.x.ai'
|
||||
export const DEFAULT_GROK_MODEL = 'grok-4.5'
|
||||
|
||||
export const API_MODE_CHAT_COMPLETIONS: APIMode = 'chat_completions'
|
||||
export const API_MODE_RESPONSES: APIMode = 'responses'
|
||||
@@ -20,6 +24,7 @@ export const PROVIDERS: readonly Provider[] = [
|
||||
PROVIDER_OPENAI,
|
||||
PROVIDER_ANTHROPIC,
|
||||
PROVIDER_GEMINI,
|
||||
PROVIDER_GROK,
|
||||
]
|
||||
|
||||
export const API_MODES: readonly APIMode[] = [
|
||||
|
||||
@@ -399,7 +399,8 @@ export default {
|
||||
providers: {
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
gemini: 'Gemini'
|
||||
gemini: 'Gemini',
|
||||
grok: 'Grok'
|
||||
},
|
||||
extraModelsHeader: 'Extra Models',
|
||||
extraModelsEmpty: 'No extra models',
|
||||
|
||||
@@ -404,7 +404,8 @@ export default {
|
||||
providers: {
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
gemini: 'Gemini'
|
||||
gemini: 'Gemini',
|
||||
grok: 'Grok'
|
||||
},
|
||||
extraModelsHeader: '附加模型',
|
||||
extraModelsEmpty: '无附加模型',
|
||||
|
||||
@@ -234,7 +234,7 @@
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<PlatformTypeBadge :platform="row.platform" :type="row.type"
|
||||
:plan-type="row.credentials?.plan_type || row.parent_plan_type"
|
||||
:plan-type="getAccountPlanType(row)"
|
||||
:privacy-mode="row.extra?.privacy_mode || row.parent_privacy_mode"
|
||||
:subscription-expires-at="row.credentials?.subscription_expires_at || row.parent_subscription_expires_at" />
|
||||
<span
|
||||
@@ -1146,6 +1146,27 @@ const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn(
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
// Fresh billing/quota snapshots are authoritative. Imported credential tiers
|
||||
// can be stale, so they remain fallbacks together with legacy plan_type fields.
|
||||
function getAccountPlanType(row: any): string | undefined {
|
||||
if (!row) return undefined
|
||||
if (row.platform === 'grok') {
|
||||
const extra = (row.extra || {}) as Record<string, any>
|
||||
const billing = extra.grok_billing_snapshot as Record<string, any> | undefined
|
||||
const quota = extra.grok_quota_snapshot as Record<string, any> | undefined
|
||||
return (
|
||||
billing?.plan ||
|
||||
quota?.subscription_tier ||
|
||||
row.credentials?.subscription_tier ||
|
||||
extra.subscription_tier ||
|
||||
row.credentials?.plan_type ||
|
||||
row.parent_plan_type ||
|
||||
undefined
|
||||
)
|
||||
}
|
||||
return row.credentials?.plan_type || row.parent_plan_type || undefined
|
||||
}
|
||||
|
||||
// Antigravity 订阅等级辅助函数
|
||||
function getAntigravityTierFromRow(row: any): string | null {
|
||||
if (row.platform !== 'antigravity') return null
|
||||
|
||||
@@ -253,4 +253,79 @@ describe('admin AccountsView — 影子行 parent_* OR 兜底展示', () => {
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('passes fresh Grok billing and quota snapshots before stale credential fallbacks', async () => {
|
||||
const grokAccounts = [
|
||||
{
|
||||
id: 201,
|
||||
name: 'oauth-tier',
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
credentials: { subscription_tier: 'FREE', plan_type: 'legacy' },
|
||||
extra: {
|
||||
grok_billing_snapshot: { plan: 'SuperGrok' },
|
||||
subscription_tier: 'BASIC',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 202,
|
||||
name: 'billing-tier',
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
credentials: {},
|
||||
extra: {
|
||||
grok_billing_snapshot: { plan: 'SuperGrok Heavy' },
|
||||
subscription_tier: 'BASIC',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 203,
|
||||
name: 'quota-tier',
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
credentials: { subscription_tier: 'FREE' },
|
||||
extra: {
|
||||
grok_quota_snapshot: { subscription_tier: 'SuperGrok' },
|
||||
subscription_tier: 'BASIC',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 204,
|
||||
name: 'extra-tier',
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
credentials: { plan_type: 'SuperGrok' },
|
||||
extra: { subscription_tier: 'BASIC' },
|
||||
},
|
||||
{
|
||||
id: 205,
|
||||
name: 'legacy-tier',
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
credentials: { plan_type: 'SuperGrok' },
|
||||
},
|
||||
]
|
||||
|
||||
listAccounts.mockResolvedValue({
|
||||
items: grokAccounts,
|
||||
total: grokAccounts.length,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
pages: 1,
|
||||
})
|
||||
|
||||
const wrapper = mountViewWithRow()
|
||||
await flushPromises()
|
||||
|
||||
const badges = wrapper.findAllComponents(PlatformTypeBadge)
|
||||
expect(badges.map((badge) => badge.props('planType'))).toEqual([
|
||||
'SuperGrok',
|
||||
'SuperGrok Heavy',
|
||||
'SuperGrok',
|
||||
'BASIC',
|
||||
'SuperGrok',
|
||||
])
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { defineComponent } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import MonitorFormDialog from '@/components/admin/monitor/MonitorFormDialog.vue'
|
||||
import {
|
||||
DEFAULT_GROK_ENDPOINT,
|
||||
DEFAULT_GROK_MODEL,
|
||||
PROVIDERS,
|
||||
PROVIDER_GROK,
|
||||
} from '@/constants/channelMonitor'
|
||||
|
||||
const { listTemplates } = vi.hoisted(() => ({
|
||||
listTemplates: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
channelMonitor: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
channelMonitorTemplate: {
|
||||
list: listTemplates,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/api/keys', () => ({
|
||||
keysAPI: { list: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/api/groups', () => ({
|
||||
userGroupsAPI: { getUserGroupRates: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
cachedPublicSettings: null,
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}
|
||||
})
|
||||
|
||||
const BaseDialogStub = defineComponent({
|
||||
props: { show: { type: Boolean, default: false } },
|
||||
template: '<div v-if="show"><slot /><slot name="footer" /></div>',
|
||||
})
|
||||
|
||||
function mountDialog() {
|
||||
return mount(MonitorFormDialog, {
|
||||
props: { show: true, monitor: null },
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: BaseDialogStub,
|
||||
Toggle: true,
|
||||
Select: true,
|
||||
ModelTagInput: true,
|
||||
MonitorKeyPickerDialog: true,
|
||||
MonitorAdvancedRequestConfig: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('channel monitor Grok provider', () => {
|
||||
beforeEach(() => {
|
||||
listTemplates.mockReset().mockResolvedValue({ items: [] })
|
||||
})
|
||||
|
||||
it('offers Grok in the responsive provider grid and prefills its official defaults', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
|
||||
expect(PROVIDERS).toContain(PROVIDER_GROK)
|
||||
const providerButtons = wrapper.findAll('[data-testid^="monitor-provider-"]')
|
||||
expect(providerButtons).toHaveLength(4)
|
||||
expect(providerButtons[0].element.parentElement?.className).toContain('grid-cols-2')
|
||||
expect(providerButtons[0].element.parentElement?.className).toContain('sm:grid-cols-4')
|
||||
|
||||
const grokButton = wrapper.get('[data-testid="monitor-provider-grok"]')
|
||||
expect(grokButton.find('svg').exists()).toBe(true)
|
||||
expect(grokButton.text()).toContain('monitorCommon.providers.grok')
|
||||
await grokButton.trigger('click')
|
||||
expect(grokButton.classes().join(' ')).toContain('zinc')
|
||||
|
||||
const endpoint = wrapper.get('[data-testid="monitor-endpoint"]')
|
||||
const model = wrapper.get('[data-testid="monitor-primary-model"]')
|
||||
expect((endpoint.element as HTMLInputElement).value).toBe(DEFAULT_GROK_ENDPOINT)
|
||||
expect((model.element as HTMLInputElement).value).toBe(DEFAULT_GROK_MODEL)
|
||||
|
||||
await wrapper.get('[data-testid="monitor-provider-anthropic"]').trigger('click')
|
||||
expect((endpoint.element as HTMLInputElement).value).toBe('')
|
||||
expect((model.element as HTMLInputElement).value).toBe('')
|
||||
|
||||
await grokButton.trigger('click')
|
||||
await endpoint.setValue('https://gateway.example.com')
|
||||
await model.setValue('grok-custom')
|
||||
await wrapper.get('[data-testid="monitor-provider-openai"]').trigger('click')
|
||||
expect((endpoint.element as HTMLInputElement).value).toBe('https://gateway.example.com')
|
||||
expect((model.element as HTMLInputElement).value).toBe('grok-custom')
|
||||
})
|
||||
|
||||
it('prefills only empty Grok fields and preserves existing provider values', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
|
||||
const endpoint = wrapper.get('[data-testid="monitor-endpoint"]')
|
||||
const model = wrapper.get('[data-testid="monitor-primary-model"]')
|
||||
const grokButton = wrapper.get('[data-testid="monitor-provider-grok"]')
|
||||
const anthropicButton = wrapper.get('[data-testid="monitor-provider-anthropic"]')
|
||||
|
||||
await endpoint.setValue('https://gateway.example.com')
|
||||
await grokButton.trigger('click')
|
||||
expect((endpoint.element as HTMLInputElement).value).toBe('https://gateway.example.com')
|
||||
expect((model.element as HTMLInputElement).value).toBe(DEFAULT_GROK_MODEL)
|
||||
|
||||
await anthropicButton.trigger('click')
|
||||
expect((endpoint.element as HTMLInputElement).value).toBe('https://gateway.example.com')
|
||||
expect((model.element as HTMLInputElement).value).toBe('')
|
||||
|
||||
await endpoint.setValue('')
|
||||
await model.setValue('grok-custom')
|
||||
await grokButton.trigger('click')
|
||||
expect((endpoint.element as HTMLInputElement).value).toBe(DEFAULT_GROK_ENDPOINT)
|
||||
expect((model.element as HTMLInputElement).value).toBe('grok-custom')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user