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

+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}")
}