mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 16:17:56 +00:00
feat(monitor): support Grok channel health checks
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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) == "" {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user