1use std::path::{Path, PathBuf};
5use std::time::{Duration, Instant};
6
7use crate::cli::DEFAULT_HTTP;
8
9#[derive(Debug, Clone)]
11pub struct Target {
12 pub base: String,
14 pub model: String,
16 pub n_ctx: Option<u64>,
18 pub api_key: Option<String>,
20}
21
22impl Target {
23 fn key(&self) -> &str {
26 self.api_key.as_deref().unwrap_or("superfluid")
27 }
28}
29
30#[derive(Debug, Clone, Default, PartialEq)]
32pub struct Plan {
33 pub program: String,
34 pub args: Vec<String>,
35 pub env: Vec<(String, String)>,
36 pub env_default: Vec<(String, String)>,
38 pub env_remove: Vec<String>,
40 pub before: Vec<Vec<String>>,
42 pub files: Vec<(PathBuf, String)>,
45 pub notes: Vec<String>,
47}
48
49pub struct Agent {
50 pub name: &'static str,
51 pub aliases: &'static [&'static str],
52 pub title: &'static str,
53 pub binary: &'static str,
54 pub install: &'static str,
55 plan: fn(&Target, &[String]) -> Plan,
56}
57
58pub const AGENTS: &[Agent] = &[
59 Agent {
60 name: "claude",
61 aliases: &["claude-code"],
62 title: "Claude Code",
63 binary: "claude",
64 install: "curl -fsSL https://claude.ai/install.sh | bash",
65 plan: claude,
66 },
67 Agent {
68 name: "codex",
69 aliases: &["codex-cli"],
70 title: "Codex",
71 binary: "codex",
72 install: "npm install -g @openai/codex",
73 plan: codex,
74 },
75 Agent {
76 name: "pi",
77 aliases: &["pi-coding-agent"],
78 title: "pi",
79 binary: "pi",
80 install: "npm install -g @earendil-works/pi-coding-agent",
81 plan: pi,
82 },
83 Agent {
84 name: "opencode",
85 aliases: &[],
86 title: "OpenCode",
87 binary: "opencode",
88 install: "curl -fsSL https://opencode.ai/install | bash",
89 plan: opencode,
90 },
91 Agent {
92 name: "hermes",
93 aliases: &["hermes-agent"],
94 title: "Hermes Agent",
95 binary: "hermes",
96 install: "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash",
97 plan: hermes,
98 },
99 Agent {
100 name: "cline",
101 aliases: &["cline-cli"],
102 title: "Cline",
103 binary: "cline",
104 install: "npm install -g cline",
105 plan: cline,
106 },
107 Agent {
108 name: "openclaw",
109 aliases: &["clawdbot", "moltbot"],
110 title: "OpenClaw",
111 binary: "openclaw",
112 install: "npm install -g openclaw@latest",
113 plan: openclaw,
114 },
115 Agent {
116 name: "ollama",
117 aliases: &[],
118 title: "Ollama CLI",
119 binary: "ollama",
120 install: "https://ollama.com/download",
121 plan: ollama,
122 },
123];
124
125fn claude(t: &Target, extra: &[String]) -> Plan {
127 let mut env = vec![
128 ("ANTHROPIC_BASE_URL".to_string(), t.base.clone()),
129 ("ANTHROPIC_AUTH_TOKEN".to_string(), t.key().to_string()),
130 ];
131 for var in [
132 "ANTHROPIC_MODEL",
133 "ANTHROPIC_DEFAULT_OPUS_MODEL",
134 "ANTHROPIC_DEFAULT_SONNET_MODEL",
135 "ANTHROPIC_DEFAULT_HAIKU_MODEL",
136 "ANTHROPIC_SMALL_FAST_MODEL",
137 "CLAUDE_CODE_SUBAGENT_MODEL",
138 ] {
139 env.push((var.to_string(), t.model.clone()));
140 }
141 env.push(("CLAUDE_CODE_ATTRIBUTION_HEADER".to_string(), "0".to_string()));
143 let mut notes = Vec::new();
144 if let Some(n) = t.n_ctx {
146 env.push(("CLAUDE_CODE_MAX_CONTEXT_TOKENS".to_string(), n.to_string()));
147 }
148 if let Some(n) = t.n_ctx.filter(|&n| n < 200_000) {
149 env.push(("CLAUDE_CODE_AUTO_COMPACT_WINDOW".to_string(), n.to_string()));
150 if n < 32_768 {
151 notes.push(format!(
152 "the server's context window is {n} tokens; Claude Code's first request alone is about 17k. \
153 Restart the server with --max-context 65536 or more."
154 ));
155 }
156 }
157 Plan {
158 program: "claude".into(),
159 args: extra.to_vec(),
160 env,
161 env_default: vec![("CLAUDE_CODE_AUTO_MODE_SERVER".into(), "0".into())],
163 env_remove: vec!["ANTHROPIC_API_KEY".into()],
165 notes,
166 ..Plan::default()
167 }
168}
169
170const FALLBACK_WINDOW: u64 = 128_000;
172
173fn toml_str(s: &str) -> String {
175 serde_json::to_string(s).expect("a string serializes")
176}
177
178fn codex(t: &Target, extra: &[String]) -> Plan {
181 let catalog = state_dir().join("codex-models.json");
182 let entry = serde_json::json!({
183 "slug": t.model,
184 "display_name": t.model,
185 "context_window": t.n_ctx.unwrap_or(FALLBACK_WINDOW),
186 "shell_type": "default",
187 "visibility": "list",
188 "supported_in_api": true,
189 "priority": 0,
190 "truncation_policy": {"mode": "bytes", "limit": 10000},
191 "input_modalities": ["text"],
192 "base_instructions": "",
193 "support_verbosity": false,
194 "supports_parallel_tool_calls": false,
195 "supports_reasoning_summaries": false,
196 "supported_reasoning_levels": [],
197 "experimental_supported_tools": [],
198 });
199 let text = serde_json::to_string_pretty(&serde_json::json!({"models": [entry]})).expect("json") + "\n";
200 let mut args = Vec::new();
201 for (k, v) in [
202 ("model_provider", toml_str("superfluid")),
203 ("model_providers.superfluid.name", toml_str("Superfluid")),
204 ("model_providers.superfluid.base_url", toml_str(&format!("{}/v1", t.base))),
205 ("model_providers.superfluid.wire_api", toml_str("responses")),
206 ("model_providers.superfluid.env_key", toml_str("SUPERFLUID_LAUNCH_KEY")),
207 ("model_catalog_json", toml_str(&catalog.to_string_lossy())),
208 ("web_search", toml_str("disabled")),
209 ] {
210 args.push("-c".to_string());
211 args.push(format!("{k}={v}"));
212 }
213 args.push("-m".into());
214 args.push(t.model.clone());
215 args.extend(extra.iter().cloned());
216 Plan {
217 program: "codex".into(),
218 args,
219 env: vec![("SUPERFLUID_LAUNCH_KEY".into(), t.key().to_string())],
220 files: vec![(catalog, text)],
221 ..Plan::default()
222 }
223}
224
225fn pi(t: &Target, extra: &[String]) -> Plan {
227 let ext = state_dir().join("pi-superfluid.ts");
228 let window = t.n_ctx.unwrap_or(FALLBACK_WINDOW);
229 let provider = serde_json::json!({
230 "name": "Superfluid",
231 "baseUrl": format!("{}/v1", t.base),
232 "apiKey": t.key(),
233 "api": "openai-completions",
234 "models": [{
235 "id": t.model,
236 "name": t.model,
237 "reasoning": false,
238 "input": ["text"],
239 "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0},
240 "contextWindow": window,
241 "maxTokens": window.min(16_384),
242 }],
243 });
244 let text = format!(
245 "// Written by `superfluid launch pi` for one run: the server at {}.\n\
246 export default function (pi: any) {{\n pi.registerProvider(\"superfluid\", {});\n}}\n",
247 t.base,
248 serde_json::to_string_pretty(&provider).expect("json").replace('\n', "\n ")
249 );
250 let mut args = vec!["-e".to_string(), ext.to_string_lossy().into_owned()];
251 args.extend(["--provider".into(), "superfluid".into(), "--model".into(), t.model.clone()]);
252 args.extend(extra.iter().cloned());
253 Plan { program: "pi".into(), args, files: vec![(ext, text)], ..Plan::default() }
254}
255
256fn opencode(t: &Target, extra: &[String]) -> Plan {
258 let window = t.n_ctx.unwrap_or(FALLBACK_WINDOW);
259 let mut models = serde_json::Map::new();
260 models.insert(
261 t.model.clone(),
262 serde_json::json!({"name": t.model, "limit": {"context": window, "output": window.min(16_384)}}),
263 );
264 let config = serde_json::json!({
265 "$schema": "https://opencode.ai/config.json",
266 "provider": {"superfluid": {
267 "npm": "@ai-sdk/openai-compatible",
268 "name": "Superfluid",
269 "options": {"baseURL": format!("{}/v1", t.base), "apiKey": t.key()},
270 "models": models,
271 }},
272 "model": format!("superfluid/{}", t.model),
273 });
274 Plan {
275 program: "opencode".into(),
276 args: extra.to_vec(),
277 env: vec![("OPENCODE_CONFIG_CONTENT".into(), config.to_string())],
278 ..Plan::default()
279 }
280}
281
282fn hermes(t: &Target, extra: &[String]) -> Plan {
285 let v1 = format!("{}/v1", t.base);
286 let mut env = vec![("CUSTOM_BASE_URL".to_string(), v1.clone())];
287 if let Some(key) = &t.api_key {
288 env.push(("OPENAI_BASE_URL".into(), v1));
289 env.push(("OPENAI_API_KEY".into(), key.clone()));
290 }
291 let mut notes = Vec::new();
292 if let Some(n) = t.n_ctx.filter(|&n| n < 64_000) {
293 notes.push(format!(
294 "the server's context window is {n} tokens; Hermes refuses a model with less than 64000. \
295 Restart the server with --max-context 65536 or more."
296 ));
297 }
298 let mut args = vec!["--provider".to_string(), "custom".into(), "-m".into(), t.model.clone()];
299 args.extend(extra.iter().cloned());
300 Plan { program: "hermes".into(), args, env, notes, ..Plan::default() }
301}
302
303fn cline(t: &Target, extra: &[String]) -> Plan {
306 let mut updated = crate::ollama::timestamp(std::time::SystemTime::now());
308 updated.truncate(23);
309 updated.push('Z');
310 let path = state_dir().join("cline-providers.json");
311 let settings = serde_json::json!({
312 "version": 1,
313 "lastUsedProvider": "openai-compatible",
314 "modes": {},
315 "providers": {"openai-compatible": {
316 "settings": {
317 "provider": "openai-compatible",
318 "apiKey": t.key(),
319 "model": t.model,
320 "baseUrl": format!("{}/v1", t.base),
321 },
322 "updatedAt": updated,
323 "tokenSource": "manual",
324 }},
325 });
326 let text = serde_json::to_string_pretty(&settings).expect("json") + "\n";
327 Plan {
328 program: "cline".into(),
329 args: extra.to_vec(),
330 env: vec![("CLINE_PROVIDER_SETTINGS_PATH".into(), path.to_string_lossy().into_owned())],
331 files: vec![(path, text)],
332 ..Plan::default()
333 }
334}
335
336const OPENCLAW_PROFILE: &str = "superfluid";
338
339fn openclaw(t: &Target, extra: &[String]) -> Plan {
342 let window = t.n_ctx.unwrap_or(FALLBACK_WINDOW);
343 let provider = serde_json::json!({
344 "baseUrl": format!("{}/v1", t.base),
345 "apiKey": t.key(),
346 "api": "openai-completions",
347 "models": [{
348 "id": t.model,
349 "name": t.model,
350 "input": ["text"],
351 "reasoning": false,
352 "contextWindow": window,
353 "maxTokens": window.min(16_384),
354 "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0},
355 }],
356 });
357 let profile = |rest: &[&str]| -> Vec<String> {
358 ["--profile", OPENCLAW_PROFILE].iter().chain(rest).map(|s| s.to_string()).collect()
359 };
360 let mut before = Vec::new();
361 let config = std::env::home_dir().map(|h| h.join(format!(".openclaw-{OPENCLAW_PROFILE}")).join("openclaw.json"));
362 if !config.is_some_and(|c| c.exists()) {
363 before.push(profile(&["setup", "--baseline"]));
364 }
365 before.push(profile(&["config", "set", "models.providers.superfluid", &provider.to_string(), "--strict-json"]));
366 before.push(profile(&["config", "set", "agents.defaults.model.primary", &format!("superfluid/{}", t.model)]));
367 let mut args = profile(&[]);
368 if extra.is_empty() {
369 args.extend(["tui".to_string(), "--local".to_string()]);
370 } else {
371 args.extend(extra.iter().cloned());
372 }
373 Plan { program: "openclaw".into(), args, before, ..Plan::default() }
374}
375
376fn ollama(t: &Target, extra: &[String]) -> Plan {
379 let host = t.base.trim_start_matches("http://").to_string();
380 let args = if extra.is_empty() { vec!["run".to_string(), t.model.clone()] } else { extra.to_vec() };
381 Plan { program: "ollama".into(), args, env: vec![("OLLAMA_HOST".into(), host)], ..Plan::default() }
382}
383
384pub fn find(name: &str) -> Option<&'static Agent> {
385 AGENTS.iter().find(|a| a.name == name || a.aliases.contains(&name))
386}
387
388pub const HELP: &str = "Run a coding agent against a local model.
389
390Usage: superfluid launch <agent> [options] [-- <agent arguments>...]
391 superfluid launch List the agents and whether each is installed
392
393 Agents: claude, codex, pi, opencode, hermes, cline, openclaw, ollama (its CLI's chat).
394
395 Points the agent at the server on --http. When none answers there, starts
396 `superfluid serve <model>` in the background first (its log goes to
397 $SUPERFLUID_HOME/logs/serve.log) and leaves it running; `superfluid stop` stops it.
398 The model is --model, or the one launch last started a server with.
399
400Options:
401 --model <path|org/model[:tag]> The model to serve, and the one the agent uses on a server
402 that serves several [default: the server's first]
403 --http <addr:port> The server [default: $SUPERFLUID_HTTP, else 127.0.0.1:8453]
404 --api-key <key> The server's key, for one behind --api-key
405 [default: $SUPERFLUID_API_KEY]
406 --print Print what would run instead of running it
407
408Examples:
409 superfluid launch claude --model unsloth/Qwen3.8-27B-GGUF:UD-Q4_K_M
410 superfluid launch claude -- --continue
411";
412
413pub const STOP_HELP: &str = "Stop the server `superfluid launch` started.
414
415Usage: superfluid stop
416
417 Sends it SIGTERM and waits for it to finish what is in flight. A server you started
418 yourself with `superfluid serve` is yours to stop.
419";
420
421#[derive(Debug, Default, PartialEq)]
422struct Args {
423 agent: Option<String>,
424 model: Option<String>,
425 http: Option<String>,
426 api_key: Option<String>,
427 print: bool,
428 extra: Vec<String>,
429}
430
431fn parse(args: &[String]) -> Result<Args, String> {
432 let mut out = Args::default();
433 let mut it = args.iter();
434 while let Some(a) = it.next() {
435 let (flag, inline) = match a.split_once('=') {
436 Some((f, v)) if f.starts_with("--") => (f, Some(v.to_string())),
437 _ => (a.as_str(), None),
438 };
439 let mut value = |flag: &str| inline.clone().or_else(|| it.next().cloned()).ok_or(format!("{flag} needs a value"));
440 match flag {
441 "--" => {
442 out.extra = it.cloned().collect();
443 break;
444 }
445 "--model" => out.model = Some(value(flag)?),
446 "--http" => out.http = Some(value(flag)?),
447 "--api-key" => out.api_key = Some(value(flag)?),
448 "--print" => out.print = true,
449 f if f.starts_with('-') => {
450 let known = ["--model", "--http", "--api-key", "--print"];
451 return Err(match crate::cli::suggest(f, known) {
452 Some(s) => format!("unknown flag {f} (did you mean {s}?); pass the agent's own flags after --"),
453 None => format!("unknown flag {f}; pass the agent's own flags after --"),
454 });
455 }
456 name if out.agent.is_none() => out.agent = Some(name.to_string()),
457 other => return Err(format!("unexpected argument {other}; pass the agent's own arguments after --")),
458 }
459 }
460 Ok(out)
461}
462
463#[derive(Debug, Clone, PartialEq)]
465struct Served {
466 id: String,
467 n_ctx: Option<u64>,
468}
469
470enum Probe {
471 Up(Vec<Served>),
472 Unauthorized,
473 Down,
474}
475
476fn probe(base: &str, api_key: Option<&str>) -> Probe {
477 let agent = ureq::Agent::config_builder()
478 .timeout_global(Some(Duration::from_secs(3)))
479 .http_status_as_error(false)
480 .build()
481 .new_agent();
482 let mut req = agent.get(format!("{base}/v1/models"));
483 if let Some(key) = api_key {
484 req = req.header("authorization", &format!("Bearer {key}"));
485 }
486 let Ok(mut resp) = req.call() else { return Probe::Down };
487 match resp.status().as_u16() {
488 200 => {}
489 401 | 403 => return Probe::Unauthorized,
490 _ => return Probe::Down,
491 }
492 let Ok(body) = resp.body_mut().read_to_string() else { return Probe::Down };
493 match parse_models(&body) {
494 Some(models) => Probe::Up(models),
495 None => Probe::Down,
496 }
497}
498
499fn parse_models(body: &str) -> Option<Vec<Served>> {
500 let v: serde_json::Value = serde_json::from_str(body).ok()?;
501 let data = v.get("data")?.as_array()?;
502 Some(
503 data.iter()
504 .filter_map(|m| {
505 Some(Served {
506 id: m.get("id")?.as_str()?.to_string(),
507 n_ctx: m.pointer("/meta/n_ctx").and_then(serde_json::Value::as_u64),
508 })
509 })
510 .collect(),
511 )
512}
513
514fn pick<'a>(served: &'a [Served], wanted: Option<&str>) -> Option<&'a Served> {
517 let Some(wanted) = wanted else { return served.first() };
518 if let Some(m) = served.iter().find(|m| m.id == wanted) {
519 return Some(m);
520 }
521 let stem = |s: &str| {
522 let s = s.split_once(':').map_or(s, |(s, _)| s);
523 let s = s.rsplit('/').next().unwrap_or(s);
524 s.strip_suffix(".gguf").unwrap_or(s).to_ascii_lowercase()
525 };
526 let w = stem(wanted);
527 served.iter().find(|m| stem(&m.id) == w)
528}
529
530fn served_id(model: &str) -> String {
532 if crate::pulls::looks_like_id(model) {
533 crate::pulls::served_name(model)
534 } else {
535 crate::registry::model_id_for(Path::new(model))
536 }
537}
538
539fn on_path(binary: &str) -> Option<PathBuf> {
540 let path = std::env::var_os("PATH")?;
541 std::env::split_paths(&path).map(|d| d.join(binary)).find(|p| {
542 use std::os::unix::fs::PermissionsExt;
543 std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
544 })
545}
546
547fn fail(msg: impl std::fmt::Display) -> ! {
548 eprintln!("superfluid launch: {msg}");
549 std::process::exit(1);
550}
551
552fn state_dir() -> PathBuf {
554 crate::runtimes::default_home().join("launch")
555}
556
557fn server_file() -> PathBuf {
559 state_dir().join("server")
560}
561
562fn read_server() -> Option<(i32, String)> {
563 let text = std::fs::read_to_string(server_file()).ok()?;
564 let mut lines = text.lines();
565 let pid = lines.next()?.trim().parse().ok()?;
566 let addr = lines.next()?.trim().to_string();
567 alive(pid).then_some((pid, addr))
568}
569
570fn alive(pid: i32) -> bool {
571 pid > 0 && unsafe { libc::kill(pid, 0) } == 0
573}
574
575pub fn run(args: &[String]) {
576 let a = parse(args).unwrap_or_else(|e| {
577 eprintln!("superfluid launch: {e}\n\n{HELP}");
578 std::process::exit(2);
579 });
580 let Some(name) = a.agent.as_deref() else {
581 list();
582 return;
583 };
584 let agent = find(name).unwrap_or_else(|| {
585 let names = AGENTS.iter().map(|a| a.name);
586 match crate::cli::suggest(name, names) {
587 Some(s) => fail(format!("no agent called {name} (did you mean {s}?); `superfluid launch` lists them")),
588 None => fail(format!("no agent called {name}; `superfluid launch` lists them")),
589 }
590 });
591 if !a.print && on_path(agent.binary).is_none() {
592 fail(format!("{} is not installed ({} not on PATH). Install it with:\n {}", agent.title, agent.binary, agent.install));
593 }
594 let addr = a
595 .http
596 .clone()
597 .or_else(|| std::env::var("SUPERFLUID_HTTP").ok().filter(|s| !s.is_empty()))
598 .unwrap_or_else(|| DEFAULT_HTTP.to_string());
599 let addr = addr.trim_start_matches("http://").trim_end_matches('/').to_string();
600 let base = format!("http://{addr}");
601 let api_key = a.api_key.clone().or_else(|| std::env::var("SUPERFLUID_API_KEY").ok().filter(|s| !s.is_empty()));
602 let mut started = false;
603 let served = match probe(&base, api_key.as_deref()) {
604 Probe::Up(served) => served,
605 Probe::Unauthorized => fail(format!("the server at {addr} wants a key: pass --api-key or set SUPERFLUID_API_KEY")),
606 Probe::Down if a.print => {
607 let id = a.model.clone().or_else(remembered).map_or_else(|| "<model>".into(), |m| served_id(&m));
608 vec![Served { id, n_ctx: None }]
609 }
610 Probe::Down => {
611 let model = a.model.clone().or_else(remembered).unwrap_or_else(|| {
612 fail(format!(
613 "no server at {addr}, and no model to start one with. Pass one:\n \
614 superfluid launch {} --model <path|org/model[:tag]>",
615 agent.name
616 ))
617 });
618 started = true;
619 start_server(&model, &addr, api_key.as_deref())
620 }
621 };
622 let wanted = if started { None } else { a.model.as_deref() };
624 let model = pick(&served, wanted).unwrap_or_else(|| {
625 let ids: Vec<&str> = served.iter().map(|m| m.id.as_str()).collect();
626 fail(format!(
627 "the server at {addr} does not serve {}; it serves: {}",
628 a.model.as_deref().unwrap_or("any model"),
629 if ids.is_empty() { "nothing".to_string() } else { ids.join(", ") }
630 ))
631 });
632 let target = Target { base, model: model.id.clone(), n_ctx: model.n_ctx, api_key };
633 let plan = (agent.plan)(&target, &a.extra);
634 if a.print {
635 print!("{}", render(&plan));
636 return;
637 }
638 for (path, text) in &plan.files {
639 if let Err(e) = path.parent().map_or(Ok(()), std::fs::create_dir_all).and_then(|()| std::fs::write(path, text)) {
640 fail(format!("could not write {}: {e}", path.display()));
641 }
642 }
643 for args in &plan.before {
644 let out = std::process::Command::new(&plan.program)
645 .args(args)
646 .stdin(std::process::Stdio::null())
647 .output()
648 .unwrap_or_else(|e| fail(format!("could not run {}: {e}", plan.program)));
649 if !out.status.success() {
650 let err = String::from_utf8_lossy(&out.stderr);
651 let tail: Vec<&str> = err.lines().rev().take(10).collect();
652 fail(format!(
653 "`{} {}` failed ({}):\n{}",
654 plan.program,
655 args.join(" "),
656 out.status,
657 tail.into_iter().rev().collect::<Vec<_>>().join("\n")
658 ));
659 }
660 }
661 for note in &plan.notes {
662 eprintln!("superfluid launch: {note}");
663 }
664 eprintln!("superfluid launch: {} on {} at {}", agent.title, target.model, target.base);
665 exec(&plan);
666}
667
668fn list() {
669 println!("Agents (superfluid launch <agent>):\n");
670 for a in AGENTS {
671 let state = if on_path(a.binary).is_some() { "installed".to_string() } else { format!("install: {}", a.install) };
672 println!(" {:<10} {:<14} {state}", a.name, a.title);
673 }
674 match read_server() {
675 Some((pid, addr)) => println!("\nServer started by launch: {addr} (pid {pid}); `superfluid stop` stops it."),
676 None => {
677 if let Some(m) = remembered() {
678 println!("\nWith no server running, launch starts one with {m}.");
679 }
680 }
681 }
682}
683
684fn remembered() -> Option<String> {
685 std::fs::read_to_string(state_dir().join("model")).ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
686}
687
688fn start_server(model: &str, addr: &str, api_key: Option<&str>) -> Vec<Served> {
691 use std::os::unix::process::CommandExt;
692 let exe = std::env::current_exe().unwrap_or_else(|e| fail(format!("could not find this program: {e}")));
693 let logs = crate::runtimes::default_home().join("logs");
694 let log = logs.join("serve.log");
695 if let Err(e) = std::fs::create_dir_all(&logs).and_then(|()| std::fs::create_dir_all(state_dir())) {
696 fail(format!("could not create {}: {e}", logs.display()));
697 }
698 let start_len = std::fs::metadata(&log).map(|m| m.len()).unwrap_or(0);
699 let mut cmd = std::process::Command::new(exe);
700 cmd.arg("serve").arg(model).args(["--http", addr, "--no-tui", "--log-file"]).arg(&log);
701 if let Some(key) = api_key {
702 cmd.args(["--api-key", key]);
703 }
704 let stdout = std::fs::OpenOptions::new().create(true).append(true).open(&log);
705 cmd.stdin(std::process::Stdio::null())
706 .stdout(stdout.map(std::process::Stdio::from).unwrap_or_else(|_| std::process::Stdio::null()))
707 .stderr(std::process::Stdio::null());
708 unsafe {
710 cmd.pre_exec(|| {
711 libc::setsid();
712 Ok(())
713 });
714 }
715 let mut child = cmd.spawn().unwrap_or_else(|e| fail(format!("could not start the server: {e}")));
716 let pid = child.id() as i32;
717 let _ = std::fs::write(server_file(), format!("{pid}\n{addr}\n"));
718 let _ = std::fs::write(state_dir().join("model"), format!("{model}\n"));
719 eprintln!("superfluid launch: starting `superfluid serve {model}` on {addr} (log: {})", log.display());
720 let base = format!("http://{addr}");
721 let started = Instant::now();
722 let tty = std::io::IsTerminal::is_terminal(&std::io::stderr());
723 loop {
724 if let Ok(Some(status)) = child.try_wait() {
725 if tty {
726 eprint!("\r\x1b[2K");
727 }
728 let _ = std::fs::remove_file(server_file());
729 fail(format!("the server exited ({status}) before it was ready:\n{}", tail(&log, start_len, 15)));
730 }
731 if let Probe::Up(served) = probe(&base, api_key) {
732 if tty {
733 eprint!("\r\x1b[2K");
734 }
735 eprintln!("superfluid launch: server ready in {:.0} s", started.elapsed().as_secs_f64());
736 return served;
737 }
738 if tty {
739 let last = tail(&log, start_len, 1);
740 let last: String = last.trim().chars().take(90).collect();
741 eprint!("\r\x1b[2K {:>4.0} s {last}", started.elapsed().as_secs_f64());
742 }
743 std::thread::sleep(Duration::from_millis(500));
744 }
745}
746
747fn tail(log: &Path, from: u64, n: usize) -> String {
749 let Ok(bytes) = std::fs::read(log) else { return String::new() };
750 let text = String::from_utf8_lossy(bytes.get(from as usize..).unwrap_or_default());
751 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
752 lines[lines.len().saturating_sub(n)..].join("\n")
753}
754
755fn exec(plan: &Plan) -> ! {
756 use std::os::unix::process::CommandExt;
757 let mut cmd = std::process::Command::new(&plan.program);
758 cmd.args(&plan.args);
759 for k in &plan.env_remove {
760 cmd.env_remove(k);
761 }
762 cmd.envs(plan.env.iter().map(|(k, v)| (k, v)));
763 for (k, v) in &plan.env_default {
764 if std::env::var_os(k).is_none() {
765 cmd.env(k, v);
766 }
767 }
768 let err = cmd.exec();
769 fail(format!("could not run {}: {err}", plan.program))
770}
771
772pub fn render(plan: &Plan) -> String {
774 let mut out = String::new();
775 for k in &plan.env_remove {
776 out.push_str(&format!("unset {k}\n"));
777 }
778 for args in &plan.before {
779 let line: Vec<String> = std::iter::once(&plan.program).chain(args).map(|a| quote(a)).collect();
780 out.push_str(&line.join(" "));
781 out.push('\n');
782 }
783 for (path, text) in &plan.files {
784 out.push_str(&format!("# {}:\n{}", path.display(), text.lines().map(|l| format!("# {l}\n")).collect::<String>()));
785 }
786 for (k, v) in &plan.env_default {
787 out.push_str(&format!("export {k}=\"${{{k}:-{v}}}\"\n"));
788 }
789 let mut line: Vec<String> = plan.env.iter().map(|(k, v)| format!("{k}={}", quote(v))).collect();
790 line.push(quote(&plan.program));
791 line.extend(plan.args.iter().map(|a| quote(a)));
792 out.push_str(&line.join(" \\\n "));
793 out.push('\n');
794 out
795}
796
797fn quote(s: &str) -> String {
798 if !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || "-_./:=@,+%".contains(c)) {
799 s.to_string()
800 } else {
801 format!("'{}'", s.replace('\'', r"'\''"))
802 }
803}
804
805pub fn stop(args: &[String]) {
806 if let Some(a) = args.first() {
807 eprintln!("superfluid stop: unexpected argument {a}\n\n{STOP_HELP}");
808 std::process::exit(2);
809 }
810 let Some((pid, addr)) = read_server() else {
811 let _ = std::fs::remove_file(server_file());
812 eprintln!("superfluid stop: no server started by `superfluid launch` is running");
813 std::process::exit(1);
814 };
815 unsafe { libc::kill(pid, libc::SIGTERM) };
817 let started = Instant::now();
818 while alive(pid) && started.elapsed() < Duration::from_secs(90) {
819 std::thread::sleep(Duration::from_millis(200));
820 }
821 if alive(pid) {
822 eprintln!("superfluid stop: the server on {addr} (pid {pid}) is still finishing; it got SIGTERM");
823 std::process::exit(1);
824 }
825 let _ = std::fs::remove_file(server_file());
826 eprintln!("superfluid stop: stopped the server on {addr}");
827}
828
829#[cfg(test)]
830mod tests {
831 use super::*;
832
833 fn strings(v: &[&str]) -> Vec<String> {
834 v.iter().map(|s| s.to_string()).collect()
835 }
836
837 fn target(n_ctx: Option<u64>) -> Target {
838 Target { base: "http://127.0.0.1:8453".into(), model: "Qwen3.6-27B-Q4_K_M".into(), n_ctx, api_key: None }
839 }
840
841 fn env<'a>(p: &'a Plan, k: &str) -> Option<&'a str> {
842 p.env.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str())
843 }
844
845 #[test]
846 fn flags_go_to_launch_and_everything_after_the_dashes_to_the_agent() {
847 let a = parse(&strings(&["claude", "--model=m.gguf", "--http", "127.0.0.1:9000", "--", "--continue", "-p", "hi"])).unwrap();
848 assert_eq!(a.agent.as_deref(), Some("claude"));
849 assert_eq!(a.model.as_deref(), Some("m.gguf"));
850 assert_eq!(a.http.as_deref(), Some("127.0.0.1:9000"));
851 assert_eq!(a.extra, strings(&["--continue", "-p", "hi"]));
852 assert!(parse(&strings(&["claude", "--continue"])).unwrap_err().contains("after --"));
853 assert!(parse(&strings(&["claude", "--modle", "x"])).unwrap_err().contains("--model"));
854 assert!(parse(&strings(&["claude", "extra"])).is_err());
855 assert_eq!(parse(&[]).unwrap(), Args::default());
856 }
857
858 #[test]
859 fn claude_code_is_pointed_at_the_server_for_every_model_tier() {
860 let p = claude(&target(Some(262_144)), &strings(&["--continue"]));
861 assert_eq!(p.program, "claude");
862 assert_eq!(p.args, strings(&["--continue"]));
863 assert_eq!(env(&p, "ANTHROPIC_BASE_URL"), Some("http://127.0.0.1:8453"));
864 assert_eq!(env(&p, "ANTHROPIC_AUTH_TOKEN"), Some("superfluid"));
865 for k in ["ANTHROPIC_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL"] {
866 assert_eq!(env(&p, k), Some("Qwen3.6-27B-Q4_K_M"), "{k}");
867 }
868 assert_eq!(env(&p, "CLAUDE_CODE_AUTO_COMPACT_WINDOW"), None, "a window past 200k needs no cap");
869 assert_eq!(env(&p, "CLAUDE_CODE_MAX_CONTEXT_TOKENS"), Some("262144"));
870 assert_eq!(env(&p, "CLAUDE_CODE_ATTRIBUTION_HEADER"), Some("0"));
871 assert_eq!(p.env_default, vec![("CLAUDE_CODE_AUTO_MODE_SERVER".to_string(), "0".to_string())]);
872 assert_eq!(p.env_remove, strings(&["ANTHROPIC_API_KEY"]));
873 assert!(p.notes.is_empty());
874 }
875
876 #[test]
877 fn a_small_window_makes_claude_code_compact_sooner_and_a_tiny_one_is_called_out() {
878 let p = claude(&target(Some(65_536)), &[]);
879 assert_eq!(env(&p, "CLAUDE_CODE_AUTO_COMPACT_WINDOW"), Some("65536"));
880 assert!(p.notes.is_empty());
881 let p = claude(&target(Some(8192)), &[]);
882 assert!(p.notes[0].contains("--max-context"), "{:?}", p.notes);
883 let p = claude(&Target { api_key: Some("k1".into()), ..target(None) }, &[]);
884 assert_eq!(env(&p, "ANTHROPIC_AUTH_TOKEN"), Some("k1"));
885 }
886
887 #[test]
888 fn codex_gets_a_provider_of_its_own_and_a_catalog_with_the_window() {
889 let p = codex(&target(Some(131_072)), &strings(&["exec", "hi"]));
890 assert_eq!(p.program, "codex");
891 let joined = p.args.join(" ");
892 assert!(joined.contains(r#"model_provider="superfluid""#), "{joined}");
893 assert!(joined.contains(r#"model_providers.superfluid.base_url="http://127.0.0.1:8453/v1""#), "{joined}");
894 assert!(joined.contains(r#"model_providers.superfluid.wire_api="responses""#), "{joined}");
895 assert!(joined.contains(r#"web_search="disabled""#), "{joined}");
896 assert!(joined.ends_with("-m Qwen3.6-27B-Q4_K_M exec hi"), "{joined}");
897 assert_eq!(env(&p, "SUPERFLUID_LAUNCH_KEY"), Some("superfluid"));
898 let (path, text) = &p.files[0];
899 assert!(joined.contains(&path.to_string_lossy().to_string()));
900 let catalog: serde_json::Value = serde_json::from_str(text).unwrap();
901 assert_eq!(catalog["models"][0]["slug"], "Qwen3.6-27B-Q4_K_M");
902 assert_eq!(catalog["models"][0]["context_window"], 131_072);
903 assert!(catalog["models"][0].get("apply_patch_tool_type").is_none());
904 }
905
906 #[test]
907 fn pi_loads_a_one_run_extension_that_registers_the_server() {
908 let p = pi(&target(Some(262_144)), &strings(&["-p", "hi"]));
909 let (path, text) = &p.files[0];
910 assert_eq!(p.args[..2], [String::from("-e"), path.to_string_lossy().into_owned()]);
911 assert_eq!(p.args[2..], strings(&["--provider", "superfluid", "--model", "Qwen3.6-27B-Q4_K_M", "-p", "hi"]));
912 assert!(text.contains(r#"pi.registerProvider("superfluid", {"#), "{text}");
913 assert!(text.contains(r#""baseUrl": "http://127.0.0.1:8453/v1""#), "{text}");
914 assert!(text.contains(r#""contextWindow": 262144"#) && text.contains(r#""maxTokens": 16384"#), "{text}");
915 }
916
917 #[test]
918 fn opencode_gets_a_provider_in_its_per_run_config() {
919 let p = opencode(&target(Some(65_536)), &strings(&["run", "hi"]));
920 assert_eq!(p.args, strings(&["run", "hi"]));
921 let config: serde_json::Value = serde_json::from_str(env(&p, "OPENCODE_CONFIG_CONTENT").unwrap()).unwrap();
922 assert_eq!(config["model"], "superfluid/Qwen3.6-27B-Q4_K_M");
923 let provider = &config["provider"]["superfluid"];
924 assert_eq!(provider["npm"], "@ai-sdk/openai-compatible");
925 assert_eq!(provider["options"]["baseURL"], "http://127.0.0.1:8453/v1");
926 assert_eq!(provider["models"]["Qwen3.6-27B-Q4_K_M"]["limit"]["context"], 65_536);
927 }
928
929 #[test]
930 fn hermes_uses_its_custom_provider_and_a_key_only_for_this_host() {
931 let p = hermes(&target(Some(262_144)), &strings(&["-z", "hi"]));
932 assert_eq!(p.args, strings(&["--provider", "custom", "-m", "Qwen3.6-27B-Q4_K_M", "-z", "hi"]));
933 assert_eq!(env(&p, "CUSTOM_BASE_URL"), Some("http://127.0.0.1:8453/v1"));
934 assert_eq!(env(&p, "OPENAI_API_KEY"), None);
935 let p = hermes(&Target { api_key: Some("k1".into()), ..target(Some(32_768)) }, &[]);
936 assert_eq!((env(&p, "OPENAI_BASE_URL"), env(&p, "OPENAI_API_KEY")), (Some("http://127.0.0.1:8453/v1"), Some("k1")));
937 assert!(p.notes[0].contains("64000"), "{:?}", p.notes);
938 }
939
940 #[test]
941 fn cline_reads_a_provider_settings_file_of_launchs_own() {
942 let p = cline(&target(None), &strings(&["-y", "hi"]));
943 assert_eq!(p.args, strings(&["-y", "hi"]));
944 let (path, text) = &p.files[0];
945 assert_eq!(env(&p, "CLINE_PROVIDER_SETTINGS_PATH"), Some(path.to_string_lossy().as_ref()));
946 let v: serde_json::Value = serde_json::from_str(text).unwrap();
947 assert_eq!(v["lastUsedProvider"], "openai-compatible");
948 let s = &v["providers"]["openai-compatible"]["settings"];
949 assert_eq!((s["model"].as_str(), s["baseUrl"].as_str()), (Some("Qwen3.6-27B-Q4_K_M"), Some("http://127.0.0.1:8453/v1")));
950 let updated = v["providers"]["openai-compatible"]["updatedAt"].as_str().unwrap_or_default();
951 assert!(updated.len() == 24 && updated.ends_with('Z') && updated.as_bytes()[19] == b'.', "{updated}");
952 }
953
954 #[test]
955 fn openclaw_is_set_up_in_a_profile_of_its_own_and_opens_the_local_terminal_ui() {
956 let p = openclaw(&target(Some(262_144)), &[]);
957 assert_eq!(p.args, strings(&["--profile", "superfluid", "tui", "--local"]));
958 let sets: Vec<&Vec<String>> = p.before.iter().filter(|b| b.get(2).map(String::as_str) == Some("config")).collect();
959 assert_eq!(sets.len(), 2);
960 assert!(p.before.iter().all(|b| b[..2] == strings(&["--profile", "superfluid"])));
961 let provider: serde_json::Value = serde_json::from_str(&sets[0][5]).unwrap();
962 assert_eq!(sets[0][4], "models.providers.superfluid");
963 assert_eq!(provider["baseUrl"], "http://127.0.0.1:8453/v1");
964 assert_eq!(provider["models"][0]["contextWindow"], 262_144);
965 assert_eq!(sets[1][4..], strings(&["agents.defaults.model.primary", "superfluid/Qwen3.6-27B-Q4_K_M"]));
966 let p = openclaw(&target(None), &strings(&["agent", "--local", "-m", "hi"]));
967 assert_eq!(p.args, strings(&["--profile", "superfluid", "agent", "--local", "-m", "hi"]));
968 }
969
970 #[test]
971 fn the_ollama_cli_is_pointed_at_the_server_and_chats_with_the_model() {
972 let p = ollama(&target(None), &[]);
973 assert_eq!(p.args, strings(&["run", "Qwen3.6-27B-Q4_K_M"]));
974 assert_eq!(env(&p, "OLLAMA_HOST"), Some("127.0.0.1:8453"));
975 assert_eq!(ollama(&target(None), &strings(&["ps"])).args, strings(&["ps"]));
976 }
977
978 #[test]
979 fn a_model_is_found_by_id_or_by_the_name_it_was_served_from() {
980 let served = vec![
981 Served { id: "Qwen3.6-27B-Q4_K_M".into(), n_ctx: Some(262_144) },
982 Served { id: "gemma-4-E4B".into(), n_ctx: None },
983 ];
984 assert_eq!(pick(&served, None).unwrap().id, "Qwen3.6-27B-Q4_K_M");
985 assert_eq!(pick(&served, Some("gemma-4-E4B")).unwrap().id, "gemma-4-E4B");
986 assert_eq!(pick(&served, Some("./models/Qwen3.6-27B-Q4_K_M.gguf")).unwrap().id, "Qwen3.6-27B-Q4_K_M");
987 assert_eq!(pick(&served, Some("google/gemma-4-E4B")).unwrap().id, "gemma-4-E4B");
988 assert_eq!(pick(&[Served { id: "unsloth/Qwen3.8-27B-GGUF".into(), n_ctx: None }], Some("unsloth/Qwen3.8-27B-GGUF:UD-Q4_K_M")).unwrap().id, "unsloth/Qwen3.8-27B-GGUF");
989 assert!(pick(&served, Some("llama-3")).is_none());
990 assert!(pick(&served, Some("Qwen3.6")).is_none(), "a prefix is not a name");
991 assert!(pick(&[], None).is_none());
992 }
993
994 #[test]
995 fn models_and_their_windows_are_read_from_the_model_list() {
996 let body = r#"{"object":"list","data":[{"id":"a","meta":{"n_ctx":131072}},{"id":"b","meta":{}}]}"#;
997 assert_eq!(
998 parse_models(body).unwrap(),
999 vec![Served { id: "a".into(), n_ctx: Some(131_072) }, Served { id: "b".into(), n_ctx: None }]
1000 );
1001 assert!(parse_models("<html>").is_none());
1002 }
1003
1004 #[test]
1005 fn print_renders_a_command_a_shell_runs_as_is() {
1006 let p = Plan {
1007 program: "claude".into(),
1008 args: strings(&["-p", "it's here"]),
1009 env: vec![("A".into(), "http://x:1".into()), ("B".into(), "two words".into())],
1010 env_remove: strings(&["K"]),
1011 ..Plan::default()
1012 };
1013 assert_eq!(render(&p), "unset K\nA=http://x:1 \\\n B='two words' \\\n claude \\\n -p \\\n 'it'\\''s here'\n");
1014 }
1015
1016 #[test]
1017 fn every_agent_is_found_by_name_and_alias() {
1018 for a in AGENTS {
1019 assert!(find(a.name).is_some_and(|f| f.name == a.name));
1020 for al in a.aliases {
1021 assert!(find(al).is_some_and(|f| f.name == a.name));
1022 }
1023 }
1024 assert!(find("nope").is_none());
1025 }
1026}