117 lines
5.3 KiB
Go
117 lines
5.3 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// additive_reasoning_test.go is the D13.6 fail-fast gate: a stage that THINKS
|
|
// (reasoning low|medium|high) on an ADDITIVE-billing provider (xAI — reasoning bills on top
|
|
// of completion) must declare reasoning_max_tokens, or LoadPipeline fails loud — the same
|
|
// structural discipline as the echo mine. Reverting the check in LoadPipeline makes the
|
|
// "armed" cases below stop failing.
|
|
func TestReasoningBufferFailFast(t *testing.T) {
|
|
today := time.Now().UTC().Format("2006-01-02")
|
|
dir := t.TempDir()
|
|
// A prompt template LoadPipeline can stat + split.
|
|
promptPath := filepath.Join(dir, "p.md")
|
|
if err := os.WriteFile(promptPath, []byte("sys\n---USER---\n{{text}}"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
modelsPath := filepath.Join(dir, "models.yaml")
|
|
if err := os.WriteFile(modelsPath, []byte(fmt.Sprintf(`
|
|
prices_checked: %q
|
|
default_model: additive-model
|
|
providers:
|
|
xai:
|
|
kind: openai
|
|
base_url: http://x
|
|
reasoning: additive
|
|
ds:
|
|
kind: openai
|
|
base_url: http://y
|
|
reasoning: subset
|
|
models:
|
|
additive-model: { provider: xai, price: { input_per_m: 1, output_per_m: 2 } }
|
|
subset-model: { provider: ds, price: { input_per_m: 1, output_per_m: 2 } }
|
|
# switchable-model is the additive endpoint WITH a real off-switch (grok-4.3's shape): at
|
|
# reasoning=off an explicit reasoning_effort:"none" reaches the wire, thinking stops, and no
|
|
# additive buffer is owed. It is the contrast case for the D39.26-добор-B gate.
|
|
switchable-model:
|
|
provider: xai
|
|
price: { input_per_m: 1, output_per_m: 2 }
|
|
capabilities:
|
|
reasoning: { control: effort, off_effort: none }
|
|
`, today)), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
models, err := LoadModels(modelsPath)
|
|
if err != nil {
|
|
t.Fatalf("models load: %v", err)
|
|
}
|
|
|
|
load := func(stage string) error {
|
|
pp := filepath.Join(dir, "pipe.yaml")
|
|
body := fmt.Sprintf("core: C1\nversion: 1\ndefaults: { max_output_ratio: 2, min_max_tokens: 512 }\nstages:\n%s", stage)
|
|
if err := os.WriteFile(pp, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, e := LoadPipeline(pp, models, "zh-ru", nil)
|
|
return e
|
|
}
|
|
stage := func(model, reasoning string, buf int, escalateTo string) string {
|
|
s := fmt.Sprintf(" - { name: s, role: translator, model: %s, prompt_override: %s, prompt_version: v, reasoning: %q", model, promptPath, reasoning)
|
|
if buf > 0 {
|
|
s += fmt.Sprintf(", reasoning_max_tokens: %d", buf)
|
|
}
|
|
if escalateTo != "" {
|
|
s += fmt.Sprintf(", escalate_to: %s", escalateTo)
|
|
}
|
|
return s + " }"
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
stage string
|
|
wantErr bool
|
|
}{
|
|
{"think-on additive no buffer → fail", stage("additive-model", "high", 0, ""), true},
|
|
{"think-on additive with buffer → ok", stage("additive-model", "medium", 4000, ""), false},
|
|
{"think-on subset no buffer → ok", stage("subset-model", "high", 0, ""), false},
|
|
// EXPECTATION CHANGED in pack-17 (D39.26 добор B): `reasoning: "off"` no longer exempts a stage
|
|
// by itself. This fixture's additive-model declares NO capabilities block, so control=none and
|
|
// "off" sends NOTHING — the provider default (thinking ON, billed on top of completion) stands
|
|
// and the reservation would be blind. The exemption now belongs to models that carry a real
|
|
// off-switch (the case below), which is what grok-4.3 declares in the shipping catalog.
|
|
{"off additive WITHOUT an off-switch no buffer → fail (silent-thinking hole)", stage("additive-model", "off", 0, ""), true},
|
|
// An off-switch is NOT an exemption either (D39.26 добор B, the ratified "gate independent of off"):
|
|
// judging the wire would re-open the hole for exactly this shape (grok at "off"), so every additive
|
|
// stage owes a buffer. A call that really does suppress thinking merely over-reserves — the ceiling
|
|
// tightens, it never goes blind — and the reservation (AdditiveReasoningTokens) agrees with this
|
|
// gate call-for-call, so the two can never disagree about one call.
|
|
{"off additive WITH an off-switch no buffer → fail (the gate is unconditional)", stage("switchable-model", "off", 0, ""), true},
|
|
{"off additive WITH an off-switch and a buffer → ok", stage("switchable-model", "off", 4000, ""), false},
|
|
{"think-on additive WITH an off-switch no buffer → fail", stage("switchable-model", "high", 0, ""), true},
|
|
// finding #4: "" on an additive provider falls to the grok default (thinks) → MUST fail.
|
|
{"empty-reasoning additive no buffer → fail", stage("additive-model", "", 0, ""), true},
|
|
{"empty-reasoning additive with buffer → ok", stage("additive-model", "", 4000, ""), false},
|
|
{"empty-reasoning subset no buffer → ok", stage("subset-model", "", 0, ""), false},
|
|
{"escalate_to additive think-on no buffer → fail", stage("subset-model", "high", 0, "additive-model"), true},
|
|
{"escalate_to additive empty no buffer → fail", stage("subset-model", "", 0, "additive-model"), true},
|
|
{"escalate_to additive think-on with buffer → ok", stage("subset-model", "high", 4000, "additive-model"), false},
|
|
}
|
|
for _, c := range cases {
|
|
err := load(c.stage)
|
|
if c.wantErr {
|
|
if err == nil || !strings.Contains(err.Error(), "reasoning_max_tokens") {
|
|
t.Errorf("%s: want an error mentioning reasoning_max_tokens, got %v", c.name, err)
|
|
}
|
|
} else if err != nil {
|
|
t.Errorf("%s: want ok, got %v", c.name, err)
|
|
}
|
|
}
|
|
}
|