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