- Sidebar: three status LEDs (server, models, MCP) with an overview popover (/api/status) - Images: preview stage, purpose-based model choice and reference images (Nano Banana Pro edit refines the chosen preview) - Settings: restore a stored or uploaded backup (staged, applied on restart) - Provider form: test connection; saving or reloading re-checks the status at once - opencode Zen/Go: send x-opencode-session with a stable id per conversation - Releases are named vYYYYMM-NNNNN; scripts/release.sh numbers, tags, builds and publishes - Fix: backups made in the same second no longer overwrite each other Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
168 lines
5.9 KiB
Rust
168 lines
5.9 KiB
Rust
//! Model providers. Two wire protocols cover all targets:
|
|
//! Anthropic Messages and OpenAI-compatible chat completions.
|
|
|
|
pub mod anthropic;
|
|
pub mod openai;
|
|
|
|
use crate::config::{ModelConfig, ProviderConfig, ProviderKind, expand_env};
|
|
use crate::types::{Message, Usage};
|
|
use anyhow::{Result, anyhow};
|
|
use async_trait::async_trait;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use std::collections::BTreeMap;
|
|
use tokio::sync::mpsc;
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ToolDef {
|
|
pub name: String,
|
|
pub description: String,
|
|
pub input_schema: Value,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ChatRequest {
|
|
pub model: ModelConfig,
|
|
pub system: String,
|
|
pub messages: Vec<Message>,
|
|
pub tools: Vec<ToolDef>,
|
|
pub max_tokens: u64,
|
|
/// Enable extended thinking / reasoning when the model supports it.
|
|
pub reasoning: bool,
|
|
/// Stable id of the conversation; sent to gateways that route by session
|
|
/// (opencode: `x-opencode-session`) for routing and prompt caching.
|
|
pub session_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum StreamEvent {
|
|
TextDelta(String),
|
|
ReasoningDelta(String),
|
|
ReasoningSignature(String),
|
|
ToolCall { id: String, name: String, input: Value, extra: Option<Value> },
|
|
Usage(Usage),
|
|
Done { stop_reason: String },
|
|
}
|
|
|
|
#[async_trait]
|
|
pub trait Provider: Send + Sync {
|
|
async fn stream(
|
|
&self,
|
|
req: ChatRequest,
|
|
tx: mpsc::Sender<StreamEvent>,
|
|
cancel: CancellationToken,
|
|
) -> Result<()>;
|
|
|
|
async fn list_models(&self) -> Result<Vec<ModelConfig>>;
|
|
}
|
|
|
|
/// Connection settings shared by both protocol implementations.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Endpoint {
|
|
pub base_url: String,
|
|
pub api_key: String,
|
|
pub auth_header: String,
|
|
pub auth_prefix: String,
|
|
pub headers: BTreeMap<String, String>,
|
|
}
|
|
|
|
impl Endpoint {
|
|
pub fn from_config(p: &ProviderConfig) -> Self {
|
|
let (default_header, default_prefix) = match p.kind {
|
|
ProviderKind::Anthropic => ("x-api-key", ""),
|
|
ProviderKind::Openai => ("Authorization", "Bearer "),
|
|
};
|
|
let base = if p.base_url.is_empty() && p.kind == ProviderKind::Anthropic {
|
|
"https://api.anthropic.com/v1".to_string()
|
|
} else {
|
|
expand_env(&p.base_url)
|
|
};
|
|
Self {
|
|
base_url: base.trim_end_matches('/').to_string(),
|
|
api_key: expand_env(&p.api_key),
|
|
auth_header: p.auth_header.clone().unwrap_or_else(|| default_header.into()),
|
|
auth_prefix: p.auth_prefix.clone().unwrap_or_else(|| default_prefix.into()),
|
|
headers: p.headers.iter().map(|(k, v)| (k.clone(), expand_env(v))).collect(),
|
|
}
|
|
}
|
|
|
|
/// Session header for gateways that need one (opencode Zen/Go reject requests without it).
|
|
pub fn with_session(&self, rb: reqwest::RequestBuilder, session_id: Option<&str>) -> reqwest::RequestBuilder {
|
|
match session_id {
|
|
Some(id) if self.base_url.contains("opencode.ai") && !self.headers.keys().any(|k| k.eq_ignore_ascii_case("x-opencode-session")) => {
|
|
rb.header("x-opencode-session", id)
|
|
}
|
|
_ => rb,
|
|
}
|
|
}
|
|
|
|
pub fn apply(&self, mut rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
|
if !self.api_key.is_empty() {
|
|
rb = rb.header(&self.auth_header, format!("{}{}", self.auth_prefix, self.api_key));
|
|
}
|
|
for (k, v) in &self.headers {
|
|
rb = rb.header(k, v);
|
|
}
|
|
rb
|
|
}
|
|
}
|
|
|
|
/// Shared HTTP client for model requests.
|
|
pub fn http_client() -> reqwest::Client {
|
|
reqwest::Client::builder()
|
|
.connect_timeout(std::time::Duration::from_secs(20))
|
|
.user_agent(format!("codecraft/{}", crate::version::VERSION))
|
|
.build()
|
|
.expect("http client")
|
|
}
|
|
|
|
/// Create the provider for a provider configuration.
|
|
pub fn build(client: reqwest::Client, pc: &ProviderConfig) -> std::sync::Arc<dyn Provider> {
|
|
let ep = Endpoint::from_config(pc);
|
|
match pc.kind {
|
|
ProviderKind::Anthropic => std::sync::Arc::new(anthropic::Anthropic::new(client, ep)),
|
|
ProviderKind::Openai => std::sync::Arc::new(openai::OpenAi::new(client, ep)),
|
|
}
|
|
}
|
|
|
|
/// Turn an HTTP error response into a readable error.
|
|
pub async fn http_error(resp: reqwest::Response) -> anyhow::Error {
|
|
let status = resp.status();
|
|
let body = resp.text().await.unwrap_or_default();
|
|
let detail = serde_json::from_str::<Value>(&body)
|
|
.ok()
|
|
.and_then(|v| {
|
|
v.pointer("/error/message")
|
|
.or_else(|| v.pointer("/0/error/message"))
|
|
.or_else(|| v.get("message"))
|
|
.and_then(|m| m.as_str().map(String::from))
|
|
})
|
|
.unwrap_or_else(|| body.chars().take(500).collect());
|
|
anyhow!("provider returned HTTP {status}: {detail}")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod session_header_tests {
|
|
use super::*;
|
|
|
|
fn ep(base: &str) -> Endpoint {
|
|
Endpoint { base_url: base.into(), api_key: String::new(), auth_header: "Authorization".into(), auth_prefix: "Bearer ".into(), headers: BTreeMap::new() }
|
|
}
|
|
|
|
fn header(ep: &Endpoint, id: Option<&str>) -> Option<String> {
|
|
let rb = ep.with_session(reqwest::Client::new().post(format!("{}/chat/completions", ep.base_url)), id);
|
|
rb.build().unwrap().headers().get("x-opencode-session").map(|v| v.to_str().unwrap().to_string())
|
|
}
|
|
|
|
#[test]
|
|
fn opencode_gets_the_session_header() {
|
|
assert_eq!(header(&ep("https://opencode.ai/zen/go/v1"), Some("s1")).as_deref(), Some("s1"));
|
|
assert_eq!(header(&ep("https://opencode.ai/zen/go/v1"), None), None);
|
|
assert_eq!(header(&ep("https://api.openai.com/v1"), Some("s1")), None);
|
|
let mut custom = ep("https://opencode.ai/zen/v1");
|
|
custom.headers.insert("X-Opencode-Session".into(), "fixed".into());
|
|
assert_eq!(header(&custom, Some("s1")), None, "a configured header wins");
|
|
}
|
|
}
|