Context window - Requests asked for the model's full max_output, but providers count input + output against the window: long chats with large-output models (DeepSeek V4.1 Flash, 384k) failed with "input too long" before automatic compaction (85 %) kicked in. max_tokens is now what the window leaves (estimated input = last usage + messages since), the chat is compacted when less than 32k would remain for an answer, and a context-overflow error compacts and retries once. - ModelConfig.max_input (catalogue limit.input, editable in the provider settings) lowers the compaction threshold for models with an input limit. Enforced reviews (modes with refine.rounds > 0, refine.after_todos default on) - Every todo marked completed is reviewed against the work since the last review; with findings it is reopened and the feedback goes to the agent (max. rounds per todo). Passed reviews leave a visible note. - Ending the turn with open todos sends the agent back to work while it makes progress (max. 10 reminders in a row without progress); such modes may take up to 2,000 steps per turn. Reaching the step limit is reported. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
252 lines
11 KiB
Rust
252 lines
11 KiB
Rust
//! Provider, model and permission configuration shared by server and CLI.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::BTreeMap;
|
|
|
|
fn yes() -> bool {
|
|
true
|
|
}
|
|
|
|
#[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>,
|
|
/// Monthly usage limit of a subscription plan in USD (opencode Go). Usage is tracked
|
|
/// from the allowance each response reports: 5 hours 20 %, week 50 %, month 100 %.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub plan_limit: 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,
|
|
}
|
|
|
|
|
|
#[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>,
|
|
/// Input limit when the provider sets one below the context window (e.g. 272k of 400k).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub max_input: 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>,
|
|
/// Other prices from a context size on (e.g. long-context pricing); the largest
|
|
/// threshold below the request's context wins, missing prices keep the base price.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub price_tiers: Vec<PriceTier>,
|
|
/// Time-based price factors (peak hours, promotions); matching factors multiply.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub price_multipliers: Vec<PriceMultiplier>,
|
|
/// Max. simultaneous requests to this model (None = unlimited).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub max_concurrency: Option<usize>,
|
|
/// Model to use when this one's host is unreachable or its budget is used up ("provider/model").
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub fallback: Option<String>,
|
|
/// 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>,
|
|
}
|
|
|
|
/// Prices from a context size on (tokens of the whole prompt, cache included).
|
|
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
|
|
pub struct PriceTier {
|
|
pub above_context: u64,
|
|
#[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>,
|
|
}
|
|
|
|
/// A price factor that applies at certain times (all given conditions must hold, UTC).
|
|
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
|
|
pub struct PriceMultiplier {
|
|
pub factor: f64,
|
|
/// Weekdays ("mon" … "sun"); empty = every day.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub days: Vec<String>,
|
|
/// Time ranges ["HH:MM", "HH:MM") in UTC; empty = all day.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub hours_utc: Vec<(String, String)>,
|
|
/// First day it applies (YYYY-MM-DD, UTC).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub from: Option<String>,
|
|
/// First day it no longer applies (YYYY-MM-DD, UTC).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub until: Option<String>,
|
|
}
|
|
|
|
impl PriceMultiplier {
|
|
pub fn applies(&self, at: chrono::DateTime<chrono::Utc>) -> bool {
|
|
use chrono::{Datelike, Timelike};
|
|
let day = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"][at.weekday().num_days_from_monday() as usize];
|
|
if !self.days.is_empty() && !self.days.iter().any(|d| d.eq_ignore_ascii_case(day)) {
|
|
return false;
|
|
}
|
|
let minutes = |s: &str| s.split_once(':').and_then(|(h, m)| Some(h.trim().parse::<u32>().ok()? * 60 + m.trim().parse::<u32>().ok()?));
|
|
let now = at.hour() * 60 + at.minute();
|
|
if !self.hours_utc.is_empty()
|
|
&& !self.hours_utc.iter().any(|(a, b)| matches!((minutes(a), minutes(b)), (Some(a), Some(b)) if now >= a && now < b))
|
|
{
|
|
return false;
|
|
}
|
|
let date = at.format("%Y-%m-%d").to_string();
|
|
if self.from.as_deref().is_some_and(|f| date.as_str() < f) || self.until.as_deref().is_some_and(|u| date.as_str() >= u) {
|
|
return false;
|
|
}
|
|
true
|
|
}
|
|
}
|
|
|
|
impl ModelConfig {
|
|
/// Cost of one request now. Cache prices default to the input price.
|
|
/// The provider's own figure wins when it reports one (opencode).
|
|
pub fn cost(&self, u: &crate::types::Usage) -> f64 {
|
|
u.cost.unwrap_or_else(|| self.cost_at(u, chrono::Utc::now()))
|
|
}
|
|
|
|
/// Cost of one request at a given time: context tiers and time factors applied.
|
|
pub fn cost_at(&self, u: &crate::types::Usage, at: chrono::DateTime<chrono::Utc>) -> f64 {
|
|
let context = u.input_tokens + u.cache_read_tokens + u.cache_write_tokens;
|
|
let tier = self.price_tiers.iter().filter(|t| context > t.above_context).max_by_key(|t| t.above_context);
|
|
let pick = |base: Option<f64>, tiered: fn(&PriceTier) -> Option<f64>| tier.and_then(tiered).or(base);
|
|
let inp = pick(self.input_price, |t| t.input_price).unwrap_or(0.0);
|
|
let out = pick(self.output_price, |t| t.output_price).unwrap_or(0.0);
|
|
let read = pick(self.cache_read_price, |t| t.cache_read_price).unwrap_or(inp);
|
|
let write = pick(self.cache_write_price, |t| t.cache_write_price).unwrap_or(inp);
|
|
let per = |tokens: u64, price: f64| tokens as f64 * price / 1_000_000.0;
|
|
let factor: f64 = self.price_multipliers.iter().filter(|m| m.applies(at)).map(|m| m.factor).product();
|
|
(per(u.input_tokens, inp) + per(u.output_tokens, out) + per(u.cache_read_tokens, read) + per(u.cache_write_tokens, write)) * factor
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod price_tests {
|
|
use super::*;
|
|
use crate::types::Usage;
|
|
use chrono::TimeZone;
|
|
|
|
fn usage(input: u64, output: u64, read: u64) -> Usage {
|
|
Usage { input_tokens: input, output_tokens: output, cache_read_tokens: read, ..Default::default() }
|
|
}
|
|
|
|
#[test]
|
|
fn tiers_and_multipliers() {
|
|
let m = ModelConfig {
|
|
input_price: Some(2.0),
|
|
output_price: Some(6.0),
|
|
cache_read_price: Some(0.5),
|
|
price_tiers: vec![PriceTier { above_context: 200_000, input_price: Some(4.0), output_price: Some(12.0), cache_read_price: Some(1.0), ..Default::default() }],
|
|
price_multipliers: vec![PriceMultiplier {
|
|
factor: 2.0,
|
|
days: vec!["mon".into(), "tue".into(), "wed".into(), "thu".into(), "fri".into()],
|
|
hours_utc: vec![("01:00".into(), "04:00".into()), ("06:00".into(), "10:00".into())],
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let sat = chrono::Utc.with_ymd_and_hms(2026, 11, 7, 7, 0, 0).unwrap(); // Saturday 07:00
|
|
let mon_peak = chrono::Utc.with_ymd_and_hms(2026, 11, 9, 7, 30, 0).unwrap(); // Monday 07:30
|
|
let mon_off = chrono::Utc.with_ymd_and_hms(2026, 11, 9, 10, 0, 0).unwrap(); // Monday 10:00 (end excluded)
|
|
let small = usage(100_000, 1_000_000, 0);
|
|
assert!((m.cost_at(&small, sat) - (0.2 + 6.0)).abs() < 1e-9);
|
|
assert!((m.cost_at(&small, mon_peak) - 2.0 * (0.2 + 6.0)).abs() < 1e-9);
|
|
assert!((m.cost_at(&small, mon_off) - (0.2 + 6.0)).abs() < 1e-9);
|
|
// 150k fresh + 100k cached = 250k context: long-context tier.
|
|
let big = usage(150_000, 0, 100_000);
|
|
assert!((m.cost_at(&big, sat) - (0.6 + 0.1)).abs() < 1e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn date_ranges() {
|
|
let m = PriceMultiplier { factor: 2.0, from: Some("2026-12-01".into()), ..Default::default() };
|
|
assert!(!m.applies(chrono::Utc.with_ymd_and_hms(2026, 11, 30, 23, 59, 0).unwrap()));
|
|
assert!(m.applies(chrono::Utc.with_ymd_and_hms(2026, 12, 1, 0, 0, 0).unwrap()));
|
|
let until = PriceMultiplier { factor: 0.5, until: Some("2026-12-01".into()), ..Default::default() };
|
|
assert!(until.applies(chrono::Utc.with_ymd_and_hms(2026, 11, 30, 12, 0, 0).unwrap()));
|
|
assert!(!until.applies(chrono::Utc.with_ymd_and_hms(2026, 12, 1, 0, 0, 0).unwrap()));
|
|
}
|
|
}
|
|
|
|
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, Default)]
|
|
pub struct PermissionConfig {
|
|
#[serde(default)]
|
|
pub bash_rules: BTreeMap<String, String>,
|
|
#[serde(flatten)]
|
|
pub tools: BTreeMap<String, String>,
|
|
}
|
|
|
|
/// 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: ®ex::Captures| std::env::var(&c[1]).unwrap_or_default())
|
|
.into_owned()
|
|
}
|
|
|