1pub 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 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 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 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 #[test]
170 fn assembler_stitches_fragmented_args() {
171 let mut asm = ToolCallAssembler::new();
172 asm.begin(0, "c1", "run_terminal_command");
173 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 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 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}