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 that must never be TURNED OFF from our side: DeepSeek-flash // 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). // // ⚠ "Never turned off" is NOT "never configured" (D39.87): low|medium|high are a // ratified way to size the thinking BUDGET on such a model, and they go out here as // reasoning_effort with thinking still on — proven on the wire, 22 bodies carrying // reasoning_effort:"low", zero `thinking` keys, every reply with non-empty // reasoning_content. That distinction became load-bearing when DeepSeek moved its OWN // default effort to `high` on 2026-07-31 and dense-Han calls began hitting max_tokens // with an empty body: riding the provider default is a choice whose owner is the // vendor, not an absence of one. // // 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" ) // SystemMessagesMode declares how many SYSTEM messages the endpoint carries. // // It is a wire-shape fact about the ENDPOINT, not about the model's talent, which is why it // belongs here and not in the assembler: the pipeline builds a stable system prefix plus its own // memory-bank injection message (render.go MessagesWithInjection), and an endpoint that carries // only one of them drops the injection — the glossary — while answering 200 with a plausible // translation. That is the silent class this axis exists to close: nothing fails, the book just // stops being consistent, and no downstream gate can tell the difference. type SystemMessagesMode string const ( // SystemMessagesMulti is the OpenAI-compat baseline: system messages go on the wire // one-for-one, in the order the assembler produced them. Zero value, so a provider that // declares nothing keeps exactly the wire it has today AND marshals byte-identically — // declaring this axis on ONE provider does not move anybody else's snapshot. SystemMessagesMulti SystemMessagesMode = "" // SystemMessagesSingle is for an endpoint that accepts exactly ONE system message and does // not document what it does with the rest. The leading system run is JOINED into one // message, in order, before it reaches the wire. // // The vendor fact this is declared from (Gemini, generativelanguage OpenAI-compat layer; // ai.google.dev/api/generate-content, page stamped 2026-08-17, read 2026-08-28): the native // GenerateContentRequest carries `systemInstruction` as a SINGLE `object (Content)` — the // same field table writes `contents[]` and `tools[]` with the repeated-field `[]` suffix — // and `Content.role` is documented "Must be either 'user' or 'model'". So the native request // has exactly one system slot and a second system TURN is not representable at all. What the // compat shim does with a second one the vendor does not say anywhere (the "Current // limitations" section is silent, in the live page and in the 2026-08-21 snapshot alike); // our own $0 probe establishes only that they are NOT concatenated and that the FIRST one's // instruction is not executed. Whether the second survives stays open — and does not matter // here: under every reading except "concatenated", sending two loses content, and joining // them ourselves is the one form that cannot. SystemMessagesSingle SystemMessagesMode = "single" ) // systemJoinSeparator is what SystemMessagesSingle joins the system run with: a blank line — the // same seam ApplyHeading uses, and the one the vendor's own note describes for a multi-part // system Content ("content in each part will be in a separate paragraph"). const systemJoinSeparator = "\n\n" // 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"` // SystemMessages is the endpoint's system-message cardinality (default: multi). It is folded // into the job snapshot with everything else here, and for this axis that fold is not a // formality: the join happens BELOW RequestHash (the hash is taken over the neutral message // list in render.go; the join in toOpenAIMessages), so where the snapshot does not reach, // nothing at all notices that a stored checkpoint now stands for different bytes. omitempty // keeps a provider that does not declare it byte-identical to before this field existed. // // ⚠ WHERE THE FOLD DOES NOT REACH, said plainly rather than assumed away (adversarial review). // The snapshot carries the wire of the STAGE models and the single escalate_to hop; the // TERMINOLOGY GATE's model is deliberately not snapshot-folded (config/pipeline.go, the gate's // own note), and that role DOES send two system messages. So flipping this axis on a provider // that only the terminology gate uses changes that call's bytes under an unchanged // RequestHash, and its stored checkpoints keep replaying. The hole is not this axis's — every // capability axis (budget field, temperature mode, reasoning control) has ridden it since D3.1 // — but this axis is the first whose whole purpose is to change those bytes, so it is named // here. Closing it means folding the gate's model wire the way repairSnapshot folds the repair // model's; that is a snapshot-contract change and belongs to whoever owns the gate's // not-folded decision, not to a provider quirk. SystemMessages SystemMessagesMode `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 } } }