feat(llms): add ReasoningSupport capability introspection

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 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-13 16:14:55 +07:00
parent f6774dad78
commit 904fa6c8df
2 changed files with 213 additions and 0 deletions
+118
View File
@@ -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
}
}
+95
View File
@@ -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)
}
})
}