mirror of
https://github.com/vxcontrol/langchaingo.git
synced 2026-07-19 21:54:17 -04:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user