| 1 | package provider |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "slices" |
| 6 | "strings" |
| 7 | ) |
| 8 | |
| 9 | // ReasoningOption is an adapter-owned identifier, not a global effort enum. |
| 10 | type ReasoningOption struct { |
| 11 | ID string `json:"id"` |
| 12 | Name string `json:"name"` |
| 13 | Description string `json:"description,omitempty"` |
| 14 | } |
| 15 | |
| 16 | // ReasoningCapability describes one resolved endpoint/model. Empty Default |
| 17 | // preserves provider-default behavior; an empty option list offers no control. |
| 18 | type ReasoningCapability struct { |
| 19 | Options []ReasoningOption `json:"options"` |
| 20 | Default string `json:"default,omitempty"` |
| 21 | // Unknown distinguishes missing metadata from a declared lack of controls. |
| 22 | Unknown bool `json:"unknown,omitempty"` |
| 23 | } |
| 24 | |
| 25 | type ReasoningProvider interface{ ReasoningCapability() ReasoningCapability } |
| 26 | |
| 27 | // UnsupportedReasoningEffort is returned before provider I/O. Never clamp an |
| 28 | // explicit selection or silently replace it with the configured default. |
| 29 | type UnsupportedReasoningEffort struct { |
| 30 | Model, Effort string |
| 31 | Supported []string |
| 32 | Unknown bool |
| 33 | } |
| 34 | |
| 35 | func (e *UnsupportedReasoningEffort) Error() string { |
| 36 | if e.Unknown { |
| 37 | return fmt.Sprintf("UNKNOWN_MODEL_REASONING: reasoning levels for model %q are not declared; cannot validate %q. Select auto or configure this model's reasoning_protocol and supported_efforts", e.Model, e.Effort) |
| 38 | } |
| 39 | return fmt.Sprintf("UNSUPPORTED_REASONING_EFFORT: model %q does not support %q (supported: %v)", e.Model, e.Effort, e.Supported) |
| 40 | } |
| 41 | |
| 42 | // UnknownReasoning allows provider-default requests without inventing controls. |
| 43 | func UnknownReasoning() ReasoningCapability { |
| 44 | return ReasoningCapability{Options: []ReasoningOption{}, Unknown: true} |
| 45 | } |
| 46 | |
| 47 | func (c ReasoningCapability) State() string { |
| 48 | if c.Unknown { |
| 49 | return "unknown" |
| 50 | } |
| 51 | if len(c.Options) == 0 { |
| 52 | return "unsupported" |
| 53 | } |
| 54 | return "supported" |
| 55 | } |
| 56 | func (c ReasoningCapability) IDs() []string { |
| 57 | ids := make([]string, 0, len(c.Options)) |
| 58 | for _, option := range c.Options { |
| 59 | ids = append(ids, option.ID) |
| 60 | } |
| 61 | return ids |
| 62 | } |
| 63 | func (c ReasoningCapability) Clone() ReasoningCapability { |
| 64 | c.Options = slices.Clone(c.Options) |
| 65 | return c |
| 66 | } |
| 67 | func (c ReasoningCapability) Validate(model, effort string) error { |
| 68 | ids := c.IDs() |
| 69 | seen := map[string]bool{} |
| 70 | for _, id := range ids { |
| 71 | if id == "" || id == "auto" || seen[id] { |
| 72 | return fmt.Errorf("INVALID_MODEL_REASONING: model %q has invalid or repeated effort ID %q", model, id) |
| 73 | } |
| 74 | seen[id] = true |
| 75 | } |
| 76 | if c.Default != "" && !slices.Contains(ids, c.Default) { |
| 77 | return &UnsupportedReasoningEffort{Model: model, Effort: c.Default, Supported: ids, Unknown: c.Unknown} |
| 78 | } |
| 79 | if effort == "" { |
| 80 | return nil |
| 81 | } |
| 82 | if !slices.Contains(c.IDs(), effort) { |
| 83 | return &UnsupportedReasoningEffort{Model: model, Effort: effort, Supported: c.IDs(), Unknown: c.Unknown} |
| 84 | } |
| 85 | return nil |
| 86 | } |
| 87 | func ReasoningOptions(def string, ids ...string) ReasoningCapability { |
| 88 | c := ReasoningCapability{Options: make([]ReasoningOption, 0, len(ids)), Default: def} |
| 89 | for _, id := range ids { |
| 90 | c.Options = append(c.Options, ReasoningOption{ID: id, Name: id}) |
| 91 | } |
| 92 | return c |
| 93 | } |
| 94 | |
| 95 | // DeclaredReasoning replaces fallback vocabulary only when a deployment has |
| 96 | // explicitly declared it. The adapter still owns the serializer and policy. |
| 97 | func DeclaredReasoning(cfg Config, fallback ReasoningCapability) ReasoningCapability { |
| 98 | ids, _ := cfg.Extra["supported_efforts"].([]string) |
| 99 | clean := make([]string, 0, len(ids)) |
| 100 | for _, id := range ids { |
| 101 | if strings.TrimSpace(id) != "" && strings.TrimSpace(id) != "auto" && !slices.Contains(clean, id) { |
| 102 | clean = append(clean, id) |
| 103 | } |
| 104 | } |
| 105 | ids = clean |
| 106 | if len(ids) > 0 { |
| 107 | fallback = ReasoningOptions(ids[0], ids...) |
| 108 | } |
| 109 | if def, _ := cfg.Extra["default_effort"].(string); def != "" { |
| 110 | fallback.Default = def |
| 111 | } |
| 112 | return fallback |
| 113 | } |
| 114 | |
| 115 | // Reasoning factories register alongside adapter factories during init. They |
| 116 | // consume non-secret configuration and must not perform I/O. |
| 117 | var reasoningRegistry = map[string]func(Config) ReasoningCapability{} |
| 118 | |
| 119 | func RegisterReasoning(kind string, resolve func(Config) ReasoningCapability) { |
| 120 | if _, exists := reasoningRegistry[kind]; exists { |
| 121 | panic("duplicate reasoning adapter: " + kind) |
| 122 | } |
| 123 | reasoningRegistry[kind] = resolve |
| 124 | } |
| 125 | func ReasoningForConfig(kind string, cfg Config) ReasoningCapability { |
| 126 | if resolve, ok := reasoningRegistry[kind]; ok { |
| 127 | return resolve(cfg).Clone() |
| 128 | } |
| 129 | return UnknownReasoning() |
| 130 | } |
| 131 | |
| 132 | // RestrictReasoning keeps deployment declarations within a fixed wire protocol. |
| 133 | func RestrictReasoning(c ReasoningCapability, ids ...string) ReasoningCapability { |
| 134 | options := make([]ReasoningOption, 0, len(c.Options)) |
| 135 | for _, option := range c.Options { |
| 136 | if slices.Contains(ids, option.ID) { |
| 137 | options = append(options, option) |
| 138 | } |
| 139 | } |
| 140 | c.Options = options |
| 141 | return c |
| 142 | } |
| 143 | |
| 144 | // PreferredReasoning is for host-owned automatic policies only. Explicit user |
| 145 | // selections must use Validate and report rejection, never this fallback. |
| 146 | func PreferredReasoning(p Provider, id string) string { |
| 147 | if owner, ok := p.(ReasoningProvider); ok && owner.ReasoningCapability().Validate("", id) == nil { |
| 148 | return id |
| 149 | } |
| 150 | return "" |
| 151 | } |
| 152 |