feat(monitor): support Grok channel health checks

This commit is contained in:
superman2003
2026-07-14 12:24:42 +08:00
parent d8a07e91a5
commit 0a64a6d8ce
16 changed files with 304 additions and 36 deletions
+2 -1
View File
@@ -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)
+2 -2
View File
@@ -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},
+1 -1
View File
@@ -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")
}