From b6363f7dd244c22174142edd83294fc9dadcddd4 Mon Sep 17 00:00:00 2001 From: toki Date: Thu, 9 Jul 2026 11:23:34 +0900 Subject: [PATCH] =?UTF-8?q?feat(openai):=20explicit=20reasoning=20?= =?UTF-8?q?=EB=B3=B4=EC=A1=B4=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=ED=95=B8=EB=93=A4=EB=9F=AC=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strict output 모드에서 reasoning이 명시적으로 요청된 경우 이를 보존할 수 있도록 normalizeCompletionOutput 함수와 관련 핸들러들의 로직을 업데이트한다. --- apps/edge/internal/openai/chat_handler.go | 13 ++- .../edge/internal/openai/responses_handler.go | 2 +- apps/edge/internal/openai/server_test.go | 110 +++++++++++++++++- apps/edge/internal/openai/stream.go | 5 +- apps/edge/internal/openai/strict_output.go | 5 +- apps/edge/internal/openai/types.go | 4 + 6 files changed, 133 insertions(+), 6 deletions(-) diff --git a/apps/edge/internal/openai/chat_handler.go b/apps/edge/internal/openai/chat_handler.go index 43ebfc4..2ea612b 100644 --- a/apps/edge/internal/openai/chat_handler.go +++ b/apps/edge/internal/openai/chat_handler.go @@ -65,6 +65,9 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { return } outputPolicy := s.resolveOutputPolicy(basePrompt) + if responseMode == responseModePassthrough && !responseModeWasExplicit(runMeta) && outputPolicy.Strict { + responseMode = responseModeTransformed + } if catalogEntry := s.findProviderPoolEntry(req.Model); catalogEntry != nil { applyModelCatalogGenerationPolicyToChat(&req, *catalogEntry, outputPolicy.Strict) } @@ -312,6 +315,14 @@ func parseResponseMode(runMeta map[string]string) (string, error) { } } +func responseModeWasExplicit(runMeta map[string]string) bool { + if runMeta == nil { + return false + } + mode, ok := runMeta[responseModeMetadataKey] + return ok && strings.TrimSpace(mode) != "" +} + // routeUsesProviderTunnel reports whether the resolved dispatch targets an // OpenAI-compatible provider that serves raw tunnel passthrough. Provider-pool // catalog routes and openai_compat/vllm type routes qualify; CLI and other @@ -499,7 +510,7 @@ func collectChatCompletionOutput(ctx context.Context, req chatCompletionRequest, if err != nil { return chatCompletionOutput{}, err } - text, reasoning, normalized := normalizeCompletionOutput(outputPolicy, text, reasoning) + text, reasoning, normalized := normalizeCompletionOutput(outputPolicy, text, reasoning, req.explicitlyIncludesReasoning()) message := chatMessage{Role: "assistant", Content: text, ReasoningContent: reasoning} includeReasoning := req.includeReasoning() if !includeReasoning { diff --git a/apps/edge/internal/openai/responses_handler.go b/apps/edge/internal/openai/responses_handler.go index b3d46fb..6a66054 100644 --- a/apps/edge/internal/openai/responses_handler.go +++ b/apps/edge/internal/openai/responses_handler.go @@ -174,7 +174,7 @@ func (s *Server) completeResponse(w http.ResponseWriter, r *http.Request, req re writeError(w, httpStatusForRunError(err), "run_error", err.Error()) return } - text, reasoning, normalized := normalizeCompletionOutput(outputPolicy, text, reasoning) + text, reasoning, normalized := normalizeCompletionOutput(outputPolicy, text, reasoning, false) s.logger.Info("openai responses output", zap.String("run_id", handle.Dispatch().RunID), zap.Bool("strict_output", outputPolicy.Strict), diff --git a/apps/edge/internal/openai/server_test.go b/apps/edge/internal/openai/server_test.go index 6f87ccf..1ec2fd3 100644 --- a/apps/edge/internal/openai/server_test.go +++ b/apps/edge/internal/openai/server_test.go @@ -2364,6 +2364,34 @@ func TestChatCompletionsStreamsReasoningSSE(t *testing.T) { } } +func TestChatCompletionsStrictOutputStreamsExplicitReasoningSSE(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 4)} + fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "think"} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "hi"} + fake.events <- &iop.RunEvent{Type: "complete"} + + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", StrictOutput: true}, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ + "model":"stream-model", + "stream":true, + "messages":[{"role":"user","content":"hi"}], + "include_reasoning": true + }`)) + w := httptest.NewRecorder() + + srv.handleChatCompletions(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + body := w.Body.String() + if !strings.Contains(body, `"reasoning_content":"think"`) || + !strings.Contains(body, `"content":"\u003cattempt_completion\u003e\u003cresult\u003ehi\u003c/result\u003e\u003c/attempt_completion\u003e"`) || + !strings.Contains(body, "data: [DONE]") { + t.Fatalf("unexpected SSE body:\n%s", body) + } +} + func TestChatCompletionsStreamFallsBackToReasoningWhenContentEmpty(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)} fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "think only"} @@ -2517,6 +2545,37 @@ func TestChatCompletionsStrictOutputNormalizesXMLStyleAgentResponse(t *testing.T } } +func TestChatCompletionsStrictOutputPreservesExplicitReasoningContent(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 4)} + fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "hidden thought"} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "\nok\n"} + fake.events <- &iop.RunEvent{Type: "complete"} + + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", StrictOutput: true}, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ + "model":"agent-model", + "messages":[{"role":"user","content":"finish"}], + "include_reasoning": true + }`)) + w := httptest.NewRecorder() + + srv.handleChatCompletions(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + var resp chatCompletionResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.Choices[0].Message.Content != "\nok\n" { + t.Fatalf("content: %q", resp.Choices[0].Message.Content) + } + if resp.Choices[0].Message.ReasoningContent != "hidden thought" { + t.Fatalf("reasoning_content: got %q, want hidden thought", resp.Choices[0].Message.ReasoningContent) + } +} + func TestChatCompletionsStrictOutputWrapsPlainTextWhenPromptDeclaresCompletionTool(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "plain answer"} @@ -4084,6 +4143,54 @@ func TestChatCompletionsProviderPoolDispatch(t *testing.T) { } } +func TestChatCompletionsStrictOutputProviderPoolDefaultUsesTransformedPath(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 4)} + fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "think"} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"} + fake.events <- &iop.RunEvent{Type: "complete"} + + catalog := []config.ModelCatalogEntry{ + {ID: "qwen3.6:35b", Providers: map[string]string{"prov-vllm": "Qwen3-35B-A22B"}}, + } + srv := NewServer(config.EdgeOpenAIConf{StrictOutput: true}, fake, nil) + srv.SetModelCatalog(catalog) + + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ + "model":"qwen3.6:35b", + "messages":[{"role":"user","content":"hello"}], + "include_reasoning": true + }`)) + w := httptest.NewRecorder() + srv.handleChatCompletions(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + if len(fake.tunnelReqsSnapshot()) != 0 { + t.Fatal("strict output default must keep normalized path unless passthrough is explicit") + } + reqs := fake.reqsSnapshot() + if len(reqs) != 1 { + t.Fatalf("expected 1 SubmitRun call, got %d", len(reqs)) + } + if !reqs[0].ProviderPool { + t.Fatal("provider-pool dispatch should be preserved on transformed path") + } + if got := reqs[0].Metadata[responseModeMetadataKey]; got != responseModeTransformed { + t.Fatalf("response mode metadata: got %q, want %q", got, responseModeTransformed) + } + if got := w.Header().Get(responseModeHeaderName); got != responseModeTransformed { + t.Fatalf("response mode header: got %q, want %q", got, responseModeTransformed) + } + var resp chatCompletionResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.Choices[0].Message.ReasoningContent != "think" { + t.Fatalf("reasoning_content: got %q, want think", resp.Choices[0].Message.ReasoningContent) + } +} + func TestChatCompletionsResponseModeRouting(t *testing.T) { cases := []struct { name string @@ -4625,7 +4732,8 @@ func TestChatCompletionsPassthroughProviderPoolGenerationPolicy(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"pool-model", "messages":[{"role":"user","content":"hello"}], - "max_completion_tokens":20 + "max_completion_tokens":20, + "metadata":{"iop_response_mode":"passthrough"} }`)) w := httptest.NewRecorder() srv.handleChatCompletions(w, req) diff --git a/apps/edge/internal/openai/stream.go b/apps/edge/internal/openai/stream.go index 67fa304..500d318 100644 --- a/apps/edge/internal/openai/stream.go +++ b/apps/edge/internal/openai/stream.go @@ -50,6 +50,7 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re model := responseModel(req.Model, handle.Dispatch().Target) traceStream := openAICompatTraceStreamEnabled(submitReq.Metadata) traceSeq := 0 + shouldExposeReasoning := req.includeReasoning() && (!outputPolicy.Strict || req.explicitlyIncludesReasoning()) writeTracedContentDelta := func(content, source string, filtered bool) { if content == "" { return @@ -181,7 +182,7 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re continue } reasoningBuilder.WriteString(reasoning) - if outputPolicy.Strict || !req.includeReasoning() { + if !shouldExposeReasoning { continue } writeTracedReasoningDelta(reasoning) @@ -189,7 +190,7 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re processContentDelta(contentSentinelFilter.Flush()) if reasoning := reasoningSentinelFilter.Flush(); reasoning != "" { reasoningBuilder.WriteString(reasoning) - if !outputPolicy.Strict && req.includeReasoning() { + if shouldExposeReasoning { writeTracedReasoningDelta(reasoning) } } diff --git a/apps/edge/internal/openai/strict_output.go b/apps/edge/internal/openai/strict_output.go index bd9e656..8f83c60 100644 --- a/apps/edge/internal/openai/strict_output.go +++ b/apps/edge/internal/openai/strict_output.go @@ -25,7 +25,7 @@ func prependSystemMessage(messages []chatMessage, content string) []chatMessage return out } -func normalizeCompletionOutput(policy strictOutputPolicy, content, reasoning string) (string, string, bool) { +func normalizeCompletionOutput(policy strictOutputPolicy, content, reasoning string, preserveReasoning bool) (string, string, bool) { var sanitized bool content, sanitized = sanitizeKnownChatTemplateSentinels(content) reasoning, reasoningSanitized := sanitizeKnownChatTemplateSentinels(reasoning) @@ -43,6 +43,9 @@ func normalizeCompletionOutput(policy strictOutputPolicy, content, reasoning str if policy.XMLCompletionTool != "" && !startsWithXMLRoot(normalized) { normalized = wrapXMLCompletion(policy, normalized) } + if preserveReasoning { + return normalized, reasoning, sanitized || normalized != content + } return normalized, "", sanitized || normalized != content || reasoning != "" } diff --git a/apps/edge/internal/openai/types.go b/apps/edge/internal/openai/types.go index 3010bc3..dd917f2 100644 --- a/apps/edge/internal/openai/types.go +++ b/apps/edge/internal/openai/types.go @@ -130,6 +130,10 @@ func (req chatCompletionRequest) includeReasoning() bool { return true } +func (req chatCompletionRequest) explicitlyIncludesReasoning() bool { + return req.IncludeReasoning != nil && *req.IncludeReasoning +} + func shouldForwardProviderToolChoice(toolChoice any) bool { if toolChoice == nil { return false