Skip to main content

locode_provider/
lib.rs

1//! locode-provider — the [`Provider`] trait over an API-agnostic [`ConversationRequest`]
2//! (ADR-0007), the normalized [`Completion`] response, the [`ProviderError`] taxonomy,
3//! a scripted [`MockProvider`], and the streaming [`ToolCallAssembler`].
4//!
5//! One `Provider` impl = one **wire schema** (Anthropic Messages, OpenAI Chat/Responses,
6//! or `mock`); gateways (OpenRouter/Bedrock/proxy) are configuration pointed at a
7//! schema, not separate impls. v0 ships `mock` here; the live Anthropic wire is Task 12.
8//!
9//! [ADR-0007]: https://github.com/Luolc/locode-core/blob/main/docs/decisions/ADR-0007-provider-trait.md
10
11pub mod anthropic;
12mod assemble;
13mod completion;
14pub mod http;
15mod mock;
16pub mod openai;
17mod provider;
18mod repair;
19mod request;
20
21pub use anthropic::{AnthropicProvider, AuthRefresh};
22pub use assemble::{AssembleError, ToolCallAssembler};
23pub use completion::{Completion, CompletionDelta, StopReason};
24pub use http::{HttpFailure, RetryPolicy};
25pub use mock::MockProvider;
26pub use openai::responses::OpenAiResponsesProvider;
27pub use openai::{OpenAiBackend, OpenAiModelConfig, SystemPlacement};
28pub use provider::{Provider, ProviderError};
29pub use repair::{RepairStats, repair_pairing};
30pub use request::{
31    CacheHint, ConversationRequest, DEFAULT_MAX_TOKENS, ReasoningEffort, SamplingArgs,
32};
33
34#[cfg(test)]
35mod tests {
36    use super::*;
37    use locode_protocol::{ContentBlock, Message, ReasoningFormat, Role, Usage};
38    use serde_json::json;
39
40    fn text_completion(text: &str) -> Completion {
41        Completion {
42            content: vec![ContentBlock::Text { text: text.into() }],
43            usage: Usage::default(),
44            stop: StopReason::EndTurn,
45        }
46    }
47
48    fn tool_call_completion(id: &str, name: &str, input: serde_json::Value) -> Completion {
49        Completion {
50            content: vec![ContentBlock::ToolUse {
51                id: id.into(),
52                name: name.into(),
53                input,
54            }],
55            usage: Usage::default(),
56            stop: StopReason::ToolUse,
57        }
58    }
59
60    fn empty_request() -> ConversationRequest {
61        ConversationRequest {
62            messages: vec![Message {
63                role: Role::User,
64                content: vec![ContentBlock::Text { text: "hi".into() }],
65            }],
66            tools: vec![],
67            sampling_args: SamplingArgs::default(),
68            cache_hint: CacheHint::default(),
69        }
70    }
71
72    #[tokio::test]
73    async fn mock_emits_scripted_turns_in_order() {
74        // A realistic loop: a tool-call turn, then a final-text turn.
75        let mock = MockProvider::new(vec![
76            tool_call_completion(
77                "c1",
78                "run_terminal_command",
79                json!({ "command": "echo hi" }),
80            ),
81            text_completion("done"),
82        ]);
83
84        let first = mock.complete(&empty_request()).await.expect("first turn");
85        assert!(first.has_tool_calls());
86        assert_eq!(first.stop, StopReason::ToolUse);
87        assert_eq!(first.tool_uses().count(), 1);
88
89        let second = mock.complete(&empty_request()).await.expect("second turn");
90        assert!(!second.has_tool_calls());
91        assert_eq!(second.text().as_deref(), Some("done"));
92        assert_eq!(second.stop, StopReason::EndTurn);
93    }
94
95    #[tokio::test]
96    async fn mock_can_script_errors() {
97        let mock =
98            MockProvider::with_results(vec![Err(ProviderError::RateLimited { retry_after: None })]);
99        let err = mock
100            .complete(&empty_request())
101            .await
102            .expect_err("scripted error");
103        assert!(err.retryable(), "rate limits should be retryable");
104    }
105
106    #[tokio::test]
107    #[should_panic(expected = "script exhausted")]
108    async fn mock_panics_when_over_consumed() {
109        let mock = MockProvider::new(vec![text_completion("only one")]);
110        let _ = mock.complete(&empty_request()).await;
111        // Second call has no script left → panic.
112        let _ = mock.complete(&empty_request()).await;
113    }
114
115    #[test]
116    fn api_schema_is_the_wire_id() {
117        let mock = MockProvider::new(vec![]);
118        assert_eq!(mock.api_schema(), "mock");
119    }
120
121    #[test]
122    fn provider_error_classifies_retryable() {
123        assert!(ProviderError::Transport("reset".into()).retryable());
124        assert!(
125            ProviderError::Api {
126                status: 503,
127                message: "overloaded".into()
128            }
129            .retryable()
130        );
131        assert!(
132            !ProviderError::Api {
133                status: 400,
134                message: "bad request".into()
135            }
136            .retryable()
137        );
138        assert!(!ProviderError::ContextOverflow.retryable());
139        assert!(!ProviderError::Quota.retryable());
140        assert!(!ProviderError::Auth("401".into()).retryable());
141    }
142
143    #[test]
144    fn completion_preserves_thinking_blocks() {
145        // Thinking with a signature must survive in the normalized completion so the
146        // engine can replay it (ADR-0013).
147        let completion = Completion {
148            content: vec![
149                ContentBlock::Reasoning {
150                    format: ReasoningFormat::Anthropic,
151                    text: "let me think".into(),
152                    signature: Some("sig-abc".into()),
153                    payload: None,
154                },
155                ContentBlock::Text {
156                    text: "answer".into(),
157                },
158            ],
159            usage: Usage::default(),
160            stop: StopReason::EndTurn,
161        };
162        assert_eq!(completion.text().as_deref(), Some("answer"));
163        assert!(matches!(
164            completion.content.first(),
165            Some(ContentBlock::Reasoning { signature: Some(sig), .. }) if sig == "sig-abc"
166        ));
167    }
168
169    // ---- ToolCallAssembler: the partial-JSON accumulation contract ----
170
171    #[test]
172    fn assembler_stitches_fragmented_args() {
173        let mut asm = ToolCallAssembler::new();
174        asm.begin(0, "c1", "run_terminal_command");
175        // Fragments that are each invalid JSON on their own.
176        asm.push_json(0, "{\"comm").unwrap();
177        asm.push_json(0, "and\":\"echo").unwrap();
178        asm.push_json(0, " hi\"}").unwrap();
179
180        let blocks = asm.finish().expect("valid once assembled");
181        assert_eq!(
182            blocks,
183            vec![ContentBlock::ToolUse {
184                id: "c1".into(),
185                name: "run_terminal_command".into(),
186                input: json!({ "command": "echo hi" }),
187            }]
188        );
189    }
190
191    #[test]
192    fn assembler_empty_input_becomes_empty_object() {
193        let mut asm = ToolCallAssembler::new();
194        asm.begin(0, "c1", "list");
195        // No push_json — Anthropic sends empty input for no-arg tools.
196        let blocks = asm.finish().expect("empty is valid");
197        assert_eq!(
198            blocks,
199            vec![ContentBlock::ToolUse {
200                id: "c1".into(),
201                name: "list".into(),
202                input: json!({}),
203            }]
204        );
205    }
206
207    #[test]
208    fn assembler_preserves_index_order() {
209        let mut asm = ToolCallAssembler::new();
210        // Begin out of order; finish must yield index order (0 then 1).
211        asm.begin(1, "c2", "b");
212        asm.begin(0, "c1", "a");
213        asm.push_json(1, "{}").unwrap();
214        asm.push_json(0, "{}").unwrap();
215
216        let blocks = asm.finish().unwrap();
217        let ids: Vec<_> = blocks
218            .iter()
219            .map(|b| match b {
220                ContentBlock::ToolUse { id, .. } => id.as_str(),
221                _ => panic!("expected tool_use"),
222            })
223            .collect();
224        assert_eq!(ids, vec!["c1", "c2"]);
225    }
226
227    #[test]
228    fn assembler_rejects_fragment_without_start() {
229        let mut asm = ToolCallAssembler::new();
230        let err = asm.push_json(3, "{}").expect_err("no begin at index 3");
231        assert!(matches!(err, AssembleError::MissingStart(3)));
232    }
233
234    #[test]
235    fn assembler_reports_invalid_json() {
236        let mut asm = ToolCallAssembler::new();
237        asm.begin(0, "c1", "a");
238        asm.push_json(0, "{not json").unwrap();
239        let err = asm.finish().expect_err("bad json");
240        assert!(matches!(err, AssembleError::InvalidJson { index: 0, .. }));
241    }
242}