Files
DeepSeek-Reasonix/internal/serve/modelcaps_test.go
T
yhh b18d8ef3aa feat(studio): storage relocation, composer intake, and the checkpoint agreement
Accumulated work from the branch, carried across the rebase onto origin/studio
and merged with what landed there in the meantime.

Storage: a workspace can name where its state lives, and the server tracks the
one relocation it may be running (internal/storage, config/storage_roots,
serve/storage, the Storage panel).

Composer intake: what a drop or a long paste becomes is one pure function —
payload in, action out — so "what happens if I drop a folder" is a test rather
than a branch of an onDrop handler. It sits on the drop router that landed
upstream: that one answers which pane a drop hit, this one answers what it
means and says so before the pointer is released. A paste long enough to bury
the composer becomes a chip beside it instead.

Session: the checkpoint agreement and the lineage/torn-log recovery cases,
plus the conflict diagnostic learning where two transcripts parted and which
roles stood there — a position and a role tell a local reshape from a foreign
write without recording any content.

Model refs: the default-model write checks the live catalog, so an extension
model the runtime knows can be persisted as the default.
2026-08-19 23:52:04 -07:00

212 lines
6.3 KiB
Go

package serve
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"reasonix/internal/config"
"reasonix/internal/control"
)
// One host reached under both protocols, holding a mixed vision/text model set,
// a per-model price and a context window — every capability the picker reads.
const mixedVendorConfig = `default_model = "mixed/text-only"
[[providers]]
name = "mixed"
kind = "openai"
base_url = "https://gateway.invalid/v1"
models = ["text-only", "vision-pro"]
default = "text-only"
vision_models = ["vision-pro"]
context_window = 131072
api_key_env = "MIXED_API_KEY"
[providers.prices.vision-pro]
input = 2.0
output = 8.0
currency = "CNY"
[[providers]]
name = "mixed-anthropic"
kind = "anthropic"
base_url = "https://gateway.invalid/anthropic"
models = ["text-only"]
default = "text-only"
api_key_env = "MIXED_API_KEY"
`
func modelsByRef(t *testing.T, cfgBody string) map[string]modelEntry {
t.Helper()
t.Setenv("REASONIX_HOME", t.TempDir())
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
if _, err := config.SetCredential("MIXED_API_KEY", "sk-test"); err != nil {
t.Fatal(err)
}
path := config.UserConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(cfgBody), 0o600); err != nil {
t.Fatal(err)
}
bc := NewBroadcaster()
ctrl := control.New(control.Options{
Sink: bc, Label: "text-only", ModelRef: "mixed/text-only", SessionDir: t.TempDir(),
})
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
defer srv.Close()
resp, err := http.Get(srv.URL + "/models")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET /models = %d", resp.StatusCode)
}
var body struct {
Models []modelEntry `json:"models"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
out := make(map[string]modelEntry, len(body.Models))
for _, m := range body.Models {
out[m.Ref] = m
}
return out
}
func TestModelsReportVisionPerModelNotPerProvider(t *testing.T) {
got := modelsByRef(t, mixedVendorConfig)
if e, ok := got["mixed/text-only"]; !ok || e.Vision {
t.Fatalf("mixed/text-only vision = %v (present %v), want text-only", e.Vision, ok)
}
if e, ok := got["mixed/vision-pro"]; !ok || !e.Vision {
t.Fatalf("mixed/vision-pro vision = %v (present %v), want image input", e.Vision, ok)
}
}
// The old picker offered six effort levels to every model. A model whose
// endpoint exposes no effort vocabulary has to say so by staying empty, or the
// list is back to advertising levels the provider will reject.
func TestModelsOfferOnlyTheEffortLevelsTheEndpointHas(t *testing.T) {
got := modelsByRef(t, mixedVendorConfig)
plain, ok := got["mixed/text-only"]
if !ok {
t.Fatal("mixed/text-only missing from the catalog")
}
if len(plain.Efforts) != 0 {
t.Fatalf("a plain OpenAI-compatible endpoint offered efforts %v", plain.Efforts)
}
anth, ok := got["mixed-anthropic/text-only"]
if !ok {
t.Fatal("mixed-anthropic/text-only missing from the catalog")
}
if len(anth.Efforts) == 0 || anth.Efforts[0] != "auto" {
t.Fatalf("anthropic efforts = %v, want a list starting at auto", anth.Efforts)
}
}
// Two config entries on one host are one service with two routes. Without a
// shared vendor the picker lists the same model twice with nothing to choose
// between the rows.
func TestModelsShareAVendorAcrossProtocolRoutes(t *testing.T) {
got := modelsByRef(t, mixedVendorConfig)
openai := got["mixed/text-only"]
anth := got["mixed-anthropic/text-only"]
if openai.Vendor != "gateway.invalid" || anth.Vendor != openai.Vendor {
t.Fatalf("vendors = %q and %q, want both gateway.invalid", openai.Vendor, anth.Vendor)
}
if openai.Kind == anth.Kind {
t.Fatalf("both routes reported kind %q; the fold would have nothing to offer", openai.Kind)
}
}
func TestModelsCarryPriceAndWindowWhereDeclared(t *testing.T) {
got := modelsByRef(t, mixedVendorConfig)
priced, ok := got["mixed/vision-pro"]
if !ok {
t.Fatal("mixed/vision-pro missing from the catalog")
}
if priced.Price == nil {
t.Fatal("the per-model price did not reach the catalog")
}
if priced.Price.Input != 2 || priced.Price.Output != 8 || priced.Price.Currency != "CNY" {
t.Fatalf("price = %+v", priced.Price)
}
if priced.ContextWindow != 131072 {
t.Fatalf("context window = %d, want the provider-wide 131072", priced.ContextWindow)
}
// Nothing declares a price for this one, and a picker must not invent one.
if e := got["mixed/text-only"]; e.Price != nil {
t.Fatalf("an undeclared price surfaced as %+v", e.Price)
}
}
// The switch rebuilds the running controller; nothing about that reaches disk.
// Without persistence the next launch boots from default_model and lands back
// on the previous choice, which reads as the picker not having worked.
func TestSwitchingModelSurvivesARestart(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
if _, err := config.SetCredential("MIXED_API_KEY", "sk-test"); err != nil {
t.Fatal(err)
}
path := config.UserConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(mixedVendorConfig), 0o600); err != nil {
t.Fatal(err)
}
persistDefaultModel("mixed-anthropic/text-only", nil)
// A fresh load is what a restart sees.
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
if cfg.DefaultModel != "mixed-anthropic/text-only" {
t.Fatalf("default_model = %q after switching, want the model that was chosen", cfg.DefaultModel)
}
}
// A ref nothing resolves must not overwrite a working default.
func TestPersistingAnUnknownModelLeavesTheDefaultAlone(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
if _, err := config.SetCredential("MIXED_API_KEY", "sk-test"); err != nil {
t.Fatal(err)
}
path := config.UserConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(mixedVendorConfig), 0o600); err != nil {
t.Fatal(err)
}
persistDefaultModel("nobody/nothing", nil)
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
if cfg.DefaultModel != "mixed/text-only" {
t.Fatalf("default_model = %q, want the original left intact", cfg.DefaultModel)
}
}