pub mod auth;
pub mod catalog;
pub mod codex;
pub mod continuation;
pub mod kimi;
pub mod read_rewrite;
pub mod sse;
#[cfg(feature = "breakdown")]
pub mod tui;
use std::collections::BTreeMap;
use anyhow::Result;
use serde_json::{Value, json};
pub struct UpstreamRewrite {
pub host: String,
pub path: String,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
pub model: String,
pub provider: SubProvider,
}
pub fn build_upstream(
provider: SubProvider,
anthropic_body: &Value,
overrides: &BTreeMap<String, String>,
token: &auth::TokenSet,
session_id: Option<&str>,
) -> Result<UpstreamRewrite> {
build_upstream_for_model(provider, anthropic_body, None, overrides, token, session_id)
}
pub(crate) fn build_upstream_for_model(
provider: SubProvider,
anthropic_body: &Value,
logical_model: Option<&str>,
overrides: &BTreeMap<String, String>,
token: &auth::TokenSet,
session_id: Option<&str>,
) -> Result<UpstreamRewrite> {
let incoming = logical_model.unwrap_or_else(|| {
anthropic_body
.get("model")
.and_then(|v| v.as_str())
.unwrap_or_default()
});
let model = resolve_model(provider, incoming, overrides);
let (host, path, body, headers) = match provider {
SubProvider::Codex => {
let b = codex::build_request_body(anthropic_body, &model, session_id)?;
let h = codex::request_headers_with_mode(
&token.access,
token.account_id.as_deref(),
session_id,
codex::uses_official_codex_client(&model),
);
(codex::HOST, codex::PATH, serde_json::to_vec(&b)?, h)
}
SubProvider::Kimi => {
let b = kimi::build_request_body(anthropic_body, &model, session_id)?;
let h = kimi::request_headers(&token.access, token.account_id.as_deref(), session_id);
(kimi::HOST, kimi::PATH, serde_json::to_vec(&b)?, h)
}
};
Ok(UpstreamRewrite {
host: host.to_string(),
path: path.to_string(),
headers,
body,
model,
provider,
})
}
pub enum StreamReducer {
Codex(codex::Reducer),
Kimi(kimi::Reducer),
}
impl StreamReducer {
pub fn new(provider: SubProvider, model: &str) -> Self {
match provider {
SubProvider::Codex => StreamReducer::Codex(codex::Reducer::new(model)),
SubProvider::Kimi => StreamReducer::Kimi(kimi::Reducer::new(model)),
}
}
pub fn push(&mut self, chunk: &[u8]) -> Vec<sse::ReduceEvent> {
match self {
StreamReducer::Codex(r) => r.push(chunk),
StreamReducer::Kimi(r) => r.push(chunk),
}
}
pub fn finish(&mut self) -> Vec<sse::ReduceEvent> {
match self {
StreamReducer::Codex(r) => r.finish(),
StreamReducer::Kimi(r) => r.finish(),
}
}
pub fn take_codex_output_items(&mut self) -> Vec<Value> {
match self {
StreamReducer::Codex(r) => r.take_output_items(),
StreamReducer::Kimi(_) => vec![],
}
}
}
pub fn count_tokens_json(anthropic_body: &Value) -> Value {
let mut text = String::new();
if let Some(system) = anthropic_body.get("system") {
collect_text(system, &mut text);
}
let mut messages = 0usize;
if let Some(arr) = anthropic_body.get("messages").and_then(|v| v.as_array()) {
messages = arr.len();
for m in arr {
if let Some(c) = m.get("content") {
collect_text(c, &mut text);
}
}
}
if let Some(tools) = anthropic_body.get("tools") {
collect_text(tools, &mut text);
}
let model = anthropic_body.get("model").and_then(|v| v.as_str());
let base = count_text_tokens(&text, model);
let est = (((base + messages as i64 * 4) as f64) * 1.1).ceil() as i64;
json!({ "input_tokens": est })
}
fn collect_text(v: &Value, out: &mut String) {
match v {
Value::String(s) => {
out.push_str(s);
out.push(' ');
}
Value::Array(a) => a.iter().for_each(|e| collect_text(e, out)),
Value::Object(o) => o.values().for_each(|e| collect_text(e, out)),
_ => {}
}
}
fn count_text_tokens(text: &str, model: Option<&str>) -> i64 {
use llmtrim_core::ir::ProviderKind;
match llmtrim_core::tokenizer::counter_for(ProviderKind::Anthropic, model) {
Ok(counter) => counter.count(text) as i64,
Err(_) => (text.chars().count() as f64 / 3.5).ceil() as i64,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SubProvider {
Codex,
Kimi,
}
impl SubProvider {
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"codex" | "chatgpt" | "openai" => Some(SubProvider::Codex),
"kimi" | "moonshot" => Some(SubProvider::Kimi),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
SubProvider::Codex => "codex",
SubProvider::Kimi => "kimi",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tier {
Opus,
Sonnet,
Haiku,
Fable,
}
impl Tier {
pub fn as_str(self) -> &'static str {
match self {
Tier::Opus => "opus",
Tier::Sonnet => "sonnet",
Tier::Haiku => "haiku",
Tier::Fable => "fable",
}
}
pub const ALL: [Tier; 4] = [Tier::Opus, Tier::Sonnet, Tier::Haiku, Tier::Fable];
}
pub fn classify_tier(model: &str) -> Option<Tier> {
let m = model.to_ascii_lowercase();
if m.contains("opus") {
Some(Tier::Opus)
} else if m.contains("sonnet") {
Some(Tier::Sonnet)
} else if m.contains("haiku") {
Some(Tier::Haiku)
} else if m.contains("fable") {
Some(Tier::Fable)
} else {
None
}
}
pub fn default_codex_tier_model(tier: Tier) -> &'static str {
match tier {
Tier::Opus => "gpt-5.6-terra",
Tier::Sonnet => "gpt-5.6-luna",
Tier::Haiku => "gpt-5.4-mini",
Tier::Fable => "gpt-5.6-sol",
}
}
pub const KIMI_MODEL: &str = "kimi-for-coding";
pub fn resolve_model(
provider: SubProvider,
incoming: &str,
overrides: &std::collections::BTreeMap<String, String>,
) -> String {
let (base, _fast) = normalize_incoming(incoming);
if provider == SubProvider::Kimi {
return KIMI_MODEL.to_string();
}
let key = base.to_ascii_lowercase();
if let Some(m) = overrides.get(&key) {
return m.clone();
}
if let Some(tier) = classify_tier(&base) {
if let Some(m) = overrides.get(tier.as_str()) {
return m.clone();
}
return default_codex_tier_model(tier).to_string();
}
if is_codex_model(&base) {
return base;
}
overrides
.get(Tier::Sonnet.as_str())
.cloned()
.unwrap_or_else(|| default_codex_tier_model(Tier::Sonnet).to_string())
}
pub const BILLING_HEADER_PREFIX: &str = "x-anthropic-billing-header:";
pub fn flatten_system_text(system: Option<&Value>) -> Option<String> {
let texts: Vec<&str> = match system? {
Value::String(s) => vec![s.as_str()],
Value::Array(blocks) => blocks
.iter()
.filter_map(|b| b.get("text").and_then(Value::as_str))
.collect(),
_ => return None,
};
let kept: Vec<&str> = texts
.into_iter()
.filter(|t| !t.starts_with(BILLING_HEADER_PREFIX) && !t.is_empty())
.collect();
if kept.is_empty() {
None
} else {
Some(kept.join("\n\n"))
}
}
pub const CODEX_MODELS: [&str; 9] = [
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.6-sol",
"gpt-5.2",
"gpt-5.3-codex",
"gpt-5.3-codex-spark",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.5",
];
fn is_codex_model(m: &str) -> bool {
let m = m.to_ascii_lowercase();
CODEX_MODELS.contains(&m.as_str()) || m.starts_with("gpt-")
}
pub fn normalize_incoming(model: &str) -> (String, bool) {
let mut m = model.trim();
if m.len() >= 4 && m[m.len() - 4..].eq_ignore_ascii_case("[1m]") {
m = m[..m.len() - 4].trim();
}
if let Some(base) = m.strip_suffix("-fast") {
(base.to_string(), true)
} else {
(m.to_string(), false)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
#[test]
fn classify_handles_aliases_and_dated_ids() {
assert_eq!(classify_tier("opus"), Some(Tier::Opus));
assert_eq!(classify_tier("claude-opus-4-8"), Some(Tier::Opus));
assert_eq!(classify_tier("claude-sonnet-5"), Some(Tier::Sonnet));
assert_eq!(
classify_tier("claude-haiku-4-5-20251001"),
Some(Tier::Haiku)
);
assert_eq!(classify_tier("claude-fable-5"), Some(Tier::Fable));
assert_eq!(classify_tier("gpt-5.5"), None);
}
#[test]
fn normalize_strips_1m_and_fast() {
assert_eq!(normalize_incoming("gpt-5.5[1m]"), ("gpt-5.5".into(), false));
assert_eq!(normalize_incoming("gpt-5.5[1M]"), ("gpt-5.5".into(), false));
assert_eq!(
normalize_incoming("gpt-5.4-mini-fast"),
("gpt-5.4-mini".into(), true)
);
assert_eq!(
normalize_incoming("claude-opus-4-8[1m]"),
("claude-opus-4-8".into(), false)
);
}
#[test]
fn default_preset_maps_fable_to_flagship() {
let ov = BTreeMap::new();
assert_eq!(
resolve_model(SubProvider::Codex, "claude-opus-4-8", &ov),
"gpt-5.6-terra"
);
assert_eq!(
resolve_model(SubProvider::Codex, "sonnet", &ov),
"gpt-5.6-luna"
);
assert_eq!(
resolve_model(SubProvider::Codex, "claude-sonnet-5", &ov),
"gpt-5.6-luna"
);
assert_eq!(
resolve_model(SubProvider::Codex, "haiku", &ov),
"gpt-5.4-mini"
);
assert_eq!(
resolve_model(SubProvider::Codex, "claude-fable-5", &ov),
"gpt-5.6-sol"
);
}
#[test]
fn overrides_win_over_preset() {
let mut ov = BTreeMap::new();
ov.insert("sonnet".to_string(), "gpt-5.3-codex".to_string());
assert_eq!(
resolve_model(SubProvider::Codex, "claude-sonnet-5", &ov),
"gpt-5.3-codex"
);
ov.insert("claude-sonnet-5".to_string(), "gpt-5.5".to_string());
assert_eq!(
resolve_model(SubProvider::Codex, "claude-sonnet-5", &ov),
"gpt-5.5"
);
}
#[test]
fn kimi_collapses_every_tier() {
let ov = BTreeMap::new();
assert_eq!(
resolve_model(SubProvider::Kimi, "claude-opus-4-8", &ov),
KIMI_MODEL
);
assert_eq!(resolve_model(SubProvider::Kimi, "gpt-5.5", &ov), KIMI_MODEL);
}
#[test]
fn concrete_codex_model_passes_through() {
let ov = BTreeMap::new();
assert_eq!(
resolve_model(SubProvider::Codex, "gpt-5.3-codex", &ov),
"gpt-5.3-codex"
);
assert_eq!(
resolve_model(SubProvider::Codex, "gpt-5.5[1m]", &ov),
"gpt-5.5"
);
}
#[test]
fn unknown_non_codex_falls_back_to_sonnet() {
let ov = BTreeMap::new();
assert_eq!(
resolve_model(SubProvider::Codex, "mystery-model", &ov),
"gpt-5.6-luna"
);
}
}