feat(reasoning): add three-state On/Off/Default reasoning control

Add an explicit reasoning Off to the per-agent config, backed by the langchaingo
tri-state API. Off emits llms.WithReasoningDisabled() (the provider disable wire)
via BuildOptions, and UsesAdaptiveThinking is guarded so Off overrides the
adaptive-only auto-adaptive.

Per-model capability is derived at runtime from llms.ReasoningSupportFor and
surfaced through GraphQL ModelReasoningInfo, so the UI only offers Off where it
actually disables: cannotDisable reports when Off would be rejected (always-on
models) OR a silent no-op (an unclassified default-on model whose disable wire is
omitted), and capability is surfaced for any thinking-capable model (e.g. Gemini,
which declares thinking without a reasoning block). A new
ProviderType.ReasoningProvider() supplies the provider to the resolver.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-13 19:50:43 +07:00
co-authored by Claude Opus 4.8
parent ffbbabb517
commit 2853c97549
10 changed files with 389 additions and 29 deletions
+43 -10
View File
@@ -12,6 +12,7 @@ import (
"pentagi/pkg/tools"
"github.com/vxcontrol/langchaingo/llms"
"github.com/vxcontrol/langchaingo/llms/reasoning"
)
func ConvertFlows(flows []database.Flow, containers []database.Container) []*model.Flow {
@@ -537,7 +538,7 @@ func ConvertFlowTemplates(templates []database.FlowTemplate) []*model.FlowTempla
return result
}
func ConvertModels(models pconfig.ModelsConfig) []*model.ModelConfig {
func ConvertModels(models pconfig.ModelsConfig, rp reasoning.Provider) []*model.ModelConfig {
gmodels := make([]*model.ModelConfig, 0, len(models))
for _, m := range models {
modelConfig := &model.ModelConfig{
@@ -562,16 +563,33 @@ func ConvertModels(models pconfig.ModelsConfig) []*model.ModelConfig {
if m.Thinking != nil {
modelConfig.Thinking = m.Thinking
}
if m.Reasoning != nil {
reasoning := &model.ModelReasoningInfo{}
if m.Reasoning.Mode != pconfig.ModelReasoningNone {
mode := convertModelReasoningMode(m.Reasoning.Mode)
reasoning.Mode = &mode
// Surface reasoning capability for any thinking-capable model, not only
// those with an explicit reasoning block: models like Gemini declare
// thinking:true with no reasoning section, yet Off (thinkingBudget:0) is
// meaningful and supported.
if m.Reasoning != nil || (m.Thinking != nil && *m.Thinking) {
reasoningInfo := &model.ModelReasoningInfo{}
if m.Reasoning != nil {
if m.Reasoning.Mode != pconfig.ModelReasoningNone {
mode := convertModelReasoningMode(m.Reasoning.Mode)
reasoningInfo.Mode = &mode
}
for _, effort := range m.Reasoning.Efforts {
reasoningInfo.Efforts = append(reasoningInfo.Efforts, model.ReasoningEffort(effort))
}
}
for _, effort := range m.Reasoning.Efforts {
reasoning.Efforts = append(reasoning.Efforts, model.ReasoningEffort(effort))
}
modelConfig.Reasoning = reasoning
// Capability is derived from the langchaingo reasoning tables (single
// source of truth). cannotDisable reports whether Off would take no
// effect — either the API rejects it, or the disable wire is omitted on
// a model that is NOT off by default (an unclassified default-on model
// where Off is a silent no-op) — so the UI only offers Off when it works.
support := llms.ReasoningSupportFor(m.Name, rp)
supported := support.Supported
cannotDisable := !offEffective(reasoning.ResolveOff(m.Name, rp), support.DefaultOn)
reasoningInfo.Supported = &supported
reasoningInfo.CannotDisable = &cannotDisable
reasoningInfo.DefaultOn = support.DefaultOn
modelConfig.Reasoning = reasoningInfo
}
gmodels = append(gmodels, modelConfig)
@@ -580,6 +598,21 @@ func ConvertModels(models pconfig.ModelsConfig) []*model.ModelConfig {
return gmodels
}
// offEffective reports whether turning reasoning Off actually disables thinking:
// a real disable wire always does; an omitted wire only does when the model is
// known to be off by default. A silent no-op (unclassified default-on model) or an
// API rejection (OffUnsupported) does not, so the UI must not offer Off there.
func offEffective(off reasoning.OffWire, defaultOn *bool) bool {
switch off {
case reasoning.OffDisableClaude, reasoning.OffZeroBudget, reasoning.OffEffortNone:
return true
case reasoning.OffOmit:
return defaultOn != nil && !*defaultOn
default: // OffUnsupported
return false
}
}
// Bridges the one spelling difference between the pconfig and GraphQL reasoning
// enums: adaptive-only (hyphen) vs adaptive_only (underscore).
func convertModelReasoningMode(m pconfig.ModelReasoningMode) model.ModelReasoningMode {
@@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/vxcontrol/langchaingo/llms"
"github.com/vxcontrol/langchaingo/llms/reasoning"
)
func TestIsAgentTool(t *testing.T) {
@@ -76,7 +77,7 @@ func TestConvertModelsReasoning(t *testing.T) {
}
byName := make(map[string]*model.ModelConfig)
for _, m := range ConvertModels(models) {
for _, m := range ConvertModels(models, reasoning.ProviderUnknown) {
byName[m.Name] = m
}
@@ -92,3 +93,83 @@ func TestConvertModelsReasoning(t *testing.T) {
assert.Nil(t, byName["none"].Reasoning, "model without a reasoning descriptor maps to nil")
}
func TestConvertModels_OffCapability(t *testing.T) {
tr := func(b bool) *bool { return &b }
models := pconfig.ModelsConfig{
// Gemini: thinking:true, no reasoning block -> capability still surfaced,
// Off effective via thinkingBudget:0.
{Name: "gemini-2.5-flash", Thinking: tr(true)},
// Unclassified OpenAI-family reasoning model: Off is a silent no-op (OffOmit,
// default-on unknown) -> cannotDisable must be true so the UI hides Off.
{Name: "glm-5.2", Reasoning: &pconfig.ModelReasoningInfo{Mode: pconfig.ModelReasoningBudget}},
// Adaptive-only but off by default: Off works by omission -> disablable.
{Name: "claude-opus-4-8", Reasoning: &pconfig.ModelReasoningInfo{Mode: pconfig.ModelReasoningAdaptiveOnly}},
// Always-on Claude: Off is rejected (OffUnsupported) -> not disablable.
{Name: "claude-fable-5", Reasoning: &pconfig.ModelReasoningInfo{Mode: pconfig.ModelReasoningAdaptiveOnly}},
// Plain non-thinking model: no reasoning capability at all.
{Name: "gpt-4.1"},
}
byProvider := map[string]reasoning.Provider{
"gemini-2.5-flash": reasoning.ProviderGoogleAI,
"glm-5.2": reasoning.ProviderOpenAI,
"claude-opus-4-8": reasoning.ProviderAnthropic,
"claude-fable-5": reasoning.ProviderAnthropic,
"gpt-4.1": reasoning.ProviderOpenAI,
}
get := func(name string) *model.ModelReasoningInfo {
for _, mc := range ConvertModels(pconfig.ModelsConfig{lookup(models, name)}, byProvider[name]) {
if mc.Name == name {
return mc.Reasoning
}
}
return nil
}
// Gemini: capability present, Off effective.
gem := get("gemini-2.5-flash")
require.NotNil(t, gem, "thinking-capable model must surface reasoning capability")
require.NotNil(t, gem.CannotDisable)
assert.False(t, *gem.CannotDisable, "Gemini Off works via thinkingBudget:0")
// glm-5.2: Off is a no-op -> cannotDisable true (UI hides Off).
glm := get("glm-5.2")
require.NotNil(t, glm)
require.NotNil(t, glm.CannotDisable)
assert.True(t, *glm.CannotDisable, "unclassified default-on model: Off is a no-op, must hide it")
// opus-4-8: off by default -> disablable.
opus := get("claude-opus-4-8")
require.NotNil(t, opus.CannotDisable)
assert.False(t, *opus.CannotDisable, "off-by-default model: Off works by omission")
// fable-5: always on -> not disablable.
fable := get("claude-fable-5")
require.NotNil(t, fable.CannotDisable)
assert.True(t, *fable.CannotDisable, "always-on model rejects Off")
// non-thinking model: no capability.
assert.Nil(t, get("gpt-4.1"), "non-thinking model has no reasoning capability")
}
func lookup(ms pconfig.ModelsConfig, name string) pconfig.ModelConfig {
for _, m := range ms {
if m.Name == name {
return m
}
}
return pconfig.ModelConfig{}
}
func TestOffEffective(t *testing.T) {
tr := func(b bool) *bool { return &b }
assert.True(t, offEffective(reasoning.OffDisableClaude, nil))
assert.True(t, offEffective(reasoning.OffZeroBudget, nil))
assert.True(t, offEffective(reasoning.OffEffortNone, nil))
assert.True(t, offEffective(reasoning.OffOmit, tr(false)), "off-by-default: omit disables")
assert.False(t, offEffective(reasoning.OffOmit, tr(true)), "default-on + omit = no-op")
assert.False(t, offEffective(reasoning.OffOmit, nil), "unknown default + omit = assume no-op")
assert.False(t, offEffective(reasoning.OffUnsupported, nil), "rejected")
}
+161 -2
View File
@@ -341,8 +341,11 @@ type ComplexityRoot struct {
}
ModelReasoningInfo struct {
Efforts func(childComplexity int) int
Mode func(childComplexity int) int
CannotDisable func(childComplexity int) int
DefaultOn func(childComplexity int) int
Efforts func(childComplexity int) int
Mode func(childComplexity int) int
Supported func(childComplexity int) int
}
ModelUsageStats struct {
@@ -2242,6 +2245,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.ModelPrice.Output(childComplexity), true
case "ModelReasoningInfo.cannotDisable":
if e.complexity.ModelReasoningInfo.CannotDisable == nil {
break
}
return e.complexity.ModelReasoningInfo.CannotDisable(childComplexity), true
case "ModelReasoningInfo.defaultOn":
if e.complexity.ModelReasoningInfo.DefaultOn == nil {
break
}
return e.complexity.ModelReasoningInfo.DefaultOn(childComplexity), true
case "ModelReasoningInfo.efforts":
if e.complexity.ModelReasoningInfo.Efforts == nil {
break
@@ -2256,6 +2273,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.ModelReasoningInfo.Mode(childComplexity), true
case "ModelReasoningInfo.supported":
if e.complexity.ModelReasoningInfo.Supported == nil {
break
}
return e.complexity.ModelReasoningInfo.Supported(childComplexity), true
case "ModelUsageStats.model":
if e.complexity.ModelUsageStats.Model == nil {
break
@@ -17550,6 +17574,12 @@ func (ec *executionContext) fieldContext_ModelConfig_reasoning(_ context.Context
return ec.fieldContext_ModelReasoningInfo_mode(ctx, field)
case "efforts":
return ec.fieldContext_ModelReasoningInfo_efforts(ctx, field)
case "supported":
return ec.fieldContext_ModelReasoningInfo_supported(ctx, field)
case "cannotDisable":
return ec.fieldContext_ModelReasoningInfo_cannotDisable(ctx, field)
case "defaultOn":
return ec.fieldContext_ModelReasoningInfo_defaultOn(ctx, field)
}
return nil, fmt.Errorf("no field named %q was found under type ModelReasoningInfo", field.Name)
},
@@ -17866,6 +17896,129 @@ func (ec *executionContext) fieldContext_ModelReasoningInfo_efforts(_ context.Co
return fc, nil
}
func (ec *executionContext) _ModelReasoningInfo_supported(ctx context.Context, field graphql.CollectedField, obj *model.ModelReasoningInfo) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_ModelReasoningInfo_supported(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return obj.Supported, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
return graphql.Null
}
res := resTmp.(*bool)
fc.Result = res
return ec.marshalOBoolean2ᚖbool(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_ModelReasoningInfo_supported(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "ModelReasoningInfo",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type Boolean does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _ModelReasoningInfo_cannotDisable(ctx context.Context, field graphql.CollectedField, obj *model.ModelReasoningInfo) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_ModelReasoningInfo_cannotDisable(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return obj.CannotDisable, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
return graphql.Null
}
res := resTmp.(*bool)
fc.Result = res
return ec.marshalOBoolean2ᚖbool(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_ModelReasoningInfo_cannotDisable(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "ModelReasoningInfo",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type Boolean does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _ModelReasoningInfo_defaultOn(ctx context.Context, field graphql.CollectedField, obj *model.ModelReasoningInfo) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_ModelReasoningInfo_defaultOn(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return obj.DefaultOn, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
return graphql.Null
}
res := resTmp.(*bool)
fc.Result = res
return ec.marshalOBoolean2ᚖbool(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_ModelReasoningInfo_defaultOn(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "ModelReasoningInfo",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type Boolean does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _ModelUsageStats_model(ctx context.Context, field graphql.CollectedField, obj *model.ModelUsageStats) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_ModelUsageStats_model(ctx, field)
if err != nil {
@@ -39088,6 +39241,12 @@ func (ec *executionContext) _ModelReasoningInfo(ctx context.Context, sel ast.Sel
out.Values[i] = ec._ModelReasoningInfo_mode(ctx, field, obj)
case "efforts":
out.Values[i] = ec._ModelReasoningInfo_efforts(ctx, field, obj)
case "supported":
out.Values[i] = ec._ModelReasoningInfo_supported(ctx, field, obj)
case "cannotDisable":
out.Values[i] = ec._ModelReasoningInfo_cannotDisable(ctx, field, obj)
case "defaultOn":
out.Values[i] = ec._ModelReasoningInfo_defaultOn(ctx, field, obj)
default:
panic("unknown field " + strconv.Quote(field.Name))
}
+8 -3
View File
@@ -329,8 +329,11 @@ type ModelPrice struct {
}
type ModelReasoningInfo struct {
Mode *ModelReasoningMode `json:"mode,omitempty"`
Efforts []ReasoningEffort `json:"efforts,omitempty"`
Mode *ModelReasoningMode `json:"mode,omitempty"`
Efforts []ReasoningEffort `json:"efforts,omitempty"`
Supported *bool `json:"supported,omitempty"`
CannotDisable *bool `json:"cannotDisable,omitempty"`
DefaultOn *bool `json:"defaultOn,omitempty"`
}
type ModelUsageStats struct {
@@ -1285,16 +1288,18 @@ type ReasoningMode string
const (
ReasoningModeAdaptive ReasoningMode = "adaptive"
ReasoningModeBudget ReasoningMode = "budget"
ReasoningModeOff ReasoningMode = "off"
)
var AllReasoningMode = []ReasoningMode{
ReasoningModeAdaptive,
ReasoningModeBudget,
ReasoningModeOff,
}
func (e ReasoningMode) IsValid() bool {
switch e {
case ReasoningModeAdaptive, ReasoningModeBudget:
case ReasoningModeAdaptive, ReasoningModeBudget, ReasoningModeOff:
return true
}
return false
+12 -2
View File
@@ -33,10 +33,13 @@ enum ReasoningEffort {
low
}
# Reasoning control mode for provider-specific thinking APIs
# Reasoning control mode for provider-specific thinking APIs. "off" explicitly
# disables thinking (distinct from an absent mode, which defers to the model
# default — models like Gemini 2.5 / Claude Fable 5 think by default).
enum ReasoningMode {
adaptive
budget
off
}
# A model's reasoning capability — distinct from the per-agent ReasoningMode
@@ -48,10 +51,17 @@ enum ModelReasoningMode {
}
# Declares how a model supports extended thinking so the UI can offer a valid
# mode/effort without hardcoding model names.
# mode/effort/on-off without hardcoding model names. The capability fields are
# derived from the langchaingo reasoning tables. An absent reasoning object means
# the model is not thinking-capable (no reasoning controls); cannotDisable=true
# hides the off option (the disable would error or be a no-op); defaultOn=true
# means the model thinks unless explicitly turned off.
type ModelReasoningInfo {
mode: ModelReasoningMode
efforts: [ReasoningEffort!]
supported: Boolean
cannotDisable: Boolean
defaultOn: Boolean
}
# Template types for AI agent prompts and system operations
+11 -11
View File
@@ -2124,22 +2124,22 @@ func (r *queryResolver) SettingsProviders(ctx context.Context) (*model.Providers
case provider.ProviderOpenAI:
config.Default.Openai = mpcfg
if models, err := openai.DefaultModels(); err == nil {
config.Models.Openai = converter.ConvertModels(models)
config.Models.Openai = converter.ConvertModels(models, prvtype.ReasoningProvider())
}
case provider.ProviderAnthropic:
config.Default.Anthropic = mpcfg
if models, err := anthropic.DefaultModels(); err == nil {
config.Models.Anthropic = converter.ConvertModels(models)
config.Models.Anthropic = converter.ConvertModels(models, prvtype.ReasoningProvider())
}
case provider.ProviderGemini:
config.Default.Gemini = mpcfg
if models, err := gemini.DefaultModels(); err == nil {
config.Models.Gemini = converter.ConvertModels(models)
config.Models.Gemini = converter.ConvertModels(models, prvtype.ReasoningProvider())
}
case provider.ProviderBedrock:
config.Default.Bedrock = mpcfg
if models, err := bedrock.DefaultModels(r.Config); err == nil {
config.Models.Bedrock = converter.ConvertModels(models)
config.Models.Bedrock = converter.ConvertModels(models, prvtype.ReasoningProvider())
} else {
// A bad BEDROCK_MODELS_PATH otherwise yields an empty model list with no signal.
r.Logger.WithError(err).Warn("failed to load bedrock models")
@@ -2151,27 +2151,27 @@ func (r *queryResolver) SettingsProviders(ctx context.Context) (*model.Providers
case provider.ProviderDeepSeek:
config.Default.Deepseek = mpcfg
if models, err := deepseek.DefaultModels(); err == nil {
config.Models.Deepseek = converter.ConvertModels(models)
config.Models.Deepseek = converter.ConvertModels(models, prvtype.ReasoningProvider())
}
case provider.ProviderGLM:
config.Default.Glm = mpcfg
if models, err := glm.DefaultModels(); err == nil {
config.Models.Glm = converter.ConvertModels(models)
config.Models.Glm = converter.ConvertModels(models, prvtype.ReasoningProvider())
}
case provider.ProviderKimi:
config.Default.Kimi = mpcfg
if models, err := kimi.DefaultModels(); err == nil {
config.Models.Kimi = converter.ConvertModels(models)
config.Models.Kimi = converter.ConvertModels(models, prvtype.ReasoningProvider())
}
case provider.ProviderQwen:
config.Default.Qwen = mpcfg
if models, err := qwen.DefaultModels(); err == nil {
config.Models.Qwen = converter.ConvertModels(models)
config.Models.Qwen = converter.ConvertModels(models, prvtype.ReasoningProvider())
}
case provider.ProviderMiniMax:
config.Default.Minimax = mpcfg
if models, err := minimax.DefaultModels(); err == nil {
config.Models.Minimax = converter.ConvertModels(models)
config.Models.Minimax = converter.ConvertModels(models, prvtype.ReasoningProvider())
}
}
}
@@ -2190,12 +2190,12 @@ func (r *queryResolver) SettingsProviders(ctx context.Context) (*model.Providers
case provider.ProviderOllama:
config.Enabled.Ollama = true
if p, ok := defaultProviders[provider.DefaultProviderNameOllama]; ok {
config.Models.Ollama = converter.ConvertModels(p.GetModels())
config.Models.Ollama = converter.ConvertModels(p.GetModels(), prvtype.ReasoningProvider())
}
case provider.ProviderCustom:
config.Enabled.Custom = true
if p, ok := defaultProviders[provider.DefaultProviderNameCustom]; ok {
config.Models.Custom = converter.ConvertModels(p.GetModels())
config.Models.Custom = converter.ConvertModels(p.GetModels(), prvtype.ReasoningProvider())
}
case provider.ProviderDeepSeek:
config.Enabled.Deepseek = true
+12
View File
@@ -212,6 +212,10 @@ const (
ReasoningModeDefault ReasoningMode = ""
ReasoningModeAdaptive ReasoningMode = "adaptive"
ReasoningModeBudget ReasoningMode = "budget"
// ReasoningModeOff explicitly disables thinking. It is distinct from Default:
// Default defers to the model (models like Gemini 2.5 / Claude Fable 5 think
// by default), while Off forces the provider's disable wire via langchaingo.
ReasoningModeOff ReasoningMode = "off"
)
type ReasoningConfig struct {
@@ -715,6 +719,8 @@ func (ac *AgentConfig) BuildOptions() []llms.CallOption {
}
if _, ok := ac.raw["reasoning"]; ok && !ac.Reasoning.IsZero() {
switch ac.Reasoning.EffectiveMode() {
case ReasoningModeOff:
options = append(options, llms.WithReasoningDisabled())
case ReasoningModeAdaptive:
// Adaptive thinking is applied per-call by PrepareAdaptiveCallOptions
// (it appends llms.WithAdaptiveReasoning); no CallOption is emitted here.
@@ -997,6 +1003,12 @@ func (pc *ProviderConfig) reasoningConfigForType(opt ProviderOptionsType) (Reaso
// thinking: the agent selected adaptive mode, or the model only supports adaptive
// (e.g. Opus 4.7/4.8, where budget thinking returns a 400).
func (pc *ProviderConfig) UsesAdaptiveThinking(models ModelsConfig, opt ProviderOptionsType) bool {
// An explicit off wins over the adaptive-only auto-adaptive below: the caller
// disabled thinking, so no adaptive CallOption may be appended (the disable is
// emitted in BuildOptions instead).
if reasoning, ok := pc.reasoningConfigForType(opt); ok && reasoning.EffectiveMode() == ReasoningModeOff {
return false
}
if pc.modelReasoningMode(models, opt) == ModelReasoningAdaptiveOnly {
return true
}
@@ -29,6 +29,8 @@ func TestProviderConfig_UsesAdaptiveThinking(t *testing.T) {
adaptiveOnly, &AgentConfig{Model: "opus-4-8"}, true},
{"adaptive-only model overrides an agent budget choice",
adaptiveOnly, &AgentConfig{Model: "opus-4-8", Reasoning: ReasoningConfig{Mode: ReasoningModeBudget, MaxTokens: 4096}}, true},
{"explicit off wins over the adaptive-only auto-adaptive",
adaptiveOnly, &AgentConfig{Model: "opus-4-8", Reasoning: ReasoningConfig{Mode: ReasoningModeOff}}, false},
{"agent selects adaptive on an adaptive-capable model",
adaptiveCapable, &AgentConfig{Model: "opus-4-6", Reasoning: ReasoningConfig{Mode: ReasoningModeAdaptive}}, true},
{"agent selects budget on an adaptive-capable model",
@@ -0,0 +1,39 @@
package pconfig
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/vxcontrol/langchaingo/llms"
"gopkg.in/yaml.v3"
)
// An explicit off mode must emit llms.WithReasoningDisabled() so the langchaingo
// adapter sends the provider's disable wire.
func TestProviderConfig_EmitsDisableOnOffMode(t *testing.T) {
var ac AgentConfig
require.NoError(t, yaml.Unmarshal([]byte("model: gpt-5.5\nreasoning:\n mode: \"off\"\n"), &ac))
require.Equal(t, ReasoningModeOff, ac.Reasoning.Mode)
require.False(t, ac.Reasoning.IsZero(), "off must be non-zero so BuildOptions emits it")
require.Equal(t, ReasoningModeOff, ac.Reasoning.EffectiveMode())
pc := &ProviderConfig{Simple: &ac}
var applied llms.CallOptions
for _, opt := range pc.GetOptionsForType(OptionsTypeSimple) {
opt(&applied)
}
require.NotNil(t, applied.Reasoning, "off must emit a reasoning option")
assert.True(t, applied.Reasoning.IsDisabled(), "off must disable reasoning on the call options")
}
// gopkg.in/yaml.v3 follows YAML 1.2 core (only true/false are booleans), so an
// unquoted off in a user-authored provider YAML parses as the string mode, not a
// boolean. This guards the user-editable config path against the YAML 1.1 off gotcha.
func TestReasoningConfig_UnquotedOffParsesAsMode(t *testing.T) {
var ac AgentConfig
require.NoError(t, yaml.Unmarshal([]byte("model: gpt-5.5\nreasoning:\n mode: off\n"), &ac))
assert.Equal(t, ReasoningModeOff, ac.Reasoning.Mode)
}
@@ -10,6 +10,7 @@ import (
"pentagi/pkg/templates"
"github.com/vxcontrol/langchaingo/llms"
"github.com/vxcontrol/langchaingo/llms/reasoning"
"github.com/vxcontrol/langchaingo/llms/streaming"
)
@@ -19,6 +20,24 @@ func (p ProviderType) String() string {
return string(p)
}
// ReasoningProvider maps the provider type to the langchaingo reasoning.Provider
// consumed by capability introspection (llms.ReasoningSupportFor) and the disable
// resolver. OpenAI-compatible providers all speak the OpenAI reasoning wire.
func (p ProviderType) ReasoningProvider() reasoning.Provider {
switch p {
case ProviderAnthropic:
return reasoning.ProviderAnthropic
case ProviderBedrock:
return reasoning.ProviderBedrock
case ProviderGemini:
return reasoning.ProviderGoogleAI
case ProviderOpenAI, ProviderDeepSeek, ProviderGLM, ProviderKimi, ProviderQwen, ProviderMiniMax, ProviderCustom:
return reasoning.ProviderOpenAI
default: // ProviderOllama and anything unrecognized
return reasoning.ProviderUnknown
}
}
const (
ProviderOpenAI ProviderType = "openai"
ProviderAnthropic ProviderType = "anthropic"