Files
OneCAD/crates/ui-egui/src/agent_chat/acp.rs
T

559 lines
26 KiB
Rust

//! The client side of the Agent Client Protocol (ACP, <https://agentclientprotocol.com>), version 1:
//! JSON-RPC 2.0, one message per line over the agent's stdin/stdout. This file only builds and
//! reads messages; the process lives in `process.rs` and the state in `mod.rs`, so everything
//! here is plain data and tested without an agent.
//!
//! What SolidCraft says it can do (`clientCapabilities`): no file system and no terminal (the agent
//! reaches the design through SolidCraft's MCP server instead), and terminal login, so an agent
//! that needs one describes a command for the user to run rather than asking for a key.
use serde_json::{Value, json};
/// The protocol version we speak.
pub const PROTOCOL_VERSION: u64 = 1;
/// JSON-RPC "method not found", our answer to every agent request we don't serve.
pub const METHOD_NOT_FOUND: i64 = -32601;
/// The error code ACP agents use for "authentication required".
pub const AUTH_REQUIRED: i64 = -32000;
/// One request or notification to send.
pub fn request(id: u64, method: &str, params: Value) -> Value {
json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})
}
pub fn notification(method: &str, params: Value) -> Value {
json!({"jsonrpc": "2.0", "method": method, "params": params})
}
pub fn reply(id: Value, result: Value) -> Value {
json!({"jsonrpc": "2.0", "id": id, "result": result})
}
pub fn reply_error(id: Value, code: i64, message: &str) -> Value {
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})
}
pub fn initialize_params(version: &str) -> Value {
json!({
"protocolVersion": PROTOCOL_VERSION,
"clientCapabilities": {
"fs": {"readTextFile": false, "writeTextFile": false},
"terminal": false,
"auth": {"terminal": true},
"_meta": {"terminal-auth": true},
},
"clientInfo": {"name": "solidcraft", "title": "SolidCraft", "version": version},
})
}
/// The MCP server the agent starts to reach the design: `solidcraft-cli mcp --connect` bridged to
/// this app's control port. The server finds the port's token in the token file the app wrote
/// (docs/control-protocol.md), so the token never travels through the agent, and it keeps its
/// default folder limits (docs/mcp.md): no `--allow-any-path`.
#[derive(Clone, Debug, PartialEq)]
pub struct McpBridge {
pub cli: std::path::PathBuf,
pub port: u16,
}
impl McpBridge {
pub fn to_json(&self) -> Value {
json!({
"name": "solidcraft",
"command": self.cli.to_string_lossy(),
"args": ["mcp", "--connect", format!("127.0.0.1:{}", self.port)],
"env": [],
})
}
}
/// What every session is told about where it is. Agents that don't read `_meta` ignore it.
pub const SYSTEM_NOTE: &str = "You are running inside SolidCraft, a parametric 3D CAD application, as the user's assistant. \
The open design is reachable through the `solidcraft` MCP server: use its tools (list_commands, batch, execute, inspect_design, \
measure, screenshot, set_parameter, undo) to read and change the design the user is looking at, and verify with measure or \
inspect_design. Prefer one `batch` over many small calls. Saving, opening or exporting files waits for the user's approval in \
the chat, and each message allows a limited number of changes. Don't use shell or file tools: work through the SolidCraft tools. \
Keep answers short.";
/// The Claude Code adapter's own tools that reach the computer rather than the design: turned off
/// for the session (`_meta.claudeCode.options.disallowedTools`), so it works through SolidCraft's.
pub const CLAUDE_DISALLOWED_TOOLS: &[&str] =
&["Bash", "BashOutput", "KillShell", "Read", "Write", "Edit", "MultiEdit", "NotebookEdit", "Glob", "Grep", "WebFetch", "WebSearch", "Task"];
pub fn session_new_params(cwd: &std::path::Path, bridge: Option<&McpBridge>) -> Value {
let servers: Vec<Value> = bridge.map(McpBridge::to_json).into_iter().collect();
json!({
"cwd": cwd.to_string_lossy(),
"mcpServers": servers,
"_meta": {
"systemPrompt": {"append": SYSTEM_NOTE},
// Hosts may opt a session out of "bypass permissions" (the Claude adapter reads it):
// every tool call the agent asks about reaches the panel.
// Its own shell and file tools are off: the agent works through SolidCraft's tools.
"claudeCode": {"options": {"allowDangerouslySkipPermissions": false, "disallowedTools": CLAUDE_DISALLOWED_TOOLS}},
},
})
}
pub fn prompt_params(session: &str, parts: &[super::backend::PromptPart]) -> Value {
json!({"sessionId": session, "prompt": super::backend::content_blocks(parts)})
}
/// The agent's modes in a `session/new` reply: the current one and each `(id, name)`.
pub fn modes(session: &Value) -> Option<(String, Vec<(String, String)>)> {
let m = session.get("modes")?;
let current = m.get("currentModeId").and_then(Value::as_str).unwrap_or("").to_string();
let available: Vec<(String, String)> = m
.get("availableModes")?
.as_array()?
.iter()
.take(16)
.filter_map(|x| {
let id = x.get("id")?.as_str()?.to_string();
let name = x.get("name").and_then(Value::as_str).unwrap_or(&id).to_string();
Some((id, name))
})
.collect();
(!available.is_empty()).then_some((current, available))
}
/// One model an agent offers.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ModelInfo {
pub id: String,
pub name: String,
pub description: String,
}
/// The agent's models in a `session/new` or `session/load` reply (`models`: `currentModelId` and
/// `availableModels: [{modelId, name, description?}]`): the current one and each model. `None`
/// when the agent reports none.
pub fn models(session: &Value) -> Option<(String, Vec<ModelInfo>)> {
let m = session.get("models")?;
let current = m.get("currentModelId").and_then(Value::as_str).unwrap_or("").to_string();
let available: Vec<ModelInfo> = m
.get("availableModels")?
.as_array()?
.iter()
.take(200)
.filter_map(|x| {
let id = x.get("modelId").or_else(|| x.get("id"))?.as_str()?.to_string();
let name = x.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()).unwrap_or(&id).to_string();
let description = x.get("description").and_then(Value::as_str).unwrap_or("").to_string();
Some(ModelInfo { id, name, description })
})
.collect();
(!available.is_empty()).then_some((current, available))
}
/// `session/set_model` parameters.
pub fn set_model_params(session: &str, model: &str) -> Value {
json!({"sessionId": session, "modelId": model})
}
/// Whether the agent takes pictures in a message (`agentCapabilities.promptCapabilities.image`).
pub fn takes_images(init: &Value) -> bool {
init.pointer("/agentCapabilities/promptCapabilities/image").and_then(Value::as_bool).unwrap_or(false)
}
/// One way to log in that the agent offers (`authMethods` in its `initialize` reply).
#[derive(Clone, Debug, PartialEq)]
pub struct AuthMethod {
pub id: String,
pub name: String,
pub description: String,
pub kind: AuthKind,
}
#[derive(Clone, Debug, PartialEq)]
pub enum AuthKind {
/// The agent signs in by itself when asked (`authenticate`), e.g. by opening a browser.
Agent,
/// The user runs the agent's own login in a terminal: the agent's command plus `args`.
Terminal { args: Vec<String>, command: Option<(String, Vec<String>)> },
/// The agent reads a key from an environment variable the user sets themselves.
EnvVar { vars: Vec<String> },
}
pub fn auth_methods(init: &Value) -> Vec<AuthMethod> {
let strings = |v: Option<&Value>| -> Vec<String> {
v.and_then(Value::as_array).map(|a| a.iter().take(64).filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
};
init.get("authMethods")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.take(32)
.filter_map(|m| {
let s = |k: &str| m.get(k).and_then(Value::as_str).unwrap_or("").to_string();
let id = s("id");
if id.is_empty() {
return None;
}
let kind = match m.get("type").and_then(Value::as_str) {
Some("terminal") => {
let meta = m.get("_meta").and_then(|x| x.get("terminal-auth"));
let command = meta.and_then(|t| Some((t.get("command")?.as_str()?.to_string(), strings(t.get("args")))));
AuthKind::Terminal { args: strings(m.get("args")), command }
}
Some("env_var") => AuthKind::EnvVar {
vars: m
.get("vars")
.and_then(Value::as_array)
.map(|v| v.iter().take(16).filter_map(|x| x.get("name").and_then(Value::as_str).map(str::to_string)).collect())
.unwrap_or_default(),
},
// A key the agent would take from the client: SolidCraft never handles
// keys, so this becomes "set it in your environment" instead.
_ if m.get("_meta").and_then(|x| x.get("api-key")).is_some() => {
let provider = m
.get("_meta")
.and_then(|x| x.get("api-key"))
.and_then(|k| k.get("provider"))
.and_then(Value::as_str)
.unwrap_or("agent");
AuthKind::EnvVar { vars: vec![format!("{}_API_KEY", provider.to_ascii_uppercase())] }
}
_ => AuthKind::Agent,
};
Some(AuthMethod { id, name: s("name"), description: s("description"), kind })
})
.collect()
})
.unwrap_or_default()
}
/// The agent's name and version from its `initialize` reply.
pub fn agent_info(init: &Value) -> String {
let i = init.get("agentInfo");
let s = |k: &str| i.and_then(|i| i.get(k)).and_then(Value::as_str);
match (s("title").or(s("name")), s("version")) {
(Some(n), Some(v)) => format!("{n} {v}"),
(Some(n), None) => n.to_string(),
_ => String::new(),
}
}
/// A tool call's state as the agent reports it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ToolStatus {
Pending,
InProgress,
Completed,
Failed,
}
impl ToolStatus {
pub fn parse(s: &str) -> Option<ToolStatus> {
Some(match s {
"pending" => ToolStatus::Pending,
"in_progress" => ToolStatus::InProgress,
"completed" => ToolStatus::Completed,
"failed" => ToolStatus::Failed,
_ => return None,
})
}
}
/// One choice of a permission request.
#[derive(Clone, Debug, PartialEq)]
pub struct PermissionOption {
pub id: String,
pub name: String,
/// `allow_once`, `allow_always`, `reject_once` or `reject_always`.
pub kind: String,
}
/// What a message from the agent means for the panel.
#[derive(Clone, Debug, PartialEq)]
pub enum Incoming {
/// The reply to one of our requests.
Reply { id: u64, result: Result<Value, (i64, String)> },
/// Text the agent streams (`thought`: its reasoning, shown dimmed).
Text { thought: bool, text: String },
/// A new tool call, or news about one (`title`/`status` when they changed).
Tool { id: String, title: Option<String>, kind: Option<String>, status: Option<ToolStatus>, output: Option<String> },
/// The agent's plan: each entry with its status.
Plan(Vec<(String, String)>),
/// The agent asks before running a tool call; answered with [`permission_reply`].
Permission { rpc: Value, title: String, options: Vec<PermissionOption> },
/// The agent says whether it is logged in (an extension both adapters send).
AuthStatus(String),
/// The agent switched to another of its modes.
ModeChanged(String),
/// The agent switched to another model.
ModelChanged(String),
/// A request we don't serve (file system, terminal, …): answer "method not found".
Unsupported { rpc: Value, method: String },
/// Anything else (mode and command lists, usage): nothing to show.
Ignored,
}
/// The longest text kept from one tool call's output.
const MAX_OUTPUT: usize = 2000;
fn content_text(c: &Value) -> Option<String> {
// A content block, or a tool call content item wrapping one (`{type: "content", content}`).
let block = if c.get("type").and_then(Value::as_str) == Some("content") { c.get("content")? } else { c };
match block.get("type").and_then(Value::as_str) {
Some("text") => block.get("text").and_then(Value::as_str).map(str::to_string),
Some("image") => Some("[image]".into()),
Some("resource_link") => block.get("uri").and_then(Value::as_str).map(|u| format!("[{u}]")),
Some("diff") => block.get("path").and_then(Value::as_str).map(|p| format!("[diff {p}]")),
Some("terminal") => Some("[terminal]".into()),
_ => None,
}
}
fn tool_output(u: &Value) -> Option<String> {
let parts: Vec<String> = u.get("content")?.as_array()?.iter().take(16).filter_map(content_text).collect();
if parts.is_empty() {
return None;
}
let mut s = parts.join("\n");
if s.len() > MAX_OUTPUT {
let mut cut = MAX_OUTPUT;
while !s.is_char_boundary(cut) {
cut -= 1;
}
s.truncate(cut);
s.push('…');
}
Some(s)
}
/// Read one line from the agent.
pub fn parse(line: &str) -> Option<Incoming> {
let msg: Value = serde_json::from_str(line.trim()).ok()?;
let method = msg.get("method").and_then(Value::as_str);
let params = msg.get("params").cloned().unwrap_or(Value::Null);
match (method, msg.get("id")) {
(None, Some(id)) => {
let id = id.as_u64()?;
let result = match msg.get("error") {
Some(e) => {
Err((e.get("code").and_then(Value::as_i64).unwrap_or(0), e.get("message").and_then(Value::as_str).unwrap_or("error").to_string()))
}
None => Ok(msg.get("result").cloned().unwrap_or(Value::Null)),
};
Some(Incoming::Reply { id, result })
}
(Some("session/request_permission"), Some(id)) => {
let call = params.get("toolCall");
let title = call.and_then(|c| c.get("title")).and_then(Value::as_str).unwrap_or("").to_string();
let options = params
.get("options")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.take(8)
.filter_map(|o| {
Some(PermissionOption {
id: o.get("optionId")?.as_str()?.to_string(),
name: o.get("name").and_then(Value::as_str).unwrap_or("").to_string(),
kind: o.get("kind").and_then(Value::as_str).unwrap_or("").to_string(),
})
})
.collect()
})
.unwrap_or_default();
Some(Incoming::Permission { rpc: id.clone(), title, options })
}
(Some(m), Some(id)) => Some(Incoming::Unsupported { rpc: id.clone(), method: m.to_string() }),
(Some("session/update"), None) => Some(update(params.get("update")?)),
(Some("_auth/status_update"), None) => {
params.get("authStatus").and_then(|s| s.get("label")).and_then(Value::as_str).map(|l| Incoming::AuthStatus(l.to_string()))
}
_ => Some(Incoming::Ignored),
}
}
fn update(u: &Value) -> Incoming {
let s = |k: &str| u.get(k).and_then(Value::as_str).map(str::to_string);
match u.get("sessionUpdate").and_then(Value::as_str) {
Some(kind @ ("agent_message_chunk" | "agent_thought_chunk")) => match u.get("content").and_then(content_text) {
Some(text) => Incoming::Text { thought: kind == "agent_thought_chunk", text },
None => Incoming::Ignored,
},
Some("tool_call" | "tool_call_update") => match s("toolCallId") {
Some(id) => Incoming::Tool {
id,
title: s("title"),
kind: s("kind"),
status: u.get("status").and_then(Value::as_str).and_then(ToolStatus::parse),
output: tool_output(u),
},
None => Incoming::Ignored,
},
Some("current_mode_update") => match s("currentModeId").or_else(|| s("modeId")) {
Some(m) => Incoming::ModeChanged(m),
None => Incoming::Ignored,
},
Some("current_model_update") => match s("currentModelId").or_else(|| s("modelId")) {
Some(m) => Incoming::ModelChanged(m),
None => Incoming::Ignored,
},
Some("plan") => Incoming::Plan(
u.get("entries")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.take(64)
.map(|e| {
let g = |k: &str| e.get(k).and_then(Value::as_str).unwrap_or("").to_string();
(g("content"), g("status"))
})
.collect()
})
.unwrap_or_default(),
),
_ => Incoming::Ignored,
}
}
/// The answer to a permission request: the option picked, or `None` for "cancelled".
pub fn permission_reply(rpc: Value, option: Option<&str>) -> Value {
let outcome = match option {
Some(o) => json!({"outcome": "selected", "optionId": o}),
None => json!({"outcome": "cancelled"}),
};
reply(rpc, json!({"outcome": outcome}))
}
/// Split a command line into the program and its arguments: whitespace separates, double or
/// single quotes group, a backslash before a quote keeps it. No shell runs it.
pub fn split_command(line: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut quote: Option<char> = None;
let mut started = false;
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
match (quote, c) {
(_, '\\') if matches!(chars.peek(), Some('"') | Some('\'')) => {
if let Some(n) = chars.next() {
cur.push(n);
}
started = true;
}
(None, '"' | '\'') => {
quote = Some(c);
started = true;
}
(Some(q), _) if c == q => quote = None,
(None, c) if c.is_whitespace() => {
if started {
out.push(std::mem::take(&mut cur));
started = false;
}
}
_ => {
cur.push(c);
started = true;
}
}
}
if started {
out.push(cur);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn commands_split_like_a_shell_without_one() {
assert_eq!(split_command("gemini --acp"), ["gemini", "--acp"]);
assert_eq!(split_command(r#" "C:\Program Files\x\agent.exe" --flag 'a b' "#), [r"C:\Program Files\x\agent.exe", "--flag", "a b"]);
assert_eq!(split_command(r#"a "" b"#), ["a", "", "b"]);
assert!(split_command(" ").is_empty());
}
#[test]
fn the_session_gets_the_bridged_mcp_server_without_a_token_or_any_path() {
let b = McpBridge { cli: "/opt/solidcraft/solidcraft-cli".into(), port: 41234 };
let p = session_new_params(std::path::Path::new("/home/u/designs"), Some(&b));
let s = &p["mcpServers"][0];
assert_eq!(s["name"], "solidcraft");
assert_eq!(s["args"], json!(["mcp", "--connect", "127.0.0.1:41234"]));
let text = p.to_string();
assert!(!text.contains("allow-any-path") && !text.contains("TOKEN") && !text.contains("token"), "{text}");
assert!(p["_meta"]["claudeCode"]["options"]["disallowedTools"].as_array().is_some_and(|t| t.iter().any(|x| x == "Bash")));
assert_eq!(session_new_params(std::path::Path::new("/tmp"), None)["mcpServers"], json!([]));
}
#[test]
fn auth_methods_of_both_adapters_are_read() {
// As the Claude adapter offers them to a client with terminal login.
let claude = json!({"authMethods": [{"description": "Use Claude subscription ", "name": "Claude Subscription", "id": "claude-ai-login",
"type": "terminal", "args": ["--cli", "auth", "login", "--claudeai"],
"_meta": {"terminal-auth": {"command": "/usr/bin/node", "args": ["/x/dist/index.js", "--cli", "auth", "login", "--claudeai"], "label": "Claude Login"}}}]});
let m = auth_methods(&claude);
assert_eq!(m.len(), 1);
let AuthKind::Terminal { args, command } = &m[0].kind else { panic!("{m:?}") };
assert_eq!(args.last().map(String::as_str), Some("--claudeai"));
assert_eq!(command.as_ref().map(|c| c.0.as_str()), Some("/usr/bin/node"));
// As the Codex adapter offers them.
let codex = json!({"authMethods": [{"id": "chatgpt", "name": "Login with ChatGPT"},
{"type": "env_var", "id": "openai-api-key", "name": "Use OPENAI_API_KEY", "vars": [{"name": "OPENAI_API_KEY"}]}]});
let m = auth_methods(&codex);
assert_eq!(m[0].kind, AuthKind::Agent);
// Codex 2.x offers its key login as a method the client would feed: shown as a variable.
let codex2 = json!({"authMethods": [{"id": "api-key", "name": "API Key", "_meta": {"api-key": {"provider": "openai"}}}]});
assert_eq!(auth_methods(&codex2)[0].kind, AuthKind::EnvVar { vars: vec!["OPENAI_API_KEY".into()] });
assert_eq!(m[1].kind, AuthKind::EnvVar { vars: vec!["OPENAI_API_KEY".into()] });
}
#[test]
fn updates_permissions_and_replies_are_told_apart() {
let text = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Hi"}}}}"#;
assert_eq!(parse(text), Some(Incoming::Text { thought: false, text: "Hi".into() }));
let tool = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"tool_call_update","toolCallId":"t1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"ok"}}]}}}"#;
assert_eq!(
parse(tool),
Some(Incoming::Tool { id: "t1".into(), title: None, kind: None, status: Some(ToolStatus::Completed), output: Some("ok".into()) })
);
let perm = r#"{"jsonrpc":"2.0","id":7,"method":"session/request_permission","params":{"sessionId":"s","toolCall":{"toolCallId":"t1","title":"batch"},"options":[{"optionId":"allow","name":"Allow","kind":"allow_once"},{"optionId":"reject","name":"Reject","kind":"reject_once"}]}}"#;
let Some(Incoming::Permission { rpc, title, options }) = parse(perm) else { panic!() };
assert_eq!((title.as_str(), options.len(), options[1].kind.as_str()), ("batch", 2, "reject_once"));
assert_eq!(permission_reply(rpc.clone(), Some("allow"))["result"]["outcome"], json!({"outcome": "selected", "optionId": "allow"}));
assert_eq!(permission_reply(rpc, None)["result"]["outcome"]["outcome"], "cancelled");
let fs = r#"{"jsonrpc":"2.0","id":8,"method":"fs/read_text_file","params":{}}"#;
assert!(matches!(parse(fs), Some(Incoming::Unsupported { .. })));
let err = r#"{"jsonrpc":"2.0","id":3,"error":{"code":-32000,"message":"Authentication required"}}"#;
assert_eq!(parse(err), Some(Incoming::Reply { id: 3, result: Err((AUTH_REQUIRED, "Authentication required".into())) }));
assert_eq!(parse("not json"), None);
let mode = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"current_mode_update","currentModeId":"plan"}}}"#;
assert_eq!(parse(mode), Some(Incoming::ModeChanged("plan".into())));
let new = json!({"sessionId": "s", "modes": {"currentModeId": "default", "availableModes": [{"id": "default", "name": "Default"}, {"id": "acceptEdits", "name": "Accept Edits"}]}});
assert_eq!(modes(&new), Some(("default".into(), vec![("default".into(), "Default".into()), ("acceptEdits".into(), "Accept Edits".into())])));
}
#[test]
fn long_tool_output_is_cut_on_a_character_boundary() {
let long = "é".repeat(3000);
let u = json!({"sessionUpdate": "tool_call", "toolCallId": "t", "content": [{"type": "content", "content": {"type": "text", "text": long}}]});
let Incoming::Tool { output: Some(o), .. } = update(&u) else { panic!() };
assert!(o.len() <= MAX_OUTPUT + 3 && o.ends_with('…'));
}
#[test]
fn the_models_of_a_session_reply_are_read_like_its_modes() {
let v = json!({"sessionId": "s", "models": {"currentModelId": "sonnet", "availableModels": [
{"modelId": "opus", "name": "Opus", "description": "Most capable"},
{"modelId": "sonnet", "name": "Sonnet"},
{"modelId": "haiku"},
{"name": "no id"}]}});
let (current, list) = models(&v).unwrap();
assert_eq!(current, "sonnet");
let names: Vec<_> = list.iter().map(|m| (m.id.as_str(), m.name.as_str(), m.description.as_str())).collect();
assert_eq!(names, [("opus", "Opus", "Most capable"), ("sonnet", "Sonnet", ""), ("haiku", "haiku", "")]);
// An agent without models, or with an empty list, reports none.
assert!(models(&json!({"sessionId": "s"})).is_none());
assert!(models(&json!({"models": {"currentModelId": "x", "availableModels": []}})).is_none());
let changed = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"current_model_update","currentModelId":"haiku"}}}"#;
assert_eq!(parse(changed), Some(Incoming::ModelChanged("haiku".into())));
assert_eq!(set_model_params("s", "opus"), json!({"sessionId": "s", "modelId": "opus"}));
}
}