package llm // capability.go is the per-MODEL wire-shape layer (05-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, leaving the provider's OWN default. // Use it for a model whose thinking must NOT be pushed off that default from our // side: DeepSeek-flash on the draft path defaults to thinking ON, and "off" here is // a deliberate NO-OP (emitting nothing keeps it ON) — DISABLING DeepSeek thinking // arms the echo mine (it returns the untranslated CJK source at HTTP 200; // config.echoMineViolation fail-fasts on it). This is the WRONG control for a model // whose thinking is ON BY OMISSION: xAI-Grok defaults reasoning_effort to "low" (it // thinks), so a role that must NOT think (the editor) uses ReasoningEffortField with // OffEffort "none" to send an EXPLICIT reasoning_effort:"none" instead. 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 // MinMaxTokens is a per-model FLOOR on the derived max_tokens budget (D24.3): // a thinking model (DeepSeek/Gemini/Kimi) whose reasoning consumes the output // budget before any content is emitted returns finish=length or empty content // below a per-model minimum — acceptance stage A traced draft→deepseek-v4-pro // escalation hops receiving an inherited 2048/3291 and returning length. The // floor is NOT a wire body key (applyToBody never reads it); it is applied in // the runner's budget derivation BEFORE the request_hash, so it is wire-visible // via the resulting max_tokens. It lives on Capability so it is folded into the // job snapshot alongside the other wire-affecting inputs (snapshot.go marshals // the resolved Capability for the primary AND the escalate_to model) — editing // a floor is then a loud --resnapshot, not a silent false-hit. omitempty keeps a // floor-less model's snapshot byte-identical to before this field existed. 0 = // no floor (reasoning-off models: GLM, grok). MinMaxTokens int `json:",omitempty"` } // 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 } } }