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