1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
//! Provider routing. Every provider is a command template.
use super::graph::Tier;
use super::yes;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// Every provider is a command template. `{prompt}`, `{system}`, `{model}`
/// and `{tier}` are substituted before spawn. This keeps BYOK support out of
/// the Rust build entirely: if you can invoke it from a shell, loopsmith can
/// route to it.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ProviderSpec {
pub id: String,
pub kind: ProviderKind,
#[serde(default)]
pub tiers: Vec<Tier>,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub model: Option<String>,
/// Environment variables that must be present. Values are never read by
/// loopsmith, only checked for presence, so keys stay out of the ledger.
#[serde(default)]
pub requires_env: Vec<String>,
#[serde(default)]
pub timeout_seconds: Option<u64>,
/// Send the prompt on stdin instead of substituting into args.
#[serde(default)]
pub prompt_on_stdin: bool,
/// Regex with one capture group pulling a token count out of the
/// provider's own output. Without it, usage is estimated from character
/// count, which is enough to make a budget ceiling real but is not exact.
#[serde(default)]
pub usage_regex: Option<String>,
/// Price per thousand tokens, for the cost ceiling.
#[serde(default)]
pub cost_per_1k_tokens: Option<f64>,
}
/// Provider families. Each variant accepts the spellings people actually
/// write, because a config that rejects `openai` in favour of `open_ai` is a
/// config that wastes the author's afternoon.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ProviderKind {
#[serde(
rename = "claude_code",
alias = "claude-code",
alias = "claude",
alias = "claudecode"
)]
ClaudeCode,
#[serde(alias = "Ollama")]
Ollama,
#[serde(rename = "grok_cli", alias = "grok-cli", alias = "grok")]
GrokCli,
#[serde(rename = "grok_build", alias = "grok-build")]
GrokBuild,
#[serde(alias = "Hermes")]
Hermes,
#[serde(
rename = "openai",
alias = "open_ai",
alias = "open-ai",
alias = "OpenAI"
)]
OpenAi,
#[serde(alias = "google_gemini", alias = "google-gemini", alias = "Gemini")]
Gemini,
/// Any OpenAI-compatible or bespoke endpoint driven by a command.
#[serde(alias = "BYOK", alias = "custom")]
Byok,
/// An MCP server spoken to over stdio.
#[serde(alias = "MCP")]
Mcp,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ProviderRouting {
#[serde(default)]
pub providers: Vec<ProviderSpec>,
/// Ordered fallback chain per tier. First reachable provider wins.
#[serde(default)]
pub cascade: BTreeMap<String, Vec<String>>,
/// A judge must not run on the provider that produced the work.
#[serde(default = "yes")]
pub enforce_judge_independence: bool,
}