mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-26 13:06:32 +00:00
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:
co-authored by
Claude Opus 4.8
parent
ffbbabb517
commit
2853c97549
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user