diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 71d61ed6..e670da30 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.136 +0.1.137 diff --git a/backend/ent/channelmonitor/channelmonitor.go b/backend/ent/channelmonitor/channelmonitor.go index e5a6bfe7..51e0f9e3 100644 --- a/backend/ent/channelmonitor/channelmonitor.go +++ b/backend/ent/channelmonitor/channelmonitor.go @@ -153,6 +153,7 @@ const ( ProviderOpenai Provider = "openai" ProviderAnthropic Provider = "anthropic" ProviderGemini Provider = "gemini" + ProviderKiro Provider = "kiro" ) func (pr Provider) String() string { @@ -162,7 +163,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, ProviderKiro: return nil default: return fmt.Errorf("channelmonitor: invalid enum value for provider field: %q", pr) diff --git a/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go b/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go index 65b8d641..3935ff0c 100644 --- a/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go +++ b/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go @@ -96,6 +96,7 @@ const ( ProviderOpenai Provider = "openai" ProviderAnthropic Provider = "anthropic" ProviderGemini Provider = "gemini" + ProviderKiro Provider = "kiro" ) func (pr Provider) String() string { @@ -105,7 +106,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, ProviderKiro: return nil default: return fmt.Errorf("channelmonitorrequesttemplate: invalid enum value for provider field: %q", pr) diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 525ff092..fb5248d2 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -427,7 +427,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", "kiro"}}, {Name: "endpoint", Type: field.TypeString, Size: 500}, {Name: "api_key_encrypted", Type: field.TypeString}, {Name: "primary_model", Type: field.TypeString, Size: 200}, @@ -565,7 +565,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", "kiro"}}, {Name: "description", Type: field.TypeString, Nullable: true, Size: 500, Default: ""}, {Name: "extra_headers", Type: field.TypeJSON}, {Name: "body_override_mode", Type: field.TypeString, Size: 10, Default: "off"}, diff --git a/backend/ent/schema/channel_monitor.go b/backend/ent/schema/channel_monitor.go index 355ade4b..5d53b717 100644 --- a/backend/ent/schema/channel_monitor.go +++ b/backend/ent/schema/channel_monitor.go @@ -35,7 +35,7 @@ func (ChannelMonitor) Fields() []ent.Field { NotEmpty(). MaxLen(100), field.Enum("provider"). - Values("openai", "anthropic", "gemini"), + Values("openai", "anthropic", "gemini", "kiro"), field.String("endpoint"). NotEmpty(). MaxLen(500). diff --git a/backend/ent/schema/channel_monitor_request_template.go b/backend/ent/schema/channel_monitor_request_template.go index 59df2f29..4ff09b8f 100644 --- a/backend/ent/schema/channel_monitor_request_template.go +++ b/backend/ent/schema/channel_monitor_request_template.go @@ -39,7 +39,7 @@ func (ChannelMonitorRequestTemplate) Fields() []ent.Field { NotEmpty(). MaxLen(100), field.Enum("provider"). - Values("openai", "anthropic", "gemini"), + Values("openai", "anthropic", "gemini", "kiro"), field.String("description"). Optional(). Default(""). diff --git a/backend/internal/handler/admin/channel_monitor_handler.go b/backend/internal/handler/admin/channel_monitor_handler.go index e92c81fe..a418e446 100644 --- a/backend/internal/handler/admin/channel_monitor_handler.go +++ b/backend/internal/handler/admin/channel_monitor_handler.go @@ -37,7 +37,7 @@ 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 kiro"` 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"` @@ -53,7 +53,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 kiro"` Endpoint *string `json:"endpoint" binding:"omitempty,max=500"` APIKey *string `json:"api_key" binding:"omitempty,max=2000"` PrimaryModel *string `json:"primary_model" binding:"omitempty,max=200"` diff --git a/backend/internal/handler/admin/channel_monitor_template_handler.go b/backend/internal/handler/admin/channel_monitor_template_handler.go index bebe0929..4689d17a 100644 --- a/backend/internal/handler/admin/channel_monitor_template_handler.go +++ b/backend/internal/handler/admin/channel_monitor_template_handler.go @@ -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 kiro"` Description string `json:"description" binding:"max=500"` ExtraHeaders map[string]string `json:"extra_headers"` BodyOverrideMode string `json:"body_override_mode" binding:"omitempty,oneof=off merge replace"` diff --git a/backend/internal/pkg/kiro/translator.go b/backend/internal/pkg/kiro/translator.go index c0553184..2fecbe02 100644 --- a/backend/internal/pkg/kiro/translator.go +++ b/backend/internal/pkg/kiro/translator.go @@ -51,10 +51,11 @@ var ( ) type Usage struct { - InputTokens int - OutputTokens int - TotalTokens int - CacheReadInputTokens int + InputTokens int + OutputTokens int + TotalTokens int + CacheReadInputTokens int + CacheCreationInputTokens int } type StreamResult struct { @@ -368,6 +369,8 @@ func StreamEventStreamAsAnthropicWithContext(ctx context.Context, body io.Reader inThinkingBlock := false stripThinkingLeadingNewline := false sawNonThinkingBlock := false + estimatedOutputTokens := 0 + contextInputTokens := 0 writeEvent := func(event string, data any) error { payload, err := json.Marshal(data) @@ -488,6 +491,7 @@ func StreamEventStreamAsAnthropicWithContext(ctx context.Context, body io.Reader if toolUseID == "" || !streamingToolStarted[toolUseID] || streamingToolStopped[toolUseID] { return nil } + estimatedOutputTokens += countKiroTextTokens(fragment) return writeEvent("content_block_delta", map[string]any{ "type": "content_block_delta", "index": streamingToolBlockIndices[toolUseID], @@ -571,6 +575,7 @@ func StreamEventStreamAsAnthropicWithContext(ctx context.Context, body io.Reader return err } } + estimatedOutputTokens += countKiroTextTokens(text) return writeEvent("content_block_delta", map[string]any{ "type": "content_block_delta", "index": contentBlockIndex, @@ -611,6 +616,7 @@ func StreamEventStreamAsAnthropicWithContext(ctx context.Context, body io.Reader return err } inputJSON, _ := json.Marshal(tool.Input) + estimatedOutputTokens += estimateKiroOutputTokens("", []KiroToolUse{tool}) if err := writeEvent("content_block_delta", map[string]any{ "type": "content_block_delta", "index": contentBlockIndex, @@ -678,6 +684,7 @@ func StreamEventStreamAsAnthropicWithContext(ctx context.Context, body io.Reader return err } } + estimatedOutputTokens += countKiroTextTokens(text) return writeEvent("content_block_delta", map[string]any{ "type": "content_block_delta", "index": thinkingBlockIndex, @@ -883,6 +890,14 @@ func StreamEventStreamAsAnthropicWithContext(ctx context.Context, body io.Reader if err := processStreamingToolUseEvent(event); err != nil { return nil, err } + case "contextUsageEvent": + contextUsage := nestedEvent(event, "contextUsageEvent") + if contextUsagePercent, ok := firstFloat(contextUsage, "contextUsagePercentage", "context_usage_percentage"); ok { + contextInputTokens = int(contextUsagePercent * float64(kiroContextWindowSize(model)) / 100) + if contextUsagePercent >= 100 && stopReason == "" { + stopReason = "model_context_window_exceeded" + } + } case "messageMetadataEvent", "metadataEvent", "supplementaryWebLinksEvent", "usageEvent", "messageStopEvent", "message_stop": updateUsageFromEvent(&usage, msg.EventType, event) default: @@ -912,6 +927,12 @@ func StreamEventStreamAsAnthropicWithContext(ctx context.Context, body io.Reader if err := closeThinking(); err != nil { return nil, err } + if usage.OutputTokens == 0 && estimatedOutputTokens > 0 { + usage.OutputTokens = estimatedOutputTokens + } + if contextInputTokens > 0 { + usage.InputTokens = contextInputTokens + } if usage.TotalTokens == 0 { usage.TotalTokens = usage.InputTokens + usage.OutputTokens } @@ -935,7 +956,7 @@ func StreamEventStreamAsAnthropicWithContext(ctx context.Context, body io.Reader "input_tokens": usage.InputTokens, "output_tokens": usage.OutputTokens, "cache_read_input_tokens": usage.CacheReadInputTokens, - "cache_creation_input_tokens": 0, + "cache_creation_input_tokens": usage.CacheCreationInputTokens, }, }); err != nil { return nil, err @@ -1883,9 +1904,10 @@ func buildClaudeResponse(content string, toolUses []KiroToolUse, model string, u "content": blocks, "stop_reason": stopReason, "usage": map[string]interface{}{ - "input_tokens": usage.InputTokens, - "output_tokens": usage.OutputTokens, - "cache_read_input_tokens": usage.CacheReadInputTokens, + "input_tokens": usage.InputTokens, + "output_tokens": usage.OutputTokens, + "cache_read_input_tokens": usage.CacheReadInputTokens, + "cache_creation_input_tokens": usage.CacheCreationInputTokens, }, } result, _ := json.Marshal(response) @@ -2649,6 +2671,9 @@ func updateUsageFromEvent(usage *Usage, eventType string, event map[string]inter usage.InputTokens += value } } + if value, ok := firstInt(tokenUsage, "cacheCreationInputTokens", "cacheWriteInputTokens", "cacheCreateInputTokens", "cacheCreationTokens", "cacheWriteTokens", "uploadedInputTokens", "uploadInputTokens", "uploadedTokens", "uploadTokens"); ok { + usage.CacheCreationInputTokens = value + } } if value, ok := firstInt(event, "inputTokens", "inputTokenCount", "promptTokens", "prompt_tokens"); ok && value > 0 { usage.InputTokens = value @@ -2668,6 +2693,18 @@ func updateUsageFromEvent(usage *Usage, eventType string, event map[string]inter if value, ok := firstInt(meta, "totalTokens", "totalTokenCount"); ok && value > 0 { usage.TotalTokens = value } + if value, ok := firstInt(event, "cacheReadInputTokens", "cachedInputTokens", "cacheReadTokens", "cachedTokens", "cached_tokens"); ok && value > 0 { + usage.CacheReadInputTokens = value + } + if value, ok := firstInt(meta, "cacheReadInputTokens", "cachedInputTokens", "cacheReadTokens", "cachedTokens", "cached_tokens"); ok && value > 0 { + usage.CacheReadInputTokens = value + } + if value, ok := firstInt(event, "cacheCreationInputTokens", "cacheWriteInputTokens", "cacheCreateInputTokens", "cacheCreationTokens", "cacheWriteTokens", "uploadedInputTokens", "uploadInputTokens", "uploadedTokens", "uploadTokens"); ok && value > 0 { + usage.CacheCreationInputTokens = value + } + if value, ok := firstInt(meta, "cacheCreationInputTokens", "cacheWriteInputTokens", "cacheCreateInputTokens", "cacheCreationTokens", "cacheWriteTokens", "uploadedInputTokens", "uploadInputTokens", "uploadedTokens", "uploadTokens"); ok && value > 0 { + usage.CacheCreationInputTokens = value + } } func firstInt(m map[string]interface{}, keys ...string) (int, bool) { @@ -2679,6 +2716,39 @@ func firstInt(m map[string]interface{}, keys ...string) (int, bool) { return 0, false } +func firstFloat(m map[string]interface{}, keys ...string) (float64, bool) { + for _, key := range keys { + switch v := m[key].(type) { + case float64: + return v, true + case float32: + return float64(v), true + case int: + return float64(v), true + case int64: + return float64(v), true + case json.Number: + if parsed, err := v.Float64(); err == nil { + return parsed, true + } + case string: + if parsed, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil { + return parsed, true + } + } + } + return 0, false +} + +func kiroContextWindowSize(model string) int { + switch MapModel(model) { + case "claude-opus-4.6", "claude-sonnet-4.6": + return 1_000_000 + default: + return 200_000 + } +} + func estimateKiroOutputTokens(content string, toolUses []KiroToolUse) int { total := countKiroTextTokens(content) for _, tool := range toolUses { diff --git a/backend/internal/pkg/kiro/translator_test.go b/backend/internal/pkg/kiro/translator_test.go index b0e8527f..0ee82812 100644 --- a/backend/internal/pkg/kiro/translator_test.go +++ b/backend/internal/pkg/kiro/translator_test.go @@ -292,6 +292,7 @@ func TestParseNonStreamingEventStreamUsageAliases(t *testing.T) { "inputTokenCount": 12, "completionTokens": 7, "cachedTokens": 3, + "uploadedTokens": 5, "totalTokenCount": 22, }, }, @@ -302,8 +303,10 @@ func TestParseNonStreamingEventStreamUsageAliases(t *testing.T) { require.Equal(t, 15, result.Usage.InputTokens) require.Equal(t, 7, result.Usage.OutputTokens) require.Equal(t, 3, result.Usage.CacheReadInputTokens) + require.Equal(t, 5, result.Usage.CacheCreationInputTokens) require.Equal(t, 22, result.Usage.TotalTokens) require.Equal(t, float64(3), gjson.GetBytes(result.ResponseBody, "usage.cache_read_input_tokens").Float()) + require.Equal(t, float64(5), gjson.GetBytes(result.ResponseBody, "usage.cache_creation_input_tokens").Float()) } func TestParseNonStreamingEventStreamEstimatesMissingOutputTokens(t *testing.T) { @@ -316,7 +319,7 @@ func TestParseNonStreamingEventStreamEstimatesMissingOutputTokens(t *testing.T) _, _ = stream.Write(buildEventStreamFrame(t, "messageMetadataEvent", map[string]any{ "messageMetadataEvent": map[string]any{ "tokenUsage": map[string]any{ - "uncachedInputTokens": 12, + "uncachedInputTokens": 12, "cacheReadInputTokens": 3, }, }, @@ -581,6 +584,89 @@ func TestStreamEventStreamAsAnthropicSkipsLeadingWhitespaceOnlyChunk(t *testing. require.NotContains(t, output, `"delta":{"text":"","type":"text_delta"}`) } +func TestStreamEventStreamAsAnthropicUsageAliases(t *testing.T) { + stream := bytes.NewBuffer(nil) + _, _ = stream.Write(buildEventStreamFrame(t, "assistantResponseEvent", map[string]any{ + "assistantResponseEvent": map[string]any{ + "content": "hello", + }, + })) + _, _ = stream.Write(buildEventStreamFrame(t, "metadataEvent", map[string]any{ + "metadataEvent": map[string]any{ + "tokenUsage": map[string]any{ + "inputTokenCount": 12, + "completionTokens": 7, + "cachedTokens": 3, + "uploadedTokens": 5, + "totalTokenCount": 27, + }, + }, + })) + + var out bytes.Buffer + result, err := StreamEventStreamAsAnthropic(context.Background(), stream, &out, "claude-sonnet-4-5", 9) + require.NoError(t, err) + require.Equal(t, 15, result.Usage.InputTokens) + require.Equal(t, 7, result.Usage.OutputTokens) + require.Equal(t, 3, result.Usage.CacheReadInputTokens) + require.Equal(t, 5, result.Usage.CacheCreationInputTokens) + require.Equal(t, 27, result.Usage.TotalTokens) + + finalUsage := lastStreamUsage(t, out.String()) + require.Equal(t, int64(15), finalUsage.Get("input_tokens").Int()) + require.Equal(t, int64(7), finalUsage.Get("output_tokens").Int()) + require.Equal(t, int64(3), finalUsage.Get("cache_read_input_tokens").Int()) + require.Equal(t, int64(5), finalUsage.Get("cache_creation_input_tokens").Int()) +} + +func TestStreamEventStreamAsAnthropicEstimatesMissingOutputTokens(t *testing.T) { + stream := bytes.NewBuffer(nil) + _, _ = stream.Write(buildEventStreamFrame(t, "assistantResponseEvent", map[string]any{ + "assistantResponseEvent": map[string]any{ + "content": "Hello from Kiro", + }, + })) + _, _ = stream.Write(buildEventStreamFrame(t, "messageMetadataEvent", map[string]any{ + "messageMetadataEvent": map[string]any{ + "tokenUsage": map[string]any{ + "uncachedInputTokens": 9, + }, + }, + })) + + var out bytes.Buffer + result, err := StreamEventStreamAsAnthropic(context.Background(), stream, &out, "claude-sonnet-4-5", 9) + require.NoError(t, err) + require.Equal(t, 9, result.Usage.InputTokens) + require.Equal(t, 4, result.Usage.OutputTokens) + require.Equal(t, 13, result.Usage.TotalTokens) + + finalUsage := lastStreamUsage(t, out.String()) + require.Equal(t, int64(4), finalUsage.Get("output_tokens").Int()) +} + +func TestStreamEventStreamAsAnthropicUsesContextUsageInputTokens(t *testing.T) { + stream := bytes.NewBuffer(nil) + _, _ = stream.Write(buildEventStreamFrame(t, "contextUsageEvent", map[string]any{ + "contextUsageEvent": map[string]any{ + "contextUsagePercentage": 2.5, + }, + })) + _, _ = stream.Write(buildEventStreamFrame(t, "assistantResponseEvent", map[string]any{ + "assistantResponseEvent": map[string]any{ + "content": "ok", + }, + })) + + var out bytes.Buffer + result, err := StreamEventStreamAsAnthropic(context.Background(), stream, &out, "claude-opus-4.6", 9) + require.NoError(t, err) + require.Equal(t, 25000, result.Usage.InputTokens) + + finalUsage := lastStreamUsage(t, out.String()) + require.Equal(t, int64(25000), finalUsage.Get("input_tokens").Int()) +} + func TestStreamEventStreamAsAnthropicSkipsTrailingWhitespaceOnlyChunk(t *testing.T) { stream := bytes.NewBuffer(nil) _, _ = stream.Write(buildEventStreamFrame(t, "assistantResponseEvent", map[string]any{ @@ -1271,3 +1357,21 @@ func buildEventStreamFrame(t *testing.T, eventType string, payload any) []byte { require.NoError(t, binary.Write(frame, binary.BigEndian, uint32(0))) return frame.Bytes() } + +func lastStreamUsage(t *testing.T, output string) gjson.Result { + t.Helper() + var usage gjson.Result + for _, frame := range strings.Split(output, "\n\n") { + if !strings.Contains(frame, "event: message_delta") { + continue + } + for _, line := range strings.Split(frame, "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + usage = gjson.Get(strings.TrimPrefix(line, "data: "), "usage") + } + } + require.True(t, usage.Exists(), "message_delta usage not found in stream: %s", output) + return usage +} diff --git a/backend/internal/service/channel_monitor_checker.go b/backend/internal/service/channel_monitor_checker.go index 33570629..d01d6345 100644 --- a/backend/internal/service/channel_monitor_checker.go +++ b/backend/internal/service/channel_monitor_checker.go @@ -196,6 +196,23 @@ var providerAdapters = map[string]providerAdapter{ }, textPath: "content.0.text", }, + MonitorProviderKiro: { + buildPath: func(string) string { return providerAnthropicPath }, + 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, + }) + }, + buildHeaders: func(apiKey string) map[string]string { + return map[string]string{ + "x-api-key": apiKey, + "anthropic-version": monitorAnthropicAPIVersion, + } + }, + textPath: "content.0.text", + }, MonitorProviderGemini: { // Gemini 把 model 名写在 URL path 上:/v1beta/models/{model}:generateContent buildPath: func(model string) string { return fmt.Sprintf(providerGeminiPathTemplate, model) }, @@ -323,6 +340,7 @@ func buildRequestBody(adapter providerAdapter, provider, model, prompt string, o var bodyMergeKeyDenyList = map[string]map[string]bool{ MonitorProviderOpenAI: {"model": true, "messages": true, "stream": true}, MonitorProviderAnthropic: {"model": true, "messages": true}, + MonitorProviderKiro: {"model": true, "messages": true}, MonitorProviderGemini: {"contents": true}, } diff --git a/backend/internal/service/channel_monitor_checker_body_test.go b/backend/internal/service/channel_monitor_checker_body_test.go index 323cf8b7..40ba8915 100644 --- a/backend/internal/service/channel_monitor_checker_body_test.go +++ b/backend/internal/service/channel_monitor_checker_body_test.go @@ -7,6 +7,8 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "regexp" + "strconv" "strings" "testing" "time" @@ -36,6 +38,11 @@ func (h *captureHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { _ = json.NewDecoder(r.Body).Decode(&parsed) h.lastBody = parsed + respondText := h.respondText + if respondText == autoChallengeResponse { + respondText = solveMonitorChallengeFromBody(parsed) + } + if h.status == 0 { h.status = 200 } @@ -44,11 +51,35 @@ func (h *captureHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // 构造 Anthropic 格式的响应:content[0].text = h.respondText _ = json.NewEncoder(w).Encode(map[string]any{ "content": []map[string]any{ - {"type": "text", "text": h.respondText}, + {"type": "text", "text": respondText}, }, }) } +const autoChallengeResponse = "__auto_challenge__" + +var testChallengeLineRegex = regexp.MustCompile(`Q:\s*(\d+)\s*([+-])\s*(\d+)\s*=\s*\?`) + +func solveMonitorChallengeFromBody(body map[string]any) string { + msgs, _ := body["messages"].([]any) + if len(msgs) == 0 { + return "" + } + first, _ := msgs[0].(map[string]any) + prompt, _ := first["content"].(string) + matches := testChallengeLineRegex.FindAllStringSubmatch(prompt, -1) + if len(matches) == 0 { + return "" + } + last := matches[len(matches)-1] + a, _ := strconv.Atoi(last[1]) + b, _ := strconv.Atoi(last[3]) + if last[2] == "-" { + return strconv.Itoa(a - b) + } + return strconv.Itoa(a + b) +} + func setupFakeAnthropic(t *testing.T, handler *captureHandler) string { t.Helper() swapMonitorHTTPClient(t) @@ -58,7 +89,7 @@ func setupFakeAnthropic(t *testing.T, handler *captureHandler) string { } func TestRunCheckForModel_OffMode_PreservesDefaultBody(t *testing.T) { - h := &captureHandler{respondText: "the answer is 42"} + h := &captureHandler{respondText: autoChallengeResponse} endpoint := setupFakeAnthropic(t, h) // 跑一次 off 模式(opts=nil),确认默认 body 行为未变 @@ -75,6 +106,29 @@ func TestRunCheckForModel_OffMode_PreservesDefaultBody(t *testing.T) { } } +func TestRunCheckForModel_KiroUsesAnthropicCompatibleMessages(t *testing.T) { + h := &captureHandler{respondText: autoChallengeResponse} + endpoint := setupFakeAnthropic(t, h) + + res := runCheckForModel(context.Background(), MonitorProviderKiro, endpoint, "sk-kiro", "kiro-model", nil) + + if res.Status != MonitorStatusOperational { + t.Fatalf("expected operational, got status=%s message=%q", res.Status, res.Message) + } + if h.lastBody["model"] != "kiro-model" { + t.Errorf("kiro body should contain model=kiro-model, got %v", h.lastBody["model"]) + } + if _, ok := h.lastBody["messages"]; !ok { + t.Error("kiro body should contain Anthropic-compatible messages") + } + if h.lastHeaders.Get("x-api-key") != "sk-kiro" { + t.Errorf("expected x-api-key header, got %q", h.lastHeaders.Get("x-api-key")) + } + if h.lastHeaders.Get("anthropic-version") != monitorAnthropicAPIVersion { + t.Errorf("expected anthropic-version header, got %q", h.lastHeaders.Get("anthropic-version")) + } +} + func TestRunCheckForModel_MergeMode_UserFieldsWinButDenyListProtects(t *testing.T) { h := &captureHandler{respondText: "the answer is 42"} endpoint := setupFakeAnthropic(t, h) diff --git a/backend/internal/service/channel_monitor_const.go b/backend/internal/service/channel_monitor_const.go index 2e1614f7..e459bc4c 100644 --- a/backend/internal/service/channel_monitor_const.go +++ b/backend/internal/service/channel_monitor_const.go @@ -52,10 +52,11 @@ const ( // providerGeminiPathTemplate Gemini generateContent 路径模板(含 model 占位)。 providerGeminiPathTemplate = "/v1beta/models/%s:generateContent" - // MonitorProviderOpenAI / Anthropic / Gemini provider 字符串常量(也是 ent enum 的实际值)。 + // MonitorProviderOpenAI / Anthropic / Gemini / Kiro provider 字符串常量(也是 ent enum 的实际值)。 MonitorProviderOpenAI = "openai" MonitorProviderAnthropic = "anthropic" MonitorProviderGemini = "gemini" + MonitorProviderKiro = "kiro" // MonitorStatusOperational 等监控状态字符串常量(与 ent enum 一致)。 MonitorStatusOperational = "operational" @@ -110,7 +111,7 @@ 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/kiro", ) ErrChannelMonitorInvalidInterval = infraerrors.BadRequest( "CHANNEL_MONITOR_INVALID_INTERVAL", "interval_seconds must be in [15, 3600]", diff --git a/backend/internal/service/channel_monitor_template_types.go b/backend/internal/service/channel_monitor_template_types.go index e5bf7568..eb760229 100644 --- a/backend/internal/service/channel_monitor_template_types.go +++ b/backend/internal/service/channel_monitor_template_types.go @@ -51,7 +51,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/kiro", ) ErrChannelMonitorTemplateMissingName = infraerrors.BadRequest( "CHANNEL_MONITOR_TEMPLATE_MISSING_NAME", "template name is required", diff --git a/backend/internal/service/kiro_runtime.go b/backend/internal/service/kiro_runtime.go index 49a5654b..f277d2f8 100644 --- a/backend/internal/service/kiro_runtime.go +++ b/backend/internal/service/kiro_runtime.go @@ -587,9 +587,10 @@ func kiroUsageToClaude(usage kiropkg.Usage, fallbackInput int) ClaudeUsage { inputTokens = fallbackInput } return ClaudeUsage{ - InputTokens: inputTokens, - OutputTokens: usage.OutputTokens, - CacheReadInputTokens: usage.CacheReadInputTokens, + InputTokens: inputTokens, + OutputTokens: usage.OutputTokens, + CacheReadInputTokens: usage.CacheReadInputTokens, + CacheCreationInputTokens: usage.CacheCreationInputTokens, } } diff --git a/backend/migrations/137_allow_kiro_channel_monitor_provider.sql b/backend/migrations/137_allow_kiro_channel_monitor_provider.sql new file mode 100644 index 00000000..af45f9ff --- /dev/null +++ b/backend/migrations/137_allow_kiro_channel_monitor_provider.sql @@ -0,0 +1,35 @@ +-- Migration: 137_allow_kiro_channel_monitor_provider +-- 渠道监控新增 Kiro 平台。Kiro 网关使用 Anthropic Messages 兼容协议, +-- 但监控配置和模板需要独立 provider 归属,便于筛选和复用。 + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE table_name = 'channel_monitors' + AND constraint_name = 'channel_monitors_provider_check' + ) THEN + ALTER TABLE channel_monitors + DROP CONSTRAINT channel_monitors_provider_check; + END IF; + + ALTER TABLE channel_monitors + ADD CONSTRAINT channel_monitors_provider_check + CHECK (provider IN ('openai', 'anthropic', 'gemini', 'kiro')); +END $$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE table_name = 'channel_monitor_request_templates' + AND constraint_name = 'channel_monitor_request_templates_provider_check' + ) THEN + ALTER TABLE channel_monitor_request_templates + DROP CONSTRAINT channel_monitor_request_templates_provider_check; + END IF; + + ALTER TABLE channel_monitor_request_templates + ADD CONSTRAINT channel_monitor_request_templates_provider_check + CHECK (provider IN ('openai', 'anthropic', 'gemini', 'kiro')); +END $$; diff --git a/frontend/src/api/admin/channelMonitor.ts b/frontend/src/api/admin/channelMonitor.ts index 949c4bc8..097c5b52 100644 --- a/frontend/src/api/admin/channelMonitor.ts +++ b/frontend/src/api/admin/channelMonitor.ts @@ -5,7 +5,7 @@ import { apiClient } from '../client' -export type Provider = 'openai' | 'anthropic' | 'gemini' +export type Provider = 'openai' | 'anthropic' | 'gemini' | 'kiro' export type MonitorStatus = 'operational' | 'degraded' | 'failed' | 'error' export type BodyOverrideMode = 'off' | 'merge' | 'replace' diff --git a/frontend/src/components/admin/channel/types.ts b/frontend/src/components/admin/channel/types.ts index 955b6487..26dea29a 100644 --- a/frontend/src/components/admin/channel/types.ts +++ b/frontend/src/components/admin/channel/types.ts @@ -184,6 +184,7 @@ export function getPlatformTagClass(platform: string): string { case 'openai': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' case 'gemini': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' case 'antigravity': return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' + case 'kiro': return 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400' default: return 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400' } } @@ -195,6 +196,7 @@ export function getPlatformTextClass(platform: string): string { case 'openai': return 'text-emerald-700 dark:text-emerald-400' case 'gemini': return 'text-blue-700 dark:text-blue-400' case 'antigravity': return 'text-purple-700 dark:text-purple-400' + case 'kiro': return 'text-violet-700 dark:text-violet-400' default: return '' } } diff --git a/frontend/src/components/admin/monitor/MonitorFiltersBar.vue b/frontend/src/components/admin/monitor/MonitorFiltersBar.vue index eb2a5c78..63f0c9df 100644 --- a/frontend/src/components/admin/monitor/MonitorFiltersBar.vue +++ b/frontend/src/components/admin/monitor/MonitorFiltersBar.vue @@ -70,6 +70,7 @@ import { PROVIDER_OPENAI, PROVIDER_ANTHROPIC, PROVIDER_GEMINI, + PROVIDER_KIRO, } 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_KIRO, label: t('monitorCommon.providers.kiro') }, ]) const enabledFilterOptions = computed(() => [ diff --git a/frontend/src/components/admin/monitor/MonitorFormDialog.vue b/frontend/src/components/admin/monitor/MonitorFormDialog.vue index 21fa4715..427546f4 100644 --- a/frontend/src/components/admin/monitor/MonitorFormDialog.vue +++ b/frontend/src/components/admin/monitor/MonitorFormDialog.vue @@ -13,7 +13,7 @@
-
+