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

-3751
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -5,6 +5,7 @@ edition = "2024"
description = "Web-based coding harness for AI models"
[dependencies]
codecraft-core = { path = "../core" }
anyhow = "1"
argon2 = "0.6"
sha2 = "0.11"
+10 -1
View File
@@ -20,7 +20,7 @@ use serde_json::{Map, Value, json};
use std::path::PathBuf;
use std::sync::Arc;
type AppState = Arc<App>;
pub type AppState = Arc<App>;
pub struct ApiError(pub StatusCode, pub String);
@@ -51,6 +51,7 @@ pub fn router(app: AppState) -> Router {
// public
.route("/info", get(auth::info))
.route("/auth/login", post(auth::login))
.route("/auth/cli-login", post(auth::cli_login))
.route("/auth/logout", post(auth::logout))
.route("/auth/setup", post(auth::setup))
.route("/auth/oidc/start", get(auth::oidc_start))
@@ -125,6 +126,14 @@ pub fn router(app: AppState) -> Router {
.route("/images/{id}", delete(delete_image))
.route("/images/{id}/file", get(image_file))
.route("/account/git", get(get_git_settings).put(put_git_settings))
.route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs))
.route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory))
.route("/cli/config", get(crate::cli_api::config))
.route("/cli/skill/{name}", get(crate::cli_api::skill))
.route("/cli/tool", post(crate::cli_api::tool))
.route("/llm/stream", post(crate::cli_api::llm_stream))
.route("/account/tokens", get(auth::list_api_tokens).post(auth::create_api_token))
.route("/account/tokens/{id}", delete(auth::delete_api_token))
.route("/jobs", get(list_jobs).post(register_job))
.route("/jobs/{id}", axum::routing::patch(update_job).delete(delete_job))
.route("/jobs/{id}/run", post(run_job))
+92 -2
View File
@@ -133,12 +133,57 @@ fn record_failure(app: &App, keys: &[String]) {
pub fn current_user(app: &App, headers: &HeaderMap) -> Option<User> {
let token = token_from_headers(headers)?;
app.store.user_by_token(&sha256_hex(&token)).ok().flatten()
let hash = sha256_hex(&token);
if token.starts_with(API_TOKEN_PREFIX) {
return app.store.user_by_api_token(&hash).ok().flatten();
}
app.store.user_by_token(&hash).ok().flatten()
}
/// Prefix of personal API tokens (CLI, scripts).
pub const API_TOKEN_PREFIX: &str = "cc_";
#[derive(Deserialize)]
pub struct NewApiToken {
name: String,
/// Days until it expires; None = never.
days: Option<u32>,
/// "full" (default) or "read" (GET requests only).
scope: Option<String>,
}
pub async fn list_api_tokens(Extension(user): Extension<User>, State(app): State<Arc<App>>) -> ApiResult {
Ok(Json(serde_json::json!(app.store.api_tokens(&user.id)?)))
}
/// Create a token; the plain value is returned once and only stored hashed.
pub async fn create_api_token(Extension(user): Extension<User>, State(app): State<Arc<App>>, headers: HeaderMap, Json(b): Json<NewApiToken>) -> ApiResult {
let name = b.name.trim();
if name.is_empty() || name.len() > 80 {
return Err(ApiError(StatusCode::BAD_REQUEST, "the token needs a name (max. 80 characters)".into()));
}
let token = format!("{API_TOKEN_PREFIX}{}", random_token(32));
let prefix: String = token.chars().take(10).collect();
let expires = b.days.filter(|d| *d > 0).map(|d| crate::types::now_ms() + d as i64 * 86_400_000);
let scope = match b.scope.as_deref() {
Some("read") => "read",
None | Some("full") => "full",
Some(other) => return Err(ApiError(StatusCode::BAD_REQUEST, format!("invalid scope '{other}'"))),
};
let id = app.store.api_token_add(&user.id, name, &sha256_hex(&token), &prefix, expires, scope)?;
app.audit(Some(&user), "api_token.create", format!("{name} ({scope})"), &client_ip(&headers));
Ok(Json(serde_json::json!({"id": id, "name": name, "token": token, "prefix": prefix, "expires_at": expires, "scope": scope})))
}
pub async fn delete_api_token(Extension(user): Extension<User>, State(app): State<Arc<App>>, headers: HeaderMap, Path(id): Path<String>) -> ApiResult {
let ok = app.store.api_token_delete(&user.id, &id)?;
app.audit(Some(&user), "api_token.delete", &id, &client_ip(&headers));
Ok(Json(serde_json::json!({"ok": ok})))
}
/// Paths reachable without a login.
fn is_public(path: &str) -> bool {
["/info", "/health", "/auth/login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"]
["/info", "/health", "/auth/login", "/auth/cli-login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"]
.iter()
.any(|p| path.ends_with(p))
}
@@ -149,6 +194,17 @@ pub async fn require_user(State(app): State<Arc<App>>, mut req: Request, next: N
}
match current_user(&app, req.headers()) {
Some(user) => {
// Read-only API tokens: no state-changing requests.
if let Some(token) = token_from_headers(req.headers()).filter(|t| t.starts_with(API_TOKEN_PREFIX)) {
let read_only = app.store.api_token_scope(&sha256_hex(&token)).as_deref() == Some("read");
if read_only && !matches!(*req.method(), axum::http::Method::GET | axum::http::Method::HEAD) {
return ApiError(StatusCode::FORBIDDEN, "this API token is read-only".into()).into_response();
}
// Tokens cannot manage tokens.
if req.uri().path().contains("/account/tokens") {
return ApiError(StatusCode::FORBIDDEN, "API tokens are managed in the web UI".into()).into_response();
}
}
req.extensions_mut().insert(user);
next.run(req).await
}
@@ -232,6 +288,40 @@ pub async fn login(State(app): State<Arc<App>>, headers: HeaderMap, Json(body):
Ok(([(header::SET_COOKIE, cookie)], Json(json!(user))).into_response())
}
#[derive(Deserialize)]
pub struct CliLoginBody {
username: String,
password: String,
/// Shown in the token name ("CLI on <hostname>").
hostname: Option<String>,
}
/// Command line login: checks the password (same rate limit as the web login)
/// and creates a personal API token for this machine.
pub async fn cli_login(State(app): State<Arc<App>>, headers: HeaderMap, Json(body): Json<CliLoginBody>) -> ApiResult {
let ip = client_ip(&headers);
let keys = [format!("ip:{ip}"), format!("user:{}", body.username.trim().to_lowercase())];
if locked(&app, &keys) {
return Err(ApiError(StatusCode::TOO_MANY_REQUESTS, "too many failed sign-ins – try again in 15 minutes".into()));
}
let found = app.store.user_by_name(body.username.trim())?;
let hash = found.as_ref().and_then(|(_, h)| h.clone());
let ok = verify_password(&body.password, hash.as_deref().unwrap_or("$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHRzb21lc2FsdA$Zm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFy"));
let Some((user, _)) = found.filter(|(u, _)| ok && hash.is_some() && !u.disabled) else {
record_failure(&app, &keys);
app.store.audit(None, body.username.trim(), "login.failed", "cli", &ip);
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
return Err(ApiError(StatusCode::UNAUTHORIZED, "invalid username or password".into()));
};
let host: String = body.hostname.unwrap_or_default().chars().filter(|c| c.is_alphanumeric() || "-._".contains(*c)).take(40).collect();
let name = if host.is_empty() { "CLI".to_string() } else { format!("CLI on {host}") };
let token = format!("{API_TOKEN_PREFIX}{}", random_token(32));
let prefix: String = token.chars().take(10).collect();
app.store.api_token_add(&user.id, &name, &sha256_hex(&token), &prefix, None, "full")?;
app.audit(Some(&user), "login", &format!("cli ({name})"), &ip);
Ok(Json(json!({"token": token, "user": user, "token_name": name})))
}
pub async fn logout(State(app): State<Arc<App>>, headers: HeaderMap) -> Response {
if let Some(t) = token_from_headers(&headers) {
let _ = app.store.delete_token(&sha256_hex(&t));
+250
View File
@@ -0,0 +1,250 @@
//! API for the local command line client (`codecraft`): the CLI runs the
//! agent loop and the file/shell tools on the user's machine; the server
//! provides the configuration (modes, models, rules, skills, theme), acts as
//! the model gateway (keys, budgets, statistics) and runs its MCP tools.
use crate::api::{ApiError, ApiResult, AppState};
use crate::provider::{ChatRequest, StreamEvent, ToolDef};
use crate::store::{UsageRecord, User};
use crate::tools::ToolCtx;
use crate::types::{Message, now_ms};
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
use axum::{Extension, Json};
use futures::stream::Stream;
use serde::Deserialize;
use serde_json::{Value, json};
use std::convert::Infallible;
use std::path::PathBuf;
use tokio_util::sync::CancellationToken;
/// Everything the CLI needs to run like the web UI.
pub async fn config(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let cfg = app.config();
let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace());
let agents: Vec<Value> = crate::prompts::agents(&app, &root, &root)
.into_iter()
.map(|a| {
let mut v = json!(a);
v["prompt"] = json!(a.prompt);
v
})
.collect();
let commands: Vec<Value> = crate::prompts::commands(&app, &root, &root)
.into_iter()
.filter(|c| c.description.len() < 400)
.map(|c| json!({"name": c.name, "description": c.description, "body": c.body, "agent": c.agent}))
.collect();
let skills: Vec<Value> = crate::prompts::skills(&app, &root, &root).into_iter().map(|s| json!({"name": s.name, "description": s.description})).collect();
let (direct, deferred) = app.mcp.tools(&cfg, Some(&user));
let mut mcp_tools: Vec<ToolDef> = direct.iter().map(|t| t.def()).collect();
if !deferred.is_empty() {
mcp_tools.push(crate::tools::Tool::def(&crate::mcp::McpSearch));
mcp_tools.push(crate::tools::Tool::def(&crate::mcp::McpCall));
}
let prefs: Value = app.store.user_setting(&user.id, "prefs")?;
let memory: String = app.store.user_setting(&user.id, "memory")?;
let default_model = if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) };
Ok(Json(json!({
"version": env!("CARGO_PKG_VERSION"),
"user": user,
"models": app.registry().models_json(),
"default_model": default_model,
"small_model": cfg.server.small_model,
"currency": cfg.server.currency,
"base_prompt": crate::prompts::BASE_PROMPT,
"auto_mode_prompt": crate::prompts::AUTO_MODE_PROMPT,
"agents": agents,
"commands": commands,
"skills": skills,
"permissions": cfg.permissions,
"mcp_tools": mcp_tools,
"deferred_mcp": app.mcp.deferred_summary(&cfg, Some(&user)).iter().map(|(s, n, names)| json!({"server": s, "tools": n, "examples": names})).collect::<Vec<_>>(),
"prefs": prefs,
"memory": memory,
})))
}
/// A server skill with its supporting text files (templates).
pub async fn skill(Extension(user): Extension<User>, State(app): State<AppState>, Path(name): Path<String>) -> ApiResult {
let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace());
let sk = crate::prompts::skills(&app, &root, &root).into_iter().find(|s| s.name == name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "skill not found".into()))?;
let dir = sk.path.parent().map(PathBuf::from).unwrap_or_default();
let content = std::fs::read_to_string(&sk.path)?;
let mut files = Vec::new();
for e in ignore::WalkBuilder::new(&dir).hidden(false).build().flatten() {
let p = e.path();
if !p.is_file() || p == sk.path || files.len() >= 50 {
continue;
}
if let Ok(text) = std::fs::read_to_string(p) {
if text.len() <= 200_000 {
files.push(json!({"path": p.strip_prefix(&dir).unwrap_or(p), "content": text}));
}
}
}
Ok(Json(json!({"name": sk.name, "description": sk.description, "content": content, "files": files})))
}
#[derive(Deserialize)]
pub struct LlmBody {
/// "provider/model"
model: String,
system: String,
messages: Vec<Message>,
#[serde(default)]
tools: Vec<ToolDef>,
max_tokens: Option<u64>,
#[serde(default)]
reasoning: bool,
effort: Option<String>,
/// Local session id (statistics group the requests by it).
session: Option<String>,
/// Mode / agent name (statistics).
agent: Option<String>,
}
/// Model gateway: one model request, streamed as SSE (`data: <json>`).
/// Events: text, reasoning, signature, tool_call, usage, done, error.
pub async fn llm_stream(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<LlmBody>) -> Result<Sse<impl Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
let (provider, mut model) = app.registry().resolve(&b.model).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
if let Some(e) = b.effort.filter(|e| !e.is_empty()) {
model.effort = Some(e);
}
let provider_name = b.model.split_once('/').map(|(p, _)| p.to_string()).unwrap_or_default();
crate::budget::check(&app, &provider_name, Some(&user.id)).map_err(|e| ApiError(StatusCode::PAYMENT_REQUIRED, format!("{e:#}")))?;
let max_tokens = b.max_tokens.or(model.max_output).unwrap_or(16_000);
let req = ChatRequest { model: model.clone(), system: b.system, messages: b.messages, tools: b.tools, max_tokens, reasoning: b.reasoning };
let session = format!("cli:{}", b.session.unwrap_or_else(|| "default".into()));
let agent = format!("cli:{}", b.agent.unwrap_or_else(|| "build".into()));
let spec = b.model.clone();
let (out_tx, out_rx) = tokio::sync::mpsc::channel::<Result<SseEvent, Infallible>>(256);
tokio::spawn(async move {
let slot = app.registry().acquire(&spec).await;
let started = std::time::Instant::now();
let (tx, mut rx) = tokio::sync::mpsc::channel(256);
let cancel = CancellationToken::new();
let c2 = cancel.clone();
let handle = tokio::spawn(async move {
let _slot = slot;
provider.stream(req, tx, c2).await
});
let mut usage = crate::types::Usage::default();
let mut ttft = None;
while let Some(ev) = rx.recv().await {
let v = match ev {
StreamEvent::TextDelta(t) => json!({"type": "text", "text": t}),
StreamEvent::ReasoningDelta(t) => json!({"type": "reasoning", "text": t}),
StreamEvent::ReasoningSignature(s) => json!({"type": "signature", "signature": s}),
StreamEvent::ToolCall { id, name, input, extra } => json!({"type": "tool_call", "id": id, "name": name, "input": input, "extra": extra}),
StreamEvent::Usage(u) => {
usage = u.clone();
json!({"type": "usage", "usage": u})
}
StreamEvent::Done { stop_reason } => json!({"type": "done", "stop_reason": stop_reason}),
};
if ttft.is_none() && v["type"] != "usage" {
ttft = Some(started.elapsed().as_millis() as u64);
}
if out_tx.send(Ok(SseEvent::default().data(v.to_string()))).await.is_err() {
// Client went away: stop the model request.
cancel.cancel();
break;
}
}
let result = handle.await;
let error = !matches!(result, Ok(Ok(())));
if let Ok(Err(e)) | Err(e) = result.map_err(anyhow::Error::from) {
let _ = out_tx.send(Ok(SseEvent::default().data(json!({"type": "error", "message": format!("{e:#}")}).to_string()))).await;
}
let (p, m) = spec.split_once('/').unwrap_or(("", spec.as_str()));
let _ = app.store.add_usage(&UsageRecord {
ts: now_ms(),
session_id: session.clone(),
root_session_id: session,
provider: p.into(),
model: m.into(),
agent,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cache_read_tokens: usage.cache_read_tokens,
cache_write_tokens: usage.cache_write_tokens,
ttft_ms: ttft.unwrap_or(0),
duration_ms: started.elapsed().as_millis() as u64,
cost: model.cost(&usage),
error,
user_id: Some(user.id.clone()),
});
});
let stream = futures::stream::unfold(out_rx, |mut rx| async move { rx.recv().await.map(|ev| (ev, rx)) });
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
}
#[derive(Deserialize)]
pub struct ToolBody {
name: String,
input: Value,
}
/// Run one of the server's MCP tools for the CLI (permission was asked locally).
pub async fn tool(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<ToolBody>) -> ApiResult {
let cfg = app.config();
let (direct, deferred) = app.mcp.tools(&cfg, Some(&user));
let tool: std::sync::Arc<dyn crate::tools::Tool> = match b.name.as_str() {
"mcp_search" if !deferred.is_empty() => std::sync::Arc::new(crate::mcp::McpSearch),
"mcp_call" if !deferred.is_empty() => std::sync::Arc::new(crate::mcp::McpCall),
n => direct.into_iter().find(|t| t.name() == n).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("no MCP tool '{n}' for you")))?,
};
let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace());
let ctx = ToolCtx {
app: app.clone(),
session_id: format!("cli-{}", user.id),
call_id: crate::types::new_id(),
cwd: root.clone(),
cancel: CancellationToken::new(),
agent: "build".into(),
roots: app.user_roots(Some(&user.id)),
sandbox: crate::sandbox::spec_for(&app, Some(&user.id)),
clean_room: false,
owner_id: Some(user.id.clone()),
};
app.audit(Some(&user), "cli.mcp", &b.name, "");
match tool.run(b.input, &ctx).await {
Ok(out) => Ok(Json(json!({"content": out.content, "is_error": out.is_error, "images": out.images}))),
Err(e) => Ok(Json(json!({"content": format!("Error: {e:#}"), "is_error": true}))),
}
}
/// Interface preferences (theme) shared between web UI and CLI.
pub async fn get_prefs(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let v: Value = app.store.user_setting(&user.id, "prefs")?;
Ok(Json(if v.is_null() { json!({}) } else { v }))
}
pub async fn put_prefs(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<Value>) -> ApiResult {
if !b.is_object() || b.to_string().len() > 4000 {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid preferences".into()));
}
app.store.set_user_setting(&user.id, "prefs", &b)?;
Ok(Json(b))
}
/// Personal memory (applies to all projects, web and CLI).
pub async fn get_memory(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let m: String = app.store.user_setting(&user.id, "memory")?;
Ok(Json(json!({"memory": m})))
}
#[derive(Deserialize)]
pub struct MemoryBody {
memory: String,
}
pub async fn put_memory(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<MemoryBody>) -> ApiResult {
if b.memory.len() > 20_000 {
return Err(ApiError(StatusCode::BAD_REQUEST, "the memory is limited to 20,000 characters".into()));
}
app.store.set_user_setting(&user.id, "memory", &b.memory)?;
Ok(Json(json!({"memory": b.memory})))
}
+6 -116
View File
@@ -8,6 +8,12 @@ use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub use codecraft_core::config::{ModelConfig, PermissionConfig, ProviderConfig, ProviderKind, expand_env};
fn yes() -> bool {
true
}
pub const EXAMPLE_CONFIG: &str = include_str!("../../config.example.toml");
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
@@ -270,107 +276,6 @@ impl Default for ServerConfig {
}
}
#[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,
}
fn yes() -> bool {
true
}
#[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)]
pub struct McpConfig {
#[serde(rename = "type")]
@@ -413,14 +318,6 @@ pub enum McpKind {
Http,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct PermissionConfig {
#[serde(default)]
pub bash_rules: BTreeMap<String, String>,
#[serde(flatten)]
pub tools: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct AgentConfig {
#[serde(default)]
@@ -470,13 +367,6 @@ impl Default for CraftConfig {
}
}
/// 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()
}
impl Config {
pub fn parse(text: &str) -> Result<Self> {
let cfg: Config = toml::from_str(text).context("invalid config")?;
+1
View File
@@ -161,6 +161,7 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
duration_ms: 0,
cost,
error: false,
user_id: None,
});
Ok(records)
}
+1
View File
@@ -3,6 +3,7 @@
mod api;
mod app;
mod auth;
mod cli_api;
mod budget;
mod config;
mod docs;
+3
View File
@@ -640,6 +640,9 @@ impl Tool for McpCall {
/// The session owner (MCP access is decided per user).
fn owner(ctx: &ToolCtx) -> Option<crate::store::User> {
if let Some(id) = &ctx.owner_id {
return ctx.app.store.user(id).ok().flatten();
}
let s = ctx.app.session(&ctx.session_id).ok()?;
ctx.app.store.user(s.owner_id.as_deref()?).ok().flatten()
}
+1
View File
@@ -43,6 +43,7 @@ pub async fn expand(app: &Arc<App>, session_id: &str, text: &str) -> Vec<Part> {
roots: app.user_roots(session.owner_id.as_deref()),
sandbox: crate::sandbox::spec_for(app, session.owner_id.as_deref()),
clean_room: session.clean_room(app.config().craft.clean_room),
owner_id: None,
};
let mut parts = Vec::new();
for token in tokens.into_iter().take(MAX_MENTIONS) {
+2 -332
View File
@@ -1,333 +1,3 @@
//! Permission evaluation: config rules, session permission modes and
//! "always allow" approvals given in the UI.
//! Permission rules (shared with the CLI via codecraft-core).
use crate::config::PermissionConfig;
use crate::tools::Tool;
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))
}
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(&self, tool: &dyn Tool, 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);
#[async_trait::async_trait]
impl Tool for Fake {
fn name(&self) -> &str {
self.0
}
fn description(&self) -> String {
String::new()
}
fn schema(&self) -> Value {
Value::Null
}
fn read_only(&self) -> bool {
self.1
}
async fn run(&self, _: Value, _: &crate::tools::ToolCtx) -> anyhow::Result<crate::tools::ToolOutput> {
unreachable!()
}
}
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);
}
}
pub use codecraft_core::permission::*;
+4 -2
View File
@@ -35,7 +35,9 @@ pub struct Agent {
pub path: Option<String>,
}
const BASE_PROMPT: &str = r#"You are CodeCraft, an autonomous coding agent working in the user's project through tools.
pub const AUTO_MODE_PROMPT: &str = "\n\n# Working autonomously\nThe user does not want to approve every step. Before you start a larger change (several files, installing dependencies, migrations, anything hard to undo), confirm the plan ONCE with ask_user in a short summary. After the user agrees, carry out the whole plan without asking again – reading, editing, building and testing are pre-approved. Only stop to ask when something unexpected changes the plan. Small, obvious fixes need no confirmation. Destructive or external actions (deleting, git push, deployments, changing other systems via MCP) still trigger an approval prompt automatically.";
pub const BASE_PROMPT: &str = r#"You are CodeCraft, an autonomous coding agent working in the user's project through tools.
# Working style
- Be direct and concise. Lead with what you did or found; no filler.
@@ -231,7 +233,7 @@ pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &
s.push_str("\n\n# Installing tools\nYou run without root (no apt/sudo). Install what you need in user space – it persists: Python packages with `pip install <pkg>` (user site) or a venv, CLI tools with `pipx install`, Rust with `curl https://sh.rustup.rs -sSf | sh -s -- -y` (then `~/.cargo/bin` is on PATH), Node packages with `npm i -g`, and other binaries (e.g. Godot) by downloading them to `~/.local/bin`. Check with `which`/`--version` first whether a tool is already installed.");
}
if permission_mode == "auto" && !agent.read_only {
s.push_str("\n\n# Working autonomously\nThe user does not want to approve every step. Before you start a larger change (several files, installing dependencies, migrations, anything hard to undo), confirm the plan ONCE with ask_user in a short summary. After the user agrees, carry out the whole plan without asking again – reading, editing, building and testing are pre-approved. Only stop to ask when something unexpected changes the plan. Small, obvious fixes need no confirmation. Destructive or external actions (deleting, git push, deployments, changing other systems via MCP) still trigger an approval prompt automatically.");
s.push_str(AUTO_MODE_PROMPT);
}
let git = cwd.join(".git").exists();
s.push_str(&format!(
-304
View File
@@ -1,304 +0,0 @@
//! 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())
}
}
+5 -118
View File
@@ -1,100 +1,13 @@
//! Model providers. Two wire protocols cover all targets:
//! Anthropic Messages and OpenAI-compatible chat completions.
//! Model providers (implementations in codecraft-core) and the registry of
//! configured providers with their concurrency limits.
pub mod anthropic;
pub mod openai;
pub use codecraft_core::provider::*;
use crate::config::{Config, ModelConfig, ProviderConfig, ProviderKind, expand_env};
use crate::types::{Message, Usage};
use crate::config::{Config, ModelConfig};
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use serde::Serialize;
use serde_json::Value;
use std::collections::BTreeMap;
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
#[derive(Debug, Clone, Serialize)]
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 {
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
}
}
pub struct ProviderEntry {
pub name: String,
@@ -114,13 +27,6 @@ pub struct Slot {
_permits: Vec<tokio::sync::OwnedSemaphorePermit>,
}
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")
}
impl Registry {
/// Build providers from config. Providers with an empty model list are queried
@@ -133,11 +39,7 @@ impl Registry {
if !pc.enabled {
continue;
}
let ep = Endpoint::from_config(pc);
let provider: Arc<dyn Provider> = match pc.kind {
ProviderKind::Anthropic => Arc::new(anthropic::Anthropic::new(client.clone(), ep)),
ProviderKind::Openai => Arc::new(openai::OpenAi::new(client.clone(), ep)),
};
let provider: Arc<dyn Provider> = build(client.clone(), pc);
let mut models: Vec<ModelConfig> = pc.models.iter().map(|m| m.resolved()).collect();
if let Some(n) = pc.max_concurrency.filter(|n| *n > 0) {
limits.insert(name.clone(), Arc::new(tokio::sync::Semaphore::new(n)));
@@ -236,18 +138,3 @@ impl Registry {
}
}
/// 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
@@ -1,343 +0,0 @@
//! 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());
}
}
+4
View File
@@ -385,6 +385,7 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
roots: roots.clone(),
sandbox: crate::sandbox::spec_for(&app, session.owner_id.as_deref()),
clean_room,
owner_id: None,
};
let by_name = |n: &str| tools.iter().find(|t| t.name() == n).cloned();
let all_read_only = calls.iter().all(|(_, n, _)| by_name(n).is_some_and(|t| t.read_only()));
@@ -454,6 +455,7 @@ async fn execute_call(
roots: base.roots.clone(),
sandbox: base.sandbox.clone(),
clean_room: base.clean_room,
owner_id: None,
};
// mcp_call is judged (and approved) as the MCP tool it calls.
let call_name = name;
@@ -853,6 +855,7 @@ async fn stream_step(
duration_ms: duration,
cost: req.model.cost(usage),
error,
user_id: None,
};
if let Err(e) = result {
@@ -937,6 +940,7 @@ fn record_side_usage(app: &App, session: &Session, spec: &str, model: &crate::co
duration_ms: 0,
cost: model.cost(usage),
error: false,
user_id: None,
});
}
+69 -3
View File
@@ -97,6 +97,17 @@ impl Store {
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS queue_session ON queue(session_id, created_at);
CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
prefix TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used INTEGER,
expires_at INTEGER,
scope TEXT NOT NULL DEFAULT 'full'
);
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL,
@@ -317,6 +328,8 @@ pub struct UsageRecord {
pub duration_ms: u64,
pub cost: f64,
pub error: bool,
/// User to bill when there is no session (CLI requests); else the session owner.
pub user_id: Option<String>,
}
/// Aggregation SQL shared by all statistics queries.
@@ -364,11 +377,11 @@ impl Store {
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
ttft_ms, duration_ms, cost, error, user_id)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,
(SELECT owner_id FROM sessions WHERE id = ?3))",
COALESCE(?15, (SELECT owner_id FROM sessions WHERE id = ?3)))",
params![
u.ts, u.session_id, u.root_session_id, u.provider, u.model, u.agent,
u.input_tokens as i64, u.output_tokens as i64, u.cache_read_tokens as i64,
u.cache_write_tokens as i64, u.ttft_ms as i64, u.duration_ms as i64, u.cost, u.error as i64
u.cache_write_tokens as i64, u.ttft_ms as i64, u.duration_ms as i64, u.cost, u.error as i64, u.user_id
],
)?;
Ok(())
@@ -474,7 +487,7 @@ impl Store {
// Most expensive chats in the range.
let top_sessions = {
let mut stmt = conn.prepare(&format!(
"SELECT u.root_session_id, COALESCE(json_extract(s.data, '$.title'), '(deleted)'), {AGG}
"SELECT u.root_session_id, COALESCE(json_extract(s.data, '$.title'), CASE WHEN u.root_session_id LIKE 'cli:%' THEN 'CLI (local)' ELSE '(deleted)' END), {AGG}
FROM usage u LEFT JOIN sessions s ON s.id = u.root_session_id
WHERE u.ts >= ?1 AND u.ts < ?2 AND (?3 IS NULL OR u.user_id = ?3)
GROUP BY 1 ORDER BY SUM(u.cost) DESC, COUNT(*) DESC LIMIT 15"
@@ -623,6 +636,59 @@ impl Store {
Ok(())
}
// --- personal API tokens ------------------------------------------------
pub fn api_token_add(&self, user_id: &str, name: &str, token_hash: &str, prefix: &str, expires_at: Option<i64>, scope: &str) -> Result<String> {
let id = crate::types::new_id();
self.conn.lock().unwrap().execute(
"INSERT INTO api_tokens (id, user_id, name, token_hash, prefix, created_at, expires_at, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![id, user_id, name, token_hash, prefix, now_ms(), expires_at, scope],
)?;
Ok(id)
}
pub fn api_tokens(&self, user_id: &str) -> Result<Vec<serde_json::Value>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, name, prefix, created_at, last_used, expires_at, scope FROM api_tokens WHERE user_id = ?1 ORDER BY created_at")?;
let rows = stmt.query_map([user_id], |r| {
Ok(serde_json::json!({
"id": r.get::<_, String>(0)?, "name": r.get::<_, String>(1)?, "prefix": r.get::<_, String>(2)?,
"created_at": r.get::<_, i64>(3)?, "last_used": r.get::<_, Option<i64>>(4)?, "expires_at": r.get::<_, Option<i64>>(5)?, "scope": r.get::<_, String>(6)?,
}))
})?;
Ok(rows.collect::<std::result::Result<_, _>>()?)
}
pub fn api_token_delete(&self, user_id: &str, id: &str) -> Result<bool> {
Ok(self.conn.lock().unwrap().execute("DELETE FROM api_tokens WHERE id = ?1 AND user_id = ?2", [id, user_id])? > 0)
}
/// Scope ("full" / "read") of a personal API token.
pub fn api_token_scope(&self, token_hash: &str) -> Option<String> {
self.conn.lock().unwrap().query_row("SELECT scope FROM api_tokens WHERE token_hash = ?1", [token_hash], |r| r.get(0)).optional().ok().flatten()
}
/// The (enabled) user behind a valid personal API token.
pub fn user_by_api_token(&self, token_hash: &str) -> Result<Option<User>> {
let conn = self.conn.lock().unwrap();
let user = conn
.query_row(
&format!(
"SELECT {} FROM api_tokens t JOIN users u ON u.id = t.user_id
WHERE t.token_hash = ?1 AND (t.expires_at IS NULL OR t.expires_at > ?2) AND u.disabled = 0",
USER_COLS.split(", ").map(|c| format!("u.{c}")).collect::<Vec<_>>().join(", ")
),
params![token_hash, now_ms()],
user_row,
)
.optional()?;
if user.is_some() {
// At most one write per minute per token.
conn.execute("UPDATE api_tokens SET last_used = ?2 WHERE token_hash = ?1 AND (last_used IS NULL OR last_used < ?2 - 60000)", params![token_hash, now_ms()])?;
}
Ok(user)
}
/// The (enabled) user behind a valid login token.
pub fn user_by_token(&self, token_hash: &str) -> Result<Option<User>> {
let conn = self.conn.lock().unwrap();
+15
View File
@@ -54,6 +54,8 @@ pub struct ToolCtx {
pub sandbox: Option<crate::sandbox::SandboxSpec>,
/// Craft clean room active for this session (no page sources, no binaries).
pub clean_room: bool,
/// User the call runs for when there is no session (CLI requests).
pub owner_id: Option<String>,
}
impl ToolCtx {
@@ -195,3 +197,16 @@ pub fn truncate(s: &str, max: usize) -> String {
}
format!("{}\n\n[... output truncated: {} of {} bytes shown ...]", &s[..cut], cut, s.len())
}
/// Server tools answer the permission rules' questions about themselves.
impl codecraft_core::permission::ToolInfo for dyn Tool {
fn name(&self) -> &str {
Tool::name(self)
}
fn read_only(&self) -> bool {
Tool::read_only(self)
}
fn is_edit(&self) -> bool {
Tool::is_edit(self)
}
}
+2 -221
View File
@@ -1,222 +1,3 @@
//! Provider-neutral message model and the event stream sent to clients.
//! Message and session types (shared with the CLI via codecraft-core).
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()
}
pub use codecraft_core::types::*;