Initial CodeCraft: web-based coding harness for AI models
Rust backend (axum/tokio): agent loop with streaming, tools (fs, bash, web_fetch, todo, ask_user, subagents, skills, craft snapshots/compare), permissions with modes and rules, Anthropic + OpenAI-compatible providers, MCP client (stdio/HTTP, elicitation), SQLite sessions, usage statistics, compaction, commented TOML config editable via toml_edit. React PWA: chat with live streaming, permission/question cards, modes (build/plan/craft), settings for providers/models/prices/MCP/permissions, raw config editor, statistics dashboard, i18n (en/de). Docker setup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
f3a2fe3816
62 files changed
+21835
No files matched your search
@@ -0,0 +1,209 @@
|
||||
//! Model providers. Two wire protocols cover all targets:
|
||||
//! Anthropic Messages and OpenAI-compatible chat completions.
|
||||
|
||||
pub mod anthropic;
|
||||
pub mod openai;
|
||||
|
||||
use crate::config::{Config, ModelConfig, ProviderConfig, ProviderKind, expand_env};
|
||||
use crate::types::{Message, Usage};
|
||||
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,
|
||||
pub provider: Arc<dyn Provider>,
|
||||
pub models: Vec<ModelConfig>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Registry {
|
||||
pub providers: BTreeMap<String, ProviderEntry>,
|
||||
}
|
||||
|
||||
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
|
||||
/// for their models; failures are logged and leave the list empty.
|
||||
pub async fn build(cfg: &Config) -> Self {
|
||||
let client = http_client();
|
||||
let mut providers = BTreeMap::new();
|
||||
for (name, pc) in &cfg.providers {
|
||||
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 mut models: Vec<ModelConfig> = pc.models.iter().map(|m| m.resolved()).collect();
|
||||
if models.is_empty() {
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(15), provider.list_models()).await {
|
||||
Ok(Ok(m)) => models = m,
|
||||
Ok(Err(e)) => tracing::warn!("provider {name}: model discovery failed: {e:#}"),
|
||||
Err(_) => tracing::warn!("provider {name}: model discovery timed out"),
|
||||
}
|
||||
}
|
||||
providers.insert(
|
||||
name.clone(),
|
||||
ProviderEntry { name: name.clone(), provider, models },
|
||||
);
|
||||
}
|
||||
Self { providers }
|
||||
}
|
||||
|
||||
/// Resolve "provider/model". Unknown models of a known provider are allowed
|
||||
/// with default settings so users can type any model id.
|
||||
pub fn resolve(&self, spec: &str) -> Result<(Arc<dyn Provider>, ModelConfig)> {
|
||||
let (pname, mid) = spec
|
||||
.split_once('/')
|
||||
.ok_or_else(|| anyhow!("model must be 'provider/model', got '{spec}'"))?;
|
||||
let entry = self
|
||||
.providers
|
||||
.get(pname)
|
||||
.ok_or_else(|| anyhow!("unknown or disabled provider '{pname}'"))?;
|
||||
let model = entry
|
||||
.models
|
||||
.iter()
|
||||
.find(|m| m.id == mid)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ModelConfig { id: mid.to_string(), ..Default::default() });
|
||||
Ok((entry.provider.clone(), model))
|
||||
}
|
||||
|
||||
pub fn first_model(&self) -> Option<String> {
|
||||
self.providers
|
||||
.values()
|
||||
.find_map(|e| e.models.first().map(|m| format!("{}/{}", e.name, m.id)))
|
||||
}
|
||||
|
||||
pub fn models_json(&self) -> Value {
|
||||
let list: Vec<Value> = self
|
||||
.providers
|
||||
.values()
|
||||
.map(|e| {
|
||||
serde_json::json!({
|
||||
"provider": e.name,
|
||||
"models": e.models.iter().map(|m| serde_json::json!({
|
||||
"id": m.id,
|
||||
"name": m.name.clone().unwrap_or_else(|| m.id.clone()),
|
||||
"context": m.context,
|
||||
"reasoning": m.reasoning,
|
||||
"vision": m.vision,
|
||||
})).collect::<Vec<_>>()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Value::Array(list)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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}")
|
||||
}
|
||||
Reference in new issue
Block a user