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