Files
codecraft/core/src/config.rs
T
Richard ZinkandClaude Opus 5.5 bd896794fd Answer budget within the context window; enforced reviews of completed todos
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>
2026-11-10 23:12:49 +01:00

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: &regex::Captures| std::env::var(&c[1]).unwrap_or_default())
.into_owned()
}