fix(channel-monitor): 兼容 Responses reasoning 输出

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
benjamin 2026-05-20 21:19:06 +08:00
parent 771e0ca973
commit d3d5843b9d
2 changed files with 82 additions and 7 deletions

View File

@ -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 {

View File

@ -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)