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