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}