Users, file manager, image generation, budgets and agent workflow
- User management: local accounts (Argon2) and OpenID Connect, admin/user roles, per-user folders plus admin-shared folders, read-only session sharing, event filtering per user - File manager with CodeMirror editor and git client (status, diff, stage, commit, branches, fetch/pull/push, log, clone/init) - Image generation via fal.ai: generate_image tool and image page - Budgets: monthly limits per provider, provider balances/costs (OpenRouter, Anthropic admin cost report, fal.ai), price discovery - Subagents on other models, background tasks, concurrency limits per host/model/subagent - MCP tools loaded on demand (mcp_search/mcp_call), read-only hints - Permission mode "auto", safe read-only commands always allowed - Persistent message queue with attachments, deletable - Live reasoning incl. <think> tags, activity animations, CSS themes, mobile layout, statistics per user, upload size handling - Docker: named volume for state, localhost-bound port Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f3a2fe3816
commit
49262c612c
70 files changed
+8495
-714
No files matched your search
@@ -165,6 +165,9 @@ impl Provider for Anthropic {
|
||||
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);
|
||||
|
||||
@@ -105,6 +105,13 @@ pub struct ProviderEntry {
|
||||
#[derive(Default)]
|
||||
pub struct Registry {
|
||||
pub providers: BTreeMap<String, ProviderEntry>,
|
||||
/// Concurrency limits keyed by "provider" and "provider/model".
|
||||
limits: BTreeMap<String, Arc<tokio::sync::Semaphore>>,
|
||||
}
|
||||
|
||||
/// Held while a request runs; dropping it frees the slot(s).
|
||||
pub struct Slot {
|
||||
_permits: Vec<tokio::sync::OwnedSemaphorePermit>,
|
||||
}
|
||||
|
||||
pub fn http_client() -> reqwest::Client {
|
||||
@@ -121,6 +128,7 @@ impl Registry {
|
||||
pub async fn build(cfg: &Config) -> Self {
|
||||
let client = http_client();
|
||||
let mut providers = BTreeMap::new();
|
||||
let mut limits = BTreeMap::new();
|
||||
for (name, pc) in &cfg.providers {
|
||||
if !pc.enabled {
|
||||
continue;
|
||||
@@ -131,6 +139,14 @@ impl Registry {
|
||||
ProviderKind::Openai => Arc::new(openai::OpenAi::new(client.clone(), ep)),
|
||||
};
|
||||
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)));
|
||||
}
|
||||
for m in &models {
|
||||
if let Some(n) = m.max_concurrency.filter(|n| *n > 0) {
|
||||
limits.insert(format!("{name}/{}", m.id), Arc::new(tokio::sync::Semaphore::new(n)));
|
||||
}
|
||||
}
|
||||
if models.is_empty() {
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(15), provider.list_models()).await {
|
||||
Ok(Ok(m)) => models = m,
|
||||
@@ -143,7 +159,28 @@ impl Registry {
|
||||
ProviderEntry { name: name.clone(), provider, models },
|
||||
);
|
||||
}
|
||||
Self { providers }
|
||||
Self { providers, limits }
|
||||
}
|
||||
|
||||
/// Wait for a free slot on the model and its provider. Always acquired in the
|
||||
/// same order (model, then provider), so concurrent requests cannot deadlock.
|
||||
pub async fn acquire(&self, spec: &str) -> Slot {
|
||||
let provider = spec.split_once('/').map(|(p, _)| p).unwrap_or(spec);
|
||||
let mut permits = Vec::new();
|
||||
for key in [spec, provider] {
|
||||
if let Some(sem) = self.limits.get(key) {
|
||||
if let Ok(p) = sem.clone().acquire_owned().await {
|
||||
permits.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
Slot { _permits: permits }
|
||||
}
|
||||
|
||||
/// True if a request to this model would have to wait.
|
||||
pub fn is_saturated(&self, spec: &str) -> bool {
|
||||
let provider = spec.split_once('/').map(|(p, _)| p).unwrap_or(spec);
|
||||
[spec, provider].iter().any(|k| self.limits.get(*k).is_some_and(|s| s.available_permits() == 0))
|
||||
}
|
||||
|
||||
/// Resolve "provider/model". Unknown models of a known provider are allowed
|
||||
|
||||
@@ -107,6 +107,55 @@ pub fn to_wire_messages(system: &str, messages: &[Message]) -> Vec<Value> {
|
||||
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,
|
||||
@@ -145,6 +194,9 @@ impl Provider for OpenAi {
|
||||
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() {
|
||||
@@ -153,6 +205,7 @@ impl Provider for OpenAi {
|
||||
|
||||
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");
|
||||
|
||||
@@ -182,7 +235,11 @@ impl Provider for OpenAi {
|
||||
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()) {
|
||||
tx.send(StreamEvent::TextDelta(t.into())).await.ok();
|
||||
// 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()) {
|
||||
@@ -211,6 +268,10 @@ impl Provider for OpenAi {
|
||||
}
|
||||
}
|
||||
|
||||
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!({})
|
||||
@@ -249,3 +310,34 @@ impl Provider for OpenAi {
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user