blendtutor_core/llm/
provider.rs

1//! Provider selection for LLM feedback.
2//!
3//! [`ProviderChoice`] is the closed set of LLM backends (ADR-0006). It carries
4//! only the metadata the boundary needs — which key env var to read, the default
5//! base URL, and a default model — not a rig client, because the two providers
6//! build different rig client types; constructing them lives in
7//! [`request_feedback`](super::request_feedback), one match arm each (§3.4). The
8//! `match`es here are exhaustive with no wildcard, so a new provider forces a new
9//! arm rather than silently falling through.
10
11/// The LLM backend used to grade a submission.
12///
13/// A closed enum, not a string, so an unknown provider is unrepresentable (§1.2).
14/// `Fireworks` is the default, mirroring the R package.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum ProviderChoice {
17    /// Fireworks (OpenAI-compatible chat completions). Reads `FIREWORKS_API_KEY`.
18    #[default]
19    Fireworks,
20    /// Anthropic (native messages API). Reads `ANTHROPIC_API_KEY`.
21    Anthropic,
22}
23
24impl ProviderChoice {
25    /// The environment variable holding this provider's API key.
26    ///
27    /// The single source of truth for the key var, so the boundary guard and any
28    /// "set your key" message name the same variable.
29    pub fn key_var(self) -> &'static str {
30        match self {
31            ProviderChoice::Fireworks => "FIREWORKS_API_KEY",
32            ProviderChoice::Anthropic => "ANTHROPIC_API_KEY",
33        }
34    }
35
36    /// The provider's default API base URL.
37    ///
38    /// Fireworks is OpenAI-compatible, reached at its inference root; rig appends
39    /// the `/chat/completions` path. Overridable per request to point at a mock
40    /// server in tests (ADR-0006).
41    pub fn default_base_url(self) -> &'static str {
42        match self {
43            ProviderChoice::Fireworks => "https://api.fireworks.ai/inference/v1",
44            ProviderChoice::Anthropic => "https://api.anthropic.com",
45        }
46    }
47
48    /// The default model id for this provider.
49    ///
50    /// Fireworks matches the browser BYOK fallback (`deepseek-v4-flash-0731` in
51    /// `_extensions/blendtutor/assets/exercise-feedback.js` / ADR-0016); the
52    /// legacy crates path (`assets/shared/feedback.js`) is known drift, migrated
53    /// separately. Anthropic uses a current Claude model. Model selection is not
54    /// yet configurable — a later slice can lift
55    /// these to a setting. This value is the single source for the smevals
56    /// config template (`smevals_gen`), so the runtime model and the generated
57    /// eval config cannot drift.
58    pub fn default_model(self) -> &'static str {
59        match self {
60            ProviderChoice::Fireworks => "accounts/fireworks/models/deepseek-v4-flash-0731",
61            ProviderChoice::Anthropic => "claude-sonnet-4-5",
62        }
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[test]
71    fn fireworks_is_the_default_provider() {
72        assert_eq!(ProviderChoice::default(), ProviderChoice::Fireworks);
73    }
74
75    #[test]
76    fn each_provider_names_its_own_key_var() {
77        assert_eq!(ProviderChoice::Fireworks.key_var(), "FIREWORKS_API_KEY");
78        assert_eq!(ProviderChoice::Anthropic.key_var(), "ANTHROPIC_API_KEY");
79    }
80
81    #[test]
82    fn each_provider_targets_its_own_api_host() {
83        // The base URL is the request root; rig appends the path. The integration
84        // tests always override it with a mock, so pin the real defaults here.
85        assert!(
86            ProviderChoice::Fireworks
87                .default_base_url()
88                .contains("api.fireworks.ai"),
89            "Fireworks targets its inference host, got {}",
90            ProviderChoice::Fireworks.default_base_url()
91        );
92        assert!(
93            ProviderChoice::Anthropic
94                .default_base_url()
95                .contains("api.anthropic.com"),
96            "Anthropic targets its native host, got {}",
97            ProviderChoice::Anthropic.default_base_url()
98        );
99    }
100
101    #[test]
102    fn each_provider_has_a_recognizable_default_model() {
103        // Pinned by exact equality so a wrong-but-similar model id (prefix typo,
104        // stale bump) fails rather than sneaking through a substring match. The
105        // mock ignores the model field, so nothing else exercises this value —
106        // this assertion is load-bearing. Anthropic stays a substring because its
107        // CLI default (`claude-sonnet-4-5`) differs from the browser BYOK default
108        // (`claude-opus-4-8`); only the Claude family is invariant across both.
109        assert_eq!(
110            ProviderChoice::Fireworks.default_model(),
111            "accounts/fireworks/models/deepseek-v4-flash-0731",
112            "the Fireworks default model id must match the browser BYOK fallback",
113        );
114        assert!(
115            ProviderChoice::Anthropic.default_model().contains("claude"),
116            "the Anthropic model id is a Claude model, got {}",
117            ProviderChoice::Anthropic.default_model()
118        );
119    }
120
121    #[test]
122    fn provider_default_model_is_pinned_to_0731() {
123        // The user pin: the Rust provider default must match the browser BYOK
124        // pin (ADR-0016) and the user's instruction to use the 0731 checkpoint.
125        // Asserted separately from the recognizability test so a stale bump
126        // fails by name even when the exact-equality message scrolls past.
127        assert_eq!(
128            ProviderChoice::Fireworks.default_model(),
129            "accounts/fireworks/models/deepseek-v4-flash-0731"
130        );
131    }
132}