Add API tokens, CLI server API and shared core crate

- Personal API tokens (full/read scope, expiry, revoke) and CLI password login
- Cargo workspace: codecraft-core (types, providers, permission rules) shared by
  server and the upcoming terminal client; server re-exports the old paths
- CLI server API: /cli/config, /cli/skill, /cli/tool (server MCP tools),
  /llm/stream model gateway with budgets and usage per user
- Theme/language preferences mirrored to the server; personal memory endpoint
- Statistics: provider and mode tables stacked; CLI usage labelled
- cli/: work in progress (terminal client is being rewritten as a local TUI)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 23:18:50 +01:00
1 parent fca1c41087
commit aa1f8cf016
40 files changed
+2454 -814

No files matched your search

+122
View File
@@ -0,0 +1,122 @@
//! Provider, model and permission configuration shared by server and CLI.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
fn yes() -> bool {
true
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ProviderKind {
Anthropic,
Openai,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ProviderConfig {
pub kind: ProviderKind,
#[serde(default)]
pub base_url: String,
#[serde(default)]
pub api_key: String,
#[serde(default)]
pub auth_header: Option<String>,
#[serde(default)]
pub auth_prefix: Option<String>,
#[serde(default)]
pub headers: BTreeMap<String, String>,
#[serde(default)]
pub models: Vec<ModelEntry>,
#[serde(default = "yes")]
pub enabled: bool,
/// Max. simultaneous requests to this provider/host (None = unlimited).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrency: Option<usize>,
/// Monthly budget in the configured currency (tracked from recorded costs).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub monthly_budget: Option<f64>,
/// Optional admin key to read the provider's own cost report
/// (Anthropic: sk-ant-admin01-…, organisation-wide).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub admin_key: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum ModelEntry {
Id(String),
Full(ModelConfig),
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ModelConfig {
pub id: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub context: Option<u64>,
#[serde(default)]
pub max_output: Option<u64>,
#[serde(default)]
pub reasoning: bool,
#[serde(default)]
pub vision: bool,
/// Reasoning effort (Anthropic: low|medium|high|xhigh|max, OpenAI: low|medium|high).
#[serde(default)]
pub effort: Option<String>,
/// Prices in currency units per 1M tokens (used for cost statistics).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_price: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_price: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_price: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_write_price: Option<f64>,
/// Max. simultaneous requests to this model (None = unlimited).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrency: Option<usize>,
/// Extra request body fields sent as-is (e.g. temperature, reasoning_budget_tokens).
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub options: serde_json::Map<String, serde_json::Value>,
}
impl ModelConfig {
/// Cost of one request. Cache prices default to the input price.
pub fn cost(&self, u: &crate::types::Usage) -> f64 {
let inp = self.input_price.unwrap_or(0.0);
let per = |tokens: u64, price: f64| tokens as f64 * price / 1_000_000.0;
per(u.input_tokens, inp)
+ per(u.output_tokens, self.output_price.unwrap_or(0.0))
+ per(u.cache_read_tokens, self.cache_read_price.unwrap_or(inp))
+ per(u.cache_write_tokens, self.cache_write_price.unwrap_or(inp))
}
}
impl ModelEntry {
pub fn resolved(&self) -> ModelConfig {
match self {
ModelEntry::Id(id) => ModelConfig { id: id.clone(), ..Default::default() },
ModelEntry::Full(m) => m.clone(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct PermissionConfig {
#[serde(default)]
pub bash_rules: BTreeMap<String, String>,
#[serde(flatten)]
pub tools: BTreeMap<String, String>,
}
/// Replace `${NAME}` with the environment variable (empty if unset).
pub fn expand_env(s: &str) -> String {
let re = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").unwrap();
re.replace_all(s, |c: &regex::Captures| std::env::var(&c[1]).unwrap_or_default())
.into_owned()
}
+7
View File
@@ -0,0 +1,7 @@
//! Shared core of CodeCraft (server and command line client): message types,
//! model providers (Anthropic, OpenAI-compatible) and permission rules.
pub mod config;
pub mod permission;
pub mod provider;
pub mod types;
+332
View File
@@ -0,0 +1,332 @@
//! Permission evaluation: config rules, session permission modes and
//! "always allow" approvals given in the UI.
use crate::config::PermissionConfig;
use serde_json::Value;
use std::collections::HashSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
Allow,
Ask,
Deny,
}
fn parse(s: &str) -> Option<Decision> {
match s {
"allow" => Some(Decision::Allow),
"ask" => Some(Decision::Ask),
"deny" => Some(Decision::Deny),
_ => None,
}
}
fn glob_match(pattern: &str, text: &str) -> bool {
globset::GlobBuilder::new(pattern)
.literal_separator(false)
.build()
.map(|g| g.compile_matcher().is_match(text))
.unwrap_or(false)
}
/// Split a shell command into simple commands on && || ; | and newlines.
/// Returns None when the command contains constructs we cannot judge safely
/// (command substitution, backticks).
pub fn split_command(cmd: &str) -> Option<Vec<String>> {
if cmd.contains("$(") || cmd.contains('`') || cmd.contains("<(") || cmd.contains(">(") {
return None;
}
let mut parts = Vec::new();
let mut cur = String::new();
let mut chars = cmd.chars().peekable();
let (mut sq, mut dq) = (false, false);
while let Some(c) = chars.next() {
match c {
'\'' if !dq => {
sq = !sq;
cur.push(c)
}
'"' if !sq => {
dq = !dq;
cur.push(c)
}
'\\' if !sq => {
cur.push(c);
if let Some(n) = chars.next() {
cur.push(n);
}
}
';' | '\n' | '|' | '&' if !sq && !dq => {
// "&" alone (background) and "&&", "||", "|", ";" all separate commands.
if (c == '|' || c == '&') && chars.peek() == Some(&c) {
chars.next();
}
// Redirections like 2>&1 are not separators.
if c == '&' && cur.ends_with('>') {
cur.push(c);
continue;
}
parts.push(std::mem::take(&mut cur));
}
_ => cur.push(c),
}
}
parts.push(cur);
Some(parts.into_iter().map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect())
}
/// Prefix stored for "always allow" on a bash command: the first two words.
pub fn bash_prefix(cmd: &str) -> String {
cmd.split_whitespace().take(2).collect::<Vec<_>>().join(" ")
}
/// Session permission modes.
pub const MODES: &[&str] = &["default", "auto", "accept_edits", "bypass"];
/// Commands that only read. Checked per simple command (after splitting).
const SAFE_COMMANDS: &[&str] = &[
"ls", "ll", "cat", "head", "tail", "wc", "pwd", "echo", "printf", "which", "whereis", "type", "file", "stat",
"du", "df", "tree", "grep", "egrep", "fgrep", "rg", "ag", "sort", "uniq", "cut", "tr", "diff", "cmp", "basename",
"dirname", "realpath", "readlink", "date", "uname", "whoami", "id", "hostname", "nproc", "free", "uptime", "ps",
"jq", "yq", "md5sum", "sha1sum", "sha256sum", "column", "nl", "fold", "true", "false", "test", "[", "less", "more",
];
const SAFE_GIT: &[&str] = &[
"status", "diff", "log", "show", "rev-parse", "ls-files", "blame", "describe", "grep", "shortlog", "reflog", "remote",
];
/// Strip harmless redirections so they don't count as file writes.
fn strip_redirections(cmd: &str) -> String {
cmd.replace("2>&1", "").replace("2>/dev/null", "").replace(">/dev/null", "").replace("> /dev/null", "")
}
/// A simple command that only reads.
pub fn is_safe_command(part: &str) -> bool {
let cleaned = strip_redirections(part);
if cleaned.contains('>') {
return false;
}
let words: Vec<String> = shell_words::split(&cleaned).unwrap_or_else(|_| cleaned.split_whitespace().map(String::from).collect());
// Leading VAR=value assignments are not safe (could change behaviour); keep it strict.
let Some(cmd) = words.first() else { return true };
let args: Vec<&str> = words[1..].iter().map(String::as_str).collect();
if args.len() == 1 && matches!(args[0], "--version" | "-V" | "--help" | "-h") {
return true;
}
match cmd.as_str() {
c if SAFE_COMMANDS.contains(&c) => true,
"find" => !args.iter().any(|a| matches!(*a, "-exec" | "-execdir" | "-delete" | "-ok" | "-okdir" | "-fprint" | "-fprintf" | "-fls")),
"sed" => !args.iter().any(|a| a.starts_with("-i") || a.starts_with("--in-place")),
"git" => match args.first() {
Some(sub) if SAFE_GIT.contains(sub) => !(*sub == "remote" && args.len() > 1 && args[1] != "-v"),
Some(&"branch") => !args.iter().any(|a| matches!(*a, "-d" | "-D" | "-m" | "-M" | "--delete" | "--move")),
Some(&"stash") => args.get(1) == Some(&"list"),
Some(&"tag") => args.len() == 1 || args.get(1) == Some(&"-l") || args.get(1) == Some(&"--list"),
Some(&"config") => args.iter().any(|a| *a == "--get" || *a == "--list" || *a == "-l"),
_ => false,
},
"cargo" => matches!(args.first(), Some(&"check") | Some(&"metadata") | Some(&"tree") | Some(&"--list")),
"npm" | "pnpm" | "yarn" => matches!(args.first(), Some(&"ls") | Some(&"list") | Some(&"outdated") | Some(&"view") | Some(&"why")),
"pip" | "pip3" => matches!(args.first(), Some(&"list") | Some(&"show") | Some(&"freeze")),
_ => false,
}
}
/// Commands that always need confirmation unless everything is allowed.
pub fn is_dangerous(cmd: &str) -> bool {
let c = format!(" {} ", cmd.replace('\n', " "));
const PATTERNS: &[&str] = &[
" rm -r", " rm -f", " rm -R", " rm --recursive", " sudo ", " su ", " git push", " git reset --hard", " git clean",
" git checkout -- ", " git restore ", " git rebase", " git commit --amend", " chmod -R", " chown -R", " dd ",
" mkfs", " shutdown", " reboot", " poweroff", " kill ", " pkill ", " killall ", " docker ", " docker-compose ",
" systemctl ", " npm publish", " cargo publish", " | sh", " | bash", "|sh ", "|bash ", " > /dev/sd", " crontab ",
];
PATTERNS.iter().any(|p| c.contains(p))
}
/// What the rules need to know about a tool.
pub trait ToolInfo {
fn name(&self) -> &str;
fn read_only(&self) -> bool;
/// Changes files (write, edit …): auto-approved in accept_edits/auto mode.
fn is_edit(&self) -> bool {
false
}
}
pub struct Evaluator<'a> {
pub cfg: &'a PermissionConfig,
pub mode: &'a str,
/// Session approvals: tool names, or "bash:<prefix>".
pub approved: &'a HashSet<String>,
}
impl Evaluator<'_> {
pub fn evaluate<T: ToolInfo + ?Sized>(&self, tool: &T, input: &Value) -> Decision {
let name = tool.name();
let base = self.tool_rule(name).unwrap_or(if tool.read_only() { Decision::Allow } else { Decision::Ask });
if name == "bash" {
let cmd = input["command"].as_str().unwrap_or("");
let bash = self.bash_decision(cmd);
if bash == Some(Decision::Deny) || base == Decision::Deny {
return Decision::Deny;
}
if self.mode == "bypass" {
return Decision::Allow;
}
if let Some(d) = bash {
return d;
}
// Read-only commands never need confirmation.
if split_command(cmd).is_some_and(|parts| !parts.is_empty() && parts.iter().all(|p| is_safe_command(p))) {
return Decision::Allow;
}
if self.mode == "auto" && !is_dangerous(cmd) && split_command(cmd).is_some() {
return Decision::Allow;
}
return base;
}
if base == Decision::Deny {
return Decision::Deny;
}
if self.mode == "bypass" || self.approved.contains(name) {
return Decision::Allow;
}
if (self.mode == "accept_edits" || self.mode == "auto") && tool.is_edit() {
return Decision::Allow;
}
// In auto mode everything read-only passes (web_fetch, read-only MCP tools, ...).
if self.mode == "auto" && tool.read_only() {
return Decision::Allow;
}
base
}
fn tool_rule(&self, name: &str) -> Option<Decision> {
if let Some(d) = self.cfg.tools.get(name).and_then(|s| parse(s)) {
return Some(d);
}
// Glob rules, most specific (longest) first.
let mut globs: Vec<(&String, &String)> =
self.cfg.tools.iter().filter(|(k, _)| k.contains('*')).collect();
globs.sort_by_key(|(k, _)| std::cmp::Reverse(k.len()));
globs.into_iter().find(|(k, _)| glob_match(k, name)).and_then(|(_, v)| parse(v))
}
/// Some(Allow) only if every simple command is allowed; Some(Deny) if any is denied.
fn bash_decision(&self, cmd: &str) -> Option<Decision> {
// Deny rules are checked against the full command as well as each part.
let deny_hit = |text: &str| {
self.cfg.bash_rules.iter().any(|(p, d)| d == "deny" && glob_match(p, text))
};
if deny_hit(cmd.trim()) {
return Some(Decision::Deny);
}
let parts = split_command(cmd)?;
if parts.iter().any(|p| deny_hit(p)) {
return Some(Decision::Deny);
}
let all_allowed = !parts.is_empty()
&& parts.iter().all(|p| {
self.approved.contains(&format!("bash:{}", bash_prefix(p)))
|| self.cfg.bash_rules.iter().any(|(pat, d)| d == "allow" && glob_match(pat, p))
});
if all_allowed { Some(Decision::Allow) } else { None }
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
struct Fake(&'static str, bool);
impl ToolInfo for Fake {
fn name(&self) -> &str {
self.0
}
fn read_only(&self) -> bool {
self.1
}
}
fn cfg() -> PermissionConfig {
let mut bash_rules = BTreeMap::new();
bash_rules.insert("git status*".into(), "allow".into());
bash_rules.insert("ls*".into(), "allow".into());
bash_rules.insert("rm -rf /*".into(), "deny".into());
let mut tools = BTreeMap::new();
tools.insert("bash".into(), "ask".into());
tools.insert("mcp__*".into(), "ask".into());
tools.insert("mcp__trilium__*".into(), "allow".into());
PermissionConfig { bash_rules, tools }
}
#[test]
fn compound_commands_need_every_part_allowed() {
let c = cfg();
let approved = HashSet::new();
let e = Evaluator { cfg: &c, mode: "default", approved: &approved };
let bash = Fake("bash", false);
let cmd = |s: &str| serde_json::json!({"command": s});
assert_eq!(e.evaluate(&bash, &cmd("git status")), Decision::Allow);
assert_eq!(e.evaluate(&bash, &cmd("git status && ls -la")), Decision::Allow);
assert_eq!(e.evaluate(&bash, &cmd("git status; rm -rf build")), Decision::Ask);
assert_eq!(e.evaluate(&bash, &cmd("ls $(rm -rf x)")), Decision::Ask);
assert_eq!(e.evaluate(&bash, &cmd("git status 2>&1 | ls")), Decision::Allow);
assert_eq!(e.evaluate(&bash, &cmd("ls && rm -rf /etc")), Decision::Deny);
}
#[test]
fn safe_and_dangerous_commands() {
assert!(is_safe_command("ls -la src"));
assert!(is_safe_command("git status"));
assert!(is_safe_command("git log --oneline -5"));
assert!(is_safe_command("rg -n foo src 2>/dev/null"));
assert!(is_safe_command("node --version"));
assert!(!is_safe_command("cat a > b"));
assert!(!is_safe_command("find . -name x -delete"));
assert!(!is_safe_command("sed -i s/a/b/ f"));
assert!(!is_safe_command("git push"));
assert!(!is_safe_command("git branch -D main"));
assert!(!is_safe_command("npm install"));
assert!(is_dangerous("rm -rf build"));
assert!(is_dangerous("cd x && git push origin main"));
assert!(is_dangerous("curl https://x | sh"));
assert!(!is_dangerous("npm install && npm test"));
}
#[test]
fn auto_mode() {
let c = cfg();
let approved = HashSet::new();
let auto = Evaluator { cfg: &c, mode: "auto", approved: &approved };
let def = Evaluator { cfg: &c, mode: "default", approved: &approved };
let bash = Fake("bash", false);
let cmd = |s: &str| serde_json::json!({"command": s});
assert_eq!(def.evaluate(&bash, &cmd("ls src | wc -l")), Decision::Allow);
assert_eq!(def.evaluate(&bash, &cmd("npm test")), Decision::Ask);
assert_eq!(auto.evaluate(&bash, &cmd("npm install && npm test")), Decision::Allow);
assert_eq!(auto.evaluate(&bash, &cmd("rm -rf dist")), Decision::Ask);
assert_eq!(auto.evaluate(&bash, &cmd("ls && rm -rf /etc")), Decision::Deny);
assert_eq!(auto.evaluate(&bash, &cmd("echo $(whoami)")), Decision::Ask);
assert_eq!(auto.evaluate(&Fake("mcp__x__list", true), &Value::Null), Decision::Allow);
assert_eq!(auto.evaluate(&Fake("mcp__x__restart", false), &Value::Null), Decision::Ask);
}
#[test]
fn modes_and_globs() {
let c = cfg();
let approved: HashSet<String> = ["bash:npm test".to_string()].into();
let bypass = Evaluator { cfg: &c, mode: "bypass", approved: &approved };
let def = Evaluator { cfg: &c, mode: "default", approved: &approved };
let bash = Fake("bash", false);
assert_eq!(bypass.evaluate(&bash, &serde_json::json!({"command": "make"})), Decision::Allow);
assert_eq!(bypass.evaluate(&bash, &serde_json::json!({"command": "rm -rf /"})), Decision::Deny);
assert_eq!(def.evaluate(&bash, &serde_json::json!({"command": "npm test -- --watch"})), Decision::Allow);
assert_eq!(def.evaluate(&Fake("mcp__trilium__search", false), &Value::Null), Decision::Allow);
assert_eq!(def.evaluate(&Fake("mcp__other__x", false), &Value::Null), Decision::Ask);
assert_eq!(def.evaluate(&Fake("read", true), &Value::Null), Decision::Allow);
}
}
+304
View File
@@ -0,0 +1,304 @@
//! Anthropic Messages API (streaming). Also works with Anthropic-compatible
//! third-party endpoints; first-party-only features are gated on the host.
use super::{ChatRequest, Endpoint, Provider, StreamEvent, http_error};
use crate::config::ModelConfig;
use crate::types::{Message, Part, Role, Usage};
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use eventsource_stream::Eventsource;
use futures::StreamExt;
use serde_json::{Value, json};
use std::collections::BTreeMap;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
const API_VERSION: &str = "2026-10-21";
pub struct Anthropic {
client: reqwest::Client,
ep: Endpoint,
}
impl Anthropic {
pub fn new(client: reqwest::Client, ep: Endpoint) -> Self {
Self { client, ep }
}
fn first_party(&self) -> bool {
self.ep.base_url.contains("api.anthropic.com")
}
fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
let mut rb = self.client.request(method, format!("{}{}", self.ep.base_url, path));
if !self.ep.headers.keys().any(|k| k.eq_ignore_ascii_case("anthropic-version")) {
rb = rb.header("anthropic-version", API_VERSION);
}
self.ep.apply(rb)
}
}
/// Convert our neutral history into Anthropic message params.
pub fn to_wire_messages(messages: &[Message]) -> Vec<Value> {
let mut out: Vec<Value> = Vec::new();
for m in messages {
let mut content = Vec::new();
for p in &m.parts {
match p {
Part::Text { text } if !text.is_empty() => {
content.push(json!({"type": "text", "text": text}))
}
Part::Summary { text } => content.push(json!({
"type": "text",
"text": format!("<conversation-summary>\n{text}\n</conversation-summary>")
})),
Part::Image { image } => content.push(json!({
"type": "image",
"source": {"type": "base64", "media_type": image.media_type, "data": image.data}
})),
// Thinking blocks are replayed unchanged (assistant turns only).
Part::Reasoning { text, signature } if m.role == Role::Assistant => {
match signature.as_deref() {
Some(sig) if sig.starts_with("redacted:") => content.push(json!({
"type": "redacted_thinking",
"data": &sig["redacted:".len()..]
})),
Some(sig) => content.push(json!({
"type": "thinking", "thinking": text, "signature": sig
})),
// Reasoning from another provider has no signature – drop it.
None => {}
}
}
Part::ToolCall { id, name, input, .. } => content.push(json!({
"type": "tool_use", "id": id, "name": name, "input": input
})),
Part::ToolResult { call_id, content: text, images, is_error, .. } => {
let mut blocks = vec![json!({"type": "text", "text": if text.is_empty() { "(no output)" } else { text }})];
for img in images {
blocks.push(json!({
"type": "image",
"source": {"type": "base64", "media_type": img.media_type, "data": img.data}
}));
}
content.push(json!({
"type": "tool_result", "tool_use_id": call_id,
"content": blocks, "is_error": is_error
}))
}
_ => {}
}
}
if content.is_empty() {
continue;
}
let role = match m.role {
Role::User => "user",
Role::Assistant => "assistant",
};
// Merge consecutive messages of the same role (the API requires alternation).
if let Some(last) = out.last_mut() {
if last["role"] == role {
last["content"].as_array_mut().unwrap().extend(content);
continue;
}
}
out.push(json!({"role": role, "content": content}));
}
// tool_result blocks must come first in a user message.
for msg in out.iter_mut().filter(|m| m["role"] == "user") {
let arr = msg["content"].as_array_mut().unwrap();
arr.sort_by_key(|b| if b["type"] == "tool_result" { 0 } else { 1 });
}
out
}
#[derive(Default)]
struct BlockState {
kind: String,
id: String,
name: String,
json: String,
signature: String,
}
#[async_trait]
impl Provider for Anthropic {
async fn stream(
&self,
req: ChatRequest,
tx: mpsc::Sender<StreamEvent>,
cancel: CancellationToken,
) -> Result<()> {
let first_party = self.first_party();
let tools: Vec<Value> = req
.tools
.iter()
.map(|t| {
let mut v = json!({
"name": t.name, "description": t.description, "input_schema": t.input_schema
});
if first_party {
v["eager_input_streaming"] = json!(true);
}
v
})
.collect();
let mut body = json!({
"model": req.model.id,
"max_tokens": req.max_tokens,
"stream": true,
"system": [{"type": "text", "text": req.system, "cache_control": {"type": "ephemeral"}}],
"messages": to_wire_messages(&req.messages),
});
if !tools.is_empty() {
body["tools"] = Value::Array(tools);
}
if first_party {
// Automatic breakpoint on the last cacheable block (conversation prefix).
body["cache_control"] = json!({"type": "ephemeral"});
}
if req.reasoning && req.model.reasoning {
body["thinking"] = json!({"type": "adaptive", "display": "summarized"});
}
if let Some(effort) = &req.model.effort {
body["output_config"] = json!({"effort": effort});
}
for (k, v) in &req.model.options {
body[k] = v.clone();
}
let resp = self.request(reqwest::Method::POST, "/messages").json(&body).send().await?;
if !resp.status().is_success() {
return Err(http_error(resp).await);
}
let mut events = resp.bytes_stream().eventsource();
let mut blocks: BTreeMap<u64, BlockState> = BTreeMap::new();
let mut usage = Usage::default();
let mut stop_reason = String::from("end_turn");
loop {
let ev = tokio::select! {
_ = cancel.cancelled() => return Ok(()),
ev = events.next() => ev,
};
let Some(ev) = ev else { break };
let ev = ev.map_err(|e| anyhow!("stream error: {e}"))?;
if ev.data.is_empty() {
continue;
}
let data: Value = serde_json::from_str(&ev.data)?;
match data["type"].as_str().unwrap_or_default() {
"message_start" => {
let u = &data["message"]["usage"];
usage.input_tokens = u["input_tokens"].as_u64().unwrap_or(0);
usage.cache_read_tokens = u["cache_read_input_tokens"].as_u64().unwrap_or(0);
usage.cache_write_tokens = u["cache_creation_input_tokens"].as_u64().unwrap_or(0);
}
"content_block_start" => {
let idx = data["index"].as_u64().unwrap_or(0);
let cb = &data["content_block"];
let mut st = BlockState {
kind: cb["type"].as_str().unwrap_or_default().to_string(),
..Default::default()
};
match st.kind.as_str() {
"tool_use" => {
st.id = cb["id"].as_str().unwrap_or_default().into();
st.name = cb["name"].as_str().unwrap_or_default().into();
}
"redacted_thinking" => {
st.signature = format!("redacted:{}", cb["data"].as_str().unwrap_or_default());
}
"text" => {
if let Some(t) = cb["text"].as_str().filter(|t| !t.is_empty()) {
tx.send(StreamEvent::TextDelta(t.into())).await.ok();
}
}
_ => {}
}
blocks.insert(idx, st);
}
"content_block_delta" => {
let idx = data["index"].as_u64().unwrap_or(0);
let d = &data["delta"];
let st = blocks.entry(idx).or_default();
match d["type"].as_str().unwrap_or_default() {
"text_delta" => {
tx.send(StreamEvent::TextDelta(d["text"].as_str().unwrap_or_default().into())).await.ok();
}
"thinking_delta" => {
tx.send(StreamEvent::ReasoningDelta(d["thinking"].as_str().unwrap_or_default().into())).await.ok();
}
"signature_delta" => st.signature.push_str(d["signature"].as_str().unwrap_or_default()),
"input_json_delta" => st.json.push_str(d["partial_json"].as_str().unwrap_or_default()),
_ => {}
}
}
"content_block_stop" => {
let idx = data["index"].as_u64().unwrap_or(0);
if let Some(st) = blocks.remove(&idx) {
match st.kind.as_str() {
"tool_use" => {
let input = if st.json.trim().is_empty() {
json!({})
} else {
// Invalid JSON (e.g. truncated) is passed on as a marker; the
// agent loop answers it with an error tool_result.
serde_json::from_str(&st.json)
.unwrap_or_else(|_| json!({"__invalid_json": st.json}))
};
tx.send(StreamEvent::ToolCall { id: st.id, name: st.name, input, extra: None }).await.ok();
}
"thinking" | "redacted_thinking" => {
tx.send(StreamEvent::ReasoningSignature(st.signature)).await.ok();
}
_ => {}
}
}
}
"message_delta" => {
if let Some(r) = data["delta"]["stop_reason"].as_str() {
stop_reason = r.to_string();
}
if let Some(o) = data["usage"]["output_tokens"].as_u64() {
usage.output_tokens = o;
}
}
"error" => {
let msg = data["error"]["message"].as_str().unwrap_or("unknown error");
return Err(anyhow!("provider error: {msg}"));
}
_ => {}
}
}
tx.send(StreamEvent::Usage(usage)).await.ok();
tx.send(StreamEvent::Done { stop_reason }).await.ok();
Ok(())
}
async fn list_models(&self) -> Result<Vec<ModelConfig>> {
let resp = self.request(reqwest::Method::GET, "/models?limit=100").send().await?;
if !resp.status().is_success() {
return Err(http_error(resp).await);
}
let v: Value = resp.json().await?;
Ok(v["data"]
.as_array()
.map(|a| {
a.iter()
.map(|m| ModelConfig {
id: m["id"].as_str().unwrap_or_default().into(),
name: m["display_name"].as_str().map(String::from),
context: m["max_input_tokens"].as_u64(),
max_output: m["max_tokens"].as_u64(),
reasoning: true,
vision: true,
..Default::default()
})
.collect()
})
.unwrap_or_default())
}
}
+130
View File
@@ -0,0 +1,130 @@
//! Model providers. Two wire protocols cover all targets:
//! Anthropic Messages and OpenAI-compatible chat completions.
pub mod anthropic;
pub mod openai;
use crate::config::{ModelConfig, ProviderConfig, ProviderKind, expand_env};
use crate::types::{Message, Usage};
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDef {
pub name: String,
pub description: String,
pub input_schema: Value,
}
#[derive(Debug, Clone)]
pub struct ChatRequest {
pub model: ModelConfig,
pub system: String,
pub messages: Vec<Message>,
pub tools: Vec<ToolDef>,
pub max_tokens: u64,
/// Enable extended thinking / reasoning when the model supports it.
pub reasoning: bool,
}
#[derive(Debug, Clone)]
pub enum StreamEvent {
TextDelta(String),
ReasoningDelta(String),
ReasoningSignature(String),
ToolCall { id: String, name: String, input: Value, extra: Option<Value> },
Usage(Usage),
Done { stop_reason: String },
}
#[async_trait]
pub trait Provider: Send + Sync {
async fn stream(
&self,
req: ChatRequest,
tx: mpsc::Sender<StreamEvent>,
cancel: CancellationToken,
) -> Result<()>;
async fn list_models(&self) -> Result<Vec<ModelConfig>>;
}
/// Connection settings shared by both protocol implementations.
#[derive(Debug, Clone)]
pub struct Endpoint {
pub base_url: String,
pub api_key: String,
pub auth_header: String,
pub auth_prefix: String,
pub headers: BTreeMap<String, String>,
}
impl Endpoint {
pub fn from_config(p: &ProviderConfig) -> Self {
let (default_header, default_prefix) = match p.kind {
ProviderKind::Anthropic => ("x-api-key", ""),
ProviderKind::Openai => ("Authorization", "Bearer "),
};
let base = if p.base_url.is_empty() && p.kind == ProviderKind::Anthropic {
"https://api.anthropic.com/v1".to_string()
} else {
expand_env(&p.base_url)
};
Self {
base_url: base.trim_end_matches('/').to_string(),
api_key: expand_env(&p.api_key),
auth_header: p.auth_header.clone().unwrap_or_else(|| default_header.into()),
auth_prefix: p.auth_prefix.clone().unwrap_or_else(|| default_prefix.into()),
headers: p.headers.iter().map(|(k, v)| (k.clone(), expand_env(v))).collect(),
}
}
pub fn apply(&self, mut rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if !self.api_key.is_empty() {
rb = rb.header(&self.auth_header, format!("{}{}", self.auth_prefix, self.api_key));
}
for (k, v) in &self.headers {
rb = rb.header(k, v);
}
rb
}
}
/// Shared HTTP client for model requests.
pub fn http_client() -> reqwest::Client {
reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(20))
.user_agent(concat!("codecraft/", env!("CARGO_PKG_VERSION")))
.build()
.expect("http client")
}
/// Create the provider for a provider configuration.
pub fn build(client: reqwest::Client, pc: &ProviderConfig) -> std::sync::Arc<dyn Provider> {
let ep = Endpoint::from_config(pc);
match pc.kind {
ProviderKind::Anthropic => std::sync::Arc::new(anthropic::Anthropic::new(client, ep)),
ProviderKind::Openai => std::sync::Arc::new(openai::OpenAi::new(client, ep)),
}
}
/// Turn an HTTP error response into a readable error.
pub async fn http_error(resp: reqwest::Response) -> anyhow::Error {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
let detail = serde_json::from_str::<Value>(&body)
.ok()
.and_then(|v| {
v.pointer("/error/message")
.or_else(|| v.pointer("/0/error/message"))
.or_else(|| v.get("message"))
.and_then(|m| m.as_str().map(String::from))
})
.unwrap_or_else(|| body.chars().take(500).collect());
anyhow!("provider returned HTTP {status}: {detail}")
}
+343
View File
@@ -0,0 +1,343 @@
//! OpenAI-compatible chat completions (streaming). Covers Gemini (OpenAI compat),
//! opencode Go, fal/OpenRouter, Ollama, vLLM, llama.cpp, LM Studio, ...
use super::{ChatRequest, Endpoint, Provider, StreamEvent, http_error};
use crate::config::ModelConfig;
use crate::types::{Message, Part, Role, Usage};
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use eventsource_stream::Eventsource;
use futures::StreamExt;
use serde_json::{Value, json};
use std::collections::BTreeMap;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
pub struct OpenAi {
client: reqwest::Client,
ep: Endpoint,
}
impl OpenAi {
pub fn new(client: reqwest::Client, ep: Endpoint) -> Self {
Self { client, ep }
}
}
fn data_url(img: &crate::types::ImageData) -> String {
format!("data:{};base64,{}", img.media_type, img.data)
}
pub fn to_wire_messages(system: &str, messages: &[Message]) -> Vec<Value> {
let mut out = vec![json!({"role": "system", "content": system})];
for m in messages {
match m.role {
Role::User => {
let mut parts = Vec::new();
let mut tool_images = Vec::new();
for p in &m.parts {
match p {
Part::Text { text } if !text.is_empty() => {
parts.push(json!({"type": "text", "text": text}))
}
Part::Summary { text } => parts.push(json!({
"type": "text",
"text": format!("<conversation-summary>\n{text}\n</conversation-summary>")
})),
Part::Image { image } => parts.push(json!({
"type": "image_url", "image_url": {"url": data_url(image)}
})),
Part::ToolResult { call_id, name, content, images, is_error, .. } => {
let text = if *is_error { format!("ERROR: {content}") } else { content.clone() };
out.push(json!({
"role": "tool", "tool_call_id": call_id, "name": name,
"content": if text.is_empty() { "(no output)".into() } else { text }
}));
tool_images.extend(images.iter().cloned());
}
_ => {}
}
}
// Tool messages cannot carry images: send them as a user message.
if !tool_images.is_empty() {
let mut c = vec![json!({"type": "text", "text": "Images returned by the tool calls above:"})];
c.extend(tool_images.iter().map(|i| json!({"type": "image_url", "image_url": {"url": data_url(i)}})));
out.push(json!({"role": "user", "content": c}));
}
if !parts.is_empty() {
out.push(json!({"role": "user", "content": parts}));
}
}
Role::Assistant => {
let mut text = String::new();
let mut reasoning = String::new();
let mut calls = Vec::new();
for p in &m.parts {
match p {
Part::Text { text: t } => text.push_str(t),
Part::Reasoning { text: t, .. } => reasoning.push_str(t),
Part::ToolCall { id, name, input, extra } => {
let mut c = json!({
"id": id, "type": "function",
"function": {"name": name, "arguments": input.to_string()}
});
if let Some(e) = extra {
c["extra_content"] = e.clone();
}
calls.push(c);
}
_ => {}
}
}
let mut msg = json!({"role": "assistant", "content": if text.is_empty() { Value::Null } else { Value::String(text) }});
if !calls.is_empty() {
msg["tool_calls"] = Value::Array(calls);
}
// Some reasoning models (DeepSeek, Kimi) expect their reasoning back during tool loops.
if !reasoning.is_empty() {
msg["reasoning_content"] = Value::String(reasoning);
}
if msg["content"].is_null() && msg.get("tool_calls").is_none() {
continue;
}
out.push(msg);
}
}
}
out
}
/// Splits streamed content into text and inline `<think>…</think>` reasoning.
/// Tags may be split across chunks, so a possible partial tag is held back.
#[derive(Default)]
pub struct ThinkSplitter {
in_think: bool,
pending: String,
}
const OPEN: &str = "<think>";
const CLOSE: &str = "</think>";
impl ThinkSplitter {
/// Returns (is_reasoning, text) pieces that are safe to emit.
pub fn push(&mut self, chunk: &str) -> Vec<(bool, String)> {
self.pending.push_str(chunk);
let mut out = Vec::new();
loop {
let tag = if self.in_think { CLOSE } else { OPEN };
if let Some(pos) = self.pending.find(tag) {
let before: String = self.pending[..pos].to_string();
if !before.is_empty() {
out.push((self.in_think, before));
}
self.pending.drain(..pos + tag.len());
self.in_think = !self.in_think;
continue;
}
// Keep a trailing prefix of the tag (e.g. "<thi") until the next chunk.
let keep = (1..tag.len()).rev().find(|n| self.pending.ends_with(&tag[..*n])).unwrap_or(0);
let emit_len = self.pending.len() - keep;
if emit_len > 0 {
let text: String = self.pending.drain(..emit_len).collect();
out.push((self.in_think, text));
}
break;
}
// Leading whitespace after </think> is noise.
out.retain(|(_, t)| !t.is_empty());
out
}
pub fn finish(&mut self) -> Vec<(bool, String)> {
if self.pending.is_empty() {
return vec![];
}
vec![(self.in_think, std::mem::take(&mut self.pending))]
}
}
#[derive(Default)]
struct PendingCall {
id: String,
name: String,
args: String,
extra: Option<Value>,
}
#[async_trait]
impl Provider for OpenAi {
async fn stream(
&self,
req: ChatRequest,
tx: mpsc::Sender<StreamEvent>,
cancel: CancellationToken,
) -> Result<()> {
let tools: Vec<Value> = req
.tools
.iter()
.map(|t| json!({
"type": "function",
"function": {"name": t.name, "description": t.description, "parameters": t.input_schema}
}))
.collect();
let mut body = json!({
"model": req.model.id,
"messages": to_wire_messages(&req.system, &req.messages),
"stream": true,
"stream_options": {"include_usage": true},
"max_tokens": req.max_tokens,
});
if !tools.is_empty() {
body["tools"] = Value::Array(tools);
}
if let Some(effort) = &req.model.effort {
body["reasoning_effort"] = json!(effort);
}
for (k, v) in &req.model.options {
body[k] = v.clone();
}
let rb = self.client.post(format!("{}/chat/completions", self.ep.base_url)).json(&body);
let resp = self.ep.apply(rb).send().await?;
if !resp.status().is_success() {
return Err(http_error(resp).await);
}
let mut events = resp.bytes_stream().eventsource();
let mut calls: BTreeMap<u64, PendingCall> = BTreeMap::new();
let mut think = ThinkSplitter::default();
let mut usage = Usage::default();
let mut stop_reason = String::from("stop");
loop {
let ev = tokio::select! {
_ = cancel.cancelled() => return Ok(()),
ev = events.next() => ev,
};
let Some(ev) = ev else { break };
let ev = ev.map_err(|e| anyhow!("stream error: {e}"))?;
if ev.data.trim() == "[DONE]" {
break;
}
if ev.data.trim().is_empty() {
continue;
}
let data: Value = serde_json::from_str(&ev.data)?;
if let Some(err) = data.get("error") {
return Err(anyhow!("provider error: {}", err["message"].as_str().unwrap_or(&err.to_string())));
}
if let Some(u) = data.get("usage").filter(|u| u.is_object()) {
let cached = u["prompt_tokens_details"]["cached_tokens"].as_u64().unwrap_or(0);
usage.input_tokens = u["prompt_tokens"].as_u64().unwrap_or(0).saturating_sub(cached);
usage.cache_read_tokens = cached;
usage.output_tokens = u["completion_tokens"].as_u64().unwrap_or(0);
}
let Some(choice) = data["choices"].get(0) else { continue };
let d = &choice["delta"];
if let Some(t) = d["content"].as_str().filter(|t| !t.is_empty()) {
// Models served by llama.cpp & co. may inline their reasoning as <think>…</think>.
for (is_reasoning, chunk) in think.push(t) {
let ev = if is_reasoning { StreamEvent::ReasoningDelta(chunk) } else { StreamEvent::TextDelta(chunk) };
tx.send(ev).await.ok();
}
}
let r = d["reasoning_content"].as_str().or_else(|| d["reasoning"].as_str());
if let Some(r) = r.filter(|r| !r.is_empty()) {
tx.send(StreamEvent::ReasoningDelta(r.into())).await.ok();
}
if let Some(tcs) = d["tool_calls"].as_array() {
for (pos, tc) in tcs.iter().enumerate() {
let idx = tc["index"].as_u64().unwrap_or(pos as u64);
let pc = calls.entry(idx).or_default();
if let Some(id) = tc["id"].as_str().filter(|s| !s.is_empty()) {
pc.id = id.into();
}
if let Some(n) = tc["function"]["name"].as_str() {
pc.name.push_str(n);
}
if let Some(a) = tc["function"]["arguments"].as_str() {
pc.args.push_str(a);
}
if let Some(e) = tc.get("extra_content") {
pc.extra = Some(e.clone());
}
}
}
if let Some(f) = choice["finish_reason"].as_str() {
stop_reason = f.to_string();
}
}
for (is_reasoning, chunk) in think.finish() {
let ev = if is_reasoning { StreamEvent::ReasoningDelta(chunk) } else { StreamEvent::TextDelta(chunk) };
tx.send(ev).await.ok();
}
for (i, pc) in calls {
let input = if pc.args.trim().is_empty() {
json!({})
} else {
serde_json::from_str(&pc.args).unwrap_or_else(|_| json!({"__invalid_json": pc.args}))
};
let id = if pc.id.is_empty() { format!("call_{}_{i}", crate::types::new_id()) } else { pc.id };
tx.send(StreamEvent::ToolCall { id, name: pc.name, input, extra: pc.extra }).await.ok();
}
tx.send(StreamEvent::Usage(usage)).await.ok();
tx.send(StreamEvent::Done { stop_reason }).await.ok();
Ok(())
}
async fn list_models(&self) -> Result<Vec<ModelConfig>> {
let rb = self.client.get(format!("{}/models", self.ep.base_url));
let resp = self.ep.apply(rb).send().await?;
if !resp.status().is_success() {
return Err(http_error(resp).await);
}
let v: Value = resp.json().await?;
let mut models: Vec<ModelConfig> = v["data"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|m| m["id"].as_str())
// Gemini lists "models/<id>"; the chat endpoint wants the bare id.
.map(|id| ModelConfig {
id: id.trim_start_matches("models/").to_string(),
..Default::default()
})
.collect()
})
.unwrap_or_default();
models.sort_by(|a, b| a.id.cmp(&b.id));
Ok(models)
}
}
#[cfg(test)]
mod tests {
use super::ThinkSplitter;
fn run(chunks: &[&str]) -> (String, String) {
let mut s = ThinkSplitter::default();
let mut text = String::new();
let mut reasoning = String::new();
let mut parts: Vec<(bool, String)> = chunks.iter().flat_map(|c| s.push(c)).collect();
parts.extend(s.finish());
for (r, t) in parts {
if r { reasoning.push_str(&t) } else { text.push_str(&t) }
}
(text, reasoning)
}
#[test]
fn splits_think_tags_across_chunks() {
let (text, reasoning) = run(&["<thi", "nk>plan ", "the fix</th", "ink>Here is ", "the answer."]);
assert_eq!(reasoning, "plan the fix");
assert_eq!(text, "Here is the answer.");
}
#[test]
fn plain_text_and_lookalikes_pass_through() {
let (text, reasoning) = run(&["a < b and <thin", "g> stays"]);
assert_eq!(text, "a < b and <thing> stays");
assert!(reasoning.is_empty());
}
}
+222
View File
@@ -0,0 +1,222 @@
//! Provider-neutral message model and the event stream sent to clients.
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Role {
User,
Assistant,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ImageData {
pub media_type: String,
/// Base64-encoded bytes.
pub data: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Part {
Text {
text: String,
},
Image {
image: ImageData,
},
Reasoning {
text: String,
/// Provider-specific signature needed to replay thinking blocks (Anthropic).
#[serde(default, skip_serializing_if = "Option::is_none")]
signature: Option<String>,
},
ToolCall {
id: String,
name: String,
input: Value,
/// Opaque provider data that must be echoed back (e.g. Gemini thought signatures).
#[serde(default, skip_serializing_if = "Option::is_none")]
extra: Option<Value>,
},
ToolResult {
call_id: String,
/// Tool name, kept for providers that need it (Gemini via OpenAI compat) and the UI.
#[serde(default)]
name: String,
content: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
images: Vec<ImageData>,
#[serde(default)]
is_error: bool,
/// Structured data for the UI (diffs, todo lists, ...). Never sent to the model.
#[serde(default, skip_serializing_if = "Option::is_none")]
meta: Option<Value>,
},
/// Marks the start of a compacted history; the text is the summary.
Summary {
text: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub id: String,
pub role: Role,
pub parts: Vec<Part>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
pub created_at: i64,
}
impl Message {
pub fn new(role: Role, parts: Vec<Part>) -> Self {
Self {
id: new_id(),
role,
parts,
model: None,
usage: None,
created_at: now_ms(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct Usage {
pub input_tokens: u64,
pub output_tokens: u64,
#[serde(default)]
pub cache_read_tokens: u64,
#[serde(default)]
pub cache_write_tokens: u64,
}
impl Usage {
/// Total tokens of context the last request consumed.
pub fn context_tokens(&self) -> u64 {
self.input_tokens + self.cache_read_tokens + self.cache_write_tokens + self.output_tokens
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: String,
pub title: String,
pub cwd: String,
/// "provider/model"
pub model: String,
/// Active agent mode (build, plan, craft, ...).
pub agent: String,
/// Permission mode: default | accept_edits | bypass
pub permission_mode: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
/// User who owns the session (subagent sessions inherit it).
#[serde(default)]
pub owner_id: Option<String>,
pub created_at: i64,
pub updated_at: i64,
#[serde(default)]
pub todos: Value,
#[serde(default)]
pub last_usage: Option<Usage>,
/// Craft mode: clean room (true) or deep (false); None = server default.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clean_room: Option<bool>,
/// Craft workflow phase reported by the agent (study, specify, build, check, ready).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phase: Option<String>,
/// Isolated git worktree of this session: {repo, path, branch, base}.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worktree: Option<Value>,
}
impl Session {
pub fn clean_room(&self, default: bool) -> bool {
self.clean_room.unwrap_or(default)
}
}
/// Question forwarded from an agent (or an MCP server) to the user.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Question {
pub question: String,
#[serde(default)]
pub header: String,
#[serde(default)]
pub options: Vec<QuestionOption>,
#[serde(default)]
pub multi_select: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuestionOption {
pub label: String,
#[serde(default)]
pub description: String,
}
/// Events broadcast to all connected clients. Clients filter by session id.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Event {
SessionUpdated { session: Session },
SessionDeleted { session_id: String },
SessionStatus { session_id: String, busy: bool },
MessageAdded { session_id: String, message: Message },
/// Streaming deltas for the assistant message currently being generated.
TextDelta { session_id: String, message_id: String, text: String },
ReasoningDelta { session_id: String, message_id: String, text: String },
ToolStarted { session_id: String, message_id: String, call_id: String, name: String, input: Value },
ToolProgress { session_id: String, call_id: String, output: String },
PermissionRequest { id: String, session_id: String, root_session_id: String, tool: String, title: String, input: Value, #[serde(skip_serializing_if = "Option::is_none")] diff: Option<String> },
PermissionResolved { id: String, session_id: String },
QuestionRequest { id: String, session_id: String, root_session_id: String, source: String, questions: Vec<Question> },
QuestionResolved { id: String, session_id: String },
Error { session_id: String, message: String },
/// The session waits for a free model or subagent slot (reason None = no longer waiting).
/// reason: "model:<provider/model>" or "subagent:<name>".
Waiting { session_id: String, reason: Option<String> },
McpStatus { servers: Value },
/// A model request started: timing and prompt size for the activity line.
StepStarted { session_id: String, message_id: String, started_at: i64, prompt_tokens: u64, estimated: bool },
/// Messages waiting for a busy session (full list).
QueueUpdated { session_id: String, items: Value },
}
impl Event {
pub fn session_id(&self) -> Option<&str> {
use Event::*;
match self {
SessionUpdated { session } => Some(&session.id),
SessionDeleted { session_id }
| SessionStatus { session_id, .. }
| MessageAdded { session_id, .. }
| TextDelta { session_id, .. }
| ReasoningDelta { session_id, .. }
| ToolStarted { session_id, .. }
| ToolProgress { session_id, .. }
| PermissionRequest { session_id, .. }
| PermissionResolved { session_id, .. }
| QuestionRequest { session_id, .. }
| QuestionResolved { session_id, .. }
| Error { session_id, .. }
| Waiting { session_id, .. }
| QueueUpdated { session_id, .. }
| StepStarted { session_id, .. } => Some(session_id),
McpStatus { .. } => None,
}
}
}
pub fn new_id() -> String {
uuid::Uuid::now_v7().simple().to_string()
}
pub fn now_ms() -> i64 {
chrono::Utc::now().timestamp_millis()
}