mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-12 12:18:55 +02:00
Adds a first-class `model` field on agents so users can pick the LLM model from the create / settings UI instead of editing `custom_env` / `custom_args`. Each provider's dropdown is populated from the live CLI when possible (`opencode models`, `pi --list-models`, `openclaw agents list --json`, `cursor-agent --list-models`, hermes ACP `session/new` → `SessionModelState`), with a static catalog for providers that don't enumerate.
Daemon resolves the runtime model as `agent.model → MULTICA_<PROVIDER>_MODEL → ""` — empty passes through so each backend's CLI picks its own default, avoiding static-guess drift.
Per-provider honouring:
- Claude / Codex / OpenCode / Cursor / Gemini / Pi / Copilot — CLI `--model` / thread payload.
- OpenClaw — `opts.Model` is mapped to `--agent <name>` (the CLI rejects `--model`).
- Hermes — `session/set_model` ACP RPC; stderr is sniffed for provider-level errors so HTTP 4xx from the configured LLM surfaces instead of "empty output"; explicit-model failures mark the task `failed`.
Supporting changes: migration 050 adds `agent.model`; daemon ↔ server heartbeat piggyback carries a model-discovery request; new REST endpoints under `/api/runtimes/{id}/models`; `multica agent create --model` / `update --model`; shared `ModelDropdown` in `packages/views/agents` (searchable, creatable, provider-grouped, default-badge, runtime-supported gate).
89 lines
2.9 KiB
Go
89 lines
2.9 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
// TestReportModelListResult_PreservesDefault guards the daemon → server
|
|
// → UI wire format for the model-discovery result. The `default` bool
|
|
// on each ModelEntry lights up the UI's "default" badge; if it gets
|
|
// dropped here (e.g. by going through a map[string]string), the badge
|
|
// silently disappears.
|
|
func TestReportModelListResult_PreservesDefault(t *testing.T) {
|
|
store := NewModelListStore()
|
|
req := store.Create("runtime-xyz")
|
|
|
|
// Report a completed result with one default entry and one not.
|
|
body := map[string]any{
|
|
"status": "completed",
|
|
"supported": true,
|
|
"models": []map[string]any{
|
|
{"id": "foo-default", "label": "Foo", "provider": "p", "default": true},
|
|
{"id": "bar", "label": "Bar", "provider": "p"},
|
|
},
|
|
}
|
|
raw, _ := json.Marshal(body)
|
|
|
|
// Use the store's Complete directly — we're verifying the wire
|
|
// shape, not HTTP auth. The handler itself unmarshals into
|
|
// []ModelEntry and forwards verbatim, which is the path we care
|
|
// about here.
|
|
var parsed struct {
|
|
Models []ModelEntry `json:"models"`
|
|
}
|
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
|
t.Fatalf("unmarshal report body: %v", err)
|
|
}
|
|
store.Complete(req.ID, parsed.Models, true)
|
|
|
|
got := store.Get(req.ID)
|
|
if got == nil {
|
|
t.Fatal("expected stored result")
|
|
}
|
|
if len(got.Models) != 2 {
|
|
t.Fatalf("expected 2 models, got %d: %+v", len(got.Models), got.Models)
|
|
}
|
|
if !got.Models[0].Default {
|
|
t.Errorf("first model should carry Default=true, got %+v", got.Models[0])
|
|
}
|
|
if got.Models[1].Default {
|
|
t.Errorf("second model should carry Default=false, got %+v", got.Models[1])
|
|
}
|
|
|
|
// Serialise the stored request back out (what UI actually sees)
|
|
// and confirm `default: true` survives.
|
|
out, _ := json.Marshal(got)
|
|
if !bytes.Contains(out, []byte(`"default":true`)) {
|
|
t.Errorf(`expected "default":true in JSON response, got: %s`, out)
|
|
}
|
|
}
|
|
|
|
// TestReportModelListResult_DecodesJSONBodyDefault verifies the
|
|
// handler's request-body parsing accepts the `default` bool from
|
|
// the daemon POST — not just through the store API.
|
|
func TestReportModelListResult_DecodesJSONBodyDefault(t *testing.T) {
|
|
// Simulate the shape the daemon POSTs: status + models + supported
|
|
// with `default` on one entry.
|
|
payload := `{"status":"completed","supported":true,"models":[{"id":"a","label":"A","default":true},{"id":"b","label":"B"}]}`
|
|
r := httptest.NewRequest(http.MethodPost, "/api/daemon/runtimes/rt/models/req/result", bytes.NewBufferString(payload))
|
|
|
|
var body struct {
|
|
Status string `json:"status"`
|
|
Models []ModelEntry `json:"models"`
|
|
Supported *bool `json:"supported"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(body.Models) != 2 {
|
|
t.Fatalf("want 2 models, got %d", len(body.Models))
|
|
}
|
|
if !body.Models[0].Default {
|
|
t.Errorf("default flag lost on model[0]: %+v", body.Models[0])
|
|
}
|
|
}
|