From 5c71edfac41591c2db77d9d1c420755576efe178 Mon Sep 17 00:00:00 2001 From: Raphael Westphal Date: Wed, 19 Aug 2026 11:41:31 +0200 Subject: [PATCH] feat: full model catalog (settings enabledModels + custom providers) and provider credentials in spawned containers (keys passthrough + host auth.json bind) --- daemon/api.go | 40 ++++++++++++++++++++++++++++++++++++++-- daemon/api_extra_test.go | 13 +++++++++++++ daemon/docker.go | 28 ++++++++++++++++++++-------- daemon/spawner_test.go | 10 +++++++++- docker-compose.yml | 6 ++++++ 5 files changed, 86 insertions(+), 11 deletions(-) diff --git a/daemon/api.go b/daemon/api.go index 961edeb..a3c4863 100644 --- a/daemon/api.go +++ b/daemon/api.go @@ -23,8 +23,10 @@ import ( const ( envToken string = "LVMH_TOKEN" - envModelsFile string = "LVMH_MODELS_FILE" - defaultModelsFile string = "/app/build/docker/worker-models.json" + envSettingsFile string = "LVMH_SETTINGS_FILE" + defaultSettingsFile string = "/app/build/docker/pi-agent/settings.json" + envModelsFile string = "LVMH_MODELS_FILE" + defaultModelsFile string = "/app/build/docker/worker-models.json" defaultEventsAfter int64 = 0 defaultEventsLimit int = 1000 @@ -544,6 +546,27 @@ func parseModelCatalog(data []byte) []ModelCatalogItem { return out } +// parseEnabledModels turns settings.json "enabledModels" entries +// ("provider/model-id") into catalog items, so built-in providers +// (anthropic, openai, google, ...) show up next to custom ones. +func parseEnabledModels(data []byte) []ModelCatalogItem { + var doc struct { + EnabledModels []string `json:"enabledModels"` + } + if err := json.Unmarshal(data, &doc); err != nil { + return nil + } + out := []ModelCatalogItem{} + for _, entry := range doc.EnabledModels { + provider, id, ok := strings.Cut(entry, "/") + if !ok || provider == "" || id == "" { + continue + } + out = append(out, ModelCatalogItem{Provider: provider, ID: id, Name: id}) + } + return out +} + func (s *Server) handleModelCatalog(w http.ResponseWriter, r *http.Request) { data, err := os.ReadFile(envOr(envModelsFile, defaultModelsFile)) if err != nil { @@ -555,6 +578,19 @@ func (s *Server) handleModelCatalog(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "model catalog unreadable") return } + // Merge in enabled built-in models from the baked pi settings; custom + // providers (models.json) win on duplicates. + if sdata, serr := os.ReadFile(envOr(envSettingsFile, defaultSettingsFile)); serr == nil { + seen := make(map[string]bool, len(catalog)) + for _, m := range catalog { + seen[m.Provider+"/"+m.ID] = true + } + for _, m := range parseEnabledModels(sdata) { + if !seen[m.Provider+"/"+m.ID] { + catalog = append(catalog, m) + } + } + } writeJSON(w, http.StatusOK, catalog) } diff --git a/daemon/api_extra_test.go b/daemon/api_extra_test.go index 7214ba8..6e9b997 100644 --- a/daemon/api_extra_test.go +++ b/daemon/api_extra_test.go @@ -562,3 +562,16 @@ func TestAPIRepoImagesStoreFailure(t *testing.T) { t.Fatal("DELETE with broken store must 500") } } + +func TestParseEnabledModels(t *testing.T) { + items := parseEnabledModels([]byte(`{"enabledModels":["anthropic/claude-opus-5","zai/glm","bad","/x","p/"]}`)) + if len(items) != 2 { + t.Fatalf("items = %+v, want 2 valid", items) + } + if items[0].Provider != "anthropic" || items[0].ID != "claude-opus-5" { + t.Fatalf("first = %+v", items[0]) + } + if parseEnabledModels([]byte("not json")) != nil { + t.Fatal("invalid json must yield nil") + } +} diff --git a/daemon/docker.go b/daemon/docker.go index a3c684f..604777a 100644 --- a/daemon/docker.go +++ b/daemon/docker.go @@ -40,14 +40,16 @@ const ( stateRunning string = "running" stateError string = "error" - imageRefWorker string = "lvmh-worker:latest" - labelSession string = "lvmh.session" - volumeRepoPrefix string = "lvmh-repo-" - volumeSessions string = "lvmh-sessions" - volumePiCache string = "lvmh-pi-cache" // pi package cache (git:/npm:), shared across spawns - cacheMount string = "/root/.pi/agent/cache" - workspaceMount string = "/workspace" - sessionsMount string = "/pi-sessions" + imageRefWorker string = "lvmh-worker:latest" + labelSession string = "lvmh.session" + volumeRepoPrefix string = "lvmh-repo-" + volumeSessions string = "lvmh-sessions" + volumePiCache string = "lvmh-pi-cache" // pi package cache (git:/npm:), shared across spawns + cacheMount string = "/root/.pi/agent/cache" + authMountTarget string = "/root/.pi/agent/auth.json" + envHostPiAgentDir string = "LVMH_HOST_PI_AGENT_DIR" + workspaceMount string = "/workspace" + sessionsMount string = "/pi-sessions" envWorkerDockerfile string = "LVMH_WORKER_DOCKERFILE" defaultDockerfile string = "/app/build/docker/worker.Dockerfile" @@ -515,6 +517,11 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID stri volumeSessions + ":" + sessionsMount, volumePiCache + ":" + cacheMount, } + // Host pi credentials (OAuth tokens for anthropic etc.), read-only, so + // spawned agents can use every model the catalog offers. + if hostAgent := os.Getenv(envHostPiAgentDir); hostAgent != "" { + binds = append(binds, hostAgent+"/auth.json:"+authMountTarget+":ro") + } cfg := &container.Config{ Image: image, Env: []string{ @@ -523,6 +530,11 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID stri "LVMH_URL=" + s.containerURL, envLVMHSessionID + "=" + sessionID, envLVMHAgent + "=1", + // other provider keys (empty ones are harmless) + "OPENAI_API_KEY=" + os.Getenv("OPENAI_API_KEY"), + "GEMINI_API_KEY=" + os.Getenv("GEMINI_API_KEY"), + "DEEPSEEK_KEY=" + os.Getenv("DEEPSEEK_KEY"), + "ANTHROPIC_API_KEY=" + os.Getenv("ANTHROPIC_API_KEY"), envLVMHRepo + "=" + repo, }, Labels: map[string]string{labelSession: sessionID}, diff --git a/daemon/spawner_test.go b/daemon/spawner_test.go index cfa7fe0..8a4bbb6 100644 --- a/daemon/spawner_test.go +++ b/daemon/spawner_test.go @@ -68,9 +68,17 @@ func TestSpawnerStartHappyPath(t *testing.T) { envLVMHAgent + "=1": true, envLVMHRepo + "=group/project": true, } + // provider key passthroughs are environment-dependent; ignore them here + passthrough := map[string]bool{ + "OPENAI_API_KEY": true, "GEMINI_API_KEY": true, + "DEEPSEEK_KEY": true, "ANTHROPIC_API_KEY": true, + } for _, e := range c.Env { + if name, _, ok := strings.Cut(e, "="); ok && passthrough[name] { + continue + } if !wantEnv[e] { - t.Fatalf("unexpected env %q in %v", e, c.Env) + t.Fatalf("unexpected env %q", e) } delete(wantEnv, e) } diff --git a/docker-compose.yml b/docker-compose.yml index 894a950..505244d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,12 @@ services: # How spawned containers reach the daemon: alias on the shared network. LVMH_CONTAINER_LVMH_URL: ws://lvmh:8686/agent/ws GITLAB_BASE_URL: https://git.westphal.fr + # host path of the pi config, so spawned containers can bind auth.json + LVMH_HOST_PI_AGENT_DIR: ${LVMH_PI_AGENT_DIR:-/home/raph/.dotfiles/pi/agent} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + GEMINI_API_KEY: ${GEMINI_API_KEY:-} + DEEPSEEK_KEY: ${DEEPSEEK_KEY:-} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} volumes: - /var/run/docker.sock:/var/run/docker.sock - lvmh-data:/data