From 904fa6c8dfb0558e79dd9c8aa2ab6da82da33abc Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Mon, 13 Jul 2026 16:14:55 +0700 Subject: [PATCH] feat(llms): add ReasoningSupport capability introspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ReasoningSupportFor, a best-effort static hint describing which reasoning controls a model accepts (supported, disablable, sampling-rejecting, effort tiers, default-on state) so a UI can render the right options per model. It is optimistic — an unrecognized model returns Known=false so the UI shows all controls and the provider API is the ultimate arbiter — and asserts only known facts, leaving unknown effort tiers and default state nil rather than guessed. RegisterReasoningSupport feeds proxy aliases or newer models into the resolver. Co-Authored-By: Claude Opus 4.8 --- llms/reasoning_support.go | 118 +++++++++++++++++++++++++++++++++ llms/reasoning_support_test.go | 95 ++++++++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 llms/reasoning_support.go create mode 100644 llms/reasoning_support_test.go diff --git a/llms/reasoning_support.go b/llms/reasoning_support.go new file mode 100644 index 00000000..6a9058ef --- /dev/null +++ b/llms/reasoning_support.go @@ -0,0 +1,118 @@ +package llms + +import ( + "strings" + "sync" + + "github.com/vxcontrol/langchaingo/llms/reasoning" +) + +// ReasoningSupport describes which reasoning controls a model accepts, as a HINT +// for building a UI. It is a best-effort static projection, never authoritative: +// an unrecognized model returns Known=false so the UI shows all controls and the +// provider API (an HTTP 400) is the ultimate arbiter. The table asserts only +// KNOWN facts; it never fabricates a capability it cannot back — unknown effort +// tiers and default state are left nil rather than guessed. +type ReasoningSupport struct { + // Supported reports whether the model reasons at all. + Supported bool + // Known reports whether this model is explicitly classified. When false, the + // remaining fields are best-effort and the UI should offer all controls. + Known bool + // CannotDisable is set only for models KNOWN to reject disabling + // (always-on Claude such as Fable 5, and OpenAI o-series). When false, + // disabling may still fail at the API for an unclassified model. + CannotDisable bool + // RejectsSampling is set for models that reject temperature/top_p while thinking. + RejectsSampling bool + // Efforts are the effort tiers worth offering; nil when unknown. + Efforts []ReasoningEffort + // DefaultOn reports whether thinking runs when reasoning is unset; nil when unknown. + DefaultOn *bool +} + +var ( + reasoningOverridesMu sync.RWMutex + reasoningOverrides = map[string]ReasoningSupport{} +) + +// RegisterReasoningSupport lets a deployment feed model knowledge INTO the single +// resolver instead of forking a parallel table — e.g. a proxy alias or a model +// newer than this build. A model whose string contains pattern uses info. Later +// registrations override earlier ones. +func RegisterReasoningSupport(pattern string, info ReasoningSupport) { + reasoningOverridesMu.Lock() + defer reasoningOverridesMu.Unlock() + reasoningOverrides[strings.ToLower(pattern)] = info +} + +func lookupReasoningOverride(model string) (ReasoningSupport, bool) { + reasoningOverridesMu.RLock() + defer reasoningOverridesMu.RUnlock() + m := strings.ToLower(model) + for pattern, info := range reasoningOverrides { + if strings.Contains(m, pattern) { + return info, true + } + } + return ReasoningSupport{}, false +} + +func boolPtr(b bool) *bool { return &b } + +// ReasoningSupportFor returns the reasoning-control hint for a model on a +// provider. Registered overrides win; then Claude and OpenAI reasoning models are +// classified from the shared tables; everything else returns Known=false +// (optimistic — the UI shows all controls and the API rejects what it cannot do). +func ReasoningSupportFor(model string, p reasoning.Provider) ReasoningSupport { + if s, ok := lookupReasoningOverride(model); ok { + return s + } + + if reasoning.ClaudeSupportsThinking(model) { + return ReasoningSupport{ + Supported: true, + Known: true, + CannotDisable: reasoning.ClaudeThinkingAlwaysOn(model), + RejectsSampling: reasoning.ClaudeRejectsSampling(model), + Efforts: claudeEfforts(reasoning.ClaudeReasoningKindFor(model)), + DefaultOn: boolPtr(reasoning.ClaudeThinkingDefaultsOn(model)), + } + } + + if p == reasoning.ProviderOpenAI && reasoning.IsReasoningModel(model) { + return ReasoningSupport{ + Supported: true, + Known: true, + CannotDisable: reasoning.ResolveOff(model, p) == reasoning.OffUnsupported, + Efforts: []ReasoningEffort{ReasoningLow, ReasoningMedium, ReasoningHigh}, + // DefaultOn is per-model on the GPT-5.x line (some default off) — leave unknown. + } + } + + if p == reasoning.ProviderGoogleAI && strings.Contains(strings.ToLower(model), "gemini-2.5") { + return ReasoningSupport{ + Supported: true, + Known: true, + // Gemini 2.5 thinks by default and is disablable via thinkingBudget:0. + DefaultOn: boolPtr(true), + } + } + + // Unrecognized: optimistic. Supported best-guessed from the shared classifier; + // the UI shows all controls and the provider teaches via a 400. + return ReasoningSupport{Supported: reasoning.IsReasoningModel(model), Known: false} +} + +func claudeEfforts(kind reasoning.ClaudeReasoningKind) []ReasoningEffort { + switch kind { + case reasoning.ClaudeReasoningAdaptiveOnly: + return []ReasoningEffort{ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh, ReasoningMax} + case reasoning.ClaudeReasoningAdaptiveAndBudget: + return []ReasoningEffort{ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningMax} + case reasoning.ClaudeReasoningBudgetOnly: + return []ReasoningEffort{ReasoningLow, ReasoningMedium, ReasoningHigh} + default: + return nil + } +} diff --git a/llms/reasoning_support_test.go b/llms/reasoning_support_test.go new file mode 100644 index 00000000..90b93856 --- /dev/null +++ b/llms/reasoning_support_test.go @@ -0,0 +1,95 @@ +package llms + +import ( + "testing" + + "github.com/vxcontrol/langchaingo/llms/reasoning" +) + +func TestReasoningSupportFor(t *testing.T) { + t.Parallel() + + boolp := func(b bool) *bool { return &b } + eq := func(t *testing.T, name string, got, want any) { + t.Helper() + if got != want { + t.Errorf("%s = %v, want %v", name, got, want) + } + } + + t.Run("adaptive-only Claude cannot disable and rejects sampling", func(t *testing.T) { + s := ReasoningSupportFor("us.anthropic.claude-fable-5-v1:0", reasoning.ProviderBedrock) + eq(t, "Supported", s.Supported, true) + eq(t, "Known", s.Known, true) + eq(t, "CannotDisable", s.CannotDisable, true) + eq(t, "RejectsSampling", s.RejectsSampling, true) + if s.DefaultOn == nil || !*s.DefaultOn { + t.Errorf("Fable 5 DefaultOn = %v, want true", s.DefaultOn) + } + if len(s.Efforts) == 0 { + t.Error("adaptive-only Claude should advertise effort tiers") + } + }) + + t.Run("adaptive-only off-by-default Claude is disablable", func(t *testing.T) { + s := ReasoningSupportFor("claude-opus-4-8", reasoning.ProviderAnthropic) + eq(t, "CannotDisable", s.CannotDisable, false) + eq(t, "RejectsSampling", s.RejectsSampling, true) + if s.DefaultOn == nil || *s.DefaultOn { + t.Errorf("Opus 4.8 DefaultOn = %v, want false", s.DefaultOn) + } + }) + + t.Run("budget-only Claude allows sampling and disable", func(t *testing.T) { + s := ReasoningSupportFor("claude-sonnet-4-5", reasoning.ProviderAnthropic) + eq(t, "Known", s.Known, true) + eq(t, "CannotDisable", s.CannotDisable, false) + eq(t, "RejectsSampling", s.RejectsSampling, false) + if s.DefaultOn == nil || *s.DefaultOn { + t.Errorf("Sonnet 4.5 DefaultOn = %v, want false", s.DefaultOn) + } + }) + + t.Run("OpenAI o-series cannot disable", func(t *testing.T) { + s := ReasoningSupportFor("o3-mini", reasoning.ProviderOpenAI) + eq(t, "Supported", s.Supported, true) + eq(t, "Known", s.Known, true) + eq(t, "CannotDisable", s.CannotDisable, true) + }) + + t.Run("GPT-5.x can disable", func(t *testing.T) { + s := ReasoningSupportFor("gpt-5.5", reasoning.ProviderOpenAI) + eq(t, "Supported", s.Supported, true) + eq(t, "CannotDisable", s.CannotDisable, false) + if s.DefaultOn != nil { + t.Errorf("GPT-5.x DefaultOn should be unknown (nil), got %v", *s.DefaultOn) + } + }) + + t.Run("Gemini 2.5 defaults on", func(t *testing.T) { + s := ReasoningSupportFor("gemini-2.5-flash", reasoning.ProviderGoogleAI) + eq(t, "Supported", s.Supported, true) + if s.DefaultOn == nil || !*s.DefaultOn { + t.Errorf("Gemini 2.5 DefaultOn = %v, want true", s.DefaultOn) + } + }) + + t.Run("unknown model is optimistic", func(t *testing.T) { + s := ReasoningSupportFor("some-future-model", reasoning.ProviderUnknown) + eq(t, "Known", s.Known, false) + eq(t, "CannotDisable", s.CannotDisable, false) + }) + + t.Run("registered override wins", func(t *testing.T) { + RegisterReasoningSupport("acme-proxy-thinker", ReasoningSupport{ + Supported: true, Known: true, DefaultOn: boolp(true), + Efforts: []ReasoningEffort{ReasoningLow, ReasoningHigh}, + }) + s := ReasoningSupportFor("acme-proxy-thinker-v2", reasoning.ProviderUnknown) + eq(t, "Known", s.Known, true) + eq(t, "Supported", s.Supported, true) + if s.DefaultOn == nil || !*s.DefaultOn { + t.Errorf("override DefaultOn = %v, want true", s.DefaultOn) + } + }) +}