Skip to content

Commit 1344429

Browse files
metiu1claude
andcommitted
Agentic: let the assistant install models itself (install_model + list_models tools)
When asked to use/generate media with no suitable model installed, the agent can now call install_model (e.g. 'stable diffusion' -> image/openjourney) to download it on demand, and list_models to see what's installed/installable. generate_* error now points the model at install_model instead of telling the user to run a CLI command. Resolves plain names to catalog refs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f1a9873 commit 1344429

1 file changed

Lines changed: 102 additions & 1 deletion

File tree

vortelio/internal/server/server_media_tools.go

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ func (m *mediaProvider) Tools() []rt.ToolDef {
4343
`{"type":"object","properties":{"input_file":{"type":"string","description":"Path to the audio file to transcribe."},"model":{"type":"string","description":"Optional audio model name."}},"required":["input_file"]}`),
4444
toolDef("generate_3d", "Generate a 3D model (mesh) from a text prompt or an input image using a local 3D model. Returns the saved file path.",
4545
`{"type":"object","properties":{"prompt":{"type":"string","description":"Text prompt describing the object."},"input_file":{"type":"string","description":"Optional input image path for image-to-3D."},"model":{"type":"string","description":"Optional 3D model name."}},"required":[]}`),
46+
toolDef("list_models", "List the user's installed models and a set of models that can be installed on demand. Use this before generating media if you are unsure a suitable model is installed.",
47+
`{"type":"object","properties":{},"required":[]}`),
48+
toolDef("install_model", "Download and install a model so it can be used (e.g. an image model for generate_image). Accept a catalog ref like \"image/openjourney\" or a plain name like \"stable diffusion\", \"sdxl\", \"whisper\". The download can take a few minutes; when it finishes the model is ready to use.",
49+
`{"type":"object","properties":{"model":{"type":"string","description":"Model to install: a catalog ref (image/openjourney, audio/whisper:base, llm/qwen2.5:7b) or a plain name."}},"required":["model"]}`),
4650
}
4751
}
4852

@@ -58,11 +62,108 @@ func (m *mediaProvider) Execute(name, argsJSON string) (string, error) {
5862
return m.transcribe(argsJSON)
5963
case "generate_3d":
6064
return m.generate3D(argsJSON)
65+
case "list_models":
66+
return m.listModels()
67+
case "install_model":
68+
return m.installModel(argsJSON)
6169
default:
6270
return "", fmt.Errorf("unknown media tool: %s", name)
6371
}
6472
}
6573

74+
// listModels reports installed models + a curated set installable on demand.
75+
func (m *mediaProvider) listModels() (string, error) {
76+
models, _ := hub.NewModelStore().List()
77+
installed := []string{}
78+
for _, md := range models {
79+
installed = append(installed, md.Type+"/"+md.Name+":"+md.Tag)
80+
}
81+
installable := []map[string]string{
82+
{"ref": "image/openjourney", "note": "Stable Diffusion 1.5 — light, good for 4GB GPUs"},
83+
{"ref": "image/sdxl", "note": "Stable Diffusion XL — higher quality, needs more VRAM"},
84+
{"ref": "image/flux:schnell", "note": "FLUX.1 Schnell — top quality, large"},
85+
{"ref": "audio/whisper:base", "note": "Whisper — speech-to-text"},
86+
{"ref": "audio/kokoro", "note": "Kokoro — text-to-speech"},
87+
{"ref": "llm/qwen2.5:7b", "note": "Qwen2.5 7B — capable chat/coding"},
88+
{"ref": "llm/llama3.2:3b", "note": "Llama 3.2 3B — light, fast"},
89+
}
90+
b, _ := json.Marshal(map[string]interface{}{"installed": installed, "installable": installable})
91+
return string(b), nil
92+
}
93+
94+
// resolveInstallRef maps a catalog ref or a plain model name to a catalog ref.
95+
func resolveInstallRef(q string) string {
96+
q = strings.TrimSpace(q)
97+
if q == "" {
98+
return ""
99+
}
100+
if strings.Contains(q, "/") { // already a ref like image/openjourney[:tag]
101+
return q
102+
}
103+
s := strings.ToLower(q)
104+
switch {
105+
case strings.Contains(s, "sdxl"), strings.Contains(s, "xl"):
106+
return "image/sdxl:latest"
107+
case strings.Contains(s, "flux"):
108+
return "image/flux:schnell"
109+
case strings.Contains(s, "openjourney"), strings.Contains(s, "midjourney"):
110+
return "image/openjourney:latest"
111+
case strings.Contains(s, "stable"), strings.Contains(s, "diffusion"), s == "sd", strings.Contains(s, "image"):
112+
return "image/openjourney:latest" // SD 1.5 — lightest image model
113+
case strings.Contains(s, "whisper"), strings.Contains(s, "transcri"), strings.Contains(s, "speech-to"):
114+
return "audio/whisper:base"
115+
case strings.Contains(s, "kokoro"), strings.Contains(s, "tts"), strings.Contains(s, "voice"), strings.Contains(s, "speech"):
116+
return "audio/kokoro:latest"
117+
case strings.Contains(s, "qwen"):
118+
return "llm/qwen2.5:7b"
119+
case strings.Contains(s, "llama"):
120+
return "llm/llama3.2:3b"
121+
}
122+
return q
123+
}
124+
125+
// installModel downloads a model on demand so it can be used by the other tools.
126+
func (m *mediaProvider) installModel(argsJSON string) (string, error) {
127+
var a struct {
128+
Model string `json:"model"`
129+
}
130+
json.Unmarshal([]byte(argsJSON), &a)
131+
refStr := resolveInstallRef(a.Model)
132+
if refStr == "" {
133+
return "", fmt.Errorf("specify which model to install (e.g. \"image/openjourney\" or \"stable diffusion\")")
134+
}
135+
ref, err := hub.ParseModelRef(refStr)
136+
if err != nil {
137+
return "", fmt.Errorf("unknown model %q: %v", a.Model, err)
138+
}
139+
// Already installed? Then we're done.
140+
if mdl, e := hub.NewModelStore().Resolve(ref); e == nil && mdl != nil {
141+
b, _ := json.Marshal(map[string]interface{}{"status": "ok", "model": refStr, "note": "already installed"})
142+
return string(b), nil
143+
}
144+
if m.emit != nil {
145+
m.emit("tool_progress", map[string]string{"text": "Downloading " + refStr + " — this can take a few minutes…"})
146+
}
147+
d := hub.NewDownloader()
148+
var lastPct int
149+
if err := d.Pull(ref, func(done, total int64) {
150+
if total > 0 && m.emit != nil {
151+
p := int(done * 100 / total)
152+
if p >= lastPct+10 {
153+
lastPct = p
154+
m.emit("tool_progress", map[string]string{"text": fmt.Sprintf("Downloading %s… %d%%", refStr, p)})
155+
}
156+
}
157+
}); err != nil {
158+
return "", fmt.Errorf("download failed for %s: %v", refStr, err)
159+
}
160+
b, _ := json.Marshal(map[string]interface{}{
161+
"status": "ok", "model": refStr,
162+
"note": "Installed and ready. You can now use it (e.g. call generate_image again).",
163+
})
164+
return string(b), nil
165+
}
166+
66167
// findMediaModel returns an installed model of the given type. If name is set it
67168
// matches by Name/DisplayName (substring, case-insensitive); otherwise the first
68169
// installed model of that type is returned.
@@ -94,7 +195,7 @@ func findMediaModel(typ, name string) (*hub.Model, error) {
94195
if first != nil {
95196
return first, nil
96197
}
97-
return nil, fmt.Errorf("no installed %s model found — download one first (vortelio pull %s:...)", typ, typ)
198+
return nil, fmt.Errorf("no installed %s model found — call the install_model tool to download one (for images use model \"stable diffusion\"), then retry", typ)
98199
}
99200

100201
// saveArtifact writes data to the default output dir with a timestamped name and

0 commit comments

Comments
 (0)