|
@@ -0,0 +1,445 @@
|
|
|
|
|
+//! `GET /api/model-settings?recipe=<name>` — the recipes panel's "⚙ settings"
|
|
|
|
|
+//! dialog: the *client* configuration for the recipe's served model, laid out
|
|
|
|
|
+//! per agent (pi, Claude Code, Codex, Cline/Roo, a generic OpenAI client).
|
|
|
|
|
+//!
|
|
|
|
|
+//! Pure by design: everything is derived from the recipe file plus the
|
|
|
|
|
+//! endpoint (head node + port) the caller resolved — no SSH, no probe, so the
|
|
|
|
|
+//! dialog answers instantly even when the cluster is busy. The endpoint is
|
|
|
|
|
+//! informational; the snippets are copy-paste ready.
|
|
|
|
|
+
|
|
|
|
|
+use crate::config::Config;
|
|
|
|
|
+use crate::config::Recipe;
|
|
|
|
|
+use crate::web::events::Snapshot;
|
|
|
|
|
+use axum::{extract::Query, extract::State, http::StatusCode, response::Json};
|
|
|
|
|
+use serde::Deserialize;
|
|
|
|
|
+use serde_json::{Value, json};
|
|
|
|
|
+
|
|
|
|
|
+/// Where the recipe is (or would be) served from.
|
|
|
|
|
+#[derive(Clone, Debug)]
|
|
|
|
|
+pub struct Endpoint {
|
|
|
|
|
+ /// Cluster the recipe runs on (empty when unknown).
|
|
|
|
|
+ pub cluster: String,
|
|
|
|
|
+ /// Head node name (`dgx5.lan`) — empty when unknown.
|
|
|
|
|
+ pub node: String,
|
|
|
|
|
+ /// Host part of the API URL (`192.168.88.43`), or a placeholder.
|
|
|
|
|
+ pub host: String,
|
|
|
|
|
+ /// The recipe has a live container right now.
|
|
|
|
|
+ pub running: bool,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+impl Endpoint {
|
|
|
|
|
+ fn base(&self, port: u16) -> String {
|
|
|
|
|
+ format!("http://{}:{port}", self.host)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// The handful of model facts every client config needs.
|
|
|
|
|
+#[derive(Clone, Debug, PartialEq, Eq)]
|
|
|
|
|
+pub struct ModelInfo {
|
|
|
|
|
+ /// Name the API answers to (`--served-model-name`, else the repo's leaf).
|
|
|
|
|
+ pub served: String,
|
|
|
|
|
+ /// Human title (the recipe's `name`).
|
|
|
|
|
+ pub title: String,
|
|
|
|
|
+ /// HF repo id.
|
|
|
|
|
+ pub repo: String,
|
|
|
|
|
+ pub engine: String,
|
|
|
|
|
+ /// Context window in tokens (`--max-model-len`, else a conservative 32K).
|
|
|
|
|
+ pub context: u64,
|
|
|
|
|
+ /// Context came from the recipe (not from the 32K fallback).
|
|
|
|
|
+ pub context_known: bool,
|
|
|
|
|
+ /// Recommended client output cap.
|
|
|
|
|
+ pub max_output: u64,
|
|
|
|
|
+ /// A reasoning parser is configured (the model thinks out loud).
|
|
|
|
|
+ pub reasoning: bool,
|
|
|
|
|
+ /// Tools/function calling are wired (`--enable-auto-tool-choice`).
|
|
|
|
|
+ pub tools: bool,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+impl ModelInfo {
|
|
|
|
|
+ /// Derive from a recipe: `--served-model-name`, `--max-model-len` and the
|
|
|
|
|
+ /// reasoning/tool flags are read straight out of `args`.
|
|
|
|
|
+ pub fn from_recipe(r: &Recipe) -> Self {
|
|
|
|
|
+ let args = r.args.to_vec();
|
|
|
|
|
+ let flag = |name: &str| -> Option<String> {
|
|
|
|
|
+ args.iter()
|
|
|
|
|
+ .position(|a| a == name || a.starts_with(&format!("{name}=")))
|
|
|
|
|
+ .and_then(|i| match args.get(i) {
|
|
|
|
|
+ Some(a) if a.contains('=') => a.split_once('=').map(|(_, v)| v.to_string()),
|
|
|
|
|
+ _ => args.get(i + 1).cloned(),
|
|
|
|
|
+ })
|
|
|
|
|
+ };
|
|
|
|
|
+ let has = |name: &str| {
|
|
|
|
|
+ args.iter()
|
|
|
|
|
+ .any(|a| a == name || a.starts_with(&format!("{name}=")))
|
|
|
|
|
+ };
|
|
|
|
|
+ let served = flag("--served-model-name").unwrap_or_else(|| leaf(&r.model));
|
|
|
|
|
+ let context = flag("--max-model-len")
|
|
|
|
|
+ .and_then(|v| v.parse::<u64>().ok())
|
|
|
|
|
+ .or_else(|| flag("--context-length").and_then(|v| v.parse::<u64>().ok()));
|
|
|
|
|
+ let context_known = context.is_some();
|
|
|
|
|
+ let context = context.unwrap_or(32_768);
|
|
|
|
|
+ Self {
|
|
|
|
|
+ served,
|
|
|
|
|
+ title: if r.name.is_empty() {
|
|
|
|
|
+ leaf(&r.model)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ r.name.clone()
|
|
|
|
|
+ },
|
|
|
|
|
+ repo: r.model.clone(),
|
|
|
|
|
+ engine: r.engine.clone(),
|
|
|
|
|
+ context,
|
|
|
|
|
+ context_known,
|
|
|
|
|
+ max_output: context.min(32_768),
|
|
|
|
|
+ reasoning: has("--reasoning-parser"),
|
|
|
|
|
+ tools: has("--enable-auto-tool-choice") || has("--tool-call-parser"),
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Provider key for configs that need a stable id (`beam`, `deepseek-v4-1-flash`).
|
|
|
|
|
+ fn key(&self) -> String {
|
|
|
|
|
+ let mut out = String::new();
|
|
|
|
|
+ let mut dash = false;
|
|
|
|
|
+ for ch in self.served.chars() {
|
|
|
|
|
+ if ch.is_ascii_alphanumeric() {
|
|
|
|
|
+ out.push(ch.to_ascii_lowercase());
|
|
|
|
|
+ dash = false;
|
|
|
|
|
+ } else if !dash && !out.is_empty() {
|
|
|
|
|
+ out.push('-');
|
|
|
|
|
+ dash = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ let trimmed = out.trim_end_matches('-').to_string();
|
|
|
|
|
+ if trimmed.is_empty() {
|
|
|
|
|
+ "local".to_string()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ trimmed
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn leaf(model: &str) -> String {
|
|
|
|
|
+ model.rsplit('/').next().unwrap_or(model).to_string()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// One agent's blocks. `model` is the settings a client needs for the model
|
|
|
|
|
+/// itself; `full` is the same thing with the endpoint/provider section on top.
|
|
|
|
|
+/// Both are paste-ready (no comments — a `// note` would break JSON/TOML);
|
|
|
|
|
+/// `hint` carries the one-line guidance instead.
|
|
|
|
|
+struct Agent {
|
|
|
|
|
+ id: &'static str,
|
|
|
|
|
+ label: &'static str,
|
|
|
|
|
+ /// Where the config lives (shown above the block).
|
|
|
|
|
+ target: &'static str,
|
|
|
|
|
+ model: String,
|
|
|
|
|
+ full: String,
|
|
|
|
|
+ hint: String,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Indent every line of `text` by `n` spaces (building nested config blocks).
|
|
|
|
|
+fn indent(text: &str, n: usize) -> String {
|
|
|
|
|
+ let pad = " ".repeat(n);
|
|
|
|
|
+ text.lines()
|
|
|
|
|
+ .map(|l| format!("{pad}{l}"))
|
|
|
|
|
+ .collect::<Vec<_>>()
|
|
|
|
|
+ .join("\n")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn agents(info: &ModelInfo, base: &str) -> Vec<Agent> {
|
|
|
|
|
+ let key = info.key();
|
|
|
|
|
+ let api = format!("{base}/v1");
|
|
|
|
|
+ vec![
|
|
|
|
|
+ pi_agent(info, &key, &api),
|
|
|
|
|
+ claude_code_agent(info, &key, base),
|
|
|
|
|
+ codex_agent(info, &key, &api),
|
|
|
|
|
+ cline_agent(info, &key, &api),
|
|
|
|
|
+ generic_agent(info, &api),
|
|
|
|
|
+ ]
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Join config lines (rustfmt-safe: no `\`-continuations that re-indent the
|
|
|
|
|
+/// generated text).
|
|
|
|
|
+fn join(lines: &[&str]) -> String {
|
|
|
|
|
+ lines.join("\n")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// The `models[]` entry shared by pi and Cline, in a natural key order
|
|
|
|
|
+/// (serde_json sorts keys, which reads badly in a hand-edited config).
|
|
|
|
|
+fn pi_entry(info: &ModelInfo, pad: usize) -> String {
|
|
|
|
|
+ let body = join(&[
|
|
|
|
|
+ "{",
|
|
|
|
|
+ &format!(" \"id\": \"{}\",", info.served),
|
|
|
|
|
+ &format!(" \"name\": \"{}\",", info.title),
|
|
|
|
|
+ &format!(" \"contextWindow\": {},", info.context),
|
|
|
|
|
+ &format!(" \"maxTokens\": {},", info.max_output),
|
|
|
|
|
+ " \"input\": [\"text\"],",
|
|
|
|
|
+ &format!(" \"reasoning\": {}", info.reasoning),
|
|
|
|
|
+ "}",
|
|
|
|
|
+ ]);
|
|
|
|
|
+ indent(&body, pad)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn pi_agent(info: &ModelInfo, key: &str, api: &str) -> Agent {
|
|
|
|
|
+ let model = pi_entry(info, 0);
|
|
|
|
|
+ let entry = pi_entry(info, 8);
|
|
|
|
|
+ let full = join(&[
|
|
|
|
|
+ "{",
|
|
|
|
|
+ " \"providers\": {",
|
|
|
|
|
+ &format!(" \"{key}\": {{"),
|
|
|
|
|
+ &format!(" \"baseUrl\": \"{api}\","),
|
|
|
|
|
+ " \"api\": \"openai-completions\",",
|
|
|
|
|
+ " \"apiKey\": \"dummy\",",
|
|
|
|
|
+ " \"httpIdleTimeoutMs\": 0,",
|
|
|
|
|
+ " \"models\": [",
|
|
|
|
|
+ &entry,
|
|
|
|
|
+ " ]",
|
|
|
|
|
+ " }",
|
|
|
|
|
+ " }",
|
|
|
|
|
+ "}",
|
|
|
|
|
+ ]);
|
|
|
|
|
+ Agent {
|
|
|
|
|
+ id: "pi",
|
|
|
|
|
+ label: "pi",
|
|
|
|
|
+ target: "~/.pi/agent/models.json",
|
|
|
|
|
+ model,
|
|
|
|
|
+ full,
|
|
|
|
|
+ hint: format!(
|
|
|
|
|
+ "paste the entry under a provider's \"models\", then run: pi --provider {key} --model {}",
|
|
|
|
|
+ info.served
|
|
|
|
|
+ ),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn claude_code_agent(info: &ModelInfo, _key: &str, base: &str) -> Agent {
|
|
|
|
|
+ let s = &info.served;
|
|
|
|
|
+ let model = join(&[
|
|
|
|
|
+ &format!("export ANTHROPIC_MODEL={s}"),
|
|
|
|
|
+ &format!("export ANTHROPIC_SMALL_FAST_MODEL={s}"),
|
|
|
|
|
+ ]);
|
|
|
|
|
+ let full = join(&[
|
|
|
|
|
+ "# vLLM serves the Anthropic /v1/messages API",
|
|
|
|
|
+ &format!("export ANTHROPIC_BASE_URL={base}"),
|
|
|
|
|
+ "export ANTHROPIC_AUTH_TOKEN=dummy",
|
|
|
|
|
+ &format!("export ANTHROPIC_MODEL={s}"),
|
|
|
|
|
+ &format!("export ANTHROPIC_SMALL_FAST_MODEL={s}"),
|
|
|
|
|
+ ]);
|
|
|
|
|
+ Agent {
|
|
|
|
|
+ id: "claude-code",
|
|
|
|
|
+ label: "Claude Code",
|
|
|
|
|
+ target: "shell env",
|
|
|
|
|
+ model,
|
|
|
|
|
+ full,
|
|
|
|
|
+ hint: format!("export these, then run: claude --model {s}"),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn codex_agent(info: &ModelInfo, key: &str, api: &str) -> Agent {
|
|
|
|
|
+ let model = join(&[
|
|
|
|
|
+ &format!("model = \"{}\"", info.served),
|
|
|
|
|
+ &format!("model_provider = \"{key}\""),
|
|
|
|
|
+ ]);
|
|
|
|
|
+ let full = join(&[
|
|
|
|
|
+ &model,
|
|
|
|
|
+ "",
|
|
|
|
|
+ &format!("[model_providers.{key}]"),
|
|
|
|
|
+ &format!("name = \"{}\"", info.title),
|
|
|
|
|
+ &format!("base_url = \"{api}\""),
|
|
|
|
|
+ "env_key = \"OPENAI_API_KEY\"",
|
|
|
|
|
+ "wire_api = \"chat\"",
|
|
|
|
|
+ ]);
|
|
|
|
|
+ Agent {
|
|
|
|
|
+ id: "codex",
|
|
|
|
|
+ label: "Codex",
|
|
|
|
|
+ target: "~/.codex/config.toml",
|
|
|
|
|
+ model,
|
|
|
|
|
+ full,
|
|
|
|
|
+ hint: "export OPENAI_API_KEY=dummy — the key is only a placeholder".to_string(),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// The Cline/Roo `openAiModelInfo` block (shared by both variants).
|
|
|
|
|
+fn cline_info(info: &ModelInfo) -> Vec<String> {
|
|
|
|
|
+ vec![
|
|
|
|
|
+ " \"openAiModelInfo\": {".to_string(),
|
|
|
|
|
+ format!(" \"contextWindow\": {},", info.context),
|
|
|
|
|
+ format!(" \"maxTokens\": {},", info.max_output),
|
|
|
|
|
+ " \"supportsImages\": false,".to_string(),
|
|
|
|
|
+ " \"supportsPromptCache\": true,".to_string(),
|
|
|
|
|
+ format!(" \"description\": \"{}\"", info.title.replace('"', "'")),
|
|
|
|
|
+ " }".to_string(),
|
|
|
|
|
+ ]
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn cline_agent(info: &ModelInfo, key: &str, api: &str) -> Agent {
|
|
|
|
|
+ let model_id = format!(" \"openAiModelId\": \"{}\",", info.served);
|
|
|
|
|
+ let mut model_lines = vec![
|
|
|
|
|
+ "{".to_string(),
|
|
|
|
|
+ " \"apiProvider\": \"openai\",".to_string(),
|
|
|
|
|
+ model_id.clone(),
|
|
|
|
|
+ ];
|
|
|
|
|
+ model_lines.extend(cline_info(info));
|
|
|
|
|
+ model_lines.push("}".to_string());
|
|
|
|
|
+ let mut full_lines = vec![
|
|
|
|
|
+ "{".to_string(),
|
|
|
|
|
+ " \"apiProvider\": \"openai\",".to_string(),
|
|
|
|
|
+ format!(" \"openAiBaseUrl\": \"{api}\","),
|
|
|
|
|
+ " \"openAiApiKey\": \"dummy\",".to_string(),
|
|
|
|
|
+ model_id,
|
|
|
|
|
+ ];
|
|
|
|
|
+ full_lines.extend(cline_info(info));
|
|
|
|
|
+ full_lines.push("}".to_string());
|
|
|
|
|
+ let refs = |v: &Vec<String>| -> String { v.join("\n") };
|
|
|
|
|
+ Agent {
|
|
|
|
|
+ id: "cline",
|
|
|
|
|
+ label: "Cline / Roo (VS Code)",
|
|
|
|
|
+ target: "extension settings (OpenAI Compatible)",
|
|
|
|
|
+ model: refs(&model_lines),
|
|
|
|
|
+ full: refs(&full_lines),
|
|
|
|
|
+ hint: format!("provider key: {key}"),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn generic_agent(info: &ModelInfo, api: &str) -> Agent {
|
|
|
|
|
+ let model = join(&[
|
|
|
|
|
+ "# model only — point your existing client at these",
|
|
|
|
|
+ &format!("model: {}", info.served),
|
|
|
|
|
+ &format!("context_window: {}", info.context),
|
|
|
|
|
+ &format!("max_output_tokens: {}", info.max_output),
|
|
|
|
|
+ ]);
|
|
|
|
|
+ let full = join(&[
|
|
|
|
|
+ &format!("export OPENAI_BASE_URL={api}"),
|
|
|
|
|
+ "export OPENAI_API_KEY=dummy".to_string().as_str(),
|
|
|
|
|
+ "",
|
|
|
|
|
+ &format!(
|
|
|
|
|
+ "curl -s \"$OPENAI_BASE_URL/chat/completions\" -H 'Content-Type: application/json' -d '{{\"model\":\"{}\",\"messages\":[{{\"role\":\"user\",\"content\":\"ping\"}}]}}'",
|
|
|
|
|
+ info.served
|
|
|
|
|
+ ),
|
|
|
|
|
+ ]);
|
|
|
|
|
+ Agent {
|
|
|
|
|
+ id: "generic",
|
|
|
|
|
+ label: "Generic OpenAI client",
|
|
|
|
|
+ target: "shell / SDK",
|
|
|
|
|
+ model,
|
|
|
|
|
+ full,
|
|
|
|
|
+ hint: "any OpenAI-compatible client works; the key is a placeholder".to_string(),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// The `/api/model-settings` body.
|
|
|
|
|
+pub fn payload(recipe: &Recipe, ep: &Endpoint) -> Value {
|
|
|
|
|
+ let info = ModelInfo::from_recipe(recipe);
|
|
|
|
|
+ let base = ep.base(recipe.port);
|
|
|
|
|
+ let agents: Vec<Value> = agents(&info, &base)
|
|
|
|
|
+ .into_iter()
|
|
|
|
|
+ .map(|a| {
|
|
|
|
|
+ json!({
|
|
|
|
|
+ "id": a.id,
|
|
|
|
|
+ "label": a.label,
|
|
|
|
|
+ "target": a.target,
|
|
|
|
|
+ "model": a.model,
|
|
|
|
|
+ "full": a.full,
|
|
|
|
|
+ "hint": a.hint,
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+ .collect();
|
|
|
|
|
+ json!({
|
|
|
|
|
+ "recipe": recipe.name,
|
|
|
|
|
+ "endpoint": {
|
|
|
|
|
+ "base_url": base,
|
|
|
|
|
+ "api_url": format!("{base}/v1"),
|
|
|
|
|
+ "port": recipe.port,
|
|
|
|
|
+ "host": ep.host,
|
|
|
|
|
+ "node": ep.node,
|
|
|
|
|
+ "cluster": ep.cluster,
|
|
|
|
|
+ "running": ep.running,
|
|
|
|
|
+ },
|
|
|
|
|
+ "model": {
|
|
|
|
|
+ "served": info.served,
|
|
|
|
|
+ "title": info.title,
|
|
|
|
|
+ "repo": info.repo,
|
|
|
|
|
+ "engine": info.engine,
|
|
|
|
|
+ "context": info.context,
|
|
|
|
|
+ "context_known": info.context_known,
|
|
|
|
|
+ "max_output": info.max_output,
|
|
|
|
|
+ "reasoning": info.reasoning,
|
|
|
|
|
+ "tools": info.tools,
|
|
|
|
|
+ },
|
|
|
|
|
+ "agents": agents,
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Resolve where the recipe is served from: the node that currently hosts a
|
|
|
|
|
+/// container of it (fleet snapshot), else the recipe's own `cluster` field
|
|
|
|
|
+/// (rank-0 node), else a placeholder the operator fills in by hand.
|
|
|
|
|
+pub fn resolve_endpoint(
|
|
|
|
|
+ config: &Config,
|
|
|
|
|
+ snap: Option<&Snapshot>,
|
|
|
|
|
+ r: &Recipe,
|
|
|
|
|
+ name: &str,
|
|
|
|
|
+) -> Endpoint {
|
|
|
|
|
+ if let Some(snap) = snap {
|
|
|
|
|
+ for c in &snap.clusters {
|
|
|
|
|
+ for n in &c.nodes {
|
|
|
|
|
+ if n.containers.iter().any(|ct| ct.recipe == name) {
|
|
|
|
|
+ return Endpoint {
|
|
|
|
|
+ cluster: c.name.clone(),
|
|
|
|
|
+ node: n.name.clone(),
|
|
|
|
|
+ host: n.ssh_ip.clone(),
|
|
|
|
|
+ running: true,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if let Some(want) = &r.cluster
|
|
|
|
|
+ && let Some(c) = config
|
|
|
|
|
+ .clusters
|
|
|
|
|
+ .iter()
|
|
|
|
|
+ .find(|c| &c.name == want || c.name.starts_with(want.as_str()))
|
|
|
|
|
+ && let Some(n) = c.nodes.iter().min_by_key(|n| n.rank)
|
|
|
|
|
+ {
|
|
|
|
|
+ return Endpoint {
|
|
|
|
|
+ cluster: c.name.clone(),
|
|
|
|
|
+ node: n.name.clone(),
|
|
|
|
|
+ host: n.ssh_ip.clone(),
|
|
|
|
|
+ running: false,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ Endpoint {
|
|
|
|
|
+ cluster: r.cluster.clone().unwrap_or_default(),
|
|
|
|
|
+ node: String::new(),
|
|
|
|
|
+ host: "<head-node>".to_string(),
|
|
|
|
|
+ running: false,
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+#[derive(Deserialize)]
|
|
|
|
|
+pub struct SettingsQuery {
|
|
|
|
|
+ pub recipe: String,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// `GET /api/model-settings?recipe=<name>` — the "⚙ settings" dialog body.
|
|
|
|
|
+pub async fn handle(
|
|
|
|
|
+ State(app): State<super::AppState>,
|
|
|
|
|
+ Query(q): Query<SettingsQuery>,
|
|
|
|
|
+) -> Result<Json<Value>, (StatusCode, String)> {
|
|
|
|
|
+ let name = q.recipe;
|
|
|
|
|
+ if name.is_empty() || name.contains('/') || name.contains("..") {
|
|
|
|
|
+ return Err((StatusCode::BAD_REQUEST, "invalid recipe name".to_string()));
|
|
|
|
|
+ }
|
|
|
|
|
+ let recipe = crate::config::load_recipe(&name, &app.config.recipe_sources)
|
|
|
|
|
+ .await
|
|
|
|
|
+ .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
|
|
|
|
+ let snap = app
|
|
|
|
|
+ .last
|
|
|
|
|
+ .lock()
|
|
|
|
|
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
|
|
|
+ .clone();
|
|
|
|
|
+ let ep = resolve_endpoint(&app.config, snap.as_ref(), &recipe, &name);
|
|
|
|
|
+ let mut body = payload(&recipe, &ep);
|
|
|
|
|
+ body["recipe"] = Value::String(name);
|
|
|
|
|
+ Ok(Json(body))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+#[cfg(test)]
|
|
|
|
|
+#[path = "model_settings_tests.rs"]
|
|
|
|
|
+mod tests;
|