162 lines
6.5 KiB
Go
162 lines
6.5 KiB
Go
package llm
|
||
|
||
// capability.go is the per-MODEL wire-shape layer (03-decisions-log D3.1).
|
||
// OpenAI-compatible providers share an ENDPOINT but not a BODY: Kimi accepts
|
||
// only temperature:1 (else 400), gpt-5-mini needs max_completion_tokens WITHOUT
|
||
// temperature (else 400), Gemini 3.1 Pro 400s if thinking is disabled. The
|
||
// Phase-0 wire — hardcoded max_tokens + always-send temperature — 400s on all
|
||
// three. Capability declares, per model (resolved from models.yaml: provider
|
||
// default merged with a per-model override, model wins), how to render
|
||
// budget / temperature / reasoning into the wire body.
|
||
//
|
||
// The resolver runs at openAIRequest marshal time so the neutral request stays
|
||
// vendor-free, and the resolved Capability is folded into the job snapshot
|
||
// (stageSnap) so editing a capability INVALIDATES checkpoints instead of
|
||
// silently false-hitting them — the same determinism class the snapshot payload
|
||
// closes (D5.2). Capability is the WIRE form; it is deliberately separate from
|
||
// Provider.Reasoning (subset|additive), which is a BILLING semantic feeding the
|
||
// ledger — a model can bill additively yet still disable thinking on the wire.
|
||
|
||
// BudgetField is the JSON key carrying the output-token cap.
|
||
type BudgetField string
|
||
|
||
const (
|
||
// BudgetMaxTokens is the classic OpenAI field (DeepSeek, GLM, xAI, Kimi,
|
||
// Gemini-compat, local).
|
||
BudgetMaxTokens BudgetField = "max_tokens"
|
||
// BudgetMaxCompletionTokens is the gpt-5 family field; sending max_tokens
|
||
// there is rejected.
|
||
BudgetMaxCompletionTokens BudgetField = "max_completion_tokens"
|
||
)
|
||
|
||
// TempMode decides whether and how temperature reaches the wire.
|
||
type TempMode string
|
||
|
||
const (
|
||
// TempSend emits the request's temperature verbatim, INCLUDING an explicit
|
||
// 0 — a deterministic role (judge) must not silently sample at the provider
|
||
// default ~1.0 (the Phase-0 no-omitempty fix, now expressed per-model).
|
||
TempSend TempMode = "send"
|
||
// TempOmit never emits temperature (gpt-5 family: any temperature 400s).
|
||
TempOmit TempMode = "omit"
|
||
// TempForce always emits TempValue, ignoring the request (Kimi: only
|
||
// temperature:1 is accepted).
|
||
TempForce TempMode = "force"
|
||
)
|
||
|
||
// ReasoningControl maps the neutral ReasoningEffort ("" | off | low | medium |
|
||
// high) onto the wire.
|
||
type ReasoningControl string
|
||
|
||
const (
|
||
// ReasoningNone is the OpenAI-compat baseline: low|medium|high go out as
|
||
// reasoning_effort; off and "" emit nothing (the provider's own default).
|
||
// Fits models where thinking is off by omission (DeepSeek / xAI-Grok on the
|
||
// Phase-1 draft/edit path).
|
||
ReasoningNone ReasoningControl = "none"
|
||
// ReasoningEffortField maps "off" to a fixed OffEffort value on the
|
||
// reasoning_effort field (gpt-5: "minimal"; local ollama: "none").
|
||
ReasoningEffortField ReasoningControl = "effort"
|
||
// ReasoningExtraBodyDisable disables thinking by MERGING OffExtraBody into
|
||
// the body (GLM {"thinking":{"type":"disabled"}}); for a think-ON escalation
|
||
// (D6.2) it merges OnExtraBody. effort "" leaves the provider default.
|
||
ReasoningExtraBodyDisable ReasoningControl = "extra_body_disable"
|
||
// ReasoningMandatory means thinking CANNOT be disabled — any disable attempt
|
||
// (reasoning_effort, thinking_budget:0) 400s (Gemini 3.1 Pro). The neutral
|
||
// "off" is swallowed: nothing reasoning-related is emitted, the model's
|
||
// forced thinking runs and MUST be budgeted as output ($12/M, D6.3).
|
||
ReasoningMandatory ReasoningControl = "mandatory"
|
||
)
|
||
|
||
// ReasoningCap is the resolved reasoning wire-form for one model.
|
||
type ReasoningCap struct {
|
||
Control ReasoningControl
|
||
OffEffort string // ReasoningEffortField: value sent when effort=="off"
|
||
OffExtraBody map[string]any // ReasoningExtraBodyDisable: merged when effort=="off"
|
||
OnExtraBody map[string]any // ReasoningExtraBodyDisable: merged when thinking is on (D6.2)
|
||
}
|
||
|
||
// Capability is the fully-resolved wire shape for one model. Its zero value is
|
||
// the OpenAI-compat baseline (see withDefaults), so a model with no
|
||
// capabilities block and the direct-construction path in tests both reproduce
|
||
// the Phase-0 wire.
|
||
type Capability struct {
|
||
Budget BudgetField
|
||
Temp TempMode
|
||
TempValue float64
|
||
Reasoning ReasoningCap
|
||
}
|
||
|
||
// withDefaults fills the OpenAI-compat baseline for zero fields: max_tokens +
|
||
// always-send temperature + off-by-omission reasoning.
|
||
func (c Capability) withDefaults() Capability {
|
||
if c.Budget == "" {
|
||
c.Budget = BudgetMaxTokens
|
||
}
|
||
if c.Temp == "" {
|
||
c.Temp = TempSend
|
||
}
|
||
if c.Reasoning.Control == "" {
|
||
c.Reasoning.Control = ReasoningNone
|
||
}
|
||
return c
|
||
}
|
||
|
||
// applyToBody writes the budget / temperature / reasoning keys into the wire
|
||
// body map. effort is the request's neutral ReasoningEffort. Resolved standard
|
||
// keys are authoritative; config-supplied extra_body merges only into the gaps
|
||
// (see openAIRequest.MarshalJSON and mergeBody).
|
||
func (c Capability) applyToBody(m map[string]any, maxTokens int, temperature float64, effort string) {
|
||
c = c.withDefaults()
|
||
m[string(c.Budget)] = maxTokens
|
||
|
||
switch c.Temp {
|
||
case TempOmit:
|
||
// never emitted
|
||
case TempForce:
|
||
m["temperature"] = c.TempValue
|
||
default: // TempSend
|
||
m["temperature"] = temperature
|
||
}
|
||
|
||
switch c.Reasoning.Control {
|
||
case ReasoningMandatory:
|
||
// swallow everything: a disable attempt 400s, forced thinking runs
|
||
case ReasoningExtraBodyDisable:
|
||
// A disable-capability's whole purpose is to keep thinking OFF: for GLM
|
||
// the provider default IS thinking-on (the ×3-timeout blowup the switch
|
||
// exists to prevent), so an UNSPECIFIED effort ("") defaults to disabled
|
||
// exactly like "off". This preserves the Phase-0 unconditional-disable
|
||
// semantics (extra_body merged on every call); only an explicit
|
||
// low|medium|high opts into think-ON via OnExtraBody (D6.2). This is a
|
||
// deliberate per-control reading of "" — it differs from ReasoningNone,
|
||
// where "" means the provider default.
|
||
if effort == "" || effort == "off" {
|
||
mergeBody(m, c.Reasoning.OffExtraBody)
|
||
} else {
|
||
mergeBody(m, c.Reasoning.OnExtraBody)
|
||
}
|
||
case ReasoningEffortField:
|
||
if effort == "off" {
|
||
if c.Reasoning.OffEffort != "" {
|
||
m["reasoning_effort"] = c.Reasoning.OffEffort
|
||
}
|
||
} else if effort != "" {
|
||
m["reasoning_effort"] = effort
|
||
}
|
||
default: // ReasoningNone
|
||
if effort != "" && effort != "off" {
|
||
m["reasoning_effort"] = effort
|
||
}
|
||
}
|
||
}
|
||
|
||
// mergeBody copies src into dst without overwriting an existing key: config
|
||
// tuning fills gaps, it never overrides a resolved standard field.
|
||
func mergeBody(dst, src map[string]any) {
|
||
for k, v := range src {
|
||
if _, exists := dst[k]; !exists {
|
||
dst[k] = v
|
||
}
|
||
}
|
||
}
|