feat: full model catalog (settings enabledModels + custom providers) and provider credentials in spawned containers (keys passthrough + host auth.json bind)
This commit is contained in:
@@ -23,6 +23,8 @@ import (
|
|||||||
const (
|
const (
|
||||||
envToken string = "LVMH_TOKEN"
|
envToken string = "LVMH_TOKEN"
|
||||||
|
|
||||||
|
envSettingsFile string = "LVMH_SETTINGS_FILE"
|
||||||
|
defaultSettingsFile string = "/app/build/docker/pi-agent/settings.json"
|
||||||
envModelsFile string = "LVMH_MODELS_FILE"
|
envModelsFile string = "LVMH_MODELS_FILE"
|
||||||
defaultModelsFile string = "/app/build/docker/worker-models.json"
|
defaultModelsFile string = "/app/build/docker/worker-models.json"
|
||||||
|
|
||||||
@@ -544,6 +546,27 @@ func parseModelCatalog(data []byte) []ModelCatalogItem {
|
|||||||
return out
|
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) {
|
func (s *Server) handleModelCatalog(w http.ResponseWriter, r *http.Request) {
|
||||||
data, err := os.ReadFile(envOr(envModelsFile, defaultModelsFile))
|
data, err := os.ReadFile(envOr(envModelsFile, defaultModelsFile))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -555,6 +578,19 @@ func (s *Server) handleModelCatalog(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusInternalServerError, "model catalog unreadable")
|
writeError(w, http.StatusInternalServerError, "model catalog unreadable")
|
||||||
return
|
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)
|
writeJSON(w, http.StatusOK, catalog)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -562,3 +562,16 @@ func TestAPIRepoImagesStoreFailure(t *testing.T) {
|
|||||||
t.Fatal("DELETE with broken store must 500")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ const (
|
|||||||
volumeSessions string = "lvmh-sessions"
|
volumeSessions string = "lvmh-sessions"
|
||||||
volumePiCache string = "lvmh-pi-cache" // pi package cache (git:/npm:), shared across spawns
|
volumePiCache string = "lvmh-pi-cache" // pi package cache (git:/npm:), shared across spawns
|
||||||
cacheMount string = "/root/.pi/agent/cache"
|
cacheMount string = "/root/.pi/agent/cache"
|
||||||
|
authMountTarget string = "/root/.pi/agent/auth.json"
|
||||||
|
envHostPiAgentDir string = "LVMH_HOST_PI_AGENT_DIR"
|
||||||
workspaceMount string = "/workspace"
|
workspaceMount string = "/workspace"
|
||||||
sessionsMount string = "/pi-sessions"
|
sessionsMount string = "/pi-sessions"
|
||||||
|
|
||||||
@@ -515,6 +517,11 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID stri
|
|||||||
volumeSessions + ":" + sessionsMount,
|
volumeSessions + ":" + sessionsMount,
|
||||||
volumePiCache + ":" + cacheMount,
|
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{
|
cfg := &container.Config{
|
||||||
Image: image,
|
Image: image,
|
||||||
Env: []string{
|
Env: []string{
|
||||||
@@ -523,6 +530,11 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID stri
|
|||||||
"LVMH_URL=" + s.containerURL,
|
"LVMH_URL=" + s.containerURL,
|
||||||
envLVMHSessionID + "=" + sessionID,
|
envLVMHSessionID + "=" + sessionID,
|
||||||
envLVMHAgent + "=1",
|
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,
|
envLVMHRepo + "=" + repo,
|
||||||
},
|
},
|
||||||
Labels: map[string]string{labelSession: sessionID},
|
Labels: map[string]string{labelSession: sessionID},
|
||||||
|
|||||||
@@ -68,9 +68,17 @@ func TestSpawnerStartHappyPath(t *testing.T) {
|
|||||||
envLVMHAgent + "=1": true,
|
envLVMHAgent + "=1": true,
|
||||||
envLVMHRepo + "=group/project": 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 {
|
for _, e := range c.Env {
|
||||||
|
if name, _, ok := strings.Cut(e, "="); ok && passthrough[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if !wantEnv[e] {
|
if !wantEnv[e] {
|
||||||
t.Fatalf("unexpected env %q in %v", e, c.Env)
|
t.Fatalf("unexpected env %q", e)
|
||||||
}
|
}
|
||||||
delete(wantEnv, e)
|
delete(wantEnv, e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ services:
|
|||||||
# How spawned containers reach the daemon: alias on the shared network.
|
# How spawned containers reach the daemon: alias on the shared network.
|
||||||
LVMH_CONTAINER_LVMH_URL: ws://lvmh:8686/agent/ws
|
LVMH_CONTAINER_LVMH_URL: ws://lvmh:8686/agent/ws
|
||||||
GITLAB_BASE_URL: https://git.westphal.fr
|
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:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- lvmh-data:/data
|
- lvmh-data:/data
|
||||||
|
|||||||
Reference in New Issue
Block a user