Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type Config struct {
HTTP HTTPConfig `yaml:"http"`
Admin AdminConfig `yaml:"admin"`
Guardrails GuardrailsConfig `yaml:"guardrails"`
ThinkExtract ThinkExtractConfig `yaml:"think_extract"`
Failover FailoverConfig `yaml:"failover"`
Workflows WorkflowsConfig `yaml:"workflows"`
Resilience ResilienceConfig `yaml:"resilience"`
Expand Down Expand Up @@ -186,6 +187,10 @@ func buildDefaultConfig() *Config {
LiveLogsHeartbeatSeconds: 15,
},
Guardrails: GuardrailsConfig{},
ThinkExtract: ThinkExtractConfig{
// Pointer-nil so IsEnabled() falls through to the default (true).
// Operators set THINK_EXTRACT_ENABLED=false to opt out per deployment.
},
Session: SessionConfig{
Enabled: true,
AutoDetect: true,
Expand Down
91 changes: 91 additions & 0 deletions config/thinkextract.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package config

// ThinkExtractConfig controls the response-path translation of legacy
// `<think>...</think>` (and configured equivalents) into the native reasoning
// field. The translation only runs on responses — request-side bodies are
// never modified.
//
// The default is enabled because the translation is lossless on the wire
// (no model-visible character is dropped) and the dialect converters for
// OpenAI chat completions, OpenAI responses, and Anthropic messages all
// surface ExtraFields["reasoning_content"] as their native reasoning field.
// Operators who observe a regression on a model that already emits structured
// reasoning can disable the feature per deployment.
type ThinkExtractConfig struct {
// Enabled toggles the translation globally. Default true.
Enabled *bool `yaml:"enabled" env:"THINK_EXTRACT_ENABLED"`
// ChatEnabled toggles the translation on the chat completions surface.
// Nil falls back to Enabled. Env: THINK_EXTRACT_CHAT_ENABLED.
ChatEnabled *bool `yaml:"chat_enabled" env:"THINK_EXTRACT_CHAT_ENABLED"`
// ResponsesEnabled toggles the translation on the OpenAI responses
// surface. Nil falls back to Enabled. Env: THINK_EXTRACT_RESPONSES_ENABLED.
ResponsesEnabled *bool `yaml:"responses_enabled" env:"THINK_EXTRACT_RESPONSES_ENABLED"`
// MessagesPolicy controls how synthesized reasoning is emitted on the
// Anthropic messages surface. Values: off (default), unsigned, redacted.
// "off" means no extraction runs for messages requests, so legacy tags
// stay in the message content unchanged. Env: THINK_EXTRACT_MESSAGES_POLICY.
MessagesPolicy string `yaml:"messages_policy" env:"THINK_EXTRACT_MESSAGES_POLICY"`
// TagPairs overrides the recognition list. The default list covers the
// union of vLLM/SGLang/Open WebUI standard tags. Format is a
// comma-separated "<open>...</close>" list, e.g.
// "<think>...</think>,<thinking>...</thinking>".
TagPairs string `yaml:"tag_pairs" env:"THINK_EXTRACT_TAG_PAIRS"`
// MaxBufferBytes caps the size of an unclosed block held in streaming
// state before it is flushed as ordinary content. Default 65536.
MaxBufferBytes int `yaml:"max_buffer_bytes" env:"THINK_EXTRACT_MAX_BUFFER_BYTES"`
}

// IsEnabled reports whether the translation is active at the global level.
// Nil receiver and nil Enabled pointer are both treated as default-true.
func (c ThinkExtractConfig) IsEnabled() bool {
if c.Enabled == nil {
return true
}
return *c.Enabled
}

// IsEnabledForChat reports whether the translation runs on the chat
// completions surface. Falls back to the global Enabled value when the
// per-surface pointer is unset.
func (c ThinkExtractConfig) IsEnabledForChat() bool {
if c.ChatEnabled != nil {
return *c.ChatEnabled
}
return c.IsEnabled()
}

// IsEnabledForResponses reports whether the translation runs on the OpenAI
// responses surface. Falls back to the global Enabled value when unset.
func (c ThinkExtractConfig) IsEnabledForResponses() bool {
if c.ResponsesEnabled != nil {
return *c.ResponsesEnabled
}
return c.IsEnabled()
}

// IsEnabledForMessages reports whether the translation runs on the
// Anthropic messages surface. The messages policy defaults to off, so the
// translation only runs when an operator opts in explicitly. The global
// Enabled switch is also authoritative — a global off kills the feature
// everywhere regardless of the per-surface policy.
func (c ThinkExtractConfig) IsEnabledForMessages() bool {
if !c.IsEnabled() {
return false
}
switch c.MessagesPolicy {
case "unsigned", "redacted":
return true
default:
return false
}
}

// MessagesPolicyOrDefault returns the configured messages policy, falling
// back to "off" when unset so the per-call site can rely on a non-empty
// value.
func (c ThinkExtractConfig) MessagesPolicyOrDefault() string {
if c.MessagesPolicy == "" {
return "off"
}
return c.MessagesPolicy
}
94 changes: 94 additions & 0 deletions config/thinkextract_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package config

import "testing"

func boolPtr(v bool) *bool { return &v }

func TestThinkExtractConfig_Defaults(t *testing.T) {
cfg := ThinkExtractConfig{}
if !cfg.IsEnabled() {
t.Errorf("zero config: IsEnabled=false, want true")
}
if !cfg.IsEnabledForChat() {
t.Errorf("zero config: IsEnabledForChat=false, want true")
}
if cfg.IsEnabledForMessages() {
t.Errorf("zero config: IsEnabledForMessages=true, want false (messages policy defaults to off)")
}
if got := cfg.MessagesPolicyOrDefault(); got != "off" {
t.Errorf("MessagesPolicyOrDefault=%q, want %q", got, "off")
}
}

func TestThinkExtractConfig_GlobalOff(t *testing.T) {
cfg := ThinkExtractConfig{Enabled: boolPtr(false)}
if cfg.IsEnabled() {
t.Errorf("IsEnabled=true, want false")
}
if cfg.IsEnabledForChat() {
t.Errorf("IsEnabledForChat=true, want false (falls back to global)")
}
if cfg.IsEnabledForMessages() {
t.Errorf("IsEnabledForMessages=true, want false (falls back to global)")
}
}

func TestThinkExtractConfig_PerSurfaceOverride(t *testing.T) {
cfg := ThinkExtractConfig{
Enabled: boolPtr(true),
ChatEnabled: boolPtr(false),
MessagesPolicy: "unsigned",
}
if !cfg.IsEnabled() {
t.Errorf("IsEnabled=false, want true")
}
if cfg.IsEnabledForChat() {
t.Errorf("IsEnabledForChat=true, want false (per-surface override)")
}
if !cfg.IsEnabledForMessages() {
t.Errorf("IsEnabledForMessages=false, want true (unsigned policy)")
}
}

func TestThinkExtractConfig_PerSurfaceTrueCannotResurrect(t *testing.T) {
cfg := ThinkExtractConfig{
Enabled: boolPtr(false),
ChatEnabled: boolPtr(true),
}
if cfg.IsEnabled() {
t.Errorf("IsEnabled=true, want false")
}
if !cfg.IsEnabledForChat() {
t.Errorf("IsEnabledForChat=false, want true (per-surface value)")
}
}

func TestThinkExtractConfig_MessagesPolicyParsing(t *testing.T) {
tests := []struct {
name string
cfg ThinkExtractConfig
want bool
}{
{name: "empty means off", cfg: ThinkExtractConfig{}, want: false},
{name: "off explicit", cfg: ThinkExtractConfig{MessagesPolicy: "off"}, want: false},
{name: "unsigned enables", cfg: ThinkExtractConfig{MessagesPolicy: "unsigned"}, want: true},
{name: "redacted enables", cfg: ThinkExtractConfig{MessagesPolicy: "redacted"}, want: true},
{name: "unknown falls back to off", cfg: ThinkExtractConfig{MessagesPolicy: "nonsense"}, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.cfg.IsEnabledForMessages(); got != tt.want {
t.Errorf("IsEnabledForMessages()=%v, want %v", got, tt.want)
}
})
}
}

func TestThinkExtractConfig_MessagesPolicyOrDefault(t *testing.T) {
if got := (ThinkExtractConfig{}).MessagesPolicyOrDefault(); got != "off" {
t.Errorf("empty cfg default=%q, want off", got)
}
if got := (ThinkExtractConfig{MessagesPolicy: "redacted"}).MessagesPolicyOrDefault(); got != "redacted" {
t.Errorf("explicit cfg default=%q, want redacted", got)
}
}
168 changes: 168 additions & 0 deletions internal/anthropicapi/policy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package anthropicapi

import (
"encoding/json"
"io"
"strings"
"testing"

"github.com/enterpilot/gomodel/internal/core"
"github.com/enterpilot/gomodel/internal/thinkextract"
)

func chatRespWithReasoning(reasoning string, synthesized bool) *core.ChatResponse {
extra := core.UnknownJSONFields{}
fields := map[string]json.RawMessage{
"reasoning_content": json.RawMessage(`"` + reasoning + `"`),
}
if synthesized {
fields[thinkextract.SynthesizedMarkerKey] = json.RawMessage("true")
}
merged, err := core.MergeUnknownJSONFields(extra, fields)
if err != nil {
panic(err)
}
return &core.ChatResponse{
Choices: []core.Choice{{
Message: core.ResponseMessage{
Role: "assistant",
Content: "answer",
ExtraFields: merged,
},
}},
}
}

func TestFromChatResponse_NativeReasoningUnchangedByPolicy(t *testing.T) {
// Provider-supplied reasoning (no synthesized marker) always renders as a
// thinking block regardless of the policy.
for _, policy := range []thinkextract.MessagesThinkingPolicy{
thinkextract.MessagesPolicyOff,
thinkextract.MessagesPolicyUnsigned,
thinkextract.MessagesPolicyRedacted,
} {
out := FromChatResponseWithPolicy(chatRespWithReasoning("native", false), policy)
if len(out.Content) != 2 || out.Content[0].Type != "thinking" || out.Content[0].Thinking != "native" {
t.Errorf("policy=%q: native reasoning must render as thinking block, got %+v", policy, out.Content)
}
}
}

func TestFromChatResponse_SynthesizedOff(t *testing.T) {
out := FromChatResponseWithPolicy(chatRespWithReasoning("synth", true), thinkextract.MessagesPolicyOff)
for _, b := range out.Content {
if b.Type == "thinking" || b.Type == "redacted_thinking" {
t.Errorf("off policy: synthesized reasoning leaked as %q", b.Type)
}
}
// Content text survives.
if len(out.Content) != 1 || out.Content[0].Type != "text" || out.Content[0].Text != "answer" {
t.Errorf("off policy: content=%+v, want single text block", out.Content)
}
}

func TestFromChatResponse_SynthesizedUnsigned(t *testing.T) {
out := FromChatResponseWithPolicy(chatRespWithReasoning("synth", true), thinkextract.MessagesPolicyUnsigned)
if len(out.Content) != 2 || out.Content[0].Type != "thinking" || out.Content[0].Thinking != "synth" {
t.Errorf("unsigned policy: got %+v, want thinking block first", out.Content)
}
}

func TestFromChatResponse_SynthesizedRedacted(t *testing.T) {
out := FromChatResponseWithPolicy(chatRespWithReasoning("synth", true), thinkextract.MessagesPolicyRedacted)
if len(out.Content) != 2 || out.Content[0].Type != "redacted_thinking" {
t.Fatalf("redacted policy: got %+v, want redacted_thinking first", out.Content)
}
var data string
if err := json.Unmarshal(out.Content[0].Data, &data); err != nil {
t.Fatalf("redacted data unmarshal: %v", err)
}
if data != "synth" {
t.Errorf("redacted data=%q, want %q", data, "synth")
}
}

func TestParseMessagesPolicy(t *testing.T) {
tests := []struct {
raw string
want thinkextract.MessagesThinkingPolicy
}{
{"", thinkextract.MessagesPolicyOff},
{"off", thinkextract.MessagesPolicyOff},
{"unsigned", thinkextract.MessagesPolicyUnsigned},
{"redacted", thinkextract.MessagesPolicyRedacted},
{"garbage", thinkextract.MessagesPolicyOff},
}
for _, tt := range tests {
if got := thinkextract.ParseMessagesPolicy(tt.raw); got != tt.want {
t.Errorf("ParseMessagesPolicy(%q)=%q, want %q", tt.raw, got, tt.want)
}
}
}

func TestStreamConverter_PolicyOffDropsSynthesized(t *testing.T) {
input := "data: {\"id\":\"x\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"r\",\"thinkextract_synthesized\":true}}]}\n\n" +
"data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"answer\"}}]}\n\n" +
"data: [DONE]\n"
rc := NewStreamConverterWithPolicy(io.NopCloser(strings.NewReader(input)), "m", 0, thinkextract.MessagesPolicyOff)
defer rc.Close()
out, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("ReadAll: %v", err)
}
s := string(out)
if strings.Contains(s, "thinking_delta") {
t.Errorf("off policy: synthesized thinking leaked: %q", s)
}
if !strings.Contains(s, "answer") {
t.Errorf("content text dropped: %q", s)
}
}

func TestStreamConverter_PolicyUnsignedEmitsThinking(t *testing.T) {
input := "data: {\"id\":\"x\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"r\",\"thinkextract_synthesized\":true}}]}\n\n" +
"data: [DONE]\n"
rc := NewStreamConverterWithPolicy(io.NopCloser(strings.NewReader(input)), "m", 0, thinkextract.MessagesPolicyUnsigned)
defer rc.Close()
out, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("ReadAll: %v", err)
}
s := string(out)
if !strings.Contains(s, "\"thinking\"") {
t.Errorf("unsigned policy: no thinking block: %q", s)
}
if strings.Contains(s, "thinkextract_synthesized") {
t.Errorf("marker leaked to wire: %q", s)
}
}

func TestStreamConverter_PolicyRedactedEmitsRedacted(t *testing.T) {
input := "data: {\"id\":\"x\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"r\",\"thinkextract_synthesized\":true}}]}\n\n" +
"data: [DONE]\n"
rc := NewStreamConverterWithPolicy(io.NopCloser(strings.NewReader(input)), "m", 0, thinkextract.MessagesPolicyRedacted)
defer rc.Close()
out, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("ReadAll: %v", err)
}
s := string(out)
if !strings.Contains(s, "redacted_thinking") {
t.Errorf("redacted policy: no redacted_thinking block: %q", s)
}
}

func TestStreamConverter_NativeReasoningUnaffected(t *testing.T) {
// No marker: provider-native reasoning always renders as thinking.
input := "data: {\"id\":\"x\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"native\"}}]}\n\n" +
"data: [DONE]\n"
rc := NewStreamConverterWithPolicy(io.NopCloser(strings.NewReader(input)), "m", 0, thinkextract.MessagesPolicyOff)
defer rc.Close()
out, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("ReadAll: %v", err)
}
if !strings.Contains(string(out), "thinking_delta") {
t.Errorf("native reasoning dropped under off policy: %q", string(out))
}
}
Loading
Loading