diff --git a/backend/internal/service/channel_monitor_checker.go b/backend/internal/service/channel_monitor_checker.go index 25737e45..7fb829a3 100644 --- a/backend/internal/service/channel_monitor_checker.go +++ b/backend/internal/service/channel_monitor_checker.go @@ -281,9 +281,54 @@ func callProvider(ctx context.Context, provider, endpoint, apiKey, model, prompt if err != nil { return "", "", status, err } + if provider == MonitorProviderOpenAI && apiMode == MonitorAPIModeResponses { + return extractOpenAIResponsesText(respBytes), string(respBytes), status, nil + } return gjson.GetBytes(respBytes, adapter.textPath).String(), string(respBytes), status, nil } +// extractOpenAIResponsesText 聚合 Responses API 的最终 assistant 文本。 +// Responses 的 output 数组顺序由模型决定:reasoning / tool-call item 可能排在 message 前面, +// 因此不能假设文本永远在 output.0.content.0.text。 +func extractOpenAIResponsesText(respBytes []byte) string { + if text := gjson.GetBytes(respBytes, "output_text").String(); strings.TrimSpace(text) != "" { + return text + } + + var texts []string + outputs := gjson.GetBytes(respBytes, "output") + if outputs.IsArray() { + outputs.ForEach(func(_, output gjson.Result) bool { + outputType := output.Get("type").String() + if outputType != "" && outputType != "message" { + return true + } + + content := output.Get("content") + if !content.IsArray() { + return true + } + + content.ForEach(func(_, block gjson.Result) bool { + blockType := block.Get("type").String() + if blockType != "" && blockType != "output_text" { + return true + } + if text := block.Get("text").String(); strings.TrimSpace(text) != "" { + texts = append(texts, text) + } + return true + }) + return true + }) + } + + if len(texts) > 0 { + return strings.Join(texts, "") + } + return gjson.GetBytes(respBytes, providerOpenAIResponsesAdapter.textPath).String() +} + // mergeHeaders 把用户自定义 headers 合并到 adapter 默认 headers 上。 // 用户值覆盖默认;命中黑名单(hop-by-hop / 由 http.Client 自管的)的 key 静默丢弃。 func mergeHeaders(base map[string]string, opts *CheckOptions) map[string]string { diff --git a/backend/internal/service/channel_monitor_checker_body_test.go b/backend/internal/service/channel_monitor_checker_body_test.go index 620cf565..bba3d7df 100644 --- a/backend/internal/service/channel_monitor_checker_body_test.go +++ b/backend/internal/service/channel_monitor_checker_body_test.go @@ -60,10 +60,11 @@ func setupFakeAnthropic(t *testing.T, handler *captureHandler) string { } type openAICaptureHandler struct { - lastBody map[string]any - lastHeaders http.Header - lastPath string - status int + lastBody map[string]any + lastHeaders http.Header + lastPath string + status int + responsesLeadingReasoning bool } func (h *openAICaptureHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -82,10 +83,23 @@ func (h *openAICaptureHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) answer := answerFromOpenAIRequest(parsed) if h.lastPath == providerOpenAIResponsesPath { + output := []map[string]any{} + if h.responsesLeadingReasoning { + output = append(output, map[string]any{ + "type": "reasoning", + "summary": []any{}, + }) + } + output = append(output, map[string]any{ + "type": "message", + "status": "completed", + "role": "assistant", + "content": []map[string]any{ + {"type": "output_text", "text": answer}, + }, + }) _ = json.NewEncoder(w).Encode(map[string]any{ - "output": []map[string]any{{ - "content": []map[string]any{{"type": "output_text", "text": answer}}, - }}, + "output": output, }) return } @@ -212,6 +226,22 @@ func TestRunCheckForModel_OpenAIResponses_DefaultRequest(t *testing.T) { } } +func TestRunCheckForModel_OpenAIResponses_SkipsLeadingReasoningItem(t *testing.T) { + h := &openAICaptureHandler{responsesLeadingReasoning: true} + endpoint := setupFakeOpenAI(t, h) + + res := runCheckForModel(context.Background(), MonitorProviderOpenAI, endpoint, "sk-openai", "gpt-5.5", &CheckOptions{ + APIMode: MonitorAPIModeResponses, + }) + + if res.Status != MonitorStatusOperational { + t.Fatalf("responses request should find text after leading reasoning item, got status=%s message=%q", res.Status, res.Message) + } + if h.lastPath != providerOpenAIResponsesPath { + t.Fatalf("expected responses path %q, got %q", providerOpenAIResponsesPath, h.lastPath) + } +} + func TestRunCheckForModel_OpenAIResponsesReplaceMissingInstructionsFailsLocally(t *testing.T) { h := &openAICaptureHandler{} endpoint := setupFakeOpenAI(t, h)