267 lines
14 KiB
Go
267 lines
14 KiB
Go
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
|
||
|
||
// ⚠ TempSend DOES NOT MEAN THE VALUE DOES ANYTHING. On a model that is thinking, DeepSeek
|
||
// documents the parameter as accepted and inert: «Thinking mode does not support the temperature,
|
||
// top_p, presence_penalty, or frequency_penalty parameters… setting these parameters will not
|
||
// trigger an error but will also have no effect» (vendor page, read live 2026-08-30, recorded in
|
||
// docs/experiments/00-provider-quirks.md, section «Thinking / reasoning», numbered item 3 — go
|
||
// there, not to this line, for the current reading. NOT the «Параметры сэмплинга» section, which
|
||
// is a different table and does not carry this quote).
|
||
// Both shipping stages ride it: the roster sends temperature 0.3 and 0.4 to models whose thinking
|
||
// is on, so tuning either is a change to a knob that reaches the wire and moves nothing in the
|
||
// answer.
|
||
//
|
||
// It is still SENT, and deliberately. Temperature is part of RequestHash, so dropping it would
|
||
// re-key every checkpoint of every book in flight — a loud --resnapshot whose entire effect on the
|
||
// text is nil — and the same is true of declaring the inertness on Capability, which the job
|
||
// snapshot marshals. The knob is left where it is and named here instead; what it costs to make
|
||
// the silence structural is one full re-translation that changes no output.
|
||
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
|
||
}
|
||
}
|
||
}
|