Skip to content

Commit df1e7eb

Browse files
committed
feat(init): adopt the model server that is actually running
`slmcode init` ended a first run with "no model server answered" and a pointer at `slmcode doctor` — two more commands for somebody who has just scaffolded a workspace, when a server is very often running on a port that is simply not the default. That is the exact moment auto-configuration is worth the most and costs the least: the config was written seconds ago from defaults, so there is nothing of the user's to protect. It now looks around and adopts what it finds, printing what it did. Two guards keep that safe: - Only when the configured endpoint did NOT answer, so a working setup is never moved. - Never when the endpoint was pinned by --endpoint or SLMCODE_ENDPOINT. That is a statement about where the server is, and quietly configuring something else would be the tool arguing with the user. When it finds nothing it says which of the three problems this is — nothing listening, no models loaded, or no model that can write code — instead of the generic remedy, because those have different fixes. adoptDiscoveredServer takes a context rather than the cobra command: it needed nothing else from it, and cmd.Context() is nil on a command that was never executed, which made the function unsafe to call from anywhere but its one call site. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UiLDxnEu1Gf8Q5hWAFhaAd
1 parent 7345c7b commit df1e7eb

4 files changed

Lines changed: 219 additions & 0 deletions

File tree

‎cmd/slmcode/cmd_core.go‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515

1616
"github.com/spf13/cobra"
1717

18+
"github.com/UnicoLab/slmcode/pkg/autoconfig"
1819
"github.com/UnicoLab/slmcode/pkg/blocks"
1920
"github.com/UnicoLab/slmcode/pkg/cli"
2021
"github.com/UnicoLab/slmcode/pkg/config"
@@ -107,6 +108,16 @@ Run ` + "`slmcode config show --all`" + ` to see the full effective surface.`,
107108
ws.Config.Model, ws.Config.APIKey, 1500*time.Millisecond)
108109
if probe.State == cli.ProbeDown {
109110
fmt.Println(cli.Warn("no model server answered at " + ws.Config.Endpoint))
111+
// This is the moment auto-configuration is worth the most and
112+
// costs the least: the config was written seconds ago from
113+
// defaults, so there is nothing of the user's to protect, and
114+
// the alternative is sending somebody who just scaffolded a
115+
// workspace away to run two more commands. A server IS often
116+
// running — on a different port from the default.
117+
if adopted := adoptDiscoveredServer(cmd.Context(), ws.Config); adopted {
118+
fmt.Println(cli.Info("next: slmcode run -v \"…\" — agents fill context, plan, and tasks"))
119+
return nil
120+
}
110121
if probe.Remedy != "" {
111122
fmt.Println(cli.Dim(" " + probe.Remedy))
112123
}
@@ -902,3 +913,51 @@ func boardProgress(slmDir string) func() (int, int) {
902913
func studioUIIsPlaceholder(uiFS fs.FS) bool {
903914
return !server.UIIsBuilt(uiFS)
904915
}
916+
917+
// adoptDiscoveredServer looks for a model server and writes what it finds.
918+
//
919+
// Only ever called when the configured endpoint did NOT answer, so it cannot
920+
// move a working setup. It also stands down when the user pinned an endpoint
921+
// themselves: `slmcode init --endpoint X` is a statement about where the server
922+
// is, and quietly configuring Y instead would be the tool arguing with them.
923+
//
924+
// Reports what it did either way. A command that silently rewrites the config
925+
// it just told you about is one you have to re-read afterwards.
926+
func adoptDiscoveredServer(ctx context.Context, cfg *config.Config) bool {
927+
if flagEndpoint != "" || strings.TrimSpace(os.Getenv("SLMCODE_ENDPOINT")) != "" {
928+
return false
929+
}
930+
fmt.Println(cli.Dim(" looking for one elsewhere…"))
931+
932+
ctx, cancel := context.WithTimeout(ctx, autoconfig.DefaultProbeTimeout+5*time.Second)
933+
defer cancel()
934+
res := autoconfig.Discover(ctx, cfg, os.Getenv,
935+
autoconfig.HTTPProber(autoconfig.DefaultProbeTimeout))
936+
if !res.Found {
937+
// More specific than the generic remedy: it distinguishes nothing
938+
// listening from a server with no models from a server whose models
939+
// cannot write code, and those have different fixes.
940+
fmt.Println(cli.Dim(" " + res.NothingFound()))
941+
return false
942+
}
943+
944+
choice := res.Choice
945+
cfg.Provider = choice.Provider
946+
cfg.Endpoint = choice.Endpoint
947+
cfg.Model = choice.Model
948+
cfg.ActiveStack = ""
949+
cfg.Normalize()
950+
saveKeys := []string{"provider", "model", "endpoint"}
951+
if cfg.ActivePack != "" {
952+
saveKeys = append(saveKeys, "active_pack")
953+
}
954+
if err := cfg.SaveInitial(saveKeys...); err != nil {
955+
fmt.Println(cli.Warn("found " + choice.Endpoint + " but could not save: " + err.Error()))
956+
return false
957+
}
958+
fmt.Println(cli.Success("found " + choice.Provider + " at " + choice.Endpoint))
959+
cli.KeyVal("model", choice.Model)
960+
cli.KeyVal("why", choice.Why)
961+
fmt.Println(cli.Dim(" `slmcode configure` to change it, `slmcode config show` to see it"))
962+
return true
963+
}

‎cmd/slmcode/cmd_init_adopt_test.go‎

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"os"
8+
"strings"
9+
"testing"
10+
11+
"github.com/UnicoLab/slmcode/pkg/config"
12+
"github.com/UnicoLab/slmcode/pkg/orchestrator"
13+
)
14+
15+
// `slmcode init` used to end a first run with "no model server answered" and a
16+
// pointer at `slmcode doctor` — two more commands for somebody who has just
17+
// scaffolded a workspace, when a server is often running on a port that is
18+
// simply not the default.
19+
20+
func adoptWorkspace(t *testing.T) *config.Config {
21+
t.Helper()
22+
root := t.TempDir()
23+
cfg := config.Default(root)
24+
cfg.Root = root
25+
if err := orchestrator.InitWorkspace(root, cfg); err != nil {
26+
t.Fatal(err)
27+
}
28+
return cfg
29+
}
30+
31+
func adoptServer(t *testing.T, ids ...string) *httptest.Server {
32+
t.Helper()
33+
body := `{"object":"list","data":[`
34+
for i, id := range ids {
35+
if i > 0 {
36+
body += ","
37+
}
38+
body += `{"id":"` + id + `"}`
39+
}
40+
body += `]}`
41+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
42+
if !strings.HasSuffix(r.URL.Path, "/models") {
43+
w.WriteHeader(http.StatusNotFound)
44+
return
45+
}
46+
_, _ = w.Write([]byte(body))
47+
}))
48+
t.Cleanup(srv.Close)
49+
return srv
50+
}
51+
52+
func TestInitAdoptsAServerItFinds(t *testing.T) {
53+
// adoptDiscoveredServer reads two globals that other tests in this package
54+
// set; a stale one here would look like a bug in adoption.
55+
prev := flagEndpoint
56+
flagEndpoint = ""
57+
t.Cleanup(func() { flagEndpoint = prev })
58+
t.Setenv("SLMCODE_ENDPOINT", "")
59+
60+
cfg := adoptWorkspace(t)
61+
srv := adoptServer(t, "nomic-embed-text", "Qwen2.5-Coder-14B-Instruct")
62+
cfg.Endpoint = srv.URL + "/v1"
63+
64+
if !adoptDiscoveredServer(context.Background(), cfg) {
65+
t.Fatalf("a live server must be adopted (endpoint=%q flagEndpoint=%q env=%q)",
66+
cfg.Endpoint, flagEndpoint, os.Getenv("SLMCODE_ENDPOINT"))
67+
}
68+
if cfg.Model != "Qwen2.5-Coder-14B-Instruct" {
69+
t.Errorf("model = %q, want the coder rather than the embedding model", cfg.Model)
70+
}
71+
// And it reaches disk, or the next command sees the old configuration.
72+
reloaded, err := config.Load(cfg.Root)
73+
if err != nil {
74+
t.Fatal(err)
75+
}
76+
if reloaded.Endpoint != srv.URL+"/v1" {
77+
t.Errorf("saved endpoint = %q", reloaded.Endpoint)
78+
}
79+
}
80+
81+
// `slmcode init --endpoint X` is a statement about where the server is.
82+
// Quietly configuring Y instead would be the tool arguing with the user.
83+
func TestInitStandsDownWhenTheUserPinnedAnEndpoint(t *testing.T) {
84+
cfg := adoptWorkspace(t)
85+
srv := adoptServer(t, "Qwen2.5-Coder-14B-Instruct")
86+
cfg.Endpoint = srv.URL + "/v1"
87+
88+
prev := flagEndpoint
89+
flagEndpoint = "http://127.0.0.1:9999/v1"
90+
t.Cleanup(func() { flagEndpoint = prev })
91+
92+
if adoptDiscoveredServer(context.Background(), cfg) {
93+
t.Error("a pinned endpoint must not be replaced by a discovered one")
94+
}
95+
}
96+
97+
func TestInitStandsDownWhenTheEnvironmentPinnedAnEndpoint(t *testing.T) {
98+
cfg := adoptWorkspace(t)
99+
srv := adoptServer(t, "Qwen2.5-Coder-14B-Instruct")
100+
cfg.Endpoint = srv.URL + "/v1"
101+
t.Setenv("SLMCODE_ENDPOINT", "http://127.0.0.1:9999/v1")
102+
103+
if adoptDiscoveredServer(context.Background(), cfg) {
104+
t.Error("an endpoint pinned in the environment must not be replaced")
105+
}
106+
}
107+
108+
// A server whose models cannot write code is not something to adopt, and
109+
// saying so beats writing a config whose first run fails.
110+
//
111+
// Asserts that THIS server was not adopted rather than that nothing was: a
112+
// developer running LM Studio while the suite runs has a real second candidate,
113+
// and adopting it would be correct. A test that fails on a machine with a model
114+
// server running is a test nobody trusts.
115+
func TestInitDoesNotAdoptAServerWithNothingUsable(t *testing.T) {
116+
prev := flagEndpoint
117+
flagEndpoint = ""
118+
t.Cleanup(func() { flagEndpoint = prev })
119+
t.Setenv("SLMCODE_ENDPOINT", "")
120+
121+
cfg := adoptWorkspace(t)
122+
srv := adoptServer(t, "nomic-embed-text", "whisper-large-v3")
123+
cfg.Endpoint = srv.URL + "/v1"
124+
125+
adoptDiscoveredServer(context.Background(), cfg)
126+
if cfg.Endpoint == srv.URL+"/v1" && cfg.Model != "" {
127+
for _, bad := range []string{"nomic-embed-text", "whisper-large-v3"} {
128+
if cfg.Model == bad {
129+
t.Errorf("adopted %q, which cannot write code", bad)
130+
}
131+
}
132+
}
133+
}

‎docs/changelog.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,13 @@ because they have different fixes. And it is now the remedy named when a run
331331
refuses to start because the endpoint is down or the configured model is not
332332
served, which is where somebody actually needs it.
333333

334+
`slmcode init` runs it for you. It used to end a first run with "no model server
335+
answered" and a pointer at `slmcode doctor` — two more commands for somebody who
336+
has just scaffolded a workspace, when a server is often running on a port that
337+
is simply not the default. It now looks around and adopts what it finds. Only
338+
when the configured endpoint did not answer, so it can never move a working
339+
setup, and never when you pinned `--endpoint` or `SLMCODE_ENDPOINT`.
340+
334341
### Added — correction tickets are legible on the board
335342

336343
A correction ticket looked exactly like planned work: same card, same badges, a

‎docs/cli.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,26 @@ because the endpoint is down or the configured model is not served. In the
372372
Studio it is the **Find my model server** panel in Settings, with the same two
373373
steps: *Look around* changes nothing, *Configure for me* writes.
374374

375+
### It runs itself on `init`
376+
377+
`slmcode init` used to end with "no model server answered" and a pointer at
378+
`slmcode doctor` — two more commands for somebody who has just scaffolded a
379+
workspace, when a server is often running on a port that is simply not the
380+
default. It now looks around and adopts what it finds:
381+
382+
```text
383+
⚠ no model server answered at http://127.0.0.1:8000/v1
384+
looking for one elsewhere…
385+
✔ found lmstudio at http://127.0.0.1:1234/v1
386+
model Qwen3-Coder-30B-A3B-Instruct-MLX-4bit
387+
why tuned for code (coder), instruction-tuned, 30B, 3B active
388+
```
389+
390+
Only when the configured endpoint did **not** answer, so it can never move a
391+
working setup, and never when you pinned `--endpoint` or `SLMCODE_ENDPOINT` —
392+
that is a statement about where the server is, and configuring something else
393+
would be the tool arguing with you.
394+
375395
---
376396

377397
## `compose`

0 commit comments

Comments
 (0)