Skip to main content

loopsmith_core/config/
providers.rs

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