textmachine/backend/internal/llm/capability.go

168 lines
7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
// 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
}
}
}