Merge pull request #2641 from Arron196/fix/channel-monitor-responses-reasoning
fix(channel-monitor): 兼容 Responses reasoning 输出
This commit is contained in:
commit
e5d6f1727f
@ -281,9 +281,54 @@ func callProvider(ctx context.Context, provider, endpoint, apiKey, model, prompt
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", status, err
|
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
|
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 上。
|
// mergeHeaders 把用户自定义 headers 合并到 adapter 默认 headers 上。
|
||||||
// 用户值覆盖默认;命中黑名单(hop-by-hop / 由 http.Client 自管的)的 key 静默丢弃。
|
// 用户值覆盖默认;命中黑名单(hop-by-hop / 由 http.Client 自管的)的 key 静默丢弃。
|
||||||
func mergeHeaders(base map[string]string, opts *CheckOptions) map[string]string {
|
func mergeHeaders(base map[string]string, opts *CheckOptions) map[string]string {
|
||||||
|
|||||||
@ -60,10 +60,11 @@ func setupFakeAnthropic(t *testing.T, handler *captureHandler) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type openAICaptureHandler struct {
|
type openAICaptureHandler struct {
|
||||||
lastBody map[string]any
|
lastBody map[string]any
|
||||||
lastHeaders http.Header
|
lastHeaders http.Header
|
||||||
lastPath string
|
lastPath string
|
||||||
status int
|
status int
|
||||||
|
responsesLeadingReasoning bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *openAICaptureHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
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)
|
answer := answerFromOpenAIRequest(parsed)
|
||||||
if h.lastPath == providerOpenAIResponsesPath {
|
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{
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
"output": []map[string]any{{
|
"output": output,
|
||||||
"content": []map[string]any{{"type": "output_text", "text": answer}},
|
|
||||||
}},
|
|
||||||
})
|
})
|
||||||
return
|
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) {
|
func TestRunCheckForModel_OpenAIResponsesReplaceMissingInstructionsFailsLocally(t *testing.T) {
|
||||||
h := &openAICaptureHandler{}
|
h := &openAICaptureHandler{}
|
||||||
endpoint := setupFakeOpenAI(t, h)
|
endpoint := setupFakeOpenAI(t, h)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user