169 lines
6.3 KiB
Go
169 lines
6.3 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"textmachine/backend/internal/llm"
|
|
)
|
|
|
|
// TestResolveCapabilityMerge checks the D3.1 layering: kind baseline < provider
|
|
// default < per-model override, field-by-field with the model winning.
|
|
func TestResolveCapabilityMerge(t *testing.T) {
|
|
m := &Models{
|
|
Providers: map[string]Provider{
|
|
"openai": {Kind: "openai", Capabilities: &CapabilitiesConfig{
|
|
BudgetField: "max_completion_tokens",
|
|
Temperature: &TemperatureCap{Mode: "omit"},
|
|
}},
|
|
"local": {Kind: "local"},
|
|
},
|
|
Models: map[string]Model{
|
|
"gpt-5-nano": {Provider: "openai"}, // inherits the provider default
|
|
"gpt-5-mini": {Provider: "openai", Capabilities: &CapabilitiesConfig{
|
|
Temperature: &TemperatureCap{Mode: "force", Value: 0.7}, // overrides temp only
|
|
}},
|
|
"local-x": {Provider: "local"},
|
|
},
|
|
}
|
|
|
|
nano := m.ResolveCapability("gpt-5-nano")
|
|
if nano.Budget != llm.BudgetMaxCompletionTokens || nano.Temp != llm.TempOmit {
|
|
t.Fatalf("nano must inherit provider default: %+v", nano)
|
|
}
|
|
|
|
mini := m.ResolveCapability("gpt-5-mini")
|
|
if mini.Budget != llm.BudgetMaxCompletionTokens {
|
|
t.Fatalf("mini budget must still inherit the provider: %+v", mini)
|
|
}
|
|
if mini.Temp != llm.TempForce || mini.TempValue != 0.7 {
|
|
t.Fatalf("mini temperature override must win: %+v", mini)
|
|
}
|
|
|
|
loc := m.ResolveCapability("local-x")
|
|
if loc.Reasoning.Control != llm.ReasoningEffortField || loc.Reasoning.OffEffort != "none" {
|
|
t.Fatalf("local baseline must map off->none: %+v", loc.Reasoning)
|
|
}
|
|
if loc.Budget != llm.BudgetMaxTokens || loc.Temp != llm.TempSend {
|
|
t.Fatalf("local baseline budget/temp: %+v", loc)
|
|
}
|
|
}
|
|
|
|
// TestLoadModelsRejectsBadCapability confirms a bogus capability enum fails
|
|
// fast at load (part of the models.yaml problem list), not as a runtime 4xx.
|
|
func TestLoadModelsRejectsBadCapability(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "models.yaml")
|
|
body := fmt.Sprintf(`
|
|
prices_checked: %q
|
|
default_model: m
|
|
providers:
|
|
p:
|
|
kind: openai
|
|
base_url: http://x
|
|
models:
|
|
m:
|
|
provider: p
|
|
price: { input_per_m: 1, output_per_m: 2 }
|
|
capabilities:
|
|
budget_field: bogus
|
|
`, time.Now().UTC().Format("2006-01-02"))
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err := LoadModels(path)
|
|
if err == nil || !strings.Contains(err.Error(), "budget_field") {
|
|
t.Fatalf("bad budget_field must fail-fast mentioning the field, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestLoadModelsCapabilityValidation covers the other validateCapabilities
|
|
// paths the single case above misses: the PROVIDER call-site, the
|
|
// temperature.mode / reasoning.control enums, and companion-field completeness
|
|
// (a control whose disable field is absent must fail at load, not no-op at
|
|
// runtime).
|
|
func TestLoadModelsCapabilityValidation(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
capsYAML string // injected under provider p or model m as noted
|
|
onProvider bool
|
|
wantSubstr string
|
|
}{
|
|
{"provider bad reasoning.control", "capabilities: { reasoning: { control: bogus } }", true, "control"},
|
|
{"model bad temperature.mode", "capabilities: { temperature: { mode: sned } }", false, "mode"},
|
|
{"extra_body_disable without off_extra_body", "capabilities: { reasoning: { control: extra_body_disable } }", false, "off_extra_body"},
|
|
{"effort without off_effort", "capabilities: { reasoning: { control: effort } }", false, "off_effort"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
provCaps, modelCaps := "", ""
|
|
if c.onProvider {
|
|
provCaps = "\n " + c.capsYAML
|
|
} else {
|
|
modelCaps = "\n " + c.capsYAML
|
|
}
|
|
body := fmt.Sprintf(`
|
|
prices_checked: %q
|
|
default_model: m
|
|
providers:
|
|
p:
|
|
kind: openai
|
|
base_url: http://x%s
|
|
models:
|
|
m:
|
|
provider: p
|
|
price: { input_per_m: 1, output_per_m: 2 }%s
|
|
`, time.Now().UTC().Format("2006-01-02"), provCaps, modelCaps)
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "models.yaml")
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err := LoadModels(path)
|
|
if err == nil || !strings.Contains(err.Error(), c.wantSubstr) {
|
|
t.Fatalf("expected fail-fast mentioning %q, got %v", c.wantSubstr, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestThinksOnWireMirrorsTheWireForEachControl pins the predicate the load-time echo-exposure warning is
|
|
// built on. It exists because the extra_body_disable arm used to read `reasoning != "off"`, which answered
|
|
// "it thinks" for an UNSET effort — while Capability.applyToBody merges the thinking-disable for `""` and
|
|
// `"off"` alike. The predicate therefore contradicted the wire in exactly the case that matters most: a
|
|
// GLM-shaped model configured with no `reasoning:` key at all, i.e. the DEFAULT, shipping dense CJK into
|
|
// the echo zone with nothing said out loud.
|
|
func TestThinksOnWireMirrorsTheWireForEachControl(t *testing.T) {
|
|
m := &Models{
|
|
Providers: map[string]Provider{"p": {Kind: "openai"}},
|
|
Models: map[string]Model{
|
|
"disable": {Provider: "p", Capabilities: &CapabilitiesConfig{Reasoning: &ReasoningCapCfg{
|
|
Control: "extra_body_disable", OffExtraBody: map[string]any{"thinking": map[string]any{"type": "disabled"}}}}},
|
|
"effort": {Provider: "p", Capabilities: &CapabilitiesConfig{Reasoning: &ReasoningCapCfg{
|
|
Control: "effort", OffEffort: "none"}}},
|
|
"none": {Provider: "p"}, // ReasoningNone — off-by-omission, nothing reaches the wire
|
|
},
|
|
}
|
|
for _, tc := range []struct {
|
|
model, effort string
|
|
thinks bool
|
|
why string
|
|
}{
|
|
{"disable", "", false, "an unset effort MERGES the disable — this is the case the old predicate got wrong"},
|
|
{"disable", "off", false, "an explicit off merges the disable"},
|
|
{"disable", "low", true, "an explicit level opts into thinking (OnExtraBody)"},
|
|
{"effort", "", true, "an unset effort emits nothing, so the provider's own default stands"},
|
|
{"effort", "off", false, "off sends the explicit OffEffort switch"},
|
|
{"effort", "high", true, "a level is sent as-is"},
|
|
{"none", "", true, "nothing reasoning-related ever reaches the wire"},
|
|
{"none", "off", true, "off is a deliberate NO-OP here — the DeepSeek echo-mine guarantee"},
|
|
} {
|
|
if got := m.ThinksOnWire(tc.model, tc.effort); got != tc.thinks {
|
|
t.Errorf("ThinksOnWire(%q, %q) = %v, want %v — %s", tc.model, tc.effort, got, tc.thinks, tc.why)
|
|
}
|
|
}
|
|
}
|