From 2853c97549930563317c0591c230f7adbe823ea8 Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Mon, 13 Jul 2026 19:50:43 +0700 Subject: [PATCH] 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 --- backend/pkg/database/converter/converter.go | 53 ++++-- .../pkg/database/converter/converter_test.go | 83 ++++++++- backend/pkg/graph/generated.go | 163 +++++++++++++++++- backend/pkg/graph/model/models_gen.go | 11 +- backend/pkg/graph/schema.graphqls | 14 +- backend/pkg/graph/schema.resolvers.go | 22 +-- backend/pkg/providers/pconfig/config.go | 12 ++ backend/pkg/providers/pconfig/config_test.go | 2 + .../providers/pconfig/reasoning_off_test.go | 39 +++++ backend/pkg/providers/provider/provider.go | 19 ++ 10 files changed, 389 insertions(+), 29 deletions(-) create mode 100644 backend/pkg/providers/pconfig/reasoning_off_test.go diff --git a/backend/pkg/database/converter/converter.go b/backend/pkg/database/converter/converter.go index 4c1eecfc..7153833b 100644 --- a/backend/pkg/database/converter/converter.go +++ b/backend/pkg/database/converter/converter.go @@ -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 { diff --git a/backend/pkg/database/converter/converter_test.go b/backend/pkg/database/converter/converter_test.go index 2392a1c3..88617227 100644 --- a/backend/pkg/database/converter/converter_test.go +++ b/backend/pkg/database/converter/converter_test.go @@ -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") +} diff --git a/backend/pkg/graph/generated.go b/backend/pkg/graph/generated.go index 1ee82c35..cacfa2b1 100644 --- a/backend/pkg/graph/generated.go +++ b/backend/pkg/graph/generated.go @@ -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)) } diff --git a/backend/pkg/graph/model/models_gen.go b/backend/pkg/graph/model/models_gen.go index 78a63195..b1acc8b0 100644 --- a/backend/pkg/graph/model/models_gen.go +++ b/backend/pkg/graph/model/models_gen.go @@ -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 diff --git a/backend/pkg/graph/schema.graphqls b/backend/pkg/graph/schema.graphqls index 2e2c4fe9..9f8f2f96 100644 --- a/backend/pkg/graph/schema.graphqls +++ b/backend/pkg/graph/schema.graphqls @@ -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 diff --git a/backend/pkg/graph/schema.resolvers.go b/backend/pkg/graph/schema.resolvers.go index 4c832070..360b6a56 100644 --- a/backend/pkg/graph/schema.resolvers.go +++ b/backend/pkg/graph/schema.resolvers.go @@ -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 diff --git a/backend/pkg/providers/pconfig/config.go b/backend/pkg/providers/pconfig/config.go index ddb27975..f68a3213 100644 --- a/backend/pkg/providers/pconfig/config.go +++ b/backend/pkg/providers/pconfig/config.go @@ -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 } diff --git a/backend/pkg/providers/pconfig/config_test.go b/backend/pkg/providers/pconfig/config_test.go index 94d1f4c0..2f25b85a 100644 --- a/backend/pkg/providers/pconfig/config_test.go +++ b/backend/pkg/providers/pconfig/config_test.go @@ -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", diff --git a/backend/pkg/providers/pconfig/reasoning_off_test.go b/backend/pkg/providers/pconfig/reasoning_off_test.go new file mode 100644 index 00000000..58928fab --- /dev/null +++ b/backend/pkg/providers/pconfig/reasoning_off_test.go @@ -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) +} diff --git a/backend/pkg/providers/provider/provider.go b/backend/pkg/providers/provider/provider.go index 5b2c762d..5e89bb83 100644 --- a/backend/pkg/providers/provider/provider.go +++ b/backend/pkg/providers/provider/provider.go @@ -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"