SaylorTwift HF Staff commited on
Commit
767452b
·
verified ·
1 Parent(s): e5034c3

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. crates/forge_app/src/dto/anthropic/error.rs +8 -0
  2. crates/forge_app/src/dto/anthropic/mod.rs +9 -0
  3. crates/forge_app/src/dto/anthropic/request.rs +872 -0
  4. crates/forge_app/src/dto/anthropic/response.rs +881 -0
  5. crates/forge_app/src/dto/anthropic/transforms/auth_system_message.rs +187 -0
  6. crates/forge_app/src/dto/anthropic/transforms/capitalize_tool_names.rs +116 -0
  7. crates/forge_app/src/dto/anthropic/transforms/claude_code.md +1 -0
  8. crates/forge_app/src/dto/anthropic/transforms/drop_invalid_toolcalls.rs +136 -0
  9. crates/forge_app/src/dto/anthropic/transforms/enforce_schema.rs +138 -0
  10. crates/forge_app/src/dto/anthropic/transforms/mcp_tool_names.rs +116 -0
  11. crates/forge_app/src/dto/anthropic/transforms/mod.rs +19 -0
  12. crates/forge_app/src/dto/anthropic/transforms/reasoning_transform.rs +135 -0
  13. crates/forge_app/src/dto/anthropic/transforms/remove_output_format.rs +75 -0
  14. crates/forge_app/src/dto/anthropic/transforms/sanitize_tool_ids.rs +223 -0
  15. crates/forge_app/src/dto/anthropic/transforms/set_cache.rs +267 -0
  16. crates/forge_app/src/dto/google/mod.rs +5 -0
  17. crates/forge_app/src/dto/google/request.rs +982 -0
  18. crates/forge_app/src/dto/google/response.rs +790 -0
  19. crates/forge_app/src/dto/openai/copilot_model.rs +407 -0
  20. crates/forge_app/src/dto/openai/error.rs +193 -0
  21. crates/forge_app/src/dto/openai/fixtures/chutes_api_response.json +16 -0
  22. crates/forge_app/src/dto/openai/fixtures/chutes_completion_response.json +23 -0
  23. crates/forge_app/src/dto/openai/fixtures/model_invalid_pricing.json +8 -0
  24. crates/forge_app/src/dto/openai/fixtures/model_mixed_pricing.json +9 -0
  25. crates/forge_app/src/dto/openai/fixtures/model_no_pricing.json +4 -0
  26. crates/forge_app/src/dto/openai/fixtures/model_numeric_pricing.json +8 -0
  27. crates/forge_app/src/dto/openai/fixtures/model_scientific_notation.json +8 -0
  28. crates/forge_app/src/dto/openai/fixtures/model_string_pricing.json +8 -0
  29. crates/forge_app/src/dto/openai/fixtures/orcarouter_api_response.json +31 -0
  30. crates/forge_app/src/dto/openai/fixtures/zai_api_delta_response.json +1 -0
  31. crates/forge_app/src/dto/openai/fixtures/zai_api_response.json +1 -0
  32. crates/forge_app/src/dto/openai/mod.rs +17 -0
  33. crates/forge_app/src/dto/openai/model.rs +356 -0
  34. crates/forge_app/src/dto/openai/reasoning.rs +18 -0
  35. crates/forge_app/src/dto/openai/request.rs +937 -0
  36. crates/forge_app/src/dto/openai/response.rs +1120 -0
  37. crates/forge_app/src/dto/openai/responses.jsonl +20 -0
  38. crates/forge_app/src/dto/openai/snapshots/forge_app__dto__openai__request__tests__assistant_message_with_tool_call_conversion.snap +18 -0
  39. crates/forge_app/src/dto/openai/snapshots/forge_app__dto__openai__request__tests__message_with_special_chars.snap +8 -0
  40. crates/forge_app/src/dto/openai/snapshots/forge_app__dto__openai__request__tests__tool_message_conversion.snap +10 -0
  41. crates/forge_app/src/dto/openai/snapshots/forge_app__dto__openai__request__tests__tool_message_typescript_code.snap +10 -0
  42. crates/forge_app/src/dto/openai/snapshots/forge_app__dto__openai__request__tests__tool_message_with_special_chars.snap +10 -0
  43. crates/forge_app/src/dto/openai/snapshots/forge_app__dto__openai__request__tests__user_message_conversion.snap +8 -0
  44. crates/forge_app/src/dto/openai/tool_choice.rs +81 -0
  45. crates/forge_app/src/dto/openai/transformers/default_reasoning_content.rs +99 -0
  46. crates/forge_app/src/dto/openai/transformers/drop_tool_call.rs +89 -0
  47. crates/forge_app/src/dto/openai/transformers/ensure_system_first.rs +198 -0
  48. crates/forge_app/src/dto/openai/transformers/github_copilot_reasoning.rs +126 -0
  49. crates/forge_app/src/dto/openai/transformers/make_cerebras_compat.rs +43 -0
  50. crates/forge_app/src/dto/openai/transformers/make_openai_compat.rs +112 -0
crates/forge_app/src/dto/anthropic/error.rs ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ use serde::Deserialize;
2
+
3
+ #[derive(Debug, Deserialize, Clone, PartialEq, thiserror::Error)]
4
+ #[serde(rename_all = "snake_case", tag = "type")]
5
+ pub enum Error {
6
+ #[error("Overload error: {message}")]
7
+ OverloadedError { message: String },
8
+ }
crates/forge_app/src/dto/anthropic/mod.rs ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ mod error;
2
+ mod request;
3
+ mod response;
4
+ mod transforms;
5
+
6
+ pub use error::*;
7
+ pub use request::*;
8
+ pub use response::*;
9
+ pub use transforms::*;
crates/forge_app/src/dto/anthropic/request.rs ADDED
@@ -0,0 +1,872 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use derive_setters::Setters;
2
+ use forge_domain::{ContextMessage, Image};
3
+ use serde::{Deserialize, Serialize};
4
+
5
+ #[derive(Serialize, Default, Setters)]
6
+ #[setters(into, strip_option)]
7
+ pub struct Request {
8
+ pub max_tokens: u64,
9
+ pub messages: Vec<Message>,
10
+ #[serde(skip_serializing_if = "Option::is_none")]
11
+ pub model: Option<String>,
12
+ #[serde(skip_serializing_if = "Option::is_none")]
13
+ pub metadata: Option<Metadata>,
14
+ #[serde(skip_serializing_if = "Option::is_none")]
15
+ pub stop_sequence: Option<String>,
16
+ #[serde(skip_serializing_if = "Option::is_none")]
17
+ pub stream: Option<bool>,
18
+ #[serde(skip_serializing_if = "Option::is_none")]
19
+ pub system: Option<Vec<SystemMessage>>,
20
+ #[serde(skip_serializing_if = "Option::is_none")]
21
+ pub temperature: Option<f32>,
22
+ #[serde(skip_serializing_if = "Option::is_none")]
23
+ pub tool_choice: Option<ToolChoice>,
24
+ #[serde(skip_serializing_if = "Vec::is_empty")]
25
+ pub tools: Vec<ToolDefinition>,
26
+ #[serde(skip_serializing_if = "Option::is_none")]
27
+ pub top_k: Option<u64>,
28
+ #[serde(skip_serializing_if = "Option::is_none")]
29
+ pub top_p: Option<f32>,
30
+ #[serde(skip_serializing_if = "Option::is_none")]
31
+ pub thinking: Option<Thinking>,
32
+ #[serde(skip_serializing_if = "Option::is_none")]
33
+ pub output_config: Option<OutputConfig>,
34
+ #[serde(skip_serializing_if = "Option::is_none")]
35
+ pub output_format: Option<OutputFormat>,
36
+ #[serde(skip_serializing_if = "Option::is_none")]
37
+ pub anthropic_version: Option<String>,
38
+ }
39
+
40
+ #[derive(Serialize, Default)]
41
+ pub struct SystemMessage {
42
+ pub r#type: String,
43
+ pub text: String,
44
+ #[serde(skip_serializing_if = "Option::is_none")]
45
+ pub cache_control: Option<CacheControl>,
46
+ }
47
+
48
+ impl SystemMessage {
49
+ pub fn cached(mut self, cached: bool) -> Self {
50
+ self.cache_control = if cached {
51
+ Some(CacheControl::Ephemeral)
52
+ } else {
53
+ None
54
+ };
55
+ self
56
+ }
57
+
58
+ pub fn is_cached(&self) -> bool {
59
+ self.cache_control.is_some()
60
+ }
61
+ }
62
+
63
+ /// Anthropic's `thinking` request field. Opus 4.7 rejects the `Enabled` shape
64
+ /// and the orchestrator applies model-specific reasoning normalization before
65
+ /// request conversion.
66
+ #[derive(Serialize, Debug, PartialEq, Eq)]
67
+ #[serde(tag = "type", rename_all = "lowercase")]
68
+ pub enum Thinking {
69
+ Enabled {
70
+ budget_tokens: u64,
71
+ },
72
+ Adaptive {
73
+ #[serde(skip_serializing_if = "Option::is_none")]
74
+ display: Option<ThinkingDisplay>,
75
+ },
76
+ Disabled,
77
+ }
78
+
79
+ /// On Opus 4.7 adaptive thinking content is omitted from responses unless
80
+ /// `Summarized` is requested explicitly.
81
+ #[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)]
82
+ #[serde(rename_all = "lowercase")]
83
+ pub enum ThinkingDisplay {
84
+ Summarized,
85
+ Omitted,
86
+ }
87
+
88
+ #[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)]
89
+ #[serde(rename_all = "lowercase")]
90
+ pub enum OutputEffort {
91
+ Low,
92
+ Medium,
93
+ High,
94
+ XHigh,
95
+ Max,
96
+ }
97
+
98
+ #[derive(Serialize, Debug, PartialEq, Eq)]
99
+ pub struct OutputConfig {
100
+ pub effort: OutputEffort,
101
+ }
102
+
103
+ #[derive(Serialize, Debug, PartialEq)]
104
+ #[serde(tag = "type", rename_all = "snake_case")]
105
+ pub enum OutputFormat {
106
+ #[serde(rename = "json_schema")]
107
+ JsonSchema { schema: schemars::Schema },
108
+ }
109
+
110
+ impl TryFrom<forge_domain::Context> for Request {
111
+ type Error = anyhow::Error;
112
+ fn try_from(request: forge_domain::Context) -> std::result::Result<Self, Self::Error> {
113
+ let system_messages = request
114
+ .messages
115
+ .iter()
116
+ .filter_map(|msg| match &**msg {
117
+ ContextMessage::Text(msg) if msg.has_role(forge_domain::Role::System) => {
118
+ Some(SystemMessage {
119
+ r#type: "text".to_string(),
120
+ text: msg.content.clone(),
121
+ cache_control: None,
122
+ })
123
+ }
124
+ _ => None,
125
+ })
126
+ .collect::<Vec<_>>();
127
+
128
+ // Gate on the domain rule so inherited configs with `enabled: None` but
129
+ // a positive effort / `max_tokens` still emit reasoning on the wire.
130
+ let reasoning_on = request.is_reasoning_supported();
131
+ let (thinking, output_config) = if reasoning_on && let Some(reasoning) = request.reasoning {
132
+ // Adaptive thinking on 4.7 hides reasoning content by default; opting
133
+ // into reasoning should surface it unless the caller set `exclude`.
134
+ let adaptive_display = if reasoning.exclude == Some(true) {
135
+ Some(ThinkingDisplay::Omitted)
136
+ } else {
137
+ Some(ThinkingDisplay::Summarized)
138
+ };
139
+
140
+ let thinking = if let Some(budget) = reasoning.max_tokens {
141
+ Thinking::Enabled { budget_tokens: budget as u64 }
142
+ } else {
143
+ Thinking::Adaptive { display: adaptive_display }
144
+ };
145
+
146
+ // `Effort::None` is an explicit opt-out; `is_reasoning_supported`
147
+ // already filters it, but guard here so it can never become a stray
148
+ // `output_config.effort`.
149
+ let output_config = reasoning.effort.and_then(|effort| {
150
+ let output_effort = match effort {
151
+ forge_domain::Effort::None => return None,
152
+ forge_domain::Effort::Minimal | forge_domain::Effort::Low => OutputEffort::Low,
153
+ forge_domain::Effort::Medium => OutputEffort::Medium,
154
+ forge_domain::Effort::High => OutputEffort::High,
155
+ forge_domain::Effort::XHigh => OutputEffort::XHigh,
156
+ forge_domain::Effort::Max => OutputEffort::Max,
157
+ };
158
+ Some(OutputConfig { effort: output_effort })
159
+ });
160
+
161
+ (Some(thinking), output_config)
162
+ } else {
163
+ (None, None)
164
+ };
165
+
166
+ Ok(Self {
167
+ messages: request
168
+ .messages
169
+ .into_iter()
170
+ .filter(|message| !message.has_role(forge_domain::Role::System))
171
+ .map(|msg| Message::try_from(msg.message))
172
+ .collect::<std::result::Result<Vec<_>, _>>()?,
173
+ tools: request
174
+ .tools
175
+ .into_iter()
176
+ .map(ToolDefinition::try_from)
177
+ .collect::<std::result::Result<Vec<_>, _>>()?,
178
+ system: Some(system_messages),
179
+ temperature: request.temperature.map(|t| t.value()),
180
+ top_p: request.top_p.map(|t| t.value()),
181
+ top_k: request.top_k.map(|t| t.value() as u64),
182
+ tool_choice: request.tool_choice.map(ToolChoice::from),
183
+ stream: Some(request.stream.unwrap_or(true)),
184
+ thinking,
185
+ output_config,
186
+ output_format: request.response_format.and_then(|rf| match rf {
187
+ forge_domain::ResponseFormat::Text => {
188
+ // Anthropic doesn't have a "text" output format, so we skip it
189
+ None
190
+ }
191
+ forge_domain::ResponseFormat::JsonSchema(schema) => {
192
+ Some(OutputFormat::JsonSchema { schema: *schema })
193
+ }
194
+ }),
195
+ ..Default::default()
196
+ })
197
+ }
198
+ }
199
+
200
+ impl Request {
201
+ /// Get a reference to the messages
202
+ pub fn get_messages(&self) -> &[Message] {
203
+ &self.messages
204
+ }
205
+
206
+ /// Get a mutable reference to the messages
207
+ pub fn get_messages_mut(&mut self) -> &mut Vec<Message> {
208
+ &mut self.messages
209
+ }
210
+ }
211
+
212
+ #[derive(Serialize)]
213
+ pub struct Metadata {
214
+ #[serde(skip_serializing_if = "Option::is_none")]
215
+ pub user_id: Option<String>,
216
+ }
217
+
218
+ #[derive(Serialize)]
219
+ pub struct Message {
220
+ pub content: Vec<Content>,
221
+ pub role: Role,
222
+ }
223
+
224
+ impl TryFrom<ContextMessage> for Message {
225
+ type Error = anyhow::Error;
226
+ fn try_from(value: ContextMessage) -> std::result::Result<Self, Self::Error> {
227
+ Ok(match value {
228
+ ContextMessage::Text(chat_message) => {
229
+ let mut content = Vec::with_capacity(
230
+ chat_message
231
+ .tool_calls
232
+ .as_ref()
233
+ .map(|tc| tc.len())
234
+ .unwrap_or_default()
235
+ + 1,
236
+ );
237
+
238
+ if let Some(reasoning) = chat_message.reasoning_details
239
+ && let Some((sig, text)) = reasoning.into_iter().find_map(|reasoning| {
240
+ match (reasoning.signature, reasoning.text) {
241
+ (Some(sig), Some(text)) => Some((sig, text)),
242
+ _ => None,
243
+ }
244
+ })
245
+ {
246
+ content.push(Content::Thinking { signature: Some(sig), thinking: Some(text) });
247
+ }
248
+
249
+ if !chat_message.content.is_empty() {
250
+ // NOTE: Anthropic does not allow empty text content.
251
+ content.push(Content::Text { text: chat_message.content, cache_control: None });
252
+ }
253
+ if let Some(tool_calls) = chat_message.tool_calls {
254
+ for tool_call in tool_calls {
255
+ content.push(tool_call.try_into()?);
256
+ }
257
+ }
258
+
259
+ match chat_message.role {
260
+ forge_domain::Role::User => Message { role: Role::User, content },
261
+ forge_domain::Role::Assistant => Message { role: Role::Assistant, content },
262
+ forge_domain::Role::System => {
263
+ // note: Anthropic doesn't support system role messages and they're already
264
+ // filtered out. so this state is unreachable.
265
+ return Err(
266
+ forge_domain::Error::UnsupportedRole("System".to_string()).into()
267
+ );
268
+ }
269
+ }
270
+ }
271
+ ContextMessage::Tool(tool_result) => {
272
+ Message { role: Role::User, content: vec![tool_result.try_into()?] }
273
+ }
274
+ ContextMessage::Image(img) => {
275
+ Message { content: vec![Content::from(img)], role: Role::User }
276
+ }
277
+ })
278
+ }
279
+ }
280
+
281
+ impl Message {
282
+ pub fn cached(mut self, enable_cache: bool) -> Self {
283
+ // Reset cache control on all content items first
284
+ for content in &mut self.content {
285
+ *content = std::mem::take(content).cached(false);
286
+ }
287
+
288
+ // If enabling cache, set cache control on the last cacheable content item
289
+ if enable_cache
290
+ && let Some(last_cacheable_idx) =
291
+ self.content
292
+ .iter()
293
+ .enumerate()
294
+ .rev()
295
+ .find_map(|(idx, content)| match content {
296
+ Content::Text { .. }
297
+ | Content::Image { .. }
298
+ | Content::ToolUse { .. }
299
+ | Content::ToolResult { .. } => Some(idx),
300
+ _ => None,
301
+ })
302
+ && let Some(content) = self.content.get_mut(last_cacheable_idx)
303
+ {
304
+ *content = std::mem::take(content).cached(true);
305
+ }
306
+
307
+ self
308
+ }
309
+
310
+ pub fn is_cached(&self) -> bool {
311
+ self.content.iter().any(|content| content.is_cached())
312
+ }
313
+ }
314
+
315
+ impl Default for Message {
316
+ fn default() -> Self {
317
+ Message { content: vec![], role: Role::User }
318
+ }
319
+ }
320
+
321
+ impl From<Image> for Content {
322
+ fn from(value: Image) -> Self {
323
+ Content::Image {
324
+ source: ImageSource {
325
+ type_: "base64".to_string(),
326
+ media_type: Some(value.mime_type().to_string()),
327
+ data: Some(value.data().into()),
328
+ url: None,
329
+ },
330
+ cache_control: None,
331
+ }
332
+ }
333
+ }
334
+
335
+ #[derive(Serialize)]
336
+ pub struct ImageSource {
337
+ #[serde(rename = "type")]
338
+ pub type_: String,
339
+ #[serde(skip_serializing_if = "Option::is_none")]
340
+ pub media_type: Option<String>,
341
+ #[serde(skip_serializing_if = "Option::is_none")]
342
+ pub data: Option<String>,
343
+ #[serde(skip_serializing_if = "Option::is_none")]
344
+ pub url: Option<String>,
345
+ }
346
+
347
+ #[derive(Serialize)]
348
+ #[serde(rename_all = "snake_case", tag = "type")]
349
+ pub enum Content {
350
+ Image {
351
+ source: ImageSource,
352
+ #[serde(skip_serializing_if = "Option::is_none")]
353
+ cache_control: Option<CacheControl>,
354
+ },
355
+ Text {
356
+ text: String,
357
+ #[serde(skip_serializing_if = "Option::is_none")]
358
+ cache_control: Option<CacheControl>,
359
+ },
360
+ ToolUse {
361
+ id: String,
362
+ input: Option<serde_json::Value>,
363
+ name: String,
364
+ #[serde(skip_serializing_if = "Option::is_none")]
365
+ cache_control: Option<CacheControl>,
366
+ },
367
+ ToolResult {
368
+ tool_use_id: String,
369
+ #[serde(skip_serializing_if = "Option::is_none")]
370
+ content: Option<String>,
371
+ #[serde(skip_serializing_if = "Option::is_none")]
372
+ is_error: Option<bool>,
373
+ #[serde(skip_serializing_if = "Option::is_none")]
374
+ cache_control: Option<CacheControl>,
375
+ },
376
+ Thinking {
377
+ #[serde(skip_serializing_if = "Option::is_none")]
378
+ signature: Option<String>,
379
+ #[serde(skip_serializing_if = "Option::is_none")]
380
+ thinking: Option<String>,
381
+ },
382
+ }
383
+
384
+ impl Default for Content {
385
+ fn default() -> Self {
386
+ Content::Thinking { signature: None, thinking: None }
387
+ }
388
+ }
389
+
390
+ impl Content {
391
+ pub fn cached(self, enable_cache: bool) -> Self {
392
+ let cache_control = enable_cache.then_some(CacheControl::Ephemeral);
393
+
394
+ match self {
395
+ Content::Text { text, .. } => Content::Text { text, cache_control },
396
+ Content::ToolUse { id, input, name, .. } => {
397
+ Content::ToolUse { id, input, name, cache_control }
398
+ }
399
+ Content::ToolResult { tool_use_id, content, is_error, .. } => {
400
+ Content::ToolResult { tool_use_id, content, is_error, cache_control }
401
+ }
402
+ Content::Image { source, .. } => Content::Image { source, cache_control },
403
+ // TODO: verify this Thinking variants don't support cache control
404
+ Content::Thinking { signature, thinking } => Content::Thinking { signature, thinking },
405
+ }
406
+ }
407
+
408
+ pub fn is_cached(&self) -> bool {
409
+ match self {
410
+ Content::Text { cache_control, .. } => cache_control.is_some(),
411
+ Content::ToolUse { cache_control, .. } => cache_control.is_some(),
412
+ Content::ToolResult { cache_control, .. } => cache_control.is_some(),
413
+ Content::Image { cache_control, .. } => cache_control.is_some(),
414
+ Content::Thinking { .. } => false,
415
+ }
416
+ }
417
+ }
418
+
419
+ impl TryFrom<forge_domain::ToolCallFull> for Content {
420
+ type Error = anyhow::Error;
421
+ fn try_from(value: forge_domain::ToolCallFull) -> std::result::Result<Self, Self::Error> {
422
+ let call_id = value
423
+ .call_id
424
+ .as_ref()
425
+ .ok_or(forge_domain::Error::ToolCallMissingId)?;
426
+
427
+ Ok(Content::ToolUse {
428
+ id: call_id.as_str().to_string(),
429
+ input: serde_json::to_value(value.arguments).ok(),
430
+ name: value.name.to_string(),
431
+ cache_control: None,
432
+ })
433
+ }
434
+ }
435
+
436
+ impl TryFrom<forge_domain::ToolResult> for Content {
437
+ type Error = anyhow::Error;
438
+ fn try_from(value: forge_domain::ToolResult) -> std::result::Result<Self, Self::Error> {
439
+ let call_id = value
440
+ .call_id
441
+ .as_ref()
442
+ .ok_or(forge_domain::Error::ToolCallMissingId)?;
443
+ Ok(Content::ToolResult {
444
+ tool_use_id: call_id.as_str().to_string(),
445
+ cache_control: None,
446
+ content: value
447
+ .output
448
+ .values
449
+ .iter()
450
+ .filter_map(|item| item.as_str().map(|s| s.to_string()))
451
+ .next(),
452
+ is_error: Some(value.is_error()),
453
+ })
454
+ }
455
+ }
456
+
457
+ #[derive(Serialize)]
458
+ #[serde(tag = "type", rename_all = "snake_case")]
459
+ pub enum CacheControl {
460
+ Ephemeral,
461
+ }
462
+
463
+ #[derive(Serialize, Deserialize, PartialEq, Clone, Debug)]
464
+ #[serde(rename_all = "snake_case")]
465
+ pub enum Role {
466
+ User,
467
+ Assistant,
468
+ }
469
+
470
+ #[derive(Serialize)]
471
+ #[serde(rename_all = "snake_case", tag = "type")]
472
+ pub enum ToolChoice {
473
+ Auto {
474
+ #[serde(skip_serializing_if = "Option::is_none")]
475
+ disable_parallel_tool_use: Option<bool>,
476
+ },
477
+ Any {
478
+ #[serde(skip_serializing_if = "Option::is_none")]
479
+ disable_parallel_tool_use: Option<bool>,
480
+ },
481
+ Tool {
482
+ name: String,
483
+ #[serde(skip_serializing_if = "Option::is_none")]
484
+ disable_parallel_tool_use: Option<bool>,
485
+ },
486
+ }
487
+
488
+ // To understand the mappings refer: https://docs.anthropic.com/en/docs/build-with-claude/tool-use#controlling-claudes-output
489
+ impl From<forge_domain::ToolChoice> for ToolChoice {
490
+ fn from(value: forge_domain::ToolChoice) -> Self {
491
+ match value {
492
+ forge_domain::ToolChoice::Auto => ToolChoice::Auto { disable_parallel_tool_use: None },
493
+ forge_domain::ToolChoice::Call(tool_name) => {
494
+ ToolChoice::Tool { name: tool_name.to_string(), disable_parallel_tool_use: None }
495
+ }
496
+ forge_domain::ToolChoice::Required => {
497
+ ToolChoice::Any { disable_parallel_tool_use: None }
498
+ }
499
+ forge_domain::ToolChoice::None => ToolChoice::Auto { disable_parallel_tool_use: None },
500
+ }
501
+ }
502
+ }
503
+
504
+ #[derive(Serialize)]
505
+ pub struct ToolDefinition {
506
+ pub name: String,
507
+ #[serde(skip_serializing_if = "Option::is_none")]
508
+ pub description: Option<String>,
509
+ #[serde(skip_serializing_if = "Option::is_none")]
510
+ pub cache_control: Option<CacheControl>,
511
+ pub input_schema: serde_json::Value,
512
+ }
513
+
514
+ impl TryFrom<forge_domain::ToolDefinition> for ToolDefinition {
515
+ type Error = anyhow::Error;
516
+ fn try_from(value: forge_domain::ToolDefinition) -> std::result::Result<Self, Self::Error> {
517
+ Ok(ToolDefinition {
518
+ name: value.name.to_string(),
519
+ description: Some(value.description),
520
+ cache_control: None,
521
+ input_schema: serde_json::to_value(value.input_schema)?,
522
+ })
523
+ }
524
+ }
525
+
526
+ #[cfg(test)]
527
+ mod tests {
528
+ use forge_domain::{Context, ReasoningConfig};
529
+ use pretty_assertions::assert_eq;
530
+
531
+ use super::*;
532
+
533
+ #[test]
534
+ fn test_thinking_enabled_serializes_with_budget() {
535
+ let thinking = Thinking::Enabled { budget_tokens: 5000 };
536
+ let actual = serde_json::to_value(&thinking).unwrap();
537
+ let expected = serde_json::json!({
538
+ "type": "enabled",
539
+ "budget_tokens": 5000
540
+ });
541
+
542
+ assert_eq!(actual, expected);
543
+ }
544
+
545
+ #[test]
546
+ fn test_thinking_adaptive_serializes_without_display_when_none() {
547
+ let thinking = Thinking::Adaptive { display: None };
548
+ let actual = serde_json::to_value(&thinking).unwrap();
549
+ let expected = serde_json::json!({"type": "adaptive"});
550
+
551
+ assert_eq!(actual, expected);
552
+ }
553
+
554
+ #[test]
555
+ fn test_thinking_adaptive_serializes_with_summarized_display() {
556
+ let thinking = Thinking::Adaptive { display: Some(ThinkingDisplay::Summarized) };
557
+ let actual = serde_json::to_value(&thinking).unwrap();
558
+ let expected = serde_json::json!({
559
+ "type": "adaptive",
560
+ "display": "summarized"
561
+ });
562
+
563
+ assert_eq!(actual, expected);
564
+ }
565
+
566
+ #[test]
567
+ fn test_thinking_disabled_serializes() {
568
+ let thinking = Thinking::Disabled;
569
+ let actual = serde_json::to_value(&thinking).unwrap();
570
+ let expected = serde_json::json!({"type": "disabled"});
571
+
572
+ assert_eq!(actual, expected);
573
+ }
574
+
575
+ #[test]
576
+ fn test_reasoning_enabled_with_max_tokens_creates_enabled_thinking() {
577
+ let fixture = Context::default().reasoning(ReasoningConfig {
578
+ enabled: Some(true),
579
+ max_tokens: Some(8000),
580
+ effort: None,
581
+ exclude: None,
582
+ });
583
+
584
+ let actual = Request::try_from(fixture).unwrap();
585
+
586
+ assert_eq!(
587
+ actual.thinking,
588
+ Some(Thinking::Enabled { budget_tokens: 8000 })
589
+ );
590
+ assert_eq!(actual.output_config, None);
591
+ }
592
+
593
+ #[test]
594
+ fn test_reasoning_max_tokens_and_effort_emit_both() {
595
+ // Effort and budget are independent knobs — neither should hide the other.
596
+ let fixture = Context::default().reasoning(ReasoningConfig {
597
+ effort: Some(forge_domain::Effort::Low),
598
+ enabled: Some(true),
599
+ max_tokens: Some(8000),
600
+ exclude: None,
601
+ });
602
+
603
+ let actual = Request::try_from(fixture).unwrap();
604
+
605
+ assert_eq!(
606
+ actual.thinking,
607
+ Some(Thinking::Enabled { budget_tokens: 8000 })
608
+ );
609
+ assert_eq!(
610
+ actual.output_config,
611
+ Some(OutputConfig { effort: OutputEffort::Low })
612
+ );
613
+ }
614
+
615
+ #[test]
616
+ fn test_reasoning_max_tokens_alone_emits_enabled_only() {
617
+ let fixture = Context::default().reasoning(ReasoningConfig {
618
+ effort: None,
619
+ enabled: Some(true),
620
+ max_tokens: Some(8000),
621
+ exclude: None,
622
+ });
623
+
624
+ let actual = Request::try_from(fixture).unwrap();
625
+
626
+ assert_eq!(
627
+ actual.thinking,
628
+ Some(Thinking::Enabled { budget_tokens: 8000 })
629
+ );
630
+ assert_eq!(actual.output_config, None);
631
+ }
632
+
633
+ #[test]
634
+ fn test_reasoning_effort_without_budget_creates_output_config_and_adaptive() {
635
+ let fixture = Context::default().reasoning(ReasoningConfig {
636
+ effort: Some(forge_domain::Effort::Low),
637
+ enabled: Some(true),
638
+ max_tokens: None,
639
+ exclude: None,
640
+ });
641
+
642
+ let actual = Request::try_from(fixture).unwrap();
643
+
644
+ assert_eq!(
645
+ actual.output_config,
646
+ Some(OutputConfig { effort: OutputEffort::Low })
647
+ );
648
+ assert_eq!(
649
+ actual.thinking,
650
+ Some(Thinking::Adaptive { display: Some(ThinkingDisplay::Summarized) })
651
+ );
652
+ }
653
+
654
+ #[test]
655
+ fn test_reasoning_effort_with_exclude_emits_adaptive_omitted() {
656
+ let fixture = Context::default().reasoning(ReasoningConfig {
657
+ effort: Some(forge_domain::Effort::High),
658
+ enabled: Some(true),
659
+ max_tokens: None,
660
+ exclude: Some(true),
661
+ });
662
+
663
+ let actual = Request::try_from(fixture).unwrap();
664
+
665
+ assert_eq!(
666
+ actual.thinking,
667
+ Some(Thinking::Adaptive { display: Some(ThinkingDisplay::Omitted) })
668
+ );
669
+ }
670
+
671
+ #[test]
672
+ fn test_reasoning_xhigh_effort_maps_to_xhigh() {
673
+ let fixture = Context::default().reasoning(ReasoningConfig {
674
+ effort: Some(forge_domain::Effort::XHigh),
675
+ enabled: Some(true),
676
+ max_tokens: None,
677
+ exclude: None,
678
+ });
679
+
680
+ let actual = Request::try_from(fixture).unwrap();
681
+
682
+ assert_eq!(
683
+ actual.output_config,
684
+ Some(OutputConfig { effort: OutputEffort::XHigh })
685
+ );
686
+ }
687
+
688
+ #[test]
689
+ fn test_reasoning_enabled_without_budget_or_effort_defaults_to_adaptive_summarized() {
690
+ let fixture = Context::default().reasoning(ReasoningConfig {
691
+ enabled: Some(true),
692
+ max_tokens: None,
693
+ effort: None,
694
+ exclude: None,
695
+ });
696
+
697
+ let actual = Request::try_from(fixture).unwrap();
698
+
699
+ assert_eq!(
700
+ actual.thinking,
701
+ Some(Thinking::Adaptive { display: Some(ThinkingDisplay::Summarized) })
702
+ );
703
+ }
704
+
705
+ #[test]
706
+ fn test_reasoning_enabled_with_exclude_uses_omitted_display() {
707
+ let fixture = Context::default().reasoning(ReasoningConfig {
708
+ enabled: Some(true),
709
+ max_tokens: None,
710
+ effort: None,
711
+ exclude: Some(true),
712
+ });
713
+
714
+ let actual = Request::try_from(fixture).unwrap();
715
+
716
+ assert_eq!(
717
+ actual.thinking,
718
+ Some(Thinking::Adaptive { display: Some(ThinkingDisplay::Omitted) })
719
+ );
720
+ }
721
+
722
+ #[test]
723
+ fn test_reasoning_disabled_does_not_create_thinking() {
724
+ let fixture = Context::default().reasoning(ReasoningConfig {
725
+ enabled: Some(false),
726
+ max_tokens: Some(8000),
727
+ effort: None,
728
+ exclude: None,
729
+ });
730
+
731
+ let actual = Request::try_from(fixture).unwrap();
732
+
733
+ assert_eq!(actual.thinking, None);
734
+ }
735
+
736
+ #[test]
737
+ fn test_reasoning_enabled_none_with_max_tokens_still_emits_thinking() {
738
+ // Matches the domain's `is_reasoning_supported` rule: enabled: None with a
739
+ // positive budget counts as on, so inherited/merged configs don't silently
740
+ // disable reasoning on the wire.
741
+ let fixture = Context::default().reasoning(ReasoningConfig {
742
+ enabled: None,
743
+ max_tokens: Some(8000),
744
+ effort: None,
745
+ exclude: None,
746
+ });
747
+
748
+ let actual = Request::try_from(fixture).unwrap();
749
+
750
+ assert_eq!(
751
+ actual.thinking,
752
+ Some(Thinking::Enabled { budget_tokens: 8000 })
753
+ );
754
+ }
755
+
756
+ #[test]
757
+ fn test_reasoning_enabled_none_with_effort_still_emits_output_config() {
758
+ let fixture = Context::default().reasoning(ReasoningConfig {
759
+ enabled: None,
760
+ max_tokens: None,
761
+ effort: Some(forge_domain::Effort::High),
762
+ exclude: None,
763
+ });
764
+
765
+ let actual = Request::try_from(fixture).unwrap();
766
+
767
+ assert_eq!(
768
+ actual.output_config,
769
+ Some(OutputConfig { effort: OutputEffort::High })
770
+ );
771
+ assert_eq!(
772
+ actual.thinking,
773
+ Some(Thinking::Adaptive { display: Some(ThinkingDisplay::Summarized) })
774
+ );
775
+ }
776
+
777
+ #[test]
778
+ fn test_reasoning_enabled_none_with_zero_max_tokens_does_not_emit() {
779
+ // Matches `is_reasoning_supported`: max_tokens > 0 is required.
780
+ let fixture = Context::default().reasoning(ReasoningConfig {
781
+ enabled: None,
782
+ max_tokens: Some(0),
783
+ effort: None,
784
+ exclude: None,
785
+ });
786
+
787
+ let actual = Request::try_from(fixture).unwrap();
788
+
789
+ assert_eq!(actual.thinking, None);
790
+ assert_eq!(actual.output_config, None);
791
+ }
792
+
793
+ #[test]
794
+ fn test_reasoning_effort_none_does_not_emit_anything() {
795
+ // Effort::None is an explicit opt-out — no thinking, no output_config.
796
+ let fixture = Context::default().reasoning(ReasoningConfig {
797
+ enabled: None,
798
+ max_tokens: None,
799
+ effort: Some(forge_domain::Effort::None),
800
+ exclude: None,
801
+ });
802
+
803
+ let actual = Request::try_from(fixture).unwrap();
804
+
805
+ assert_eq!(actual.thinking, None);
806
+ assert_eq!(actual.output_config, None);
807
+ }
808
+
809
+ #[test]
810
+ fn test_reasoning_effort_none_overrides_enabled_and_max_tokens() {
811
+ let fixture = Context::default().reasoning(ReasoningConfig {
812
+ enabled: Some(true),
813
+ max_tokens: Some(8000),
814
+ effort: Some(forge_domain::Effort::None),
815
+ exclude: None,
816
+ });
817
+
818
+ let actual = Request::try_from(fixture).unwrap();
819
+
820
+ assert_eq!(actual.thinking, None);
821
+ assert_eq!(actual.output_config, None);
822
+ }
823
+
824
+ #[test]
825
+ fn test_reasoning_enabled_false_overrides_effort() {
826
+ // Explicit opt-out beats inferred enablement.
827
+ let fixture = Context::default().reasoning(ReasoningConfig {
828
+ enabled: Some(false),
829
+ max_tokens: None,
830
+ effort: Some(forge_domain::Effort::High),
831
+ exclude: None,
832
+ });
833
+
834
+ let actual = Request::try_from(fixture).unwrap();
835
+
836
+ assert_eq!(actual.thinking, None);
837
+ assert_eq!(actual.output_config, None);
838
+ }
839
+
840
+ #[test]
841
+ fn test_no_reasoning_config_does_not_create_thinking() {
842
+ let fixture = Context::default();
843
+
844
+ let actual = Request::try_from(fixture).unwrap();
845
+
846
+ assert_eq!(actual.thinking, None);
847
+ }
848
+
849
+ #[test]
850
+ fn test_context_conversion_stream_defaults_to_true() {
851
+ let fixture = Context::default();
852
+ let actual = Request::try_from(fixture).unwrap();
853
+
854
+ assert_eq!(actual.stream, Some(true));
855
+ }
856
+
857
+ #[test]
858
+ fn test_context_conversion_stream_explicit_true() {
859
+ let fixture = Context::default().stream(true);
860
+ let actual = Request::try_from(fixture).unwrap();
861
+
862
+ assert_eq!(actual.stream, Some(true));
863
+ }
864
+
865
+ #[test]
866
+ fn test_context_conversion_stream_explicit_false() {
867
+ let fixture = Context::default().stream(false);
868
+ let actual = Request::try_from(fixture).unwrap();
869
+
870
+ assert_eq!(actual.stream, Some(false));
871
+ }
872
+ }
crates/forge_app/src/dto/anthropic/response.rs ADDED
@@ -0,0 +1,881 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use forge_domain::{
2
+ ChatCompletionMessage, Content, ModelId, Reasoning, ReasoningPart, TokenCount, ToolCallId,
3
+ ToolCallPart, ToolName,
4
+ };
5
+ use serde::{Deserialize, Serialize};
6
+
7
+ /// Represents a value that may be either a JSON number or a numeric string.
8
+ #[derive(Deserialize, Debug, Clone, PartialEq, derive_more::TryInto, Serialize)]
9
+ #[serde(untagged)]
10
+ pub enum StringOrF64 {
11
+ Number(f64),
12
+ String(String),
13
+ }
14
+
15
+ use super::request::Role;
16
+ use crate::dto::anthropic::Error;
17
+
18
+ #[derive(Deserialize)]
19
+ pub struct ListModelResponse {
20
+ pub data: Vec<Model>,
21
+ }
22
+
23
+ #[derive(Deserialize)]
24
+ pub struct Model {
25
+ pub id: String,
26
+ pub display_name: Option<String>,
27
+ }
28
+
29
+ impl From<Model> for forge_domain::Model {
30
+ fn from(value: Model) -> Self {
31
+ let context_length = get_context_length(&value.id);
32
+ let input_modalities = if value.id.contains("claude-3")
33
+ || value.id.contains("claude-4")
34
+ || value.id.contains("claude-sonnet")
35
+ || value.id.contains("claude-opus")
36
+ || value.id.contains("claude-haiku")
37
+ || value.id.contains("claude-mythos")
38
+ || value.id.contains("claude-fable")
39
+ {
40
+ vec![
41
+ forge_domain::InputModality::Text,
42
+ forge_domain::InputModality::Image,
43
+ ]
44
+ } else {
45
+ vec![forge_domain::InputModality::Text]
46
+ };
47
+
48
+ Self {
49
+ id: ModelId::new(value.id),
50
+ name: value.display_name,
51
+ description: None,
52
+ context_length,
53
+ tools_supported: Some(true),
54
+ supports_parallel_tool_calls: None,
55
+ supports_reasoning: None,
56
+ input_modalities,
57
+ }
58
+ }
59
+ }
60
+
61
+ /// Returns the context window size for a given Claude model ID.
62
+ ///
63
+ /// Context lengths are based on official Claude documentation:
64
+ /// <https://docs.claude.com/en/docs/about-claude/models/overview>
65
+ ///
66
+ /// # Arguments
67
+ ///
68
+ /// * `model_id` - The Claude model identifier (e.g.,
69
+ /// "claude-sonnet-4-5-20250929")
70
+ ///
71
+ /// # Returns
72
+ ///
73
+ /// Returns `Some(tokens)` for known models, `None` for unknown models.
74
+ ///
75
+ /// # Notes
76
+ ///
77
+ /// - Most current models support 200K tokens
78
+ /// - Claude Sonnet 4.5 supports 1M tokens with the `context-1m-2025-08-07` beta
79
+ /// header
80
+ /// - Legacy models may have different context lengths
81
+ fn get_context_length(model_id: &str) -> Option<u64> {
82
+ // Claude Mythos / Fable models (1M context)
83
+ if model_id.starts_with("claude-mythos") || model_id.starts_with("claude-fable") {
84
+ return Some(1_000_000);
85
+ }
86
+
87
+ // Claude Sonnet 5 and Opus 5 (1M context)
88
+ if model_id.starts_with("claude-sonnet-5") || model_id.starts_with("claude-opus-5") {
89
+ return Some(1_000_000);
90
+ }
91
+
92
+ // Current models (200K context)
93
+ if model_id.starts_with("claude-sonnet-4-5-")
94
+ || model_id.starts_with("claude-haiku-4-5-")
95
+ || model_id.starts_with("claude-opus-4-1-")
96
+ {
97
+ return Some(200_000);
98
+ }
99
+
100
+ // Legacy Claude 4 models (200K context)
101
+ if model_id.starts_with("claude-sonnet-4-")
102
+ || model_id.starts_with("claude-opus-4-")
103
+ || model_id.starts_with("claude-3-7-sonnet-")
104
+ {
105
+ return Some(200_000);
106
+ }
107
+
108
+ // Claude 3.5 models (200K context)
109
+ if model_id.starts_with("claude-3-5-sonnet-") || model_id.starts_with("claude-3-5-haiku-") {
110
+ return Some(200_000);
111
+ }
112
+
113
+ // Claude 3 Opus and Sonnet (200K context)
114
+ if model_id.starts_with("claude-3-opus-") || model_id.starts_with("claude-3-sonnet-") {
115
+ return Some(200_000);
116
+ }
117
+
118
+ // Claude 3 Haiku (200K context)
119
+ if model_id.starts_with("claude-3-haiku-") {
120
+ return Some(200_000);
121
+ }
122
+
123
+ // Claude 2.1 (200K context)
124
+ if model_id.starts_with("claude-2.1") {
125
+ return Some(200_000);
126
+ }
127
+
128
+ // Claude 2.0 (100K context)
129
+ if model_id.starts_with("claude-2.0") {
130
+ return Some(100_000);
131
+ }
132
+
133
+ // Claude Instant (100K context)
134
+ if model_id.starts_with("claude-instant-") {
135
+ return Some(100_000);
136
+ }
137
+
138
+ // Unknown model
139
+ None
140
+ }
141
+
142
+ #[derive(Deserialize, PartialEq, Clone, Debug)]
143
+ pub struct MessageStart {
144
+ pub id: String,
145
+ pub r#type: String,
146
+ pub role: Role,
147
+ pub content: Vec<ContentBlock>,
148
+ pub model: String,
149
+ pub stop_reason: Option<StopReason>,
150
+ pub stop_sequence: Option<String>,
151
+ pub usage: Usage,
152
+ }
153
+
154
+ #[derive(Deserialize, PartialEq, Clone, Debug)]
155
+ pub struct Usage {
156
+ pub input_tokens: Option<usize>,
157
+ pub output_tokens: Option<usize>,
158
+
159
+ pub cache_read_input_tokens: Option<usize>,
160
+ pub cache_creation_input_tokens: Option<usize>,
161
+ }
162
+
163
+ impl From<Usage> for forge_domain::Usage {
164
+ fn from(usage: Usage) -> Self {
165
+ // Anthropic token breakdown:
166
+ // - input_tokens: tokens NOT from cache (billed at full price)
167
+ // - cache_creation_input_tokens: tokens written to cache (billed at full price
168
+ // + write cost)
169
+ // - cache_read_input_tokens: tokens read from cache (billed at 90% discount)
170
+ // Total input = input_tokens + cache_creation_input_tokens +
171
+ // cache_read_input_tokens
172
+
173
+ let input_tokens = usage.input_tokens.unwrap_or_default();
174
+ let cache_creation = usage.cache_creation_input_tokens.unwrap_or_default();
175
+ let cache_read = usage.cache_read_input_tokens.unwrap_or_default();
176
+
177
+ // prompt_tokens should include ALL input tokens
178
+ let prompt_tokens = TokenCount::Actual(input_tokens + cache_creation + cache_read);
179
+
180
+ let completion_tokens = usage
181
+ .output_tokens
182
+ .map(TokenCount::Actual)
183
+ .unwrap_or_default();
184
+
185
+ // cached_tokens represents tokens that benefited from caching
186
+ // This includes both cache creation and cache reads
187
+ let cached_tokens = TokenCount::Actual(cache_read);
188
+
189
+ let total_tokens = prompt_tokens + completion_tokens;
190
+
191
+ forge_domain::Usage {
192
+ prompt_tokens,
193
+ completion_tokens,
194
+ total_tokens,
195
+ cached_tokens,
196
+ ..Default::default()
197
+ }
198
+ }
199
+ }
200
+
201
+ #[derive(Debug, Deserialize, Clone, PartialEq)]
202
+ #[serde(rename_all = "snake_case")]
203
+ pub enum StopReason {
204
+ EndTurn,
205
+ MaxTokens,
206
+ StopSequence,
207
+ ToolUse,
208
+ Refusal,
209
+ }
210
+
211
+ impl From<StopReason> for forge_domain::FinishReason {
212
+ fn from(value: StopReason) -> Self {
213
+ match value {
214
+ StopReason::EndTurn => forge_domain::FinishReason::Stop,
215
+ StopReason::MaxTokens => forge_domain::FinishReason::Length,
216
+ StopReason::StopSequence => forge_domain::FinishReason::Stop,
217
+ StopReason::ToolUse => forge_domain::FinishReason::ToolCalls,
218
+ StopReason::Refusal => forge_domain::FinishReason::ContentFilter,
219
+ }
220
+ }
221
+ }
222
+
223
+ #[derive(Deserialize, PartialEq, Clone, Debug)]
224
+ #[serde(rename_all = "snake_case", tag = "type")]
225
+ pub enum Event {
226
+ Error {
227
+ error: Error,
228
+ },
229
+ MessageStart {
230
+ message: MessageStart,
231
+ },
232
+ ContentBlockStart {
233
+ index: u32,
234
+ content_block: ContentBlock,
235
+ },
236
+ Ping {
237
+ cost: Option<StringOrF64>,
238
+ },
239
+ ContentBlockDelta {
240
+ index: u32,
241
+ delta: ContentBlock,
242
+ },
243
+ ContentBlockStop {
244
+ index: u32,
245
+ },
246
+ MessageDelta {
247
+ delta: MessageDelta,
248
+ usage: Usage,
249
+ },
250
+ MessageStop,
251
+ }
252
+
253
+ #[derive(Deserialize, PartialEq, Clone, Debug)]
254
+ #[serde(untagged)]
255
+ pub enum EventData {
256
+ KnownEvent(Event),
257
+ // To handle any unknown events:
258
+ // ref: https://docs.anthropic.com/en/api/messages-streaming#other-events
259
+ Unknown(serde_json::Value),
260
+ }
261
+
262
+ #[derive(Deserialize, Clone, PartialEq, Debug)]
263
+ pub struct MessageDelta {
264
+ pub stop_reason: StopReason,
265
+ pub stop_sequence: Option<String>,
266
+ }
267
+
268
+ #[derive(Debug, Deserialize, Clone, PartialEq)]
269
+ #[serde(tag = "type")]
270
+ #[serde(rename_all = "snake_case")]
271
+ pub enum ContentBlock {
272
+ Text {
273
+ text: String,
274
+ },
275
+ TextDelta {
276
+ text: String,
277
+ },
278
+ ToolUse {
279
+ id: String,
280
+ name: String,
281
+ input: serde_json::Value,
282
+ },
283
+ InputJsonDelta {
284
+ partial_json: String,
285
+ },
286
+ Thinking {
287
+ thinking: Option<String>,
288
+ signature: Option<String>,
289
+ },
290
+ ThinkingDelta {
291
+ thinking: Option<String>,
292
+ },
293
+ SignatureDelta {
294
+ signature: Option<String>,
295
+ },
296
+ RedactedThinking {
297
+ data: Option<String>,
298
+ },
299
+ }
300
+
301
+ impl TryFrom<EventData> for ChatCompletionMessage {
302
+ type Error = anyhow::Error;
303
+ fn try_from(value: EventData) -> Result<Self, Self::Error> {
304
+ match value {
305
+ EventData::KnownEvent(event) => ChatCompletionMessage::try_from(event),
306
+ EventData::Unknown(_) => {
307
+ // Ignore any unknown events
308
+ Ok(ChatCompletionMessage::assistant(Content::part("")))
309
+ }
310
+ }
311
+ }
312
+ }
313
+
314
+ impl TryFrom<Event> for ChatCompletionMessage {
315
+ type Error = anyhow::Error;
316
+ fn try_from(value: Event) -> Result<Self, Self::Error> {
317
+ let result = match value {
318
+ Event::ContentBlockStart { content_block, .. }
319
+ | Event::ContentBlockDelta { delta: content_block, .. } => {
320
+ ChatCompletionMessage::try_from(content_block)?
321
+ }
322
+ Event::MessageStart { message } => {
323
+ // Extract usage from MessageStart - this contains input token counts
324
+ ChatCompletionMessage::assistant(Content::part("")).usage(message.usage)
325
+ }
326
+ Event::MessageDelta { delta, usage } => {
327
+ ChatCompletionMessage::assistant(Content::part(""))
328
+ .finish_reason(delta.stop_reason)
329
+ .usage(usage)
330
+ }
331
+ Event::Error { error } => {
332
+ return Err(error.into());
333
+ }
334
+ Event::Ping { cost: Some(cost) } => {
335
+ // OpenCode Zen sends cost in a ping event at the end of the stream
336
+ let cost_value = match cost {
337
+ StringOrF64::Number(n) => n,
338
+ StringOrF64::String(s) => s.parse().unwrap_or(0.0),
339
+ };
340
+ ChatCompletionMessage::assistant(Content::part(""))
341
+ .usage(forge_domain::Usage { cost: Some(cost_value), ..Default::default() })
342
+ }
343
+ _ => ChatCompletionMessage::assistant(Content::part("")),
344
+ };
345
+
346
+ Ok(result)
347
+ }
348
+ }
349
+
350
+ impl TryFrom<ContentBlock> for ChatCompletionMessage {
351
+ type Error = anyhow::Error;
352
+ fn try_from(value: ContentBlock) -> Result<Self, Self::Error> {
353
+ let result = match value {
354
+ ContentBlock::Text { text } | ContentBlock::TextDelta { text } => {
355
+ ChatCompletionMessage::assistant(Content::part(text))
356
+ }
357
+ ContentBlock::Thinking { thinking, signature } => {
358
+ if let Some(thinking) = thinking {
359
+ ChatCompletionMessage::assistant(Content::part(""))
360
+ .reasoning(Content::part(thinking.clone()))
361
+ .add_reasoning_detail(Reasoning::Part(vec![
362
+ ReasoningPart::default()
363
+ .text(Some(thinking))
364
+ .signature(signature),
365
+ ]))
366
+ } else {
367
+ ChatCompletionMessage::assistant(Content::part(""))
368
+ }
369
+ }
370
+ ContentBlock::RedactedThinking { data } => {
371
+ if let Some(data) = data {
372
+ ChatCompletionMessage::assistant(Content::part(""))
373
+ .reasoning(Content::part(data.clone()))
374
+ .add_reasoning_detail(Reasoning::Part(vec![
375
+ ReasoningPart::default().text(Some(data)),
376
+ ]))
377
+ } else {
378
+ ChatCompletionMessage::assistant(Content::part(""))
379
+ }
380
+ }
381
+ ContentBlock::ThinkingDelta { thinking } => {
382
+ if let Some(thinking) = thinking {
383
+ ChatCompletionMessage::assistant(Content::part(""))
384
+ .reasoning(Content::part(thinking.clone()))
385
+ .add_reasoning_detail(Reasoning::Part(vec![
386
+ ReasoningPart::default().text(Some(thinking)),
387
+ ]))
388
+ } else {
389
+ ChatCompletionMessage::assistant(Content::part(""))
390
+ }
391
+ }
392
+ ContentBlock::SignatureDelta { signature } => {
393
+ ChatCompletionMessage::assistant(Content::part("")).add_reasoning_detail(
394
+ Reasoning::Part(vec![ReasoningPart::default().signature(signature)]),
395
+ )
396
+ }
397
+ ContentBlock::ToolUse { id, name, input } => {
398
+ // note: We've to check if the input is empty or null. else we end up adding
399
+ // empty object `{}` as prefix to tool args.
400
+ let is_empty =
401
+ input.is_null() || input.as_object().is_some_and(|map| map.is_empty());
402
+ ChatCompletionMessage::assistant(Content::part("")).add_tool_call(ToolCallPart {
403
+ call_id: Some(ToolCallId::new(id)),
404
+ name: Some(ToolName::new(name)),
405
+ arguments_part: if is_empty {
406
+ "".to_string()
407
+ } else {
408
+ serde_json::to_string(&input)?
409
+ },
410
+ thought_signature: None,
411
+ })
412
+ }
413
+ ContentBlock::InputJsonDelta { partial_json } => {
414
+ ChatCompletionMessage::assistant(Content::part("")).add_tool_call(ToolCallPart {
415
+ call_id: None,
416
+ name: None,
417
+ arguments_part: partial_json,
418
+ thought_signature: None,
419
+ })
420
+ }
421
+ };
422
+
423
+ Ok(result)
424
+ }
425
+ }
426
+
427
+ #[cfg(test)]
428
+ mod tests {
429
+ use super::*;
430
+
431
+ #[test]
432
+ fn test_unknow_event() {
433
+ let event = r#"{"type": "random_error", "error": {"type": "overloaded_error", "message": "Overloaded"}}"#;
434
+ let event_data = serde_json::from_str::<EventData>(event).unwrap();
435
+ assert!(matches!(event_data, EventData::Unknown(_)));
436
+ }
437
+
438
+ #[test]
439
+ fn test_event_deser() {
440
+ let tests = vec![
441
+ (
442
+ "error",
443
+ r#"{"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}"#,
444
+ Event::Error {
445
+ error: Error::OverloadedError { message: "Overloaded".to_string() },
446
+ },
447
+ ),
448
+ (
449
+ "message_start",
450
+ r#"{"type":"message_start","message":{"id":"msg_019LBLYFJ7fG3fuAqzuRQbyi","type":"message","role":"assistant","content":[],"model":"claude-3-opus-20240229","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":1}}}"#,
451
+ Event::MessageStart {
452
+ message: MessageStart {
453
+ id: "msg_019LBLYFJ7fG3fuAqzuRQbyi".to_string(),
454
+ r#type: "message".to_string(),
455
+ role: Role::Assistant,
456
+ content: vec![],
457
+ model: "claude-3-opus-20240229".to_string(),
458
+ stop_reason: None,
459
+ stop_sequence: None,
460
+ usage: Usage {
461
+ input_tokens: Some(10),
462
+ output_tokens: Some(1),
463
+ cache_creation_input_tokens: None,
464
+ cache_read_input_tokens: None,
465
+ },
466
+ },
467
+ },
468
+ ),
469
+ (
470
+ "content_block_start",
471
+ r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#,
472
+ Event::ContentBlockStart {
473
+ index: 0,
474
+ content_block: ContentBlock::Text { text: "".to_string() },
475
+ },
476
+ ),
477
+ ("ping", r#"{"type": "ping"}"#, Event::Ping { cost: None }),
478
+ (
479
+ "content_block_delta",
480
+ r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#,
481
+ Event::ContentBlockDelta {
482
+ index: 0,
483
+ delta: ContentBlock::TextDelta { text: "Hello".to_string() },
484
+ },
485
+ ),
486
+ (
487
+ "content_block_delta",
488
+ r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"!"}}"#,
489
+ Event::ContentBlockDelta {
490
+ index: 0,
491
+ delta: ContentBlock::TextDelta { text: "!".to_string() },
492
+ },
493
+ ),
494
+ (
495
+ "content_block_stop",
496
+ r#"{"type":"content_block_stop","index":0}"#,
497
+ Event::ContentBlockStop { index: 0 },
498
+ ),
499
+ (
500
+ "message_delta",
501
+ r#"{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":12}}"#,
502
+ Event::MessageDelta {
503
+ delta: MessageDelta { stop_reason: StopReason::EndTurn, stop_sequence: None },
504
+ usage: Usage {
505
+ input_tokens: None,
506
+ output_tokens: Some(12),
507
+ cache_creation_input_tokens: None,
508
+ cache_read_input_tokens: None,
509
+ },
510
+ },
511
+ ),
512
+ (
513
+ "message_stop",
514
+ r#"{"type":"message_stop"}"#,
515
+ Event::MessageStop,
516
+ ),
517
+ ];
518
+ for (name, input, expected) in tests {
519
+ let actual: Event = serde_json::from_str(input).unwrap();
520
+ assert_eq!(actual, expected, "test failed for event data: {name}");
521
+ }
522
+ }
523
+
524
+ #[test]
525
+ fn test_model_deser() {
526
+ let input = r#"{
527
+ "data": [
528
+ {
529
+ "type": "model",
530
+ "id": "claude-3-5-sonnet-20241022",
531
+ "display_name": "Claude 3.5 Sonnet (New)",
532
+ "created_at": "2024-10-22T00:00:00Z"
533
+ },
534
+ {
535
+ "type": "model",
536
+ "id": "claude-3-5-haiku-20241022",
537
+ "display_name": "Claude 3.5 Haiku",
538
+ "created_at": "2024-10-22T00:00:00Z"
539
+ }
540
+ ],
541
+ "has_more": false,
542
+ "first_id": "claude-3-5-sonnet-20241022",
543
+ "last_id": "claude-3-opus-20240229"
544
+ }"#;
545
+ let response = serde_json::from_str::<ListModelResponse>(input);
546
+ assert!(response.is_ok());
547
+ assert!(response.unwrap().data.len() == 2);
548
+ }
549
+
550
+ #[test]
551
+ fn test_usage_conversion_with_cache_read_tokens() {
552
+ use forge_domain::TokenCount;
553
+
554
+ // Simulate a response with cache reads
555
+ let fixture = Usage {
556
+ input_tokens: Some(100),
557
+ output_tokens: Some(50),
558
+ cache_creation_input_tokens: Some(200),
559
+ cache_read_input_tokens: Some(300),
560
+ };
561
+
562
+ let actual: forge_domain::Usage = fixture.into();
563
+
564
+ // prompt_tokens should include ALL input tokens
565
+ let expected_prompt = TokenCount::Actual(100 + 200 + 300);
566
+ assert_eq!(actual.prompt_tokens, expected_prompt);
567
+
568
+ // cached_tokens should only include cache reads (tokens that benefited from
569
+ // caching)
570
+ let expected_cached = TokenCount::Actual(300);
571
+ assert_eq!(actual.cached_tokens, expected_cached);
572
+
573
+ // completion_tokens should be output tokens
574
+ let expected_completion = TokenCount::Actual(50);
575
+ assert_eq!(actual.completion_tokens, expected_completion);
576
+
577
+ // total_tokens should be prompt + completion
578
+ let expected_total = TokenCount::Actual(600 + 50);
579
+ assert_eq!(actual.total_tokens, expected_total);
580
+ }
581
+
582
+ #[test]
583
+ fn test_usage_conversion_without_cache() {
584
+ use forge_domain::TokenCount;
585
+
586
+ let fixture = Usage {
587
+ input_tokens: Some(100),
588
+ output_tokens: Some(50),
589
+ cache_creation_input_tokens: None,
590
+ cache_read_input_tokens: None,
591
+ };
592
+
593
+ let actual: forge_domain::Usage = fixture.into();
594
+
595
+ let expected_prompt = TokenCount::Actual(100);
596
+ assert_eq!(actual.prompt_tokens, expected_prompt);
597
+
598
+ let expected_cached = TokenCount::Actual(0);
599
+ assert_eq!(actual.cached_tokens, expected_cached);
600
+
601
+ let expected_completion = TokenCount::Actual(50);
602
+ assert_eq!(actual.completion_tokens, expected_completion);
603
+
604
+ let expected_total = TokenCount::Actual(150);
605
+ assert_eq!(actual.total_tokens, expected_total);
606
+ }
607
+
608
+ #[test]
609
+ fn test_usage_conversion_cache_read_only() {
610
+ use forge_domain::TokenCount;
611
+
612
+ // Scenario: All tokens came from cache (cache hit)
613
+ let fixture = Usage {
614
+ input_tokens: Some(0),
615
+ output_tokens: Some(50),
616
+ cache_creation_input_tokens: Some(0),
617
+ cache_read_input_tokens: Some(500),
618
+ };
619
+
620
+ let actual: forge_domain::Usage = fixture.into();
621
+
622
+ let expected_prompt = TokenCount::Actual(500);
623
+ assert_eq!(actual.prompt_tokens, expected_prompt);
624
+
625
+ let expected_cached = TokenCount::Actual(500);
626
+ assert_eq!(actual.cached_tokens, expected_cached);
627
+
628
+ // Cache percentage should be 100%
629
+ let cache_percentage = (*actual.cached_tokens * 100) / *actual.prompt_tokens;
630
+ assert_eq!(cache_percentage, 100);
631
+ }
632
+
633
+ #[test]
634
+ fn test_vertex_ai_streaming_usage() {
635
+ use forge_domain::TokenCount;
636
+
637
+ // Simulate Vertex AI Anthropic streaming response
638
+ // message_start event with initial usage
639
+ let message_start_json = r#"{"type":"message_start","message":{"id":"msg_test","type":"message","role":"assistant","content":[],"model":"claude-3-5-sonnet-v2@20241022","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":100,"cache_creation_input_tokens":0,"cache_read_input_tokens":50,"output_tokens":0}}}"#;
640
+
641
+ let message_start_event: Event = serde_json::from_str(message_start_json).unwrap();
642
+
643
+ // Extract usage from message_start
644
+ let initial_usage = match message_start_event {
645
+ Event::MessageStart { message } => message.usage,
646
+ _ => panic!("Expected MessageStart event"),
647
+ };
648
+
649
+ // message_delta event with final usage (includes output tokens)
650
+ let message_delta_json = r#"{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":75}}"#;
651
+
652
+ let message_delta_event: Event = serde_json::from_str(message_delta_json).unwrap();
653
+
654
+ // Extract usage from message_delta
655
+ let delta_usage = match message_delta_event {
656
+ Event::MessageDelta { usage, .. } => usage,
657
+ _ => panic!("Expected MessageDelta event"),
658
+ };
659
+
660
+ // Convert both to domain Usage
661
+ let initial_domain: forge_domain::Usage = initial_usage.into();
662
+ let delta_domain: forge_domain::Usage = delta_usage.into();
663
+
664
+ // Verify initial usage
665
+ assert_eq!(initial_domain.prompt_tokens, TokenCount::Actual(150)); // 100 + 0 + 50
666
+ assert_eq!(initial_domain.completion_tokens, TokenCount::Actual(0));
667
+ assert_eq!(initial_domain.cached_tokens, TokenCount::Actual(50));
668
+
669
+ // Verify delta usage (only has output_tokens)
670
+ assert_eq!(delta_domain.prompt_tokens, TokenCount::Actual(0));
671
+ assert_eq!(delta_domain.completion_tokens, TokenCount::Actual(75));
672
+ assert_eq!(delta_domain.cached_tokens, TokenCount::Actual(0));
673
+
674
+ // Merge usage (simulating how we'd combine them in practice)
675
+ // Using merge (max) instead of accumulate (sum) since Anthropic
676
+ // usage values are cumulative, not incremental deltas.
677
+ let merged = initial_domain.merge(&delta_domain);
678
+ assert_eq!(merged.prompt_tokens, TokenCount::Actual(150));
679
+ assert_eq!(merged.completion_tokens, TokenCount::Actual(75));
680
+ assert_eq!(merged.cached_tokens, TokenCount::Actual(50));
681
+ assert_eq!(merged.total_tokens, TokenCount::Actual(150)); // max(150, 75)
682
+ }
683
+
684
+ #[test]
685
+ fn test_message_start_event_includes_usage() {
686
+ use forge_domain::TokenCount;
687
+
688
+ // Test that MessageStart event properly extracts usage
689
+ let message_start_json = r#"{"type":"message_start","message":{"id":"msg_test","type":"message","role":"assistant","content":[],"model":"claude-3-5-sonnet-v2@20241022","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1000,"cache_creation_input_tokens":200,"cache_read_input_tokens":300,"output_tokens":0}}}"#;
690
+
691
+ let event: Event = serde_json::from_str(message_start_json).unwrap();
692
+ let message: ChatCompletionMessage = event.try_into().unwrap();
693
+
694
+ // Verify usage was extracted from MessageStart
695
+ let usage = message.usage.expect("Usage should be present");
696
+ assert_eq!(usage.prompt_tokens, TokenCount::Actual(1500)); // 1000 + 200 + 300
697
+ assert_eq!(usage.completion_tokens, TokenCount::Actual(0));
698
+ assert_eq!(usage.cached_tokens, TokenCount::Actual(300));
699
+ assert_eq!(usage.total_tokens, TokenCount::Actual(1500));
700
+ }
701
+
702
+ #[test]
703
+ fn test_get_context_length_current_models() {
704
+ // Current models (200K context)
705
+ assert_eq!(
706
+ get_context_length("claude-sonnet-4-5-20250929"),
707
+ Some(200_000)
708
+ );
709
+ assert_eq!(
710
+ get_context_length("claude-haiku-4-5-20251001"),
711
+ Some(200_000)
712
+ );
713
+ assert_eq!(
714
+ get_context_length("claude-opus-4-1-20250805"),
715
+ Some(200_000)
716
+ );
717
+ }
718
+
719
+ #[test]
720
+ fn test_get_context_length_legacy_claude_4() {
721
+ // Legacy Claude 4 models (200K context)
722
+ assert_eq!(
723
+ get_context_length("claude-sonnet-4-20250514"),
724
+ Some(200_000)
725
+ );
726
+ assert_eq!(get_context_length("claude-opus-4-20250514"), Some(200_000));
727
+ assert_eq!(
728
+ get_context_length("claude-3-7-sonnet-20250219"),
729
+ Some(200_000)
730
+ );
731
+ }
732
+
733
+ #[test]
734
+ fn test_get_context_length_claude_3_5() {
735
+ // Claude 3.5 models (200K context)
736
+ assert_eq!(
737
+ get_context_length("claude-3-5-sonnet-20241022"),
738
+ Some(200_000)
739
+ );
740
+ assert_eq!(
741
+ get_context_length("claude-3-5-haiku-20241022"),
742
+ Some(200_000)
743
+ );
744
+ }
745
+
746
+ #[test]
747
+ fn test_get_context_length_claude_3() {
748
+ // Claude 3 models (200K context)
749
+ assert_eq!(get_context_length("claude-3-opus-20240229"), Some(200_000));
750
+ assert_eq!(
751
+ get_context_length("claude-3-sonnet-20240229"),
752
+ Some(200_000)
753
+ );
754
+ assert_eq!(get_context_length("claude-3-haiku-20240307"), Some(200_000));
755
+ }
756
+
757
+ #[test]
758
+ fn test_get_context_length_claude_2() {
759
+ // Claude 2.1 (200K context)
760
+ assert_eq!(get_context_length("claude-2.1"), Some(200_000));
761
+
762
+ // Claude 2.0 (100K context)
763
+ assert_eq!(get_context_length("claude-2.0"), Some(100_000));
764
+ }
765
+
766
+ #[test]
767
+ fn test_get_context_length_claude_instant() {
768
+ // Claude Instant (100K context)
769
+ assert_eq!(get_context_length("claude-instant-1.2"), Some(100_000));
770
+ }
771
+
772
+ #[test]
773
+ fn test_get_context_length_unknown_model() {
774
+ // Unknown models should return None
775
+ assert_eq!(get_context_length("unknown-model"), None);
776
+ assert_eq!(get_context_length("claude-future-5-0"), None);
777
+ assert_eq!(get_context_length(""), None);
778
+ }
779
+
780
+ #[test]
781
+ fn test_model_conversion_includes_context_length() {
782
+ let fixture = Model {
783
+ id: "claude-sonnet-4-5-20250929".to_string(),
784
+ display_name: Some("Claude 3.5 Sonnet (New)".to_string()),
785
+ };
786
+
787
+ let actual: forge_domain::Model = fixture.into();
788
+
789
+ assert_eq!(actual.context_length, Some(200_000));
790
+ assert_eq!(actual.id.as_str(), "claude-sonnet-4-5-20250929");
791
+ assert_eq!(actual.name, Some("Claude 3.5 Sonnet (New)".to_string()));
792
+ }
793
+
794
+ #[test]
795
+ fn test_model_conversion_unknown_model_no_context() {
796
+ let fixture = Model {
797
+ id: "unknown-claude-model".to_string(),
798
+ display_name: Some("Unknown Model".to_string()),
799
+ };
800
+
801
+ let actual: forge_domain::Model = fixture.into();
802
+
803
+ assert_eq!(actual.context_length, None);
804
+ assert_eq!(actual.id.as_str(), "unknown-claude-model");
805
+ }
806
+
807
+ #[test]
808
+ fn test_ping_event_with_string_cost() {
809
+ // Fixture: OpenCode Zen sends cost as a string in a ping event
810
+ let fixture = r#"{"type":"ping","cost":"0.00724710"}"#;
811
+
812
+ let actual: Event = serde_json::from_str(fixture).unwrap();
813
+
814
+ let expected = Event::Ping { cost: Some(StringOrF64::String("0.00724710".into())) };
815
+ assert_eq!(actual, expected);
816
+ }
817
+
818
+ #[test]
819
+ fn test_ping_event_with_numeric_cost() {
820
+ // Fixture: Cost as a numeric value
821
+ let fixture = r#"{"type":"ping","cost":0.05}"#;
822
+
823
+ let actual: Event = serde_json::from_str(fixture).unwrap();
824
+
825
+ let expected = Event::Ping { cost: Some(StringOrF64::Number(0.05)) };
826
+ assert_eq!(actual, expected);
827
+ }
828
+
829
+ #[test]
830
+ fn test_ping_event_with_cost_produces_usage() {
831
+ // Fixture: Ping event with cost should produce a usage with cost
832
+ let fixture = Event::Ping { cost: Some(StringOrF64::Number(0.00724710)) };
833
+
834
+ let actual = ChatCompletionMessage::try_from(fixture).unwrap();
835
+
836
+ let expected_usage = forge_domain::Usage { cost: Some(0.00724710), ..Default::default() };
837
+ assert_eq!(actual.usage, Some(expected_usage));
838
+ }
839
+
840
+ #[test]
841
+ fn test_ping_event_without_cost_produces_empty_message() {
842
+ // Fixture: Standard ping without cost should produce empty message
843
+ let fixture = Event::Ping { cost: None };
844
+
845
+ let actual = ChatCompletionMessage::try_from(fixture).unwrap();
846
+
847
+ assert_eq!(actual.usage, None);
848
+ }
849
+
850
+ #[test]
851
+ fn test_message_delta_refusal_stop_reason() {
852
+ // Fable returns HTTP 200 with stop_reason "refusal" when safety
853
+ // classifiers decline a request. This must parse as a known event,
854
+ // not fall through to EventData::Unknown (which silently drops the
855
+ // finish reason and triggers an empty-completion retry loop).
856
+ let fixture = r#"{"type":"message_delta","delta":{"stop_reason":"refusal","stop_sequence":null},"usage":{"output_tokens":0}}"#;
857
+
858
+ let actual = serde_json::from_str::<EventData>(fixture).unwrap();
859
+
860
+ let expected = EventData::KnownEvent(Event::MessageDelta {
861
+ delta: MessageDelta { stop_reason: StopReason::Refusal, stop_sequence: None },
862
+ usage: Usage {
863
+ input_tokens: None,
864
+ output_tokens: Some(0),
865
+ cache_creation_input_tokens: None,
866
+ cache_read_input_tokens: None,
867
+ },
868
+ });
869
+ assert_eq!(actual, expected);
870
+ }
871
+
872
+ #[test]
873
+ fn test_refusal_maps_to_content_filter() {
874
+ let fixture = StopReason::Refusal;
875
+
876
+ let actual = forge_domain::FinishReason::from(fixture);
877
+
878
+ let expected = forge_domain::FinishReason::ContentFilter;
879
+ assert_eq!(actual, expected);
880
+ }
881
+ }
crates/forge_app/src/dto/anthropic/transforms/auth_system_message.rs ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use forge_domain::Transformer;
2
+
3
+ use crate::dto::anthropic::{Request, SystemMessage};
4
+
5
+ /// Adds authentication system message when OAuth is enabled.
6
+ pub struct AuthSystemMessage {
7
+ message: String,
8
+ }
9
+
10
+ impl AuthSystemMessage {
11
+ const AUTH_MESSAGE: &'static str = include_str!("claude_code.md");
12
+
13
+ /// Creates a new AuthSystemMessage with the provided message.
14
+ pub fn new(message: String) -> Self {
15
+ Self { message }
16
+ }
17
+ }
18
+
19
+ impl Default for AuthSystemMessage {
20
+ fn default() -> Self {
21
+ Self::new(Self::AUTH_MESSAGE.to_string())
22
+ }
23
+ }
24
+
25
+ impl Transformer for AuthSystemMessage {
26
+ type Value = Request;
27
+
28
+ /// Prepends auth system message when enabled.
29
+ fn transform(&mut self, mut request: Self::Value) -> Self::Value {
30
+ let auth_system_message = SystemMessage {
31
+ r#type: "text".to_string(),
32
+ text: self.message.trim().to_string(),
33
+ cache_control: None,
34
+ };
35
+
36
+ // Get or create the system messages vector
37
+ let mut system_messages = request.system.take().unwrap_or_default();
38
+
39
+ // Prepend the auth message at the beginning
40
+ system_messages.insert(0, auth_system_message);
41
+
42
+ request.system = Some(system_messages);
43
+ request
44
+ }
45
+ }
46
+
47
+ #[cfg(test)]
48
+ mod tests {
49
+ use forge_domain::{Context, ContextMessage, ModelId, Role, TextMessage};
50
+ use pretty_assertions::assert_eq;
51
+
52
+ use super::*;
53
+
54
+ async fn load_auth_message() -> String {
55
+ forge_test_kit::fixture!("/src/dto/anthropic/transforms/claude_code.md").await
56
+ }
57
+
58
+ fn create_request_with_system_messages(system_count: usize) -> Request {
59
+ let mut messages = Vec::new();
60
+
61
+ // Add system messages
62
+ for i in 0..system_count {
63
+ messages.push(
64
+ ContextMessage::Text(TextMessage::new(
65
+ Role::System,
66
+ format!("System message {}", i),
67
+ ))
68
+ .into(),
69
+ );
70
+ }
71
+
72
+ // Add at least one user message
73
+ messages.push(
74
+ ContextMessage::Text(
75
+ TextMessage::new(Role::User, "Hello")
76
+ .model(ModelId::new("claude-3-5-sonnet-20241022")),
77
+ )
78
+ .into(),
79
+ );
80
+
81
+ let context = Context {
82
+ conversation_id: None,
83
+ messages,
84
+ tools: vec![],
85
+ tool_choice: None,
86
+ max_tokens: None,
87
+ temperature: None,
88
+ top_p: None,
89
+ top_k: None,
90
+ reasoning: None,
91
+ stream: None,
92
+ response_format: None,
93
+ initiator: None,
94
+ };
95
+
96
+ Request::try_from(context).unwrap()
97
+ }
98
+
99
+ #[tokio::test]
100
+ async fn test_enabled_adds_auth_message() {
101
+ let auth_message = load_auth_message().await;
102
+ let fixture = create_request_with_system_messages(0);
103
+ let mut transformer = AuthSystemMessage::new(auth_message.clone()).when(|_| true);
104
+
105
+ let actual = transformer.transform(fixture);
106
+
107
+ let system_messages = actual.system.unwrap();
108
+ assert_eq!(system_messages.len(), 1);
109
+ assert_eq!(system_messages[0].text, auth_message.trim());
110
+ assert_eq!(system_messages[0].r#type, "text");
111
+ }
112
+
113
+ #[tokio::test]
114
+ async fn test_disabled_does_not_add_auth_message() {
115
+ let auth_message = load_auth_message().await;
116
+ let fixture = create_request_with_system_messages(0);
117
+ let mut transformer = AuthSystemMessage::new(auth_message).when(|_| false);
118
+
119
+ let actual = transformer.transform(fixture);
120
+
121
+ // Should have no system messages or empty vector
122
+ let system_messages = actual.system.unwrap_or_default();
123
+ assert_eq!(system_messages.len(), 0);
124
+ }
125
+
126
+ #[tokio::test]
127
+ async fn test_prepends_to_existing_system_messages() {
128
+ let auth_message = load_auth_message().await;
129
+ let fixture = create_request_with_system_messages(2);
130
+ let mut transformer = AuthSystemMessage::new(auth_message.clone()).when(|_| true);
131
+
132
+ let actual = transformer.transform(fixture);
133
+
134
+ let system_messages = actual.system.unwrap();
135
+ assert_eq!(system_messages.len(), 3);
136
+
137
+ // Auth message should be first
138
+ assert_eq!(system_messages[0].text, auth_message.trim());
139
+ assert_eq!(system_messages[0].r#type, "text");
140
+
141
+ // Existing messages should follow
142
+ assert_eq!(system_messages[1].text, "System message 0");
143
+ assert_eq!(system_messages[2].text, "System message 1");
144
+ }
145
+
146
+ #[tokio::test]
147
+ async fn test_auth_message_content_matches_file() {
148
+ let auth_message = load_auth_message().await;
149
+ let fixture = create_request_with_system_messages(0);
150
+ let mut transformer = AuthSystemMessage::new(auth_message).when(|_| true);
151
+
152
+ let actual = transformer.transform(fixture);
153
+
154
+ let system_messages = actual.system.unwrap();
155
+ let expected_content = "You are Claude Code, Anthropic's official CLI for Claude.";
156
+ assert_eq!(system_messages[0].text, expected_content);
157
+ }
158
+
159
+ #[tokio::test]
160
+ async fn test_with_one_existing_system_message() {
161
+ let auth_message = load_auth_message().await;
162
+ let fixture = create_request_with_system_messages(1);
163
+ let mut transformer = AuthSystemMessage::new(auth_message.clone()).when(|_| true);
164
+
165
+ let actual = transformer.transform(fixture);
166
+
167
+ let system_messages = actual.system.unwrap();
168
+ assert_eq!(system_messages.len(), 2);
169
+ assert_eq!(system_messages[0].text, auth_message.trim());
170
+ assert_eq!(system_messages[1].text, "System message 0");
171
+ }
172
+
173
+ #[tokio::test]
174
+ async fn test_disabled_preserves_existing_system_messages() {
175
+ let auth_message = load_auth_message().await;
176
+ let fixture = create_request_with_system_messages(2);
177
+ let expected_count = fixture.system.as_ref().unwrap().len();
178
+
179
+ let mut transformer = AuthSystemMessage::new(auth_message).when(|_| false);
180
+ let actual = transformer.transform(fixture);
181
+
182
+ let system_messages = actual.system.unwrap();
183
+ assert_eq!(system_messages.len(), expected_count);
184
+ assert_eq!(system_messages[0].text, "System message 0");
185
+ assert_eq!(system_messages[1].text, "System message 1");
186
+ }
187
+ }
crates/forge_app/src/dto/anthropic/transforms/capitalize_tool_names.rs ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use forge_domain::Transformer;
2
+
3
+ use crate::dto::anthropic::Request;
4
+
5
+ /// Transformer that capitalizes specific tool names for Anthropic
6
+ /// compatibility.
7
+ ///
8
+ /// This transformer modifies tool names to use PascalCase for certain tools:
9
+ /// - `read` -> `Read`
10
+ /// - `write` -> `Write`
11
+ ///
12
+ /// When the LLM sends back tool calls, both the capitalized and lowercase
13
+ /// versions are supported through alias handling in the deserialization logic.
14
+ pub struct CapitalizeToolNames;
15
+
16
+ impl Transformer for CapitalizeToolNames {
17
+ type Value = Request;
18
+
19
+ fn transform(&mut self, mut request: Self::Value) -> Self::Value {
20
+ for tool in &mut request.tools {
21
+ tool.name = match tool.name.as_str() {
22
+ "read" => "Read".to_string(),
23
+ "write" => "Write".to_string(),
24
+ "task" => "Task".to_string(),
25
+ _ => tool.name.clone(),
26
+ };
27
+ }
28
+ request
29
+ }
30
+ }
31
+
32
+ #[cfg(test)]
33
+ mod tests {
34
+ use forge_domain::{Context, ContextMessage, ModelId, ToolDefinition, Transformer};
35
+
36
+ use super::*;
37
+
38
+ #[test]
39
+ fn test_capitalizes_read_tool() {
40
+ let fixture = Context::default()
41
+ .add_tool(ToolDefinition::new("read").description("Read a file"))
42
+ .add_message(ContextMessage::user(
43
+ "test",
44
+ Some(ModelId::new("claude-3-5-sonnet-20241022")),
45
+ ));
46
+
47
+ let mut request = Request::try_from(fixture).unwrap();
48
+ request = CapitalizeToolNames.transform(request);
49
+
50
+ assert_eq!(request.tools[0].name, "Read");
51
+ }
52
+
53
+ #[test]
54
+ fn test_capitalizes_write_tool() {
55
+ let fixture = Context::default()
56
+ .add_tool(ToolDefinition::new("write").description("Write a file"))
57
+ .add_message(ContextMessage::user(
58
+ "test",
59
+ Some(ModelId::new("claude-3-5-sonnet-20241022")),
60
+ ));
61
+
62
+ let mut request = Request::try_from(fixture).unwrap();
63
+ request = CapitalizeToolNames.transform(request);
64
+
65
+ assert_eq!(request.tools[0].name, "Write");
66
+ }
67
+
68
+ #[test]
69
+ fn test_leaves_other_tools_unchanged() {
70
+ let fixture = Context::default()
71
+ .add_tool(ToolDefinition::new("shell").description("Execute shell command"))
72
+ .add_tool(ToolDefinition::new("fs_search").description("Search files"))
73
+ .add_message(ContextMessage::user(
74
+ "test",
75
+ Some(ModelId::new("claude-3-5-sonnet-20241022")),
76
+ ));
77
+
78
+ let mut request = Request::try_from(fixture).unwrap();
79
+ request = CapitalizeToolNames.transform(request);
80
+
81
+ assert_eq!(request.tools[0].name, "shell");
82
+ assert_eq!(request.tools[1].name, "fs_search");
83
+ }
84
+
85
+ #[test]
86
+ fn test_handles_multiple_tools_including_read_and_write() {
87
+ let fixture = Context::default()
88
+ .add_tool(ToolDefinition::new("read").description("Read a file"))
89
+ .add_tool(ToolDefinition::new("write").description("Write a file"))
90
+ .add_tool(ToolDefinition::new("shell").description("Execute shell command"))
91
+ .add_message(ContextMessage::user(
92
+ "test",
93
+ Some(ModelId::new("claude-3-5-sonnet-20241022")),
94
+ ));
95
+
96
+ let mut request = Request::try_from(fixture).unwrap();
97
+ request = CapitalizeToolNames.transform(request);
98
+
99
+ assert_eq!(request.tools[0].name, "Read");
100
+ assert_eq!(request.tools[1].name, "Write");
101
+ assert_eq!(request.tools[2].name, "shell");
102
+ }
103
+
104
+ #[test]
105
+ fn test_handles_empty_tools_list() {
106
+ let fixture = Context::default().add_message(ContextMessage::user(
107
+ "test",
108
+ Some(ModelId::new("claude-3-5-sonnet-20241022")),
109
+ ));
110
+
111
+ let mut request = Request::try_from(fixture).unwrap();
112
+ request = CapitalizeToolNames.transform(request);
113
+
114
+ assert!(request.tools.is_empty());
115
+ }
116
+ }
crates/forge_app/src/dto/anthropic/transforms/claude_code.md ADDED
@@ -0,0 +1 @@
 
 
1
+ You are Claude Code, Anthropic's official CLI for Claude.
crates/forge_app/src/dto/anthropic/transforms/drop_invalid_toolcalls.rs ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use forge_domain::Transformer;
2
+ use serde_json::json;
3
+
4
+ use crate::dto::anthropic::{Content, Request};
5
+
6
+ /// Transformer that normalizes ToolUse content to ensure inputs are always
7
+ /// objects.
8
+ /// - Preserves `Content::Text` and other content types as-is
9
+ /// - For `Content::ToolUse`:
10
+ /// - If input is already an object, keeps it unchanged
11
+ /// - If input is None, keeps it as None
12
+ /// - If input is non-object (string, array, number, etc.), wraps it in
13
+ /// `{"json": value}`
14
+ pub struct DropInvalidToolUse;
15
+
16
+ impl Transformer for DropInvalidToolUse {
17
+ type Value = Request;
18
+
19
+ fn transform(&mut self, mut request: Self::Value) -> Self::Value {
20
+ for message in request.get_messages_mut() {
21
+ for content in &mut message.content {
22
+ if let Content::ToolUse { input, .. } = content {
23
+ *input = match input.take() {
24
+ Some(value) if value.is_object() => Some(value),
25
+ Some(value) => Some(json!({ "json": value })),
26
+ None => None,
27
+ };
28
+ }
29
+ }
30
+ }
31
+
32
+ request
33
+ }
34
+ }
35
+
36
+ #[cfg(test)]
37
+ mod tests {
38
+ use forge_domain::{
39
+ Context, ContextMessage, ModelId, Role, TextMessage, ToolCallArguments, ToolCallFull,
40
+ Transformer,
41
+ };
42
+ use pretty_assertions::assert_eq;
43
+ use serde_json::json;
44
+
45
+ use super::*;
46
+ use crate::dto::anthropic::Request;
47
+
48
+ fn transform_tool_call(json_args: &str) -> Request {
49
+ let fixture = Context::default().messages(vec![
50
+ ContextMessage::Text(
51
+ TextMessage::new(Role::User, "Hello")
52
+ .tool_calls(vec![
53
+ ToolCallFull::new("test_tool")
54
+ .call_id("call_123")
55
+ .arguments(ToolCallArguments::from_json(json_args)),
56
+ ])
57
+ .model(ModelId::new("claude-3-5-sonnet-20241022")),
58
+ )
59
+ .into(),
60
+ ]);
61
+ DropInvalidToolUse.transform(Request::try_from(fixture).unwrap())
62
+ }
63
+
64
+ fn get_tool_input(request: &Request) -> &Option<serde_json::Value> {
65
+ if let Content::ToolUse { input, .. } = &request.messages[0].content[1] {
66
+ input
67
+ } else {
68
+ panic!("Expected ToolUse content")
69
+ }
70
+ }
71
+
72
+ #[test]
73
+ fn test_preserves_tool_use_with_object_input() {
74
+ let actual = transform_tool_call(r#"{"key": "value"}"#);
75
+ assert_eq!(get_tool_input(&actual), &Some(json!({"key": "value"})));
76
+ }
77
+
78
+ #[test]
79
+ fn test_wraps_tool_use_with_string_input() {
80
+ let actual = transform_tool_call(r#""string_value""#);
81
+ assert_eq!(
82
+ get_tool_input(&actual),
83
+ &Some(json!({"json": "string_value"}))
84
+ );
85
+ }
86
+
87
+ #[test]
88
+ fn test_wraps_tool_use_with_array_input() {
89
+ let actual = transform_tool_call(r#"[1, 2, 3]"#);
90
+ assert_eq!(get_tool_input(&actual), &Some(json!({"json": [1, 2, 3]})));
91
+ }
92
+
93
+ #[test]
94
+ fn test_wraps_tool_use_with_number_input() {
95
+ let actual = transform_tool_call(r#"42"#);
96
+ assert_eq!(get_tool_input(&actual), &Some(json!({"json": 42})));
97
+ }
98
+
99
+ #[test]
100
+ fn test_wraps_tool_use_with_none_input() {
101
+ let request = Request::default().messages(vec![crate::dto::anthropic::Message {
102
+ role: crate::dto::anthropic::Role::User,
103
+ content: vec![Content::ToolUse {
104
+ id: "call_123".to_string(),
105
+ name: "test_tool".to_string(),
106
+ input: None,
107
+ cache_control: None,
108
+ }],
109
+ }]);
110
+ let actual = DropInvalidToolUse.transform(request);
111
+
112
+ if let Content::ToolUse { input, .. } = &actual.messages[0].content[0] {
113
+ assert_eq!(input, &None);
114
+ }
115
+ }
116
+
117
+ #[test]
118
+ fn test_empty_messages_remain_empty() {
119
+ let actual = DropInvalidToolUse.transform(Request::try_from(Context::default()).unwrap());
120
+ assert_eq!(actual.messages.len(), 0);
121
+ }
122
+
123
+ #[test]
124
+ fn test_preserves_text_content() {
125
+ let fixture = Context::default().messages(vec![
126
+ ContextMessage::Text(TextMessage::new(Role::User, "Hello")).into(),
127
+ ]);
128
+ let actual = DropInvalidToolUse.transform(Request::try_from(fixture).unwrap());
129
+
130
+ assert_eq!(actual.messages.len(), 1);
131
+ assert!(matches!(
132
+ actual.messages[0].content[0],
133
+ Content::Text { .. }
134
+ ));
135
+ }
136
+ }
crates/forge_app/src/dto/anthropic/transforms/enforce_schema.rs ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use forge_domain::Transformer;
2
+
3
+ use crate::dto::anthropic::{OutputFormat, Request};
4
+ use crate::utils::enforce_strict_schema;
5
+
6
+ /// Transformer that normalizes output_format schema to meet Anthropic API
7
+ /// requirements.
8
+ ///
9
+ /// Anthropic requires that all object types in JSON schemas must explicitly set
10
+ /// `additionalProperties: false`. This transformer recursively processes the
11
+ /// schema to add this requirement.
12
+ ///
13
+ /// # Example
14
+ ///
15
+ /// Before normalization:
16
+ /// ```json
17
+ /// {
18
+ /// "type": "object",
19
+ /// "properties": { "name": { "type": "string" } }
20
+ /// }
21
+ /// ```
22
+ ///
23
+ /// After normalization:
24
+ /// ```json
25
+ /// {
26
+ /// "type": "object",
27
+ /// "properties": { "name": { "type": "string" } },
28
+ /// "additionalProperties": false
29
+ /// }
30
+ /// ```
31
+ pub struct EnforceStrictObjectSchema;
32
+
33
+ impl Transformer for EnforceStrictObjectSchema {
34
+ type Value = Request;
35
+
36
+ fn transform(&mut self, mut request: Self::Value) -> Self::Value {
37
+ if let Some(OutputFormat::JsonSchema { schema }) = request.output_format.take() {
38
+ // Convert schema to JSON value for normalization
39
+ if let Ok(mut schema_value) = serde_json::to_value(&schema) {
40
+ // Use non-strict mode (false) for Anthropic - only adds additionalProperties
41
+ enforce_strict_schema(&mut schema_value, false);
42
+
43
+ // Convert back to RootSchema
44
+ if let Ok(normalized_schema) = serde_json::from_value(schema_value) {
45
+ request.output_format =
46
+ Some(OutputFormat::JsonSchema { schema: normalized_schema });
47
+ } else {
48
+ // If deserialization fails, keep the original schema
49
+ request.output_format = Some(OutputFormat::JsonSchema { schema });
50
+ }
51
+ } else {
52
+ // If serialization fails, keep the original schema
53
+ request.output_format = Some(OutputFormat::JsonSchema { schema });
54
+ }
55
+ }
56
+
57
+ request
58
+ }
59
+ }
60
+
61
+ #[cfg(test)]
62
+ mod tests {
63
+ use pretty_assertions::assert_eq;
64
+ use schemars::JsonSchema;
65
+ use serde::Deserialize;
66
+
67
+ use super::*;
68
+
69
+ #[derive(Deserialize, JsonSchema)]
70
+ #[schemars(title = "test_response")]
71
+ #[allow(dead_code)]
72
+ struct TestResponse {
73
+ name: String,
74
+ nested: NestedObject,
75
+ }
76
+
77
+ #[derive(Deserialize, JsonSchema)]
78
+ #[allow(dead_code)]
79
+ struct NestedObject {
80
+ value: String,
81
+ }
82
+
83
+ #[test]
84
+ fn test_normalize_output_schema_adds_additional_properties() {
85
+ let schema = schemars::schema_for!(TestResponse);
86
+ let fixture = Request::default().output_format(OutputFormat::JsonSchema { schema });
87
+
88
+ let actual = EnforceStrictObjectSchema.transform(fixture);
89
+
90
+ // Convert to JSON to check if additionalProperties was added
91
+ if let Some(OutputFormat::JsonSchema { schema }) = actual.output_format {
92
+ let schema_json = serde_json::to_value(&schema).unwrap();
93
+
94
+ // Check top-level schema
95
+ assert_eq!(
96
+ schema_json["additionalProperties"],
97
+ serde_json::Value::Bool(false),
98
+ "Top-level additionalProperties should be false"
99
+ );
100
+
101
+ // Check nested object schema - it might be in definitions or $defs
102
+ if let Some(nested_schema) = schema_json
103
+ .get("properties")
104
+ .and_then(|p| p.get("nested"))
105
+ .and_then(|n| n.get("additionalProperties"))
106
+ {
107
+ assert_eq!(
108
+ nested_schema,
109
+ &serde_json::Value::Bool(false),
110
+ "Nested additionalProperties should be false"
111
+ );
112
+ } else if let Some(defs) = schema_json
113
+ .get("$defs")
114
+ .or_else(|| schema_json.get("definitions"))
115
+ {
116
+ // Check if NestedObject is in definitions
117
+ if let Some(nested_def) = defs.get("NestedObject") {
118
+ assert_eq!(
119
+ nested_def["additionalProperties"],
120
+ serde_json::Value::Bool(false),
121
+ "NestedObject in definitions should have additionalProperties: false"
122
+ );
123
+ }
124
+ }
125
+ } else {
126
+ panic!("Expected output_format to be Some(OutputFormat::JsonSchema)");
127
+ }
128
+ }
129
+
130
+ #[test]
131
+ fn test_normalize_output_schema_preserves_none() {
132
+ let fixture = Request::default();
133
+
134
+ let actual = EnforceStrictObjectSchema.transform(fixture);
135
+
136
+ assert_eq!(actual.output_format, None);
137
+ }
138
+ }
crates/forge_app/src/dto/anthropic/transforms/mcp_tool_names.rs ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use forge_domain::Transformer;
2
+
3
+ use crate::dto::anthropic::Request;
4
+
5
+ /// Converts MCP tool names from Forge's internal format
6
+ /// (`mcp_{server}_tool_{tool}`) to the Claude Code compatible format
7
+ /// (`mcp__{server}__{tool}`).
8
+ ///
9
+ /// Claude Code expects MCP tools to use double-underscore separators between
10
+ /// the `mcp` prefix, server name, and tool name. This transformer is applied
11
+ /// only when sending requests to the Claude Code provider (OAuth-authenticated
12
+ /// sessions).
13
+ pub struct McpToolNames;
14
+
15
+ impl Transformer for McpToolNames {
16
+ type Value = Request;
17
+
18
+ fn transform(&mut self, mut request: Self::Value) -> Self::Value {
19
+ for tool in &mut request.tools {
20
+ tool.name = to_claude_code_format(&tool.name);
21
+ }
22
+ request
23
+ }
24
+ }
25
+
26
+ /// Converts a tool name from the internal `mcp_{server}_tool_{tool}` format to
27
+ /// the Claude Code `mcp__{server}__{tool}` format.
28
+ ///
29
+ /// Uses the last occurrence of `_tool_` as the server/tool separator to
30
+ /// correctly handle server names that themselves contain `_tool_` as a
31
+ /// substring. Names that do not match the internal format are returned
32
+ /// unchanged.
33
+ fn to_claude_code_format(name: &str) -> String {
34
+ let Some(rest) = name.strip_prefix("mcp_") else {
35
+ return name.to_string();
36
+ };
37
+
38
+ // Only convert names that contain the `_tool_` separator (internal format).
39
+ // rsplit_once handles edge-case server names containing `_tool_`.
40
+ if let Some((server, tool)) = rest.rsplit_once("_tool_") {
41
+ return format!("mcp__{server}__{tool}");
42
+ }
43
+
44
+ name.to_string()
45
+ }
46
+
47
+ #[cfg(test)]
48
+ mod tests {
49
+ use forge_domain::{Context, ContextMessage, ModelId, ToolDefinition, Transformer};
50
+ use pretty_assertions::assert_eq;
51
+
52
+ use super::*;
53
+
54
+ #[test]
55
+ fn test_converts_simple_mcp_tool_name() {
56
+ let actual = to_claude_code_format("mcp_github_tool_create_issue");
57
+ let expected = "mcp__github__create_issue";
58
+ assert_eq!(actual, expected);
59
+ }
60
+
61
+ #[test]
62
+ fn test_converts_mcp_tool_name_with_underscored_server() {
63
+ let actual = to_claude_code_format("mcp_hugging_face_tool_read_channel");
64
+ let expected = "mcp__hugging_face__read_channel";
65
+ assert_eq!(actual, expected);
66
+ }
67
+
68
+ #[test]
69
+ fn test_handles_server_name_containing_tool_substring() {
70
+ // Server named "my_tool_server", tool named "action"
71
+ // rfind gives the LAST `_tool_`, so server = "my_tool_server", tool = "action"
72
+ let actual = to_claude_code_format("mcp_my_tool_server_tool_action");
73
+ let expected = "mcp__my_tool_server__action";
74
+ assert_eq!(actual, expected);
75
+ }
76
+
77
+ #[test]
78
+ fn test_leaves_already_converted_names_unchanged() {
79
+ // mcp__ prefix means it was already converted; no `_tool_` in sanitized names
80
+ let actual = to_claude_code_format("mcp__github__create_issue");
81
+ let expected = "mcp__github__create_issue";
82
+ assert_eq!(actual, expected);
83
+ }
84
+
85
+ #[test]
86
+ fn test_leaves_non_mcp_tools_unchanged() {
87
+ let actual = to_claude_code_format("read");
88
+ let expected = "read";
89
+ assert_eq!(actual, expected);
90
+ }
91
+
92
+ #[test]
93
+ fn test_transformer_converts_mcp_tools_in_request() {
94
+ let fixture = Context::default()
95
+ .add_tool(
96
+ ToolDefinition::new("mcp_github_tool_create_issue")
97
+ .description("Create a GitHub issue"),
98
+ )
99
+ .add_tool(ToolDefinition::new("read").description("Read a file"))
100
+ .add_tool(
101
+ ToolDefinition::new("mcp_slack_tool_send_message")
102
+ .description("Send a Slack message"),
103
+ )
104
+ .add_message(ContextMessage::user(
105
+ "test",
106
+ Some(ModelId::new("claude-3-5-sonnet-20241022")),
107
+ ));
108
+
109
+ let mut request = Request::try_from(fixture).unwrap();
110
+ request = McpToolNames.transform(request);
111
+
112
+ assert_eq!(request.tools[0].name, "mcp__github__create_issue");
113
+ assert_eq!(request.tools[1].name, "read");
114
+ assert_eq!(request.tools[2].name, "mcp__slack__send_message");
115
+ }
116
+ }
crates/forge_app/src/dto/anthropic/transforms/mod.rs ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ mod auth_system_message;
2
+ mod capitalize_tool_names;
3
+ mod drop_invalid_toolcalls;
4
+ mod enforce_schema;
5
+ mod mcp_tool_names;
6
+ mod reasoning_transform;
7
+ mod remove_output_format;
8
+ mod sanitize_tool_ids;
9
+ mod set_cache;
10
+
11
+ pub use auth_system_message::AuthSystemMessage;
12
+ pub use capitalize_tool_names::CapitalizeToolNames;
13
+ pub use drop_invalid_toolcalls::DropInvalidToolUse;
14
+ pub use enforce_schema::EnforceStrictObjectSchema;
15
+ pub use mcp_tool_names::McpToolNames;
16
+ pub use reasoning_transform::ReasoningTransform;
17
+ pub use remove_output_format::RemoveOutputFormat;
18
+ pub use sanitize_tool_ids::SanitizeToolIds;
19
+ pub use set_cache::SetCache;
crates/forge_app/src/dto/anthropic/transforms/reasoning_transform.rs ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use forge_domain::{Context, Transformer};
2
+
3
+ pub struct ReasoningTransform;
4
+
5
+ impl Transformer for ReasoningTransform {
6
+ type Value = Context;
7
+ fn transform(&mut self, mut context: Self::Value) -> Self::Value {
8
+ // Must stay in lockstep with the Anthropic request builder, which gates
9
+ // on the same predicate — otherwise `thinking`/`output_config` ship
10
+ // alongside sampling params that Anthropic rejects.
11
+ if context.is_reasoning_supported() {
12
+ context.top_k = None;
13
+ context.top_p = None;
14
+ }
15
+
16
+ context
17
+ }
18
+ }
19
+
20
+ #[cfg(test)]
21
+ mod tests {
22
+ use forge_domain::{Context, ReasoningConfig, TopK, TopP, Transformer};
23
+ use pretty_assertions::assert_eq;
24
+
25
+ use super::*;
26
+
27
+ fn create_context_fixture() -> Context {
28
+ Context::default()
29
+ .top_k(TopK::new(50).unwrap())
30
+ .top_p(TopP::new(0.8).unwrap())
31
+ }
32
+
33
+ fn create_reasoning_config_fixture(
34
+ enabled: bool,
35
+ max_tokens: Option<usize>,
36
+ ) -> ReasoningConfig {
37
+ ReasoningConfig {
38
+ enabled: Some(enabled),
39
+ max_tokens,
40
+ effort: None,
41
+ exclude: None,
42
+ }
43
+ }
44
+
45
+ #[test]
46
+ fn test_reasoning_enabled_with_max_tokens_removes_top_k_and_top_p() {
47
+ let fixture =
48
+ create_context_fixture().reasoning(create_reasoning_config_fixture(true, Some(1024)));
49
+ let mut transformer = ReasoningTransform;
50
+ let actual = transformer.transform(fixture);
51
+ let expected =
52
+ Context::default().reasoning(create_reasoning_config_fixture(true, Some(1024)));
53
+
54
+ assert_eq!(actual, expected);
55
+ }
56
+
57
+ #[test]
58
+ fn test_reasoning_disabled_preserves_top_k_and_top_p() {
59
+ let fixture =
60
+ create_context_fixture().reasoning(create_reasoning_config_fixture(false, Some(1024)));
61
+ let mut transformer = ReasoningTransform;
62
+ let actual = transformer.transform(fixture.clone());
63
+ let expected = fixture;
64
+
65
+ assert_eq!(actual, expected);
66
+ }
67
+
68
+ #[test]
69
+ fn test_reasoning_enabled_without_max_tokens_removes_top_k_and_top_p() {
70
+ let fixture =
71
+ create_context_fixture().reasoning(create_reasoning_config_fixture(true, None));
72
+ let mut transformer = ReasoningTransform;
73
+ let actual = transformer.transform(fixture);
74
+ let expected = Context::default().reasoning(create_reasoning_config_fixture(true, None));
75
+
76
+ assert_eq!(actual, expected);
77
+ }
78
+
79
+ #[test]
80
+ fn test_no_reasoning_config_preserves_top_k_and_top_p() {
81
+ let fixture = create_context_fixture();
82
+ let mut transformer = ReasoningTransform;
83
+ let actual = transformer.transform(fixture.clone());
84
+ let expected = fixture;
85
+
86
+ assert_eq!(actual, expected);
87
+ }
88
+
89
+ #[test]
90
+ fn test_enabled_none_with_effort_still_strips_top_k_and_top_p() {
91
+ // `enabled: None` + effort is treated as reasoning-on (domain rule).
92
+ let fixture = create_context_fixture().reasoning(ReasoningConfig {
93
+ enabled: None,
94
+ max_tokens: None,
95
+ effort: Some(forge_domain::Effort::High),
96
+ exclude: None,
97
+ });
98
+ let mut transformer = ReasoningTransform;
99
+ let actual = transformer.transform(fixture);
100
+
101
+ assert_eq!(actual.top_k, None);
102
+ assert_eq!(actual.top_p, None);
103
+ }
104
+
105
+ #[test]
106
+ fn test_enabled_none_with_positive_max_tokens_still_strips_top_k_and_top_p() {
107
+ let fixture = create_context_fixture().reasoning(ReasoningConfig {
108
+ enabled: None,
109
+ max_tokens: Some(8000),
110
+ effort: None,
111
+ exclude: None,
112
+ });
113
+ let mut transformer = ReasoningTransform;
114
+ let actual = transformer.transform(fixture);
115
+
116
+ assert_eq!(actual.top_k, None);
117
+ assert_eq!(actual.top_p, None);
118
+ }
119
+
120
+ #[test]
121
+ fn test_enabled_none_with_zero_max_tokens_preserves_top_k_and_top_p() {
122
+ // Matches `is_reasoning_supported`: max_tokens == 0 is treated as off.
123
+ let fixture = create_context_fixture().reasoning(ReasoningConfig {
124
+ enabled: None,
125
+ max_tokens: Some(0),
126
+ effort: None,
127
+ exclude: None,
128
+ });
129
+ let mut transformer = ReasoningTransform;
130
+ let actual = transformer.transform(fixture.clone());
131
+
132
+ assert_eq!(actual.top_k, fixture.top_k);
133
+ assert_eq!(actual.top_p, fixture.top_p);
134
+ }
135
+ }
crates/forge_app/src/dto/anthropic/transforms/remove_output_format.rs ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use forge_domain::Transformer;
2
+
3
+ use crate::dto::anthropic::Request;
4
+
5
+ /// Transformer that removes the output_format field from Anthropic requests.
6
+ ///
7
+ /// This is specifically needed for Vertex AI Anthropic integration, which does
8
+ /// not support the `output_format` parameter. When present, it causes a 400
9
+ /// error: "output_format: Extra inputs are not permitted"
10
+ ///
11
+ /// # Example
12
+ ///
13
+ /// Before transformation:
14
+ /// ```json
15
+ /// {
16
+ /// "messages": [...],
17
+ /// "output_format": {
18
+ /// "type": "json_schema",
19
+ /// "schema": {...}
20
+ /// }
21
+ /// }
22
+ /// ```
23
+ ///
24
+ /// After transformation:
25
+ /// ```json
26
+ /// {
27
+ /// "messages": [...]
28
+ /// }
29
+ /// ```
30
+ pub struct RemoveOutputFormat;
31
+
32
+ impl Transformer for RemoveOutputFormat {
33
+ type Value = Request;
34
+
35
+ fn transform(&mut self, mut request: Self::Value) -> Self::Value {
36
+ request.output_format = None;
37
+ request
38
+ }
39
+ }
40
+
41
+ #[cfg(test)]
42
+ mod tests {
43
+ use pretty_assertions::assert_eq;
44
+ use schemars::JsonSchema;
45
+ use serde::Deserialize;
46
+
47
+ use super::*;
48
+ use crate::dto::anthropic::OutputFormat;
49
+
50
+ #[derive(Deserialize, JsonSchema)]
51
+ #[schemars(title = "test_response")]
52
+ #[allow(dead_code)]
53
+ struct TestResponse {
54
+ name: String,
55
+ }
56
+
57
+ #[test]
58
+ fn test_removes_output_format() {
59
+ let schema = schemars::schema_for!(TestResponse);
60
+ let fixture = Request::default().output_format(OutputFormat::JsonSchema { schema });
61
+
62
+ let actual = RemoveOutputFormat.transform(fixture);
63
+
64
+ assert_eq!(actual.output_format, None);
65
+ }
66
+
67
+ #[test]
68
+ fn test_preserves_none_output_format() {
69
+ let fixture = Request::default();
70
+
71
+ let actual = RemoveOutputFormat.transform(fixture);
72
+
73
+ assert_eq!(actual.output_format, None);
74
+ }
75
+ }
crates/forge_app/src/dto/anthropic/transforms/sanitize_tool_ids.rs ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use forge_domain::Transformer;
2
+ use regex::Regex;
3
+
4
+ use crate::dto::anthropic::{Content, Request};
5
+
6
+ /// Transformer that sanitizes tool call IDs for Anthropic/Vertex Anthropic
7
+ /// compatibility.
8
+ ///
9
+ /// Anthropic requires tool call IDs to match the pattern `^[a-zA-Z0-9_-]+$`.
10
+ /// This transformer replaces any invalid characters (non-alphanumeric,
11
+ /// non-underscore, non-hyphen) with underscores.
12
+ ///
13
+ /// This is particularly important for Vertex AI Anthropic which strictly
14
+ /// validates tool_use.id and tool_result.tool_use_id fields and will reject
15
+ /// requests with IDs containing invalid characters with a 400 Bad Request
16
+ /// error.
17
+ ///
18
+ /// # Example
19
+ ///
20
+ /// ```ignore
21
+ /// // Before transformation:
22
+ /// tool_use.id = "call_123@#$%^&*()"
23
+ ///
24
+ /// // After transformation:
25
+ /// tool_use.id = "call_123_________"
26
+ /// ```
27
+ pub struct SanitizeToolIds;
28
+
29
+ impl Transformer for SanitizeToolIds {
30
+ type Value = Request;
31
+
32
+ fn transform(&mut self, mut request: Self::Value) -> Self::Value {
33
+ let regex = Regex::new(r"[^a-zA-Z0-9_-]").unwrap();
34
+
35
+ for message in &mut request.messages {
36
+ for content in &mut message.content {
37
+ match content {
38
+ Content::ToolUse { id, .. } => {
39
+ *id = regex.replace_all(id, "_").to_string();
40
+ }
41
+ Content::ToolResult { tool_use_id, .. } => {
42
+ *tool_use_id = regex.replace_all(tool_use_id, "_").to_string();
43
+ }
44
+ _ => {}
45
+ }
46
+ }
47
+ }
48
+
49
+ request
50
+ }
51
+ }
52
+
53
+ #[cfg(test)]
54
+ mod tests {
55
+ use forge_domain::{
56
+ Context, ContextMessage, ModelId, Role, TextMessage, ToolCallArguments, ToolCallFull,
57
+ ToolCallId, ToolResult, Transformer,
58
+ };
59
+
60
+ use super::*;
61
+
62
+ #[test]
63
+ fn test_sanitizes_tool_use_id_with_invalid_chars() {
64
+ let fixture = Context::default().messages(vec![
65
+ ContextMessage::Text(
66
+ TextMessage::new(Role::User, "test")
67
+ .tool_calls(vec![
68
+ ToolCallFull::new("test_tool")
69
+ .call_id("call_123@#$%^&*()")
70
+ .arguments(ToolCallArguments::from_json("{}")),
71
+ ])
72
+ .model(ModelId::new("claude-3-5-sonnet-20241022")),
73
+ )
74
+ .into(),
75
+ ]);
76
+
77
+ let mut request = Request::try_from(fixture).unwrap();
78
+ request = SanitizeToolIds.transform(request);
79
+
80
+ // Find the ToolUse content
81
+ let tool_use = request.messages.iter().find_map(|msg| {
82
+ msg.content.iter().find_map(|content| {
83
+ if let Content::ToolUse { id, .. } = content {
84
+ Some(id.clone())
85
+ } else {
86
+ None
87
+ }
88
+ })
89
+ });
90
+
91
+ assert_eq!(tool_use, Some("call_123_________".to_string()));
92
+ }
93
+
94
+ #[test]
95
+ fn test_sanitizes_tool_result_id_with_invalid_chars() {
96
+ let fixture = Context::default().messages(vec![
97
+ ContextMessage::tool_result(
98
+ ToolResult::new("test_tool")
99
+ .call_id(ToolCallId::new("toolu_01!@#$ABC123"))
100
+ .success("result"),
101
+ )
102
+ .into(),
103
+ ]);
104
+
105
+ let mut request = Request::try_from(fixture).unwrap();
106
+ request = SanitizeToolIds.transform(request);
107
+
108
+ // Find the ToolResult content
109
+ let tool_result_id = request.messages.iter().find_map(|msg| {
110
+ msg.content.iter().find_map(|content| {
111
+ if let Content::ToolResult { tool_use_id, .. } = content {
112
+ Some(tool_use_id.clone())
113
+ } else {
114
+ None
115
+ }
116
+ })
117
+ });
118
+
119
+ assert_eq!(tool_result_id, Some("toolu_01____ABC123".to_string()));
120
+ }
121
+
122
+ #[test]
123
+ fn test_leaves_valid_tool_ids_unchanged() {
124
+ let fixture = Context::default().messages(vec![
125
+ ContextMessage::Text(
126
+ TextMessage::new(Role::User, "test")
127
+ .tool_calls(vec![
128
+ ToolCallFull::new("test_tool")
129
+ .call_id("call_abc-123_XYZ")
130
+ .arguments(ToolCallArguments::from_json("{}")),
131
+ ])
132
+ .model(ModelId::new("claude-3-5-sonnet-20241022")),
133
+ )
134
+ .into(),
135
+ ]);
136
+
137
+ let mut request = Request::try_from(fixture).unwrap();
138
+ request = SanitizeToolIds.transform(request);
139
+
140
+ // Find the ToolUse content
141
+ let tool_use = request.messages.iter().find_map(|msg| {
142
+ msg.content.iter().find_map(|content| {
143
+ if let Content::ToolUse { id, .. } = content {
144
+ Some(id.clone())
145
+ } else {
146
+ None
147
+ }
148
+ })
149
+ });
150
+
151
+ assert_eq!(tool_use, Some("call_abc-123_XYZ".to_string()));
152
+ }
153
+
154
+ #[test]
155
+ fn test_handles_multiple_tool_calls_and_results() {
156
+ let fixture = Context::default().messages(vec![
157
+ ContextMessage::Text(
158
+ TextMessage::new(Role::User, "test")
159
+ .tool_calls(vec![
160
+ ToolCallFull::new("tool1")
161
+ .call_id("call_1@#$")
162
+ .arguments(ToolCallArguments::from_json("{}")),
163
+ ToolCallFull::new("tool2")
164
+ .call_id("call_2!@#")
165
+ .arguments(ToolCallArguments::from_json("{}")),
166
+ ])
167
+ .model(ModelId::new("claude-3-5-sonnet-20241022")),
168
+ )
169
+ .into(),
170
+ ContextMessage::tool_result(
171
+ ToolResult::new("tool1")
172
+ .call_id(ToolCallId::new("call_1@#$"))
173
+ .success("result1"),
174
+ )
175
+ .into(),
176
+ ContextMessage::tool_result(
177
+ ToolResult::new("tool2")
178
+ .call_id(ToolCallId::new("call_2!@#"))
179
+ .success("result2"),
180
+ )
181
+ .into(),
182
+ ]);
183
+
184
+ let mut request = Request::try_from(fixture).unwrap();
185
+ request = SanitizeToolIds.transform(request);
186
+
187
+ // Collect all tool use IDs
188
+ let mut tool_use_ids = Vec::new();
189
+ let mut tool_result_ids = Vec::new();
190
+
191
+ for msg in &request.messages {
192
+ for content in &msg.content {
193
+ match content {
194
+ Content::ToolUse { id, .. } => tool_use_ids.push(id.clone()),
195
+ Content::ToolResult { tool_use_id, .. } => {
196
+ tool_result_ids.push(tool_use_id.clone())
197
+ }
198
+ _ => {}
199
+ }
200
+ }
201
+ }
202
+
203
+ assert_eq!(tool_use_ids, vec!["call_1___", "call_2___"]);
204
+ assert_eq!(tool_result_ids, vec!["call_1___", "call_2___"]);
205
+ }
206
+
207
+ #[test]
208
+ fn test_handles_empty_messages() {
209
+ let fixture = Context::default().messages(vec![
210
+ ContextMessage::Text(
211
+ TextMessage::new(Role::User, "test")
212
+ .model(ModelId::new("claude-3-5-sonnet-20241022")),
213
+ )
214
+ .into(),
215
+ ]);
216
+
217
+ let mut request = Request::try_from(fixture).unwrap();
218
+ request = SanitizeToolIds.transform(request);
219
+
220
+ // Should not panic and should preserve the message
221
+ assert_eq!(request.messages.len(), 1);
222
+ }
223
+ }
crates/forge_app/src/dto/anthropic/transforms/set_cache.rs ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use forge_domain::Transformer;
2
+
3
+ use crate::dto::anthropic::Request;
4
+
5
+ /// Transformer that keeps Anthropic prompt-cache markers stable:
6
+ /// - Always caches every system message so the static system prefix remains
7
+ /// reusable
8
+ /// - Falls back to caching the first conversation message when there is no
9
+ /// system prompt so single-turn requests still establish a reusable prefix
10
+ /// - Uses exactly one rolling message-level marker on the newest message
11
+ pub struct SetCache;
12
+
13
+ impl Transformer for SetCache {
14
+ type Value = Request;
15
+
16
+ /// Applies the default Anthropic cache strategy:
17
+ /// 1. Cache every system message when present, otherwise cache the first
18
+ /// conversation message.
19
+ /// 2. Cache only the last message as the rolling message-level marker.
20
+ fn transform(&mut self, mut request: Self::Value) -> Self::Value {
21
+ let len = request.get_messages().len();
22
+ let sys_len = request.system.as_ref().map_or(0, |msgs| msgs.len());
23
+
24
+ if len == 0 && sys_len == 0 {
25
+ return request;
26
+ }
27
+
28
+ let has_system_prompt = request
29
+ .system
30
+ .as_ref()
31
+ .is_some_and(|messages| !messages.is_empty());
32
+
33
+ if let Some(system_messages) = request.system.as_mut() {
34
+ for message in system_messages.iter_mut() {
35
+ *message = std::mem::take(message).cached(true);
36
+ }
37
+ }
38
+
39
+ for message in request.get_messages_mut().iter_mut() {
40
+ *message = std::mem::take(message).cached(false);
41
+ }
42
+
43
+ if !has_system_prompt
44
+ && len > 0
45
+ && let Some(first_message) = request.get_messages_mut().first_mut()
46
+ {
47
+ *first_message = std::mem::take(first_message).cached(true);
48
+ }
49
+
50
+ if let Some(message) = request.get_messages_mut().last_mut() {
51
+ *message = std::mem::take(message).cached(true);
52
+ }
53
+
54
+ request
55
+ }
56
+ }
57
+
58
+ #[cfg(test)]
59
+ mod tests {
60
+ use std::collections::HashSet;
61
+
62
+ use forge_domain::{Context, ContextMessage, ModelId, Role, TextMessage};
63
+ use pretty_assertions::assert_eq;
64
+
65
+ use super::*;
66
+
67
+ fn create_test_context_with_system(
68
+ system_messages: &str,
69
+ conversation_messages: &str,
70
+ ) -> String {
71
+ let mut messages = Vec::new();
72
+
73
+ // Add system messages to the regular messages array for Anthropic format
74
+ for c in system_messages.chars() {
75
+ match c {
76
+ 's' => messages.push(
77
+ ContextMessage::Text(TextMessage::new(Role::System, c.to_string())).into(),
78
+ ),
79
+ _ => panic!("Invalid character in system message: {}", c),
80
+ }
81
+ }
82
+
83
+ // Add conversation messages
84
+ for c in conversation_messages.chars() {
85
+ match c {
86
+ 'u' => messages.push(
87
+ ContextMessage::Text(
88
+ TextMessage::new(Role::User, c.to_string())
89
+ .model(ModelId::new("claude-3-5-sonnet-20241022")),
90
+ )
91
+ .into(),
92
+ ),
93
+ 'a' => messages.push(
94
+ ContextMessage::Text(TextMessage::new(Role::Assistant, c.to_string())).into(),
95
+ ),
96
+ _ => panic!("Invalid character in conversation message: {}", c),
97
+ }
98
+ }
99
+
100
+ let context = Context {
101
+ conversation_id: None,
102
+ messages,
103
+ tools: vec![],
104
+ tool_choice: None,
105
+ max_tokens: None,
106
+ temperature: None,
107
+ top_p: None,
108
+ top_k: None,
109
+ reasoning: None,
110
+ stream: None,
111
+ response_format: None,
112
+ initiator: None,
113
+ };
114
+
115
+ let request = Request::try_from(context).expect("Failed to convert context to request");
116
+ let mut transformer = SetCache;
117
+ let request = transformer.transform(request);
118
+
119
+ let mut output = String::new();
120
+
121
+ // Check if first system message is cached
122
+ let system_cached = request
123
+ .system
124
+ .as_ref()
125
+ .and_then(|sys| sys.first())
126
+ .map(|msg| msg.is_cached())
127
+ .unwrap_or(false);
128
+
129
+ if system_cached {
130
+ output.push('[');
131
+ }
132
+ output.push_str(system_messages);
133
+
134
+ // Check which regular messages are cached
135
+ let cached_indices = request
136
+ .get_messages()
137
+ .iter()
138
+ .enumerate()
139
+ .filter(|(_, m)| m.is_cached())
140
+ .map(|(i, _)| i)
141
+ .collect::<HashSet<usize>>();
142
+
143
+ for (i, c) in conversation_messages.chars().enumerate() {
144
+ if cached_indices.contains(&i) {
145
+ output.push('[');
146
+ }
147
+ output.push(c);
148
+ }
149
+
150
+ output
151
+ }
152
+
153
+ fn create_test_context(message: impl ToString) -> String {
154
+ create_test_context_with_system("", &message.to_string())
155
+ }
156
+
157
+ #[test]
158
+ fn test_single_message() {
159
+ let actual = create_test_context("u");
160
+ let expected = "[u";
161
+ assert_eq!(actual, expected);
162
+ }
163
+
164
+ #[test]
165
+ fn test_two_messages() {
166
+ let actual = create_test_context("ua");
167
+ let expected = "[u[a";
168
+ assert_eq!(actual, expected);
169
+ }
170
+
171
+ #[test]
172
+ fn test_three_messages_cache_first_and_last_only() {
173
+ let actual = create_test_context("uau");
174
+ let expected = "[ua[u";
175
+ assert_eq!(actual, expected);
176
+ }
177
+
178
+ #[test]
179
+ fn test_four_messages_cache_first_and_last_only() {
180
+ let actual = create_test_context("uaua");
181
+ let expected = "[uau[a";
182
+ assert_eq!(actual, expected);
183
+ }
184
+
185
+ #[test]
186
+ fn test_five_messages_cache_first_and_last_only() {
187
+ let actual = create_test_context("uauau");
188
+ let expected = "[uaua[u";
189
+ assert_eq!(actual, expected);
190
+ }
191
+
192
+ #[test]
193
+ fn test_longer_conversation_caches_first_and_last_only() {
194
+ let actual = create_test_context("uauauauaua");
195
+ let expected = "[uauauauau[a";
196
+ assert_eq!(actual, expected);
197
+ }
198
+
199
+ #[test]
200
+ fn test_with_system_message_single_conversation_message() {
201
+ let actual = create_test_context_with_system("s", "u");
202
+ let expected = "[s[u";
203
+ assert_eq!(actual, expected);
204
+ }
205
+
206
+ #[test]
207
+ fn test_with_system_message_multiple_conversation_messages() {
208
+ let actual = create_test_context_with_system("ss", "uaua");
209
+ let expected = "[ssuau[a";
210
+ assert_eq!(actual, expected);
211
+ }
212
+
213
+ #[test]
214
+ fn test_with_system_message_long_conversation() {
215
+ let actual = create_test_context_with_system("s", "uauauauaua");
216
+ let expected = "[suauauauau[a";
217
+ assert_eq!(actual, expected);
218
+ }
219
+
220
+ #[test]
221
+ fn test_only_system_message() {
222
+ let actual = create_test_context_with_system("s", "");
223
+ let expected = "[s";
224
+ assert_eq!(actual, expected);
225
+ }
226
+
227
+ #[test]
228
+ fn test_multiple_system_messages_all_cached() {
229
+ let fixture = Context {
230
+ conversation_id: None,
231
+ messages: vec![
232
+ ContextMessage::Text(TextMessage::new(Role::System, "first")).into(),
233
+ ContextMessage::Text(TextMessage::new(Role::System, "second")).into(),
234
+ ContextMessage::Text(
235
+ TextMessage::new(Role::User, "user")
236
+ .model(ModelId::new("claude-3-5-sonnet-20241022")),
237
+ )
238
+ .into(),
239
+ ],
240
+ tools: vec![],
241
+ tool_choice: None,
242
+ max_tokens: None,
243
+ temperature: None,
244
+ top_p: None,
245
+ top_k: None,
246
+ reasoning: None,
247
+ stream: None,
248
+ response_format: None,
249
+ initiator: None,
250
+ };
251
+
252
+ let request = Request::try_from(fixture).expect("Failed to convert context to request");
253
+ let mut transformer = SetCache;
254
+ let request = transformer.transform(request);
255
+
256
+ let expected = vec![true, true];
257
+ let actual = request
258
+ .system
259
+ .as_ref()
260
+ .unwrap()
261
+ .iter()
262
+ .map(|message| message.is_cached())
263
+ .collect::<Vec<_>>();
264
+ assert_eq!(actual, expected);
265
+ assert!(request.get_messages()[0].is_cached());
266
+ }
267
+ }
crates/forge_app/src/dto/google/mod.rs ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ mod request;
2
+ mod response;
3
+
4
+ pub use request::Request;
5
+ pub use response::{EventData, Model, Response};
crates/forge_app/src/dto/google/request.rs ADDED
@@ -0,0 +1,982 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use derive_setters::Setters;
2
+ use forge_domain::{Context, ContextMessage};
3
+ use serde::Serialize;
4
+
5
+ #[derive(Serialize, Default, Setters)]
6
+ #[setters(into, strip_option)]
7
+ #[serde(rename_all = "camelCase")]
8
+ pub struct Request {
9
+ #[serde(skip_serializing_if = "Option::is_none")]
10
+ pub system_instruction: Option<Content>,
11
+ pub contents: Vec<Content>,
12
+ #[serde(skip_serializing_if = "Option::is_none")]
13
+ pub generation_config: Option<GenerationConfig>,
14
+ #[serde(skip_serializing_if = "Option::is_none")]
15
+ pub tools: Option<Vec<Tool>>,
16
+ #[serde(skip_serializing_if = "Option::is_none")]
17
+ pub tool_config: Option<ToolConfig>,
18
+ #[serde(skip_serializing_if = "Option::is_none")]
19
+ pub safety_settings: Option<Vec<SafetySetting>>,
20
+ #[serde(skip_serializing_if = "Option::is_none")]
21
+ pub cached_content: Option<String>,
22
+ #[serde(skip_serializing_if = "Option::is_none")]
23
+ pub labels: Option<serde_json::Value>,
24
+ }
25
+
26
+ #[derive(Serialize, Default, Setters)]
27
+ #[setters(into, strip_option)]
28
+ #[serde(rename_all = "camelCase")]
29
+ pub struct GenerationConfig {
30
+ #[serde(skip_serializing_if = "Option::is_none")]
31
+ pub thinking_config: Option<ThinkingConfig>,
32
+ #[serde(skip_serializing_if = "Option::is_none")]
33
+ pub stop_sequences: Option<Vec<String>>,
34
+ #[serde(skip_serializing_if = "Option::is_none")]
35
+ pub temperature: Option<f64>,
36
+ #[serde(skip_serializing_if = "Option::is_none")]
37
+ pub top_p: Option<f64>,
38
+ #[serde(skip_serializing_if = "Option::is_none")]
39
+ pub top_k: Option<i32>,
40
+ #[serde(skip_serializing_if = "Option::is_none")]
41
+ pub response_mime_type: Option<String>,
42
+ #[serde(skip_serializing_if = "Option::is_none")]
43
+ pub response_schema: Option<serde_json::Value>,
44
+ #[serde(skip_serializing_if = "Option::is_none")]
45
+ pub max_output_tokens: Option<i32>,
46
+ #[serde(skip_serializing_if = "Option::is_none")]
47
+ pub frequency_penalty: Option<f64>,
48
+ #[serde(skip_serializing_if = "Option::is_none")]
49
+ pub presence_penalty: Option<f64>,
50
+ #[serde(skip_serializing_if = "Option::is_none")]
51
+ pub seed: Option<i32>,
52
+ #[serde(skip_serializing_if = "Option::is_none")]
53
+ pub response_modalities: Option<Vec<String>>,
54
+ #[serde(skip_serializing_if = "Option::is_none")]
55
+ pub audio_timestamp: Option<bool>,
56
+ #[serde(skip_serializing_if = "Option::is_none")]
57
+ pub media_resolution: Option<String>,
58
+ #[serde(skip_serializing_if = "Option::is_none")]
59
+ pub image_config: Option<serde_json::Value>,
60
+ }
61
+
62
+ #[derive(Serialize, Setters)]
63
+ #[setters(into, strip_option)]
64
+ #[serde(rename_all = "camelCase")]
65
+ pub struct ThinkingConfig {
66
+ #[serde(skip_serializing_if = "Option::is_none")]
67
+ pub thinking_level: Option<Level>,
68
+ #[serde(skip_serializing_if = "Option::is_none")]
69
+ pub thinking_budget: Option<i32>,
70
+ #[serde(skip_serializing_if = "Option::is_none")]
71
+ pub include_thoughts: Option<bool>,
72
+ }
73
+
74
+ #[derive(Serialize)]
75
+ #[serde(rename_all = "lowercase")]
76
+ pub enum Level {
77
+ Minimal,
78
+ Low,
79
+ Medium,
80
+ High,
81
+ }
82
+
83
+ #[derive(Serialize)]
84
+ pub struct Content {
85
+ #[serde(skip_serializing_if = "Option::is_none")]
86
+ pub role: Option<Role>,
87
+ pub parts: Vec<Part>,
88
+ }
89
+
90
+ #[derive(Serialize)]
91
+ #[serde(rename_all = "camelCase", untagged)]
92
+ pub enum Part {
93
+ Text {
94
+ text: String,
95
+ #[serde(skip_serializing_if = "Option::is_none")]
96
+ thought: Option<bool>,
97
+ #[serde(skip_serializing_if = "Option::is_none")]
98
+ thought_signature: Option<String>,
99
+ #[serde(skip_serializing_if = "Option::is_none")]
100
+ cache_control: Option<CacheControl>,
101
+ },
102
+ Image {
103
+ inline_data: ImageSource,
104
+ #[serde(skip_serializing_if = "Option::is_none")]
105
+ cache_control: Option<CacheControl>,
106
+ },
107
+ FunctionCall {
108
+ function_call: FunctionCallData,
109
+ #[serde(skip_serializing_if = "Option::is_none")]
110
+ thought_signature: Option<String>,
111
+ },
112
+ FunctionResponse {
113
+ function_response: FunctionResponseData,
114
+ },
115
+ FileData {
116
+ file_data: FileDataInfo,
117
+ },
118
+ }
119
+
120
+ #[derive(Serialize)]
121
+ pub struct ImageSource {
122
+ #[serde(skip_serializing_if = "Option::is_none")]
123
+ pub mime_type: Option<String>,
124
+ #[serde(skip_serializing_if = "Option::is_none")]
125
+ pub data: Option<String>,
126
+ }
127
+
128
+ #[derive(Serialize)]
129
+ #[serde(rename_all = "camelCase")]
130
+ pub struct FunctionCallData {
131
+ pub name: String,
132
+ pub args: serde_json::Value,
133
+ }
134
+
135
+ #[derive(Serialize)]
136
+ #[serde(rename_all = "camelCase")]
137
+ pub struct FunctionResponseData {
138
+ pub name: String,
139
+ pub response: serde_json::Value,
140
+ }
141
+
142
+ #[derive(Serialize)]
143
+ #[serde(rename_all = "camelCase")]
144
+ pub struct FileDataInfo {
145
+ pub mime_type: String,
146
+ pub file_uri: String,
147
+ }
148
+
149
+ #[derive(Serialize)]
150
+ #[serde(tag = "type", rename_all = "snake_case")]
151
+ pub enum CacheControl {
152
+ Ephemeral,
153
+ }
154
+
155
+ #[derive(Serialize, Debug, PartialEq)]
156
+ #[serde(rename_all = "lowercase")]
157
+ pub enum Role {
158
+ User,
159
+ Model,
160
+ }
161
+
162
+ #[derive(Serialize)]
163
+ #[serde(rename_all = "camelCase", untagged)]
164
+ pub enum Tool {
165
+ FunctionDeclarations {
166
+ #[serde(skip_serializing_if = "Option::is_none")]
167
+ function_declarations: Option<Vec<FunctionDeclaration>>,
168
+ },
169
+ GoogleSearch {
170
+ google_search: GoogleSearchTool,
171
+ },
172
+ GoogleSearchRetrieval {
173
+ google_search_retrieval: GoogleSearchRetrievalTool,
174
+ },
175
+ EnterpriseWebSearch {
176
+ enterprise_web_search: serde_json::Value,
177
+ },
178
+ UrlContext {
179
+ url_context: serde_json::Value,
180
+ },
181
+ CodeExecution {
182
+ code_execution: serde_json::Value,
183
+ },
184
+ FileSearch {
185
+ file_search: FileSearchTool,
186
+ },
187
+ GoogleMaps {
188
+ google_maps: serde_json::Value,
189
+ },
190
+ Retrieval {
191
+ retrieval: RetrievalTool,
192
+ },
193
+ }
194
+
195
+ #[derive(Serialize, Default, Setters)]
196
+ #[setters(into, strip_option)]
197
+ #[serde(rename_all = "camelCase")]
198
+ pub struct GoogleSearchTool {
199
+ // Empty object for Gemini 2.0+
200
+ }
201
+
202
+ #[derive(Serialize, Setters)]
203
+ #[setters(into, strip_option)]
204
+ #[serde(rename_all = "camelCase")]
205
+ pub struct GoogleSearchRetrievalTool {
206
+ #[serde(skip_serializing_if = "Option::is_none")]
207
+ pub dynamic_retrieval_config: Option<DynamicRetrievalConfig>,
208
+ }
209
+
210
+ #[derive(Serialize, Setters)]
211
+ #[setters(into, strip_option)]
212
+ #[serde(rename_all = "camelCase")]
213
+ pub struct DynamicRetrievalConfig {
214
+ #[serde(skip_serializing_if = "Option::is_none")]
215
+ pub mode: Option<String>,
216
+ #[serde(skip_serializing_if = "Option::is_none")]
217
+ pub dynamic_threshold: Option<f64>,
218
+ }
219
+
220
+ #[derive(Serialize, Setters)]
221
+ #[setters(into, strip_option)]
222
+ #[serde(rename_all = "camelCase")]
223
+ pub struct FileSearchTool {
224
+ pub file_search_store_names: Vec<String>,
225
+ #[serde(skip_serializing_if = "Option::is_none")]
226
+ pub top_k: Option<i32>,
227
+ #[serde(skip_serializing_if = "Option::is_none")]
228
+ pub metadata_filter: Option<String>,
229
+ }
230
+
231
+ #[derive(Serialize, Setters)]
232
+ #[setters(into, strip_option)]
233
+ #[serde(rename_all = "camelCase")]
234
+ pub struct RetrievalTool {
235
+ pub vertex_rag_store: VertexRagStore,
236
+ }
237
+
238
+ #[derive(Serialize, Setters)]
239
+ #[setters(into, strip_option)]
240
+ #[serde(rename_all = "camelCase")]
241
+ pub struct VertexRagStore {
242
+ pub rag_resources: RagResources,
243
+ #[serde(skip_serializing_if = "Option::is_none")]
244
+ pub similarity_top_k: Option<i32>,
245
+ }
246
+
247
+ #[derive(Serialize, Setters)]
248
+ #[setters(into, strip_option)]
249
+ #[serde(rename_all = "camelCase")]
250
+ pub struct RagResources {
251
+ pub rag_corpus: String,
252
+ }
253
+
254
+ #[derive(Serialize, Setters)]
255
+ #[setters(into, strip_option)]
256
+ #[serde(rename_all = "camelCase")]
257
+ pub struct FunctionDeclaration {
258
+ pub name: String,
259
+ #[serde(skip_serializing_if = "Option::is_none")]
260
+ pub description: Option<String>,
261
+ pub parameters: serde_json::Value,
262
+ }
263
+
264
+ #[derive(Serialize, Setters)]
265
+ #[setters(into, strip_option)]
266
+ #[serde(rename_all = "camelCase")]
267
+ pub struct ToolConfig {
268
+ #[serde(skip_serializing_if = "Option::is_none")]
269
+ pub function_calling_config: Option<FunctionCallingConfig>,
270
+ #[serde(skip_serializing_if = "Option::is_none")]
271
+ pub retrieval_config: Option<serde_json::Value>,
272
+ }
273
+
274
+ #[derive(Serialize, Setters)]
275
+ #[setters(into, strip_option)]
276
+ #[serde(rename_all = "camelCase")]
277
+ pub struct FunctionCallingConfig {
278
+ #[serde(skip_serializing_if = "Option::is_none")]
279
+ pub mode: Option<FunctionCallingMode>,
280
+ #[serde(skip_serializing_if = "Option::is_none")]
281
+ pub allowed_function_names: Option<Vec<String>>,
282
+ }
283
+
284
+ #[derive(Serialize)]
285
+ #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
286
+ pub enum FunctionCallingMode {
287
+ ModeUnspecified,
288
+ Auto,
289
+ Any,
290
+ None,
291
+ }
292
+
293
+ #[derive(Serialize, Setters)]
294
+ #[setters(into, strip_option)]
295
+ #[serde(rename_all = "camelCase")]
296
+ pub struct SafetySetting {
297
+ pub category: String,
298
+ pub threshold: String,
299
+ }
300
+
301
+ impl From<Context> for Request {
302
+ fn from(context: Context) -> Self {
303
+ // Extract system instruction from ALL system messages
304
+ let system_parts: Vec<Part> = context
305
+ .messages
306
+ .iter()
307
+ .filter(|msg| msg.has_role(forge_domain::Role::System))
308
+ .filter_map(|msg| {
309
+ msg.content().map(|content| Part::Text {
310
+ text: content.to_string(),
311
+ thought: None,
312
+ thought_signature: None,
313
+ cache_control: None,
314
+ })
315
+ })
316
+ .collect();
317
+
318
+ let system_instruction = if !system_parts.is_empty() {
319
+ Some(Content { role: None, parts: system_parts })
320
+ } else {
321
+ None
322
+ };
323
+
324
+ // Convert messages (excluding system messages)
325
+ // Group consecutive tool results into single Content objects to match Google's
326
+ // API requirements
327
+ let mut contents: Vec<Content> = Vec::new();
328
+ let mut pending_tool_parts: Vec<Part> = Vec::new();
329
+
330
+ for msg in context
331
+ .messages
332
+ .into_iter()
333
+ .filter(|msg| !msg.has_role(forge_domain::Role::System))
334
+ {
335
+ match msg.message {
336
+ ContextMessage::Tool(tool_result) => {
337
+ // Collect tool result parts to be grouped together
338
+ pending_tool_parts.push(Part::from(tool_result));
339
+ }
340
+ other => {
341
+ // Flush any pending tool results first
342
+ if !pending_tool_parts.is_empty() {
343
+ contents.push(Content {
344
+ role: Some(Role::User),
345
+ parts: std::mem::take(&mut pending_tool_parts),
346
+ });
347
+ }
348
+
349
+ // Add the current non-tool message
350
+ let content = Content::from(other);
351
+ if !content.parts.is_empty() {
352
+ contents.push(content);
353
+ }
354
+ }
355
+ }
356
+ }
357
+
358
+ // Flush any remaining tool results
359
+ if !pending_tool_parts.is_empty() {
360
+ contents.push(Content { role: Some(Role::User), parts: pending_tool_parts });
361
+ }
362
+
363
+ // Convert tools
364
+ let tools = if !context.tools.is_empty() {
365
+ Some(vec![Tool::FunctionDeclarations {
366
+ function_declarations: Some(
367
+ context
368
+ .tools
369
+ .into_iter()
370
+ .map(FunctionDeclaration::from)
371
+ .collect(),
372
+ ),
373
+ }])
374
+ } else {
375
+ None
376
+ };
377
+
378
+ // Build generation config
379
+ let generation_config = Some(GenerationConfig {
380
+ max_output_tokens: context.max_tokens.map(|t| t as i32),
381
+ temperature: context.temperature.map(|t| t.value() as f64),
382
+ top_p: context.top_p.map(|t| t.value() as f64),
383
+ top_k: context.top_k.map(|t| t.value() as i32),
384
+ response_mime_type: context.response_format.as_ref().and_then(|rf| match rf {
385
+ forge_domain::ResponseFormat::JsonSchema(_) => Some("application/json".to_string()),
386
+ _ => None,
387
+ }),
388
+ response_schema: context.response_format.and_then(|rf| match rf {
389
+ forge_domain::ResponseFormat::JsonSchema(schema) => {
390
+ let mut schema_value = serde_json::to_value(*schema).ok()?;
391
+ // Sanitize schema for Gemini API compatibility
392
+ crate::utils::sanitize_gemini_schema(&mut schema_value);
393
+ Some(schema_value)
394
+ }
395
+ _ => None,
396
+ }),
397
+ thinking_config: context.reasoning.and_then(|reasoning| {
398
+ reasoning.enabled.and_then(|enabled| {
399
+ if enabled {
400
+ Some(ThinkingConfig {
401
+ thinking_level: None,
402
+ thinking_budget: reasoning.max_tokens.map(|t| t as i32),
403
+ include_thoughts: Some(true),
404
+ })
405
+ } else {
406
+ None
407
+ }
408
+ })
409
+ }),
410
+ ..Default::default()
411
+ });
412
+
413
+ // Build tool config for tool choice
414
+ // Only set tool_config if there's an explicit tool choice
415
+ let tool_config = context.tool_choice.map(ToolConfig::from);
416
+
417
+ Request {
418
+ system_instruction,
419
+ contents,
420
+ generation_config,
421
+ tools,
422
+ tool_config,
423
+ safety_settings: None,
424
+ cached_content: None,
425
+ labels: None,
426
+ }
427
+ }
428
+ }
429
+
430
+ impl From<forge_domain::ToolChoice> for ToolConfig {
431
+ fn from(choice: forge_domain::ToolChoice) -> Self {
432
+ let (mode, allowed_function_names) = match choice {
433
+ forge_domain::ToolChoice::Auto => (FunctionCallingMode::Auto, None),
434
+ forge_domain::ToolChoice::None => (FunctionCallingMode::None, None),
435
+ forge_domain::ToolChoice::Required => (FunctionCallingMode::Any, None),
436
+ forge_domain::ToolChoice::Call(name) => {
437
+ (FunctionCallingMode::Any, Some(vec![name.to_string()]))
438
+ }
439
+ };
440
+
441
+ ToolConfig {
442
+ function_calling_config: Some(FunctionCallingConfig {
443
+ mode: Some(mode),
444
+ allowed_function_names,
445
+ }),
446
+ retrieval_config: None,
447
+ }
448
+ }
449
+ }
450
+
451
+ impl From<forge_domain::ToolDefinition> for FunctionDeclaration {
452
+ fn from(tool: forge_domain::ToolDefinition) -> Self {
453
+ let mut parameters =
454
+ serde_json::to_value(tool.input_schema).unwrap_or(serde_json::json!({}));
455
+
456
+ // Sanitize schema for Gemini API compatibility (strips $schema,
457
+ // removes additionalProperties, converts integer enums, ensures
458
+ // arrays have items, removes properties from non-objects, etc.)
459
+ crate::utils::sanitize_gemini_schema(&mut parameters);
460
+
461
+ FunctionDeclaration {
462
+ name: tool.name.to_string(),
463
+ description: Some(tool.description),
464
+ parameters,
465
+ }
466
+ }
467
+ }
468
+
469
+ impl From<ContextMessage> for Content {
470
+ fn from(message: ContextMessage) -> Self {
471
+ match message {
472
+ ContextMessage::Text(text_message) => Content::from(text_message),
473
+ ContextMessage::Tool(tool_result) => Content::from(tool_result),
474
+ ContextMessage::Image(image) => Content::from(image),
475
+ }
476
+ }
477
+ }
478
+
479
+ impl From<forge_domain::TextMessage> for Content {
480
+ fn from(text_message: forge_domain::TextMessage) -> Self {
481
+ let role = match text_message.role {
482
+ forge_domain::Role::User => Some(Role::User),
483
+ forge_domain::Role::Assistant => Some(Role::Model),
484
+ forge_domain::Role::System => None, // System messages are handled separately
485
+ };
486
+
487
+ let mut parts = Vec::new();
488
+
489
+ // Add text part if content is not empty
490
+ if !text_message.content.is_empty() {
491
+ parts.push(Part::Text {
492
+ text: text_message.content,
493
+ thought: None,
494
+ thought_signature: text_message.thought_signature.clone(),
495
+ cache_control: None,
496
+ });
497
+ }
498
+
499
+ // Add function calls if present
500
+ if let Some(tool_calls) = text_message.tool_calls {
501
+ parts.extend(tool_calls.into_iter().map(Part::from));
502
+ }
503
+
504
+ Content { role, parts }
505
+ }
506
+ }
507
+
508
+ impl From<forge_domain::ToolResult> for Content {
509
+ fn from(tool_result: forge_domain::ToolResult) -> Self {
510
+ Content {
511
+ role: Some(Role::User), // Tool results come back as user messages in Google's API
512
+ parts: vec![Part::from(tool_result)],
513
+ }
514
+ }
515
+ }
516
+
517
+ impl From<forge_domain::Image> for Content {
518
+ fn from(image: forge_domain::Image) -> Self {
519
+ Content { role: Some(Role::User), parts: vec![Part::from(image)] }
520
+ }
521
+ }
522
+
523
+ impl From<forge_domain::ToolCallFull> for Part {
524
+ fn from(tool_call: forge_domain::ToolCallFull) -> Self {
525
+ Part::FunctionCall {
526
+ function_call: FunctionCallData {
527
+ name: tool_call.name.to_string(),
528
+ args: tool_call.arguments.parse().unwrap_or(serde_json::json!({})),
529
+ },
530
+ thought_signature: tool_call.thought_signature,
531
+ }
532
+ }
533
+ }
534
+
535
+ impl From<forge_domain::ToolResult> for Part {
536
+ fn from(tool_result: forge_domain::ToolResult) -> Self {
537
+ Part::FunctionResponse {
538
+ function_response: FunctionResponseData {
539
+ name: tool_result.name.to_string(),
540
+ response: serde_json::to_value(&tool_result.output)
541
+ .unwrap_or(serde_json::json!({})),
542
+ },
543
+ }
544
+ }
545
+ }
546
+
547
+ impl From<forge_domain::Image> for Part {
548
+ fn from(image: forge_domain::Image) -> Self {
549
+ Part::Image {
550
+ inline_data: ImageSource {
551
+ mime_type: Some(image.mime_type().to_string()),
552
+ data: Some(image.data().to_string()),
553
+ },
554
+ cache_control: None,
555
+ }
556
+ }
557
+ }
558
+
559
+ #[cfg(test)]
560
+ mod tests {
561
+ use forge_domain::{ToolCallArguments, ToolCallFull, ToolCallId, ToolName, ToolResult};
562
+
563
+ use super::*;
564
+
565
+ #[test]
566
+ fn test_tool_call_args_serialization() {
567
+ // Create a ToolCallFull with Unparsed JSON arguments (as it would come from
568
+ // API)
569
+ let tool_call = ToolCallFull {
570
+ name: ToolName::new("patch"),
571
+ call_id: None,
572
+ arguments: ToolCallArguments::from_json(
573
+ r#"{"file_path":"test.rs","old_string":"foo","new_string":"bar"}"#,
574
+ ),
575
+ thought_signature: None,
576
+ };
577
+
578
+ // Convert to Google Part
579
+ let part = Part::from(tool_call);
580
+
581
+ // Verify it serializes correctly
582
+ let serialized = serde_json::to_value(&part).unwrap();
583
+
584
+ // The Part enum serializes using snake_case for the variant name
585
+ if let Some(function_call) = serialized.get("function_call") {
586
+ if let Some(args) = function_call.get("args") {
587
+ // Args should be a proper JSON object, not a string
588
+ assert!(
589
+ args.is_object(),
590
+ "Args should be deserialized as JSON object, not string: {:?}",
591
+ args
592
+ );
593
+
594
+ // Verify fields are accessible
595
+ assert_eq!(args["file_path"], "test.rs");
596
+ assert_eq!(args["old_string"], "foo");
597
+ assert_eq!(args["new_string"], "bar");
598
+ } else {
599
+ panic!("Expected function_call.args to exist");
600
+ }
601
+ } else {
602
+ panic!("Expected function_call variant");
603
+ }
604
+ }
605
+
606
+ #[test]
607
+ fn test_multiple_tool_calls_serialization() {
608
+ // Simulate multiple tool calls with different args
609
+ let tool_calls = vec![
610
+ ToolCallFull {
611
+ name: ToolName::new("remove"),
612
+ call_id: None,
613
+ arguments: ToolCallArguments::from_json(r#"{"path":"file1.rs"}"#),
614
+ thought_signature: None,
615
+ },
616
+ ToolCallFull {
617
+ name: ToolName::new("remove"),
618
+ call_id: None,
619
+ arguments: ToolCallArguments::from_json(r#"{"path":"file2.rs"}"#),
620
+ thought_signature: None,
621
+ },
622
+ ];
623
+
624
+ // Convert each to Part and serialize
625
+ for (i, tool_call) in tool_calls.into_iter().enumerate() {
626
+ let part = Part::from(tool_call);
627
+ let serialized = serde_json::to_value(&part).unwrap();
628
+
629
+ if let Some(function_call) = serialized.get("function_call") {
630
+ if let Some(args) = function_call.get("args") {
631
+ assert!(
632
+ args.is_object(),
633
+ "Tool call {} args should be object, got: {:?}",
634
+ i,
635
+ args
636
+ );
637
+ let expected_path = format!("file{}.rs", i + 1);
638
+ assert_eq!(args["path"], expected_path, "Tool call {} path mismatch", i);
639
+ } else {
640
+ panic!("Expected function_call.args for tool call {}", i);
641
+ }
642
+ } else {
643
+ panic!("Expected function_call variant for tool call {}", i);
644
+ }
645
+ }
646
+ }
647
+
648
+ #[test]
649
+ fn test_consecutive_tool_results_grouped() {
650
+ use forge_domain::{Context, ContextMessage, ModelId};
651
+
652
+ // Create a context with multiple consecutive tool results (simulating 13 read
653
+ // calls)
654
+ let mut context = Context::default();
655
+
656
+ // Add initial user message
657
+ context = context.add_message(ContextMessage::user(
658
+ "Read these files",
659
+ Some(ModelId::new("test")),
660
+ ));
661
+
662
+ // Add assistant message with tool calls
663
+ context = context.add_message(ContextMessage::assistant("", None, None, None));
664
+
665
+ // Add 13 consecutive tool results (like in the dump)
666
+ for i in 1..=13 {
667
+ let tool_result = ToolResult::new("read")
668
+ .call_id(ToolCallId::new(format!("call_{}", i)))
669
+ .success(format!("Content of file {}", i));
670
+ context = context.add_message(ContextMessage::tool_result(tool_result));
671
+ }
672
+
673
+ // Convert to Google Request
674
+ let request = Request::from(context);
675
+
676
+ // Verify structure:
677
+ // 1. First content: user message
678
+ // 2. Second content: assistant message (might be empty and filtered out)
679
+ // 3. Third content: ALL 13 tool results grouped together
680
+
681
+ // Find the content with tool results
682
+ let tool_result_content = request
683
+ .contents
684
+ .iter()
685
+ .find(|c| {
686
+ c.parts
687
+ .iter()
688
+ .any(|p| matches!(p, Part::FunctionResponse { .. }))
689
+ })
690
+ .expect("Should have a content with function responses");
691
+
692
+ // Verify all 13 tool results are in ONE Content object
693
+ let function_response_count = tool_result_content
694
+ .parts
695
+ .iter()
696
+ .filter(|p| matches!(p, Part::FunctionResponse { .. }))
697
+ .count();
698
+
699
+ assert_eq!(
700
+ function_response_count, 13,
701
+ "All 13 tool results should be grouped into a single Content with 13 FunctionResponse parts"
702
+ );
703
+
704
+ // Verify the role is User
705
+ assert_eq!(
706
+ tool_result_content.role,
707
+ Some(Role::User),
708
+ "Tool results should have User role"
709
+ );
710
+ }
711
+
712
+ #[test]
713
+ fn test_non_consecutive_tool_results_not_grouped() {
714
+ use forge_domain::{Context, ContextMessage, ModelId};
715
+
716
+ // Create a context with tool results separated by other messages
717
+ let mut context = Context::default();
718
+
719
+ // User message
720
+ context = context.add_message(ContextMessage::user(
721
+ "First task",
722
+ Some(ModelId::new("test")),
723
+ ));
724
+
725
+ // Tool result #1
726
+ let tool_result_1 = ToolResult::new("read")
727
+ .call_id(ToolCallId::new("call_1"))
728
+ .success("Result 1");
729
+ context = context.add_message(ContextMessage::tool_result(tool_result_1));
730
+
731
+ // Assistant message (breaks the sequence)
732
+ context = context.add_message(ContextMessage::assistant(
733
+ "Let me do more",
734
+ None,
735
+ None,
736
+ None,
737
+ ));
738
+
739
+ // Tool result #2 (should be in a separate Content)
740
+ let tool_result_2 = ToolResult::new("read")
741
+ .call_id(ToolCallId::new("call_2"))
742
+ .success("Result 2");
743
+ context = context.add_message(ContextMessage::tool_result(tool_result_2));
744
+
745
+ // Convert to Google Request
746
+ let request = Request::from(context);
747
+
748
+ // Count how many Content objects have FunctionResponse parts
749
+ let contents_with_tool_results: Vec<_> = request
750
+ .contents
751
+ .iter()
752
+ .filter(|c| {
753
+ c.parts
754
+ .iter()
755
+ .any(|p| matches!(p, Part::FunctionResponse { .. }))
756
+ })
757
+ .collect();
758
+
759
+ // Should have 2 separate Content objects for the 2 non-consecutive tool results
760
+ assert_eq!(
761
+ contents_with_tool_results.len(),
762
+ 2,
763
+ "Non-consecutive tool results should be in separate Content objects"
764
+ );
765
+
766
+ // Each should have exactly 1 FunctionResponse
767
+ for content in contents_with_tool_results {
768
+ let count = content
769
+ .parts
770
+ .iter()
771
+ .filter(|p| matches!(p, Part::FunctionResponse { .. }))
772
+ .count();
773
+ assert_eq!(
774
+ count, 1,
775
+ "Each separate tool result group should have 1 FunctionResponse"
776
+ );
777
+ }
778
+ }
779
+
780
+ #[test]
781
+ fn test_tool_choice_conversion() {
782
+ use forge_domain::ToolChoice;
783
+
784
+ // Test Auto
785
+ let config = ToolConfig::from(ToolChoice::Auto);
786
+ let fc_config = config.function_calling_config.unwrap();
787
+ assert!(matches!(fc_config.mode, Some(FunctionCallingMode::Auto)));
788
+ assert!(fc_config.allowed_function_names.is_none());
789
+
790
+ // Test None
791
+ let config = ToolConfig::from(ToolChoice::None);
792
+ let fc_config = config.function_calling_config.unwrap();
793
+ assert!(matches!(fc_config.mode, Some(FunctionCallingMode::None)));
794
+ assert!(fc_config.allowed_function_names.is_none());
795
+
796
+ // Test Required
797
+ let config = ToolConfig::from(ToolChoice::Required);
798
+ let fc_config = config.function_calling_config.unwrap();
799
+ assert!(matches!(fc_config.mode, Some(FunctionCallingMode::Any)));
800
+ assert!(fc_config.allowed_function_names.is_none());
801
+
802
+ // Test Call
803
+ let config = ToolConfig::from(ToolChoice::Call(ToolName::new("my_tool")));
804
+ let fc_config = config.function_calling_config.unwrap();
805
+ assert!(matches!(fc_config.mode, Some(FunctionCallingMode::Any)));
806
+ assert_eq!(fc_config.allowed_function_names.unwrap(), vec!["my_tool"]);
807
+ }
808
+
809
+ #[test]
810
+ fn test_tool_definition_conversion() {
811
+ use forge_domain::ToolDefinition;
812
+ use schemars::schema_for;
813
+
814
+ #[derive(schemars::JsonSchema)]
815
+ struct Args {
816
+ _arg1: String,
817
+ }
818
+
819
+ let tool_def = ToolDefinition {
820
+ name: ToolName::new("test_tool"),
821
+ description: "A test tool".to_string(),
822
+ input_schema: schema_for!(Args),
823
+ };
824
+
825
+ let decl = FunctionDeclaration::from(tool_def);
826
+
827
+ assert_eq!(decl.name, "test_tool");
828
+ assert_eq!(decl.description.unwrap(), "A test tool");
829
+
830
+ // Check Gemini schema sanitization
831
+ let params = decl.parameters;
832
+ assert!(params.is_object());
833
+ assert!(!params.as_object().unwrap().contains_key("$schema"));
834
+ assert!(params.as_object().unwrap().contains_key("type"));
835
+ // additionalProperties should be removed by Gemini sanitization
836
+ assert!(
837
+ !params
838
+ .as_object()
839
+ .unwrap()
840
+ .contains_key("additionalProperties")
841
+ );
842
+ }
843
+
844
+ #[test]
845
+ fn test_text_message_conversion() {
846
+ use forge_domain::{Role, TextMessage};
847
+
848
+ // User message
849
+ let msg = TextMessage::new(Role::User, "Hello");
850
+ let content = Content::from(msg);
851
+ assert_eq!(content.role, Some(self::Role::User));
852
+ assert_eq!(content.parts.len(), 1);
853
+ match &content.parts[0] {
854
+ Part::Text { text, .. } => assert_eq!(text, "Hello"),
855
+ _ => panic!("Expected Text part"),
856
+ }
857
+
858
+ // Assistant message with thought signature
859
+ let msg = TextMessage::assistant("Hi", None, None).thought_signature("sig");
860
+ let content = Content::from(msg);
861
+ assert_eq!(content.role, Some(self::Role::Model));
862
+ match &content.parts[0] {
863
+ Part::Text { text, thought_signature, .. } => {
864
+ assert_eq!(text, "Hi");
865
+ assert_eq!(thought_signature.as_deref(), Some("sig"));
866
+ }
867
+ _ => panic!("Expected Text part"),
868
+ }
869
+ }
870
+
871
+ #[test]
872
+ fn test_image_conversion() {
873
+ use forge_domain::Image;
874
+
875
+ let image = Image::new_base64("base64data".to_string(), "image/png");
876
+ let content = Content::from(image.clone());
877
+
878
+ assert_eq!(content.role, Some(self::Role::User));
879
+ assert_eq!(content.parts.len(), 1);
880
+
881
+ match &content.parts[0] {
882
+ Part::Image { inline_data, .. } => {
883
+ assert_eq!(inline_data.mime_type.as_deref(), Some("image/png"));
884
+ assert_eq!(inline_data.data.as_deref(), Some("base64data"));
885
+ }
886
+ _ => panic!("Expected Image part"),
887
+ }
888
+
889
+ // Test direct Part conversion
890
+ let part = Part::from(image);
891
+ match part {
892
+ Part::Image { inline_data, .. } => {
893
+ assert_eq!(inline_data.mime_type.as_deref(), Some("image/png"));
894
+ assert_eq!(inline_data.data.as_deref(), Some("base64data"));
895
+ }
896
+ _ => panic!("Expected Image part"),
897
+ }
898
+ }
899
+
900
+ #[test]
901
+ fn test_response_schema_strips_dollar_schema() {
902
+ use forge_domain::{Context, ResponseFormat};
903
+ use schemars::schema_for;
904
+
905
+ #[allow(dead_code)]
906
+ #[derive(schemars::JsonSchema)]
907
+ struct TestResponse {
908
+ result: String,
909
+ }
910
+
911
+ // Create a context with a JSON schema response format
912
+ let schema = schema_for!(TestResponse);
913
+ let context =
914
+ Context::default().response_format(ResponseFormat::JsonSchema(Box::new(schema)));
915
+
916
+ // Convert to Google Request
917
+ let request = Request::from(context);
918
+
919
+ // Verify generation_config has response_schema
920
+ let generation_config = request
921
+ .generation_config
922
+ .expect("Should have generation_config");
923
+ let response_schema = generation_config
924
+ .response_schema
925
+ .expect("Should have response_schema");
926
+
927
+ // Verify $schema field is removed
928
+ if let Some(obj) = response_schema.as_object() {
929
+ assert!(
930
+ !obj.contains_key("$schema"),
931
+ "$schema field should be removed from response_schema"
932
+ );
933
+
934
+ // Verify other schema properties are still present
935
+ assert!(
936
+ obj.contains_key("type")
937
+ || obj.contains_key("properties")
938
+ || obj.contains_key("title"),
939
+ "Schema should still contain other properties"
940
+ );
941
+
942
+ // Verify additionalProperties is also removed by Gemini sanitization
943
+ assert!(
944
+ !obj.contains_key("additionalProperties"),
945
+ "additionalProperties should be removed by Gemini sanitization"
946
+ );
947
+ } else {
948
+ panic!("response_schema should be an object");
949
+ }
950
+
951
+ // Verify response_mime_type is set to application/json
952
+ assert_eq!(
953
+ generation_config.response_mime_type,
954
+ Some("application/json".to_string()),
955
+ "response_mime_type should be set for JSON schema"
956
+ );
957
+ }
958
+
959
+ #[test]
960
+ fn test_tool_result_part_conversion() {
961
+ use forge_domain::{ToolCallId, ToolName, ToolResult};
962
+
963
+ let result = ToolResult::new(ToolName::new("test"))
964
+ .call_id(ToolCallId::new("call_1"))
965
+ .success("output");
966
+
967
+ let part = Part::from(result);
968
+
969
+ match part {
970
+ Part::FunctionResponse { function_response } => {
971
+ assert_eq!(function_response.name, "test");
972
+ // The response should be wrapped in a JSON value
973
+ let expected = serde_json::json!({
974
+ "is_error": false,
975
+ "values": [{"text": "output"}]
976
+ });
977
+ assert_eq!(function_response.response, expected);
978
+ }
979
+ _ => panic!("Expected FunctionResponse part"),
980
+ }
981
+ }
982
+ }
crates/forge_app/src/dto/google/response.rs ADDED
@@ -0,0 +1,790 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use forge_domain::{
2
+ ChatCompletionMessage, FinishReason, Reasoning, ReasoningPart, TokenCount, ToolCallId,
3
+ ToolCallPart, ToolName,
4
+ };
5
+ use serde::Deserialize;
6
+
7
+ /// Model information from Google API
8
+ #[derive(Deserialize, Debug, Clone)]
9
+ #[serde(rename_all = "camelCase")]
10
+ pub struct Model {
11
+ pub name: String,
12
+ #[serde(skip_serializing_if = "Option::is_none")]
13
+ pub display_name: Option<String>,
14
+ #[serde(skip_serializing_if = "Option::is_none")]
15
+ pub description: Option<String>,
16
+ }
17
+
18
+ impl From<Model> for forge_domain::Model {
19
+ fn from(value: Model) -> Self {
20
+ // Extract model ID from name (format: "models/gemini-2.0-flash")
21
+ let id = value
22
+ .name
23
+ .strip_prefix("models/")
24
+ .unwrap_or(&value.name)
25
+ .to_string();
26
+
27
+ // Determine context length based on model name
28
+ let context_length = if id.contains("gemini-2.0") || id.contains("gemini-1.5") {
29
+ 2_000_000 // 2M tokens for Gemini 2.0 and 1.5
30
+ } else {
31
+ 32_000 // Default for older models
32
+ };
33
+
34
+ forge_domain::Model {
35
+ id: forge_domain::ModelId::new(id),
36
+ name: Some(value.display_name.unwrap_or(value.name)),
37
+ description: value.description,
38
+ context_length: Some(context_length),
39
+ tools_supported: Some(true), // Google models support function calling
40
+ supports_parallel_tool_calls: Some(true),
41
+ supports_reasoning: Some(true), // Gemini 2.0+ supports thinking
42
+ input_modalities: vec![], // Google supports text, images, audio, video
43
+ }
44
+ }
45
+ }
46
+
47
+ /// EventData for Google streaming responses
48
+ /// Google returns chunks directly without event wrappers
49
+ #[derive(Deserialize, PartialEq, Clone, Debug)]
50
+ #[serde(untagged)]
51
+ pub enum EventData {
52
+ Response(Response),
53
+ Error(ErrorResponse),
54
+ Ping(PingEvent),
55
+ Unknown(serde_json::Value),
56
+ }
57
+
58
+ /// Represents a value that may be either a JSON number or a numeric string,
59
+ /// used for fields like `cost` that proxies sometimes encode as strings.
60
+ #[derive(Deserialize, Debug, Clone, PartialEq, derive_more::TryInto)]
61
+ #[serde(untagged)]
62
+ pub enum StringOrF64 {
63
+ Number(f64),
64
+ String(String),
65
+ }
66
+
67
+ /// Heartbeat/cost event sent by some proxies (e.g. opencode.ai).
68
+ ///
69
+ /// Example payload: `{"type":"ping","cost":"0.02889400"}`
70
+ #[derive(Deserialize, Debug, Clone, PartialEq)]
71
+ pub struct PingEvent {
72
+ pub cost: StringOrF64,
73
+ }
74
+
75
+ #[derive(Deserialize, Debug, Clone, PartialEq)]
76
+ pub struct ErrorResponse {
77
+ pub error: ErrorContent,
78
+ }
79
+
80
+ #[derive(Deserialize, Debug, Clone, PartialEq)]
81
+ pub struct ErrorContent {
82
+ pub code: i32,
83
+ pub message: String,
84
+ pub status: String,
85
+ }
86
+
87
+ impl TryFrom<EventData> for ChatCompletionMessage {
88
+ type Error = anyhow::Error;
89
+
90
+ fn try_from(value: EventData) -> Result<Self, Self::Error> {
91
+ match value {
92
+ EventData::Response(response) => ChatCompletionMessage::try_from(response),
93
+ EventData::Error(e) => Err(anyhow::anyhow!(
94
+ "Google API Error {}: {}",
95
+ e.error.code,
96
+ e.error.message
97
+ )),
98
+ EventData::Ping(ping) => {
99
+ // Extract cost from proxy ping events (e.g. opencode.ai)
100
+ let cost = match ping.cost {
101
+ StringOrF64::Number(n) => n,
102
+ StringOrF64::String(s) => s.parse().unwrap_or(0.0),
103
+ };
104
+ let usage = forge_domain::Usage { cost: Some(cost), ..Default::default() };
105
+ Ok(ChatCompletionMessage::assistant(forge_domain::Content::part("")).usage(usage))
106
+ }
107
+ EventData::Unknown(_) => {
108
+ // Silently ignore any other unrecognised events
109
+ Ok(ChatCompletionMessage::assistant(
110
+ forge_domain::Content::part(""),
111
+ ))
112
+ }
113
+ }
114
+ }
115
+ }
116
+ #[derive(Deserialize, Debug, Clone, PartialEq)]
117
+ #[serde(rename_all = "camelCase")]
118
+ pub struct Response {
119
+ pub candidates: Vec<Candidate>,
120
+ #[serde(skip_serializing_if = "Option::is_none")]
121
+ pub usage_metadata: Option<UsageMetadata>,
122
+ #[serde(skip_serializing_if = "Option::is_none")]
123
+ pub prompt_feedback: Option<PromptFeedback>,
124
+ }
125
+
126
+ #[derive(Deserialize, Debug, Clone, PartialEq)]
127
+ #[serde(rename_all = "camelCase")]
128
+ pub struct Candidate {
129
+ #[serde(skip_serializing_if = "Option::is_none")]
130
+ pub content: Option<Content>,
131
+ #[serde(skip_serializing_if = "Option::is_none")]
132
+ pub finish_reason: Option<String>,
133
+ #[serde(skip_serializing_if = "Option::is_none")]
134
+ pub safety_ratings: Option<Vec<SafetyRating>>,
135
+ #[serde(skip_serializing_if = "Option::is_none")]
136
+ pub grounding_metadata: Option<GroundingMetadata>,
137
+ #[serde(skip_serializing_if = "Option::is_none")]
138
+ pub url_context_metadata: Option<UrlContextMetadata>,
139
+ }
140
+
141
+ #[derive(Deserialize, Debug, Clone, PartialEq)]
142
+ pub struct Content {
143
+ #[serde(skip_serializing_if = "Option::is_none")]
144
+ pub role: Option<String>,
145
+ #[serde(skip_serializing_if = "Option::is_none")]
146
+ pub parts: Option<Vec<Part>>,
147
+ }
148
+
149
+ #[derive(Deserialize, Debug, Clone, PartialEq)]
150
+ #[serde(untagged)]
151
+ pub enum Part {
152
+ FunctionCall {
153
+ #[serde(rename = "functionCall")]
154
+ function_call: FunctionCall,
155
+ #[serde(skip_serializing_if = "Option::is_none")]
156
+ #[serde(rename = "thoughtSignature")]
157
+ thought_signature: Option<String>,
158
+ },
159
+ InlineData {
160
+ #[serde(rename = "inlineData")]
161
+ inline_data: InlineData,
162
+ #[serde(skip_serializing_if = "Option::is_none")]
163
+ #[serde(rename = "thoughtSignature")]
164
+ thought_signature: Option<String>,
165
+ },
166
+ Text {
167
+ #[serde(skip_serializing_if = "Option::is_none")]
168
+ text: Option<String>,
169
+ #[serde(skip_serializing_if = "Option::is_none")]
170
+ thought: Option<bool>,
171
+ #[serde(skip_serializing_if = "Option::is_none")]
172
+ #[serde(rename = "thoughtSignature")]
173
+ thought_signature: Option<String>,
174
+ #[serde(skip_serializing_if = "Option::is_none")]
175
+ #[serde(rename = "executableCode")]
176
+ executable_code: Option<ExecutableCode>,
177
+ #[serde(skip_serializing_if = "Option::is_none")]
178
+ #[serde(rename = "codeExecutionResult")]
179
+ code_execution_result: Option<CodeExecutionResult>,
180
+ },
181
+ }
182
+
183
+ #[derive(Deserialize, Debug, Clone, PartialEq)]
184
+ #[serde(rename_all = "camelCase")]
185
+ pub struct FunctionCall {
186
+ pub name: String,
187
+ pub args: serde_json::Value,
188
+ }
189
+
190
+ #[derive(Deserialize, Debug, Clone, PartialEq)]
191
+ #[serde(rename_all = "camelCase")]
192
+ pub struct InlineData {
193
+ pub mime_type: String,
194
+ pub data: String,
195
+ }
196
+
197
+ #[derive(Deserialize, Debug, Clone, PartialEq)]
198
+ pub struct ExecutableCode {
199
+ pub language: String,
200
+ pub code: String,
201
+ }
202
+
203
+ #[derive(Deserialize, Debug, Clone, PartialEq)]
204
+ pub struct CodeExecutionResult {
205
+ pub outcome: String,
206
+ pub output: String,
207
+ }
208
+
209
+ #[derive(Deserialize, Debug, Clone, PartialEq)]
210
+ #[serde(rename_all = "camelCase")]
211
+ pub struct SafetyRating {
212
+ #[serde(skip_serializing_if = "Option::is_none")]
213
+ pub category: Option<String>,
214
+ #[serde(skip_serializing_if = "Option::is_none")]
215
+ pub probability: Option<String>,
216
+ #[serde(skip_serializing_if = "Option::is_none")]
217
+ pub probability_score: Option<f64>,
218
+ #[serde(skip_serializing_if = "Option::is_none")]
219
+ pub severity: Option<String>,
220
+ #[serde(skip_serializing_if = "Option::is_none")]
221
+ pub severity_score: Option<f64>,
222
+ #[serde(skip_serializing_if = "Option::is_none")]
223
+ pub blocked: Option<bool>,
224
+ }
225
+
226
+ #[derive(Deserialize, Debug, Clone, PartialEq)]
227
+ #[serde(rename_all = "camelCase")]
228
+ pub struct UsageMetadata {
229
+ #[serde(skip_serializing_if = "Option::is_none")]
230
+ pub cached_content_token_count: Option<i32>,
231
+ #[serde(skip_serializing_if = "Option::is_none")]
232
+ pub thoughts_token_count: Option<i32>,
233
+ #[serde(skip_serializing_if = "Option::is_none")]
234
+ pub prompt_token_count: Option<i32>,
235
+ #[serde(skip_serializing_if = "Option::is_none")]
236
+ pub candidates_token_count: Option<i32>,
237
+ #[serde(skip_serializing_if = "Option::is_none")]
238
+ pub total_token_count: Option<i32>,
239
+ #[serde(skip_serializing_if = "Option::is_none")]
240
+ pub traffic_type: Option<String>,
241
+ }
242
+
243
+ #[derive(Deserialize, Debug, Clone, PartialEq)]
244
+ #[serde(rename_all = "camelCase")]
245
+ pub struct PromptFeedback {
246
+ #[serde(skip_serializing_if = "Option::is_none")]
247
+ pub block_reason: Option<String>,
248
+ #[serde(skip_serializing_if = "Option::is_none")]
249
+ pub safety_ratings: Option<Vec<SafetyRating>>,
250
+ }
251
+
252
+ #[derive(Deserialize, Debug, Clone, PartialEq)]
253
+ #[serde(rename_all = "camelCase")]
254
+ pub struct GroundingMetadata {
255
+ #[serde(skip_serializing_if = "Option::is_none")]
256
+ pub web_search_queries: Option<Vec<String>>,
257
+ #[serde(skip_serializing_if = "Option::is_none")]
258
+ pub retrieval_queries: Option<Vec<String>>,
259
+ #[serde(skip_serializing_if = "Option::is_none")]
260
+ pub search_entry_point: Option<SearchEntryPoint>,
261
+ #[serde(skip_serializing_if = "Option::is_none")]
262
+ pub grounding_chunks: Option<Vec<GroundingChunk>>,
263
+ #[serde(skip_serializing_if = "Option::is_none")]
264
+ pub grounding_supports: Option<Vec<GroundingSupport>>,
265
+ #[serde(skip_serializing_if = "Option::is_none")]
266
+ pub retrieval_metadata: Option<serde_json::Value>,
267
+ }
268
+
269
+ #[derive(Deserialize, Debug, Clone, PartialEq)]
270
+ #[serde(rename_all = "camelCase")]
271
+ pub struct SearchEntryPoint {
272
+ pub rendered_content: String,
273
+ }
274
+
275
+ #[derive(Deserialize, Debug, Clone, PartialEq)]
276
+ pub struct GroundingChunk {
277
+ #[serde(skip_serializing_if = "Option::is_none")]
278
+ pub web: Option<WebChunk>,
279
+ #[serde(skip_serializing_if = "Option::is_none")]
280
+ pub retrieved_context: Option<RetrievedContext>,
281
+ #[serde(skip_serializing_if = "Option::is_none")]
282
+ pub maps: Option<MapsChunk>,
283
+ }
284
+
285
+ #[derive(Deserialize, Debug, Clone, PartialEq)]
286
+ pub struct WebChunk {
287
+ pub uri: String,
288
+ #[serde(skip_serializing_if = "Option::is_none")]
289
+ pub title: Option<String>,
290
+ }
291
+
292
+ #[derive(Deserialize, Debug, Clone, PartialEq)]
293
+ #[serde(rename_all = "camelCase")]
294
+ pub struct RetrievedContext {
295
+ #[serde(skip_serializing_if = "Option::is_none")]
296
+ pub uri: Option<String>,
297
+ #[serde(skip_serializing_if = "Option::is_none")]
298
+ pub title: Option<String>,
299
+ #[serde(skip_serializing_if = "Option::is_none")]
300
+ pub text: Option<String>,
301
+ #[serde(skip_serializing_if = "Option::is_none")]
302
+ pub file_search_store: Option<String>,
303
+ }
304
+
305
+ #[derive(Deserialize, Debug, Clone, PartialEq)]
306
+ #[serde(rename_all = "camelCase")]
307
+ pub struct MapsChunk {
308
+ #[serde(skip_serializing_if = "Option::is_none")]
309
+ pub uri: Option<String>,
310
+ #[serde(skip_serializing_if = "Option::is_none")]
311
+ pub title: Option<String>,
312
+ #[serde(skip_serializing_if = "Option::is_none")]
313
+ pub text: Option<String>,
314
+ #[serde(skip_serializing_if = "Option::is_none")]
315
+ pub place_id: Option<String>,
316
+ }
317
+
318
+ #[derive(Deserialize, Debug, Clone, PartialEq)]
319
+ #[serde(rename_all = "camelCase")]
320
+ pub struct GroundingSupport {
321
+ #[serde(skip_serializing_if = "Option::is_none")]
322
+ pub segment: Option<Segment>,
323
+ #[serde(skip_serializing_if = "Option::is_none")]
324
+ #[serde(rename = "segment_text")]
325
+ pub segment_text: Option<String>,
326
+ #[serde(skip_serializing_if = "Option::is_none")]
327
+ pub grounding_chunk_indices: Option<Vec<i32>>,
328
+ #[serde(skip_serializing_if = "Option::is_none")]
329
+ pub support_chunk_indices: Option<Vec<i32>>,
330
+ #[serde(skip_serializing_if = "Option::is_none")]
331
+ pub confidence_scores: Option<Vec<f64>>,
332
+ #[serde(skip_serializing_if = "Option::is_none")]
333
+ pub confidence_score: Option<Vec<f64>>,
334
+ }
335
+
336
+ #[derive(Deserialize, Debug, Clone, PartialEq)]
337
+ #[serde(rename_all = "camelCase")]
338
+ pub struct Segment {
339
+ #[serde(skip_serializing_if = "Option::is_none")]
340
+ pub start_index: Option<i32>,
341
+ #[serde(skip_serializing_if = "Option::is_none")]
342
+ pub end_index: Option<i32>,
343
+ #[serde(skip_serializing_if = "Option::is_none")]
344
+ pub text: Option<String>,
345
+ }
346
+
347
+ #[derive(Deserialize, Debug, Clone, PartialEq)]
348
+ #[serde(rename_all = "camelCase")]
349
+ pub struct UrlContextMetadata {
350
+ pub url_metadata: Vec<UrlMetadata>,
351
+ }
352
+
353
+ #[derive(Deserialize, Debug, Clone, PartialEq)]
354
+ #[serde(rename_all = "camelCase")]
355
+ pub struct UrlMetadata {
356
+ pub retrieved_url: String,
357
+ pub url_retrieval_status: String,
358
+ }
359
+
360
+ impl From<UsageMetadata> for forge_domain::Usage {
361
+ fn from(usage: UsageMetadata) -> Self {
362
+ let prompt_tokens = usage.prompt_token_count.unwrap_or_default() as usize;
363
+ let completion_tokens = usage.candidates_token_count.unwrap_or_default() as usize;
364
+ let cached_tokens = usage.cached_content_token_count.unwrap_or_default() as usize;
365
+ let total_tokens = usage.total_token_count.unwrap_or_default() as usize;
366
+
367
+ forge_domain::Usage {
368
+ prompt_tokens: TokenCount::Actual(prompt_tokens),
369
+ completion_tokens: TokenCount::Actual(completion_tokens),
370
+ total_tokens: TokenCount::Actual(total_tokens),
371
+ cached_tokens: TokenCount::Actual(cached_tokens),
372
+ ..Default::default()
373
+ }
374
+ }
375
+ }
376
+
377
+ /// Converts Google's finish reason strings to domain FinishReason
378
+ fn parse_finish_reason(reason: &str) -> Option<FinishReason> {
379
+ match reason {
380
+ "STOP" => Some(FinishReason::Stop),
381
+ "MAX_TOKENS" => Some(FinishReason::Length),
382
+ "SAFETY" | "RECITATION" => Some(FinishReason::ContentFilter),
383
+ _ => Some(FinishReason::Stop), // Default to Stop for unknown reasons
384
+ }
385
+ }
386
+
387
+ impl TryFrom<Part> for ChatCompletionMessage {
388
+ type Error = anyhow::Error;
389
+
390
+ fn try_from(part: Part) -> Result<Self, Self::Error> {
391
+ match part {
392
+ Part::Text {
393
+ text,
394
+ thought,
395
+ thought_signature,
396
+ executable_code: _,
397
+ code_execution_result: _,
398
+ } => {
399
+ let text_content = text.unwrap_or_default();
400
+ let is_thought = thought.unwrap_or(false);
401
+
402
+ if is_thought {
403
+ // This is a thinking/reasoning part
404
+ let mut msg = ChatCompletionMessage::assistant(forge_domain::Content::part(""))
405
+ .reasoning(forge_domain::Content::part(text_content.clone()))
406
+ .add_reasoning_detail(Reasoning::Part(vec![
407
+ ReasoningPart::default()
408
+ .text(Some(text_content))
409
+ .signature(thought_signature.clone()),
410
+ ]));
411
+
412
+ if let Some(signature) = thought_signature {
413
+ msg = msg.thought_signature(signature);
414
+ }
415
+
416
+ Ok(msg)
417
+ } else {
418
+ // Regular text content
419
+ let mut msg =
420
+ ChatCompletionMessage::assistant(forge_domain::Content::part(text_content));
421
+ if let Some(signature) = thought_signature {
422
+ msg = msg.thought_signature(signature);
423
+ }
424
+ Ok(msg)
425
+ }
426
+ }
427
+ Part::FunctionCall { function_call, thought_signature } => Ok(
428
+ ChatCompletionMessage::assistant(forge_domain::Content::part("")).add_tool_call(
429
+ ToolCallPart {
430
+ call_id: Some(ToolCallId::generate()),
431
+ name: Some(ToolName::new(function_call.name)),
432
+ arguments_part: serde_json::to_string(&function_call.args)?,
433
+ thought_signature,
434
+ },
435
+ ),
436
+ ),
437
+ Part::InlineData { .. } => {
438
+ // For now, skip inline data in responses (it's typically for inputs)
439
+ Ok(ChatCompletionMessage::assistant(
440
+ forge_domain::Content::part(""),
441
+ ))
442
+ }
443
+ }
444
+ }
445
+ }
446
+
447
+ impl TryFrom<Candidate> for ChatCompletionMessage {
448
+ type Error = anyhow::Error;
449
+
450
+ fn try_from(candidate: Candidate) -> Result<Self, Self::Error> {
451
+ let mut content_parts: Vec<String> = Vec::new();
452
+ let mut reasoning_parts: Vec<String> = Vec::new();
453
+ let mut tool_calls: Vec<forge_domain::ToolCall> = Vec::new();
454
+ let mut reasoning_details: Option<Vec<forge_domain::Reasoning>> = None;
455
+ let mut thought_signature: Option<String> = None;
456
+ let mut finish_reason: Option<FinishReason> = None;
457
+
458
+ // Add finish reason if present
459
+ if let Some(ref reason) = candidate.finish_reason {
460
+ finish_reason = parse_finish_reason(reason);
461
+ }
462
+
463
+ // Process content parts
464
+ if let Some(content) = candidate.content
465
+ && let Some(parts) = content.parts
466
+ {
467
+ for part in parts {
468
+ let part_message = ChatCompletionMessage::try_from(part)?;
469
+
470
+ // Collect content text
471
+ if let Some(part_content) = part_message.content {
472
+ let text = part_content.as_str();
473
+ if !text.is_empty() {
474
+ content_parts.push(text.to_string());
475
+ }
476
+ }
477
+
478
+ // Collect reasoning text
479
+ if let Some(part_reasoning) = part_message.reasoning {
480
+ let text = part_reasoning.as_str();
481
+ if !text.is_empty() {
482
+ reasoning_parts.push(text.to_string());
483
+ }
484
+ }
485
+
486
+ // Collect reasoning details (accumulate)
487
+ if let Some(details) = part_message.reasoning_details {
488
+ if let Some(ref mut current) = reasoning_details {
489
+ current.extend(details);
490
+ } else {
491
+ reasoning_details = Some(details);
492
+ }
493
+ }
494
+
495
+ // Collect tool calls
496
+ tool_calls.extend(part_message.tool_calls);
497
+
498
+ // Take thought signature (last one wins)
499
+ if part_message.thought_signature.is_some() {
500
+ thought_signature = part_message.thought_signature;
501
+ }
502
+ }
503
+ }
504
+
505
+ // Build the final message
506
+ let content = content_parts.join("");
507
+ let mut message = ChatCompletionMessage::assistant(forge_domain::Content::part(content));
508
+
509
+ if let Some(finish) = finish_reason {
510
+ message = message.finish_reason(finish);
511
+ }
512
+
513
+ if !reasoning_parts.is_empty() {
514
+ message = message.reasoning(forge_domain::Content::part(reasoning_parts.join("")));
515
+ }
516
+
517
+ if let Some(details) = reasoning_details {
518
+ message.reasoning_details = Some(details);
519
+ }
520
+
521
+ if !tool_calls.is_empty() {
522
+ message.tool_calls = tool_calls;
523
+ }
524
+
525
+ if let Some(signature) = thought_signature {
526
+ message = message.thought_signature(signature);
527
+ }
528
+
529
+ Ok(message)
530
+ }
531
+ }
532
+
533
+ impl TryFrom<Response> for ChatCompletionMessage {
534
+ type Error = anyhow::Error;
535
+
536
+ fn try_from(response: Response) -> Result<Self, Self::Error> {
537
+ // Get the first candidate
538
+ if let Some(candidate) = response.candidates.into_iter().next() {
539
+ let mut message = ChatCompletionMessage::try_from(candidate)?;
540
+
541
+ // Add usage metadata if present
542
+ if let Some(usage) = response.usage_metadata {
543
+ message.usage = Some(usage.into());
544
+ }
545
+
546
+ Ok(message)
547
+ } else {
548
+ // No candidates - return empty message
549
+ let mut message = ChatCompletionMessage::assistant(forge_domain::Content::part(""));
550
+
551
+ // Still add usage if present
552
+ if let Some(usage) = response.usage_metadata {
553
+ message.usage = Some(usage.into());
554
+ }
555
+
556
+ Ok(message)
557
+ }
558
+ }
559
+ }
560
+
561
+ #[cfg(test)]
562
+ mod tests {
563
+ use serde_json::json;
564
+
565
+ use super::*;
566
+
567
+ #[test]
568
+ fn test_chat_completion_message_from_part_function_call_generates_id() {
569
+ let function_call =
570
+ FunctionCall { name: "test_tool".to_string(), args: json!({"arg": "value"}) };
571
+
572
+ let part = Part::FunctionCall { function_call, thought_signature: None };
573
+
574
+ let message = ChatCompletionMessage::try_from(part).unwrap();
575
+
576
+ assert!(!message.tool_calls.is_empty());
577
+ let tool_calls = message.tool_calls;
578
+ assert_eq!(tool_calls.len(), 1);
579
+
580
+ let tool_call = &tool_calls[0];
581
+ match tool_call {
582
+ forge_domain::ToolCall::Part(part) => {
583
+ assert!(part.call_id.is_some());
584
+ let call_id = part.call_id.as_ref().unwrap();
585
+ assert!(call_id.as_str().starts_with("forge_call_id_"));
586
+ }
587
+ _ => panic!("Expected ToolCall::Part"),
588
+ }
589
+ }
590
+
591
+ #[test]
592
+ fn test_model_conversion() {
593
+ let model = Model {
594
+ name: "models/gemini-pro".to_string(),
595
+ display_name: Some("Gemini Pro".to_string()),
596
+ description: Some("A model".to_string()),
597
+ };
598
+ let domain_model: forge_domain::Model = model.into();
599
+ assert_eq!(domain_model.id.as_str(), "gemini-pro");
600
+ assert_eq!(domain_model.name.unwrap(), "Gemini Pro");
601
+ assert_eq!(domain_model.context_length.unwrap(), 32_000);
602
+
603
+ let model_v2 = Model {
604
+ name: "models/gemini-2.0-flash".to_string(),
605
+ display_name: None,
606
+ description: None,
607
+ };
608
+ let domain_model_v2: forge_domain::Model = model_v2.clone().into();
609
+ assert_eq!(domain_model_v2.id.as_str(), "gemini-2.0-flash");
610
+ assert_eq!(domain_model_v2.name.unwrap(), "models/gemini-2.0-flash");
611
+ assert_eq!(domain_model_v2.context_length.unwrap(), 2_000_000);
612
+ }
613
+
614
+ #[test]
615
+ fn test_ping_event_extracts_cost() {
616
+ let fixture = json!({"type": "ping", "cost": "0.02889400"});
617
+ let event_data: EventData = serde_json::from_value(fixture).unwrap();
618
+ assert!(matches!(event_data, EventData::Ping(_)));
619
+
620
+ let actual = ChatCompletionMessage::try_from(event_data).unwrap();
621
+ let expected = ChatCompletionMessage::assistant(forge_domain::Content::part(""))
622
+ .usage(forge_domain::Usage { cost: Some(0.028894), ..Default::default() });
623
+ assert_eq!(actual, expected);
624
+ }
625
+
626
+ #[test]
627
+ fn test_ping_event_with_numeric_cost() {
628
+ let fixture = json!({"type": "ping", "cost": 0.05});
629
+ let event_data: EventData = serde_json::from_value(fixture).unwrap();
630
+ assert!(matches!(event_data, EventData::Ping(_)));
631
+
632
+ let actual = ChatCompletionMessage::try_from(event_data).unwrap();
633
+ assert_eq!(actual.usage.unwrap().cost, Some(0.05));
634
+ }
635
+
636
+ #[test]
637
+ fn test_unknown_event_returns_empty_message() {
638
+ let fixture = json!({"type": "something_else", "data": 123});
639
+ let event_data: EventData = serde_json::from_value(fixture).unwrap();
640
+ assert!(matches!(event_data, EventData::Unknown(_)));
641
+
642
+ let actual = ChatCompletionMessage::try_from(event_data).unwrap();
643
+ let expected = ChatCompletionMessage::assistant(forge_domain::Content::part(""));
644
+ assert_eq!(actual, expected);
645
+ }
646
+
647
+ #[test]
648
+ fn test_event_data_parsing() {
649
+ let response_json = json!({
650
+ "candidates": [{
651
+ "content": {
652
+ "parts": [{"text": "Hello"}]
653
+ }
654
+ }]
655
+ });
656
+ let event_data: EventData = serde_json::from_value(response_json).unwrap();
657
+ match event_data {
658
+ EventData::Response(_) => {}
659
+ _ => panic!("Expected Response"),
660
+ }
661
+
662
+ let error_json = json!({
663
+ "error": {
664
+ "code": 400,
665
+ "message": "Bad Request",
666
+ "status": "INVALID_ARGUMENT"
667
+ }
668
+ });
669
+ let event_data_err: EventData = serde_json::from_value(error_json).unwrap();
670
+ match event_data_err {
671
+ EventData::Error(e) => {
672
+ assert_eq!(e.error.code, 400);
673
+ assert_eq!(e.error.message, "Bad Request");
674
+ }
675
+ _ => panic!("Expected Error"),
676
+ }
677
+ }
678
+
679
+ #[test]
680
+ fn test_candidate_to_message_conversion() {
681
+ let candidate = Candidate {
682
+ content: Some(Content {
683
+ role: Some("model".to_string()),
684
+ parts: Some(vec![
685
+ Part::Text {
686
+ text: Some("Hello".to_string()),
687
+ thought: None,
688
+ thought_signature: None,
689
+ executable_code: None,
690
+ code_execution_result: None,
691
+ },
692
+ Part::Text {
693
+ text: Some("Thinking...".to_string()),
694
+ thought: Some(true),
695
+ thought_signature: Some("sig123".to_string()),
696
+ executable_code: None,
697
+ code_execution_result: None,
698
+ },
699
+ ]),
700
+ }),
701
+ finish_reason: Some("STOP".to_string()),
702
+ safety_ratings: None,
703
+ grounding_metadata: None,
704
+ url_context_metadata: None,
705
+ };
706
+
707
+ let message = ChatCompletionMessage::try_from(candidate).unwrap();
708
+
709
+ // Check content
710
+ assert_eq!(message.content.unwrap().as_str(), "Hello");
711
+
712
+ // Check reasoning
713
+ assert_eq!(message.reasoning.unwrap().as_str(), "Thinking...");
714
+
715
+ // Check finish reason
716
+ assert_eq!(message.finish_reason.unwrap(), FinishReason::Stop);
717
+
718
+ // Check thought signature
719
+ assert_eq!(message.thought_signature.unwrap(), "sig123");
720
+ }
721
+
722
+ #[test]
723
+ fn test_usage_metadata_conversion() {
724
+ let usage = UsageMetadata {
725
+ prompt_token_count: Some(10),
726
+ candidates_token_count: Some(20),
727
+ total_token_count: Some(30),
728
+ cached_content_token_count: Some(5),
729
+ thoughts_token_count: None,
730
+ traffic_type: None,
731
+ };
732
+
733
+ let domain_usage: forge_domain::Usage = usage.into();
734
+
735
+ assert_eq!(domain_usage.prompt_tokens, TokenCount::Actual(10));
736
+ assert_eq!(domain_usage.completion_tokens, TokenCount::Actual(20));
737
+ assert_eq!(domain_usage.total_tokens, TokenCount::Actual(30));
738
+ assert_eq!(domain_usage.cached_tokens, TokenCount::Actual(5));
739
+ }
740
+
741
+ #[test]
742
+ fn test_part_text_conversion() {
743
+ let part = Part::Text {
744
+ text: Some("Hello".to_string()),
745
+ thought: None,
746
+ thought_signature: None,
747
+ executable_code: None,
748
+ code_execution_result: None,
749
+ };
750
+ let msg = ChatCompletionMessage::try_from(part).unwrap();
751
+ assert_eq!(msg.content.unwrap().as_str(), "Hello");
752
+ assert!(msg.reasoning.is_none());
753
+
754
+ // Test thought
755
+ let part = Part::Text {
756
+ text: Some("Thinking...".to_string()),
757
+ thought: Some(true),
758
+ thought_signature: Some("sig".to_string()),
759
+ executable_code: None,
760
+ code_execution_result: None,
761
+ };
762
+ let msg = ChatCompletionMessage::try_from(part).unwrap();
763
+ assert_eq!(msg.content.unwrap().as_str(), ""); // Content should be empty for pure thought part
764
+ assert_eq!(msg.reasoning.unwrap().as_str(), "Thinking...");
765
+ assert_eq!(msg.thought_signature.unwrap(), "sig");
766
+ }
767
+
768
+ #[test]
769
+ fn test_response_no_candidates() {
770
+ let response = Response {
771
+ candidates: vec![],
772
+ usage_metadata: Some(UsageMetadata {
773
+ prompt_token_count: Some(10),
774
+ candidates_token_count: Some(20),
775
+ total_token_count: Some(30),
776
+ cached_content_token_count: None,
777
+ thoughts_token_count: None,
778
+ traffic_type: None,
779
+ }),
780
+ prompt_feedback: None,
781
+ };
782
+
783
+ let msg = ChatCompletionMessage::try_from(response).unwrap();
784
+ assert_eq!(msg.content.unwrap().as_str(), "");
785
+
786
+ let usage = msg.usage.unwrap();
787
+ assert_eq!(usage.prompt_tokens, TokenCount::Actual(10));
788
+ assert_eq!(usage.completion_tokens, TokenCount::Actual(20));
789
+ }
790
+ }
crates/forge_app/src/dto/openai/copilot_model.rs ADDED
@@ -0,0 +1,407 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use forge_domain::ModelId;
2
+ use serde::{Deserialize, Serialize};
3
+
4
+ /// Identifier of the synthetic GitHub Copilot "auto" model.
5
+ ///
6
+ /// Copilot's API has no `auto` model id; official clients implement "Auto"
7
+ /// by letting the request omit the model so the service selects a default.
8
+ /// Requests made with this id must not include a `model` field.
9
+ pub const COPILOT_AUTO_MODEL_ID: &str = "auto";
10
+
11
+ /// Creates the synthetic "auto" model entry for GitHub Copilot.
12
+ ///
13
+ /// Selecting this model omits the `model` field from chat requests so the
14
+ /// Copilot service chooses the model server-side. This mirrors the "Auto"
15
+ /// option in official clients and is the included option on free and
16
+ /// student plans where premium model requests are limited.
17
+ pub fn copilot_auto_model() -> forge_domain::Model {
18
+ forge_domain::Model {
19
+ id: ModelId::new(COPILOT_AUTO_MODEL_ID),
20
+ name: Some("Auto (server default)".to_string()),
21
+ description: Some(
22
+ "Lets GitHub Copilot choose the model. Does not consume premium requests on plans with limited premium quota."
23
+ .to_string(),
24
+ ),
25
+ context_length: None,
26
+ tools_supported: Some(true),
27
+ supports_parallel_tool_calls: None,
28
+ supports_reasoning: None,
29
+ input_modalities: vec![forge_domain::InputModality::Text],
30
+ }
31
+ }
32
+
33
+ /// Response returned by the GitHub Copilot `/models` endpoint.
34
+ #[derive(Debug, Deserialize, Clone, Serialize)]
35
+ pub struct CopilotListModelResponse {
36
+ pub data: Vec<CopilotModel>,
37
+ }
38
+
39
+ /// A model entry as returned by the GitHub Copilot `/models` endpoint.
40
+ ///
41
+ /// GitHub Copilot uses a schema that differs from the standard OpenAI
42
+ /// models response: capabilities, limits and access policy are nested
43
+ /// under dedicated objects.
44
+ #[derive(Debug, Deserialize, Serialize, Clone)]
45
+ pub struct CopilotModel {
46
+ pub id: ModelId,
47
+ pub name: Option<String>,
48
+ /// Whether the model is shown in the model picker of official clients
49
+ #[serde(default)]
50
+ pub model_picker_enabled: bool,
51
+ /// Access policy for the model; models with a `disabled` state cannot
52
+ /// be used by the authenticated account
53
+ pub policy: Option<CopilotPolicy>,
54
+ pub capabilities: Option<CopilotCapabilities>,
55
+ pub supported_endpoints: Option<Vec<String>>,
56
+ pub vendor: Option<String>,
57
+ pub preview: Option<bool>,
58
+ }
59
+
60
+ /// Access policy attached to a Copilot model.
61
+ #[derive(Debug, Deserialize, Serialize, Clone, Default)]
62
+ pub struct CopilotPolicy {
63
+ pub state: Option<String>,
64
+ }
65
+
66
+ /// Capabilities of a Copilot model.
67
+ #[derive(Debug, Deserialize, Serialize, Clone, Default)]
68
+ pub struct CopilotCapabilities {
69
+ /// Model type, e.g. `chat` or `embeddings`
70
+ #[serde(rename = "type")]
71
+ pub model_type: Option<String>,
72
+ pub limits: Option<CopilotLimits>,
73
+ pub supports: Option<CopilotSupports>,
74
+ }
75
+
76
+ /// Token and vision limits of a Copilot model.
77
+ #[derive(Debug, Deserialize, Serialize, Clone, Default)]
78
+ pub struct CopilotLimits {
79
+ pub max_context_window_tokens: Option<u64>,
80
+ pub max_prompt_tokens: Option<u64>,
81
+ pub max_output_tokens: Option<u64>,
82
+ pub vision: Option<CopilotVisionLimits>,
83
+ }
84
+
85
+ /// Vision-specific limits of a Copilot model.
86
+ #[derive(Debug, Deserialize, Serialize, Clone, Default)]
87
+ pub struct CopilotVisionLimits {
88
+ pub supported_media_types: Option<Vec<String>>,
89
+ }
90
+
91
+ /// Feature support flags of a Copilot model.
92
+ #[derive(Debug, Deserialize, Serialize, Clone, Default)]
93
+ pub struct CopilotSupports {
94
+ pub tool_calls: Option<bool>,
95
+ pub parallel_tool_calls: Option<bool>,
96
+ pub streaming: Option<bool>,
97
+ pub vision: Option<bool>,
98
+ pub adaptive_thinking: Option<bool>,
99
+ pub max_thinking_budget: Option<u64>,
100
+ pub min_thinking_budget: Option<u64>,
101
+ pub reasoning_effort: Option<Vec<String>>,
102
+ }
103
+
104
+ impl CopilotModel {
105
+ /// Returns true when the model can actually be used for chat by the
106
+ /// authenticated account.
107
+ ///
108
+ /// Filters out models that are policy-disabled for the current
109
+ /// subscription (requests to them fail with `model_not_supported`) as
110
+ /// well as non-chat models such as embeddings.
111
+ pub fn is_usable(&self) -> bool {
112
+ // Models explicitly disabled by policy cannot be used and return
113
+ // `model_not_supported` errors when requested
114
+ let policy_disabled = self
115
+ .policy
116
+ .as_ref()
117
+ .and_then(|p| p.state.as_deref())
118
+ .is_some_and(|state| state == "disabled");
119
+ if policy_disabled {
120
+ return false;
121
+ }
122
+
123
+ let Some(capabilities) = self.capabilities.as_ref() else {
124
+ return false;
125
+ };
126
+
127
+ // Only chat models are usable for conversations (excludes embeddings)
128
+ if capabilities.model_type.as_deref() != Some("chat") {
129
+ return false;
130
+ }
131
+
132
+ // Require known token limits and tool call information, mirroring the
133
+ // checks official clients perform before offering a model
134
+ let has_limits = capabilities.limits.as_ref().is_some_and(|limits| {
135
+ limits.max_output_tokens.is_some() && limits.max_prompt_tokens.is_some()
136
+ });
137
+ let has_tool_info = capabilities
138
+ .supports
139
+ .as_ref()
140
+ .is_some_and(|supports| supports.tool_calls.is_some());
141
+
142
+ has_limits && has_tool_info
143
+ }
144
+ }
145
+
146
+ impl From<CopilotModel> for forge_domain::Model {
147
+ fn from(value: CopilotModel) -> Self {
148
+ let supports = value
149
+ .capabilities
150
+ .as_ref()
151
+ .and_then(|c| c.supports.as_ref());
152
+ let limits = value.capabilities.as_ref().and_then(|c| c.limits.as_ref());
153
+
154
+ let context_length =
155
+ limits.and_then(|l| l.max_context_window_tokens.or(l.max_prompt_tokens));
156
+
157
+ let tools_supported = supports.and_then(|s| s.tool_calls);
158
+ let supports_parallel_tool_calls = supports.and_then(|s| s.parallel_tool_calls);
159
+
160
+ // A model supports reasoning when it advertises adaptive thinking,
161
+ // reasoning effort levels or a thinking budget
162
+ let supports_reasoning = supports.map(|s| {
163
+ s.adaptive_thinking.unwrap_or(false)
164
+ || s.reasoning_effort
165
+ .as_ref()
166
+ .is_some_and(|efforts| !efforts.is_empty())
167
+ || s.max_thinking_budget.is_some()
168
+ });
169
+
170
+ let supports_image = supports.and_then(|s| s.vision).unwrap_or(false)
171
+ || limits
172
+ .and_then(|l| l.vision.as_ref())
173
+ .and_then(|v| v.supported_media_types.as_ref())
174
+ .is_some_and(|types| types.iter().any(|t| t.starts_with("image/")));
175
+
176
+ let mut input_modalities = vec![forge_domain::InputModality::Text];
177
+ if supports_image {
178
+ input_modalities.push(forge_domain::InputModality::Image);
179
+ }
180
+
181
+ forge_domain::Model {
182
+ id: value.id,
183
+ name: value.name,
184
+ description: None,
185
+ context_length,
186
+ tools_supported,
187
+ supports_parallel_tool_calls,
188
+ supports_reasoning,
189
+ input_modalities,
190
+ }
191
+ }
192
+ }
193
+
194
+ #[cfg(test)]
195
+ mod tests {
196
+ use pretty_assertions::assert_eq;
197
+
198
+ use super::*;
199
+
200
+ fn fixture_chat_model() -> CopilotModel {
201
+ serde_json::from_value(serde_json::json!({
202
+ "id": "claude-sonnet-4.6",
203
+ "name": "Claude Sonnet 4.6",
204
+ "model_picker_enabled": true,
205
+ "policy": { "state": "enabled" },
206
+ "vendor": "Anthropic",
207
+ "preview": false,
208
+ "supported_endpoints": ["/chat/completions", "/v1/messages"],
209
+ "capabilities": {
210
+ "type": "chat",
211
+ "family": "claude-sonnet-4.6",
212
+ "limits": {
213
+ "max_context_window_tokens": 264000,
214
+ "max_output_tokens": 64000,
215
+ "max_prompt_tokens": 200000,
216
+ "vision": {
217
+ "max_prompt_image_size": 3145728,
218
+ "max_prompt_images": 5,
219
+ "supported_media_types": ["image/jpeg", "image/png"]
220
+ }
221
+ },
222
+ "supports": {
223
+ "adaptive_thinking": true,
224
+ "max_thinking_budget": 32000,
225
+ "min_thinking_budget": 1024,
226
+ "parallel_tool_calls": true,
227
+ "reasoning_effort": ["low", "medium", "high", "max"],
228
+ "streaming": true,
229
+ "structured_outputs": true,
230
+ "tool_calls": true,
231
+ "vision": true
232
+ }
233
+ }
234
+ }))
235
+ .unwrap()
236
+ }
237
+
238
+ fn fixture_disabled_model() -> CopilotModel {
239
+ serde_json::from_value(serde_json::json!({
240
+ "id": "gpt-5.5",
241
+ "name": "GPT-5.5",
242
+ "model_picker_enabled": true,
243
+ "policy": { "state": "disabled" },
244
+ "capabilities": {
245
+ "type": "chat",
246
+ "limits": {
247
+ "max_context_window_tokens": 272000,
248
+ "max_output_tokens": 128000,
249
+ "max_prompt_tokens": 144000
250
+ },
251
+ "supports": { "tool_calls": true, "streaming": true }
252
+ }
253
+ }))
254
+ .unwrap()
255
+ }
256
+
257
+ fn fixture_embedding_model() -> CopilotModel {
258
+ serde_json::from_value(serde_json::json!({
259
+ "id": "text-embedding-3-small",
260
+ "name": "Embedding V3 small",
261
+ "model_picker_enabled": false,
262
+ "capabilities": {
263
+ "type": "embeddings",
264
+ "limits": { "max_inputs": 256 },
265
+ "supports": { "dimensions": true }
266
+ }
267
+ }))
268
+ .unwrap()
269
+ }
270
+
271
+ #[test]
272
+ fn test_usable_chat_model() {
273
+ let fixture = fixture_chat_model();
274
+ let actual = fixture.is_usable();
275
+ let expected = true;
276
+ assert_eq!(actual, expected);
277
+ }
278
+
279
+ #[test]
280
+ fn test_policy_disabled_model_is_not_usable() {
281
+ let fixture = fixture_disabled_model();
282
+ let actual = fixture.is_usable();
283
+ let expected = false;
284
+ assert_eq!(actual, expected);
285
+ }
286
+
287
+ #[test]
288
+ fn test_embedding_model_is_not_usable() {
289
+ let fixture = fixture_embedding_model();
290
+ let actual = fixture.is_usable();
291
+ let expected = false;
292
+ assert_eq!(actual, expected);
293
+ }
294
+
295
+ #[test]
296
+ fn test_model_without_policy_is_usable() {
297
+ let fixture = serde_json::from_value::<CopilotModel>(serde_json::json!({
298
+ "id": "gpt-4o",
299
+ "name": "GPT-4o",
300
+ "model_picker_enabled": false,
301
+ "capabilities": {
302
+ "type": "chat",
303
+ "limits": {
304
+ "max_context_window_tokens": 128000,
305
+ "max_output_tokens": 16384,
306
+ "max_prompt_tokens": 64000
307
+ },
308
+ "supports": { "tool_calls": true, "parallel_tool_calls": true }
309
+ }
310
+ }))
311
+ .unwrap();
312
+
313
+ let actual = fixture.is_usable();
314
+ let expected = true;
315
+ assert_eq!(actual, expected);
316
+ }
317
+
318
+ #[test]
319
+ fn test_conversion_to_domain_model() {
320
+ let fixture = fixture_chat_model();
321
+
322
+ let actual: forge_domain::Model = fixture.into();
323
+
324
+ let expected = forge_domain::Model {
325
+ id: ModelId::new("claude-sonnet-4.6"),
326
+ name: Some("Claude Sonnet 4.6".to_string()),
327
+ description: None,
328
+ context_length: Some(264000),
329
+ tools_supported: Some(true),
330
+ supports_parallel_tool_calls: Some(true),
331
+ supports_reasoning: Some(true),
332
+ input_modalities: vec![
333
+ forge_domain::InputModality::Text,
334
+ forge_domain::InputModality::Image,
335
+ ],
336
+ };
337
+
338
+ assert_eq!(actual, expected);
339
+ }
340
+
341
+ #[test]
342
+ fn test_conversion_falls_back_to_max_prompt_tokens() {
343
+ let fixture = serde_json::from_value::<CopilotModel>(serde_json::json!({
344
+ "id": "some-model",
345
+ "name": "Some Model",
346
+ "capabilities": {
347
+ "type": "chat",
348
+ "limits": { "max_prompt_tokens": 100000, "max_output_tokens": 4096 },
349
+ "supports": { "tool_calls": true }
350
+ }
351
+ }))
352
+ .unwrap();
353
+
354
+ let actual: forge_domain::Model = fixture.into();
355
+
356
+ assert_eq!(actual.context_length, Some(100000));
357
+ assert_eq!(actual.supports_reasoning, Some(false));
358
+ assert_eq!(
359
+ actual.input_modalities,
360
+ vec![forge_domain::InputModality::Text]
361
+ );
362
+ }
363
+
364
+ #[test]
365
+ fn test_list_response_deserialization_ignores_unknown_fields() {
366
+ let fixture = serde_json::json!({
367
+ "data": [
368
+ {
369
+ "id": "claude-sonnet-4.6",
370
+ "name": "Claude Sonnet 4.6",
371
+ "object": "model",
372
+ "model_picker_category": "versatile",
373
+ "warning_message": "some warning",
374
+ "capabilities": {
375
+ "type": "chat",
376
+ "object": "model_capabilities",
377
+ "tokenizer": "o200k_base",
378
+ "limits": { "max_prompt_tokens": 200000, "max_output_tokens": 64000 },
379
+ "supports": { "tool_calls": true }
380
+ }
381
+ }
382
+ ]
383
+ });
384
+
385
+ let actual = serde_json::from_value::<CopilotListModelResponse>(fixture).unwrap();
386
+
387
+ assert_eq!(actual.data.len(), 1);
388
+ assert_eq!(actual.data[0].id.as_str(), "claude-sonnet-4.6");
389
+ assert_eq!(actual.data[0].is_usable(), true);
390
+ }
391
+
392
+ #[test]
393
+ fn test_copilot_auto_model_id() {
394
+ let fixture = copilot_auto_model();
395
+ let actual = fixture.id.as_str();
396
+ let expected = COPILOT_AUTO_MODEL_ID;
397
+ assert_eq!(actual, expected);
398
+ }
399
+
400
+ #[test]
401
+ fn test_copilot_auto_model_supports_tools() {
402
+ let fixture = copilot_auto_model();
403
+ let actual = fixture.tools_supported;
404
+ let expected = Some(true);
405
+ assert_eq!(actual, expected);
406
+ }
407
+ }
crates/forge_app/src/dto/openai/error.rs ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::collections::BTreeMap;
2
+
3
+ use derive_setters::Setters;
4
+ use serde::{Deserialize, Serialize};
5
+ use thiserror::Error;
6
+
7
+ #[derive(Debug, derive_more::From, Error)]
8
+ pub enum Error {
9
+ #[error("{0}")]
10
+ Response(ErrorResponse),
11
+
12
+ #[error("Invalid Status Code: {0}")]
13
+ InvalidStatusCode(u16),
14
+ }
15
+
16
+ #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
17
+ #[serde(untagged)]
18
+ pub enum ErrorCode {
19
+ String(String),
20
+ Number(u16),
21
+ }
22
+
23
+ impl ErrorCode {
24
+ pub fn as_number(&self) -> Option<u16> {
25
+ match self {
26
+ ErrorCode::String(s) => s.parse::<u16>().ok(),
27
+ ErrorCode::Number(code) => Some(*code),
28
+ }
29
+ }
30
+
31
+ pub fn as_str(&self) -> Option<&str> {
32
+ match self {
33
+ ErrorCode::String(s) => Some(s),
34
+ ErrorCode::Number(_) => None,
35
+ }
36
+ }
37
+ }
38
+
39
+ #[derive(Default, Debug, Deserialize, Serialize, Clone, Setters)]
40
+ #[setters(strip_option)]
41
+ pub struct ErrorResponse {
42
+ #[serde(default, skip_serializing_if = "Option::is_none")]
43
+ pub error: Option<Box<ErrorResponse>>,
44
+
45
+ #[serde(default, skip_serializing_if = "Option::is_none")]
46
+ pub message: Option<String>,
47
+
48
+ #[serde(default, skip_serializing_if = "Option::is_none")]
49
+ pub errno: Option<i32>,
50
+
51
+ #[serde(default, skip_serializing_if = "Option::is_none")]
52
+ pub code: Option<ErrorCode>,
53
+
54
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
55
+ pub metadata: BTreeMap<String, serde_json::Value>,
56
+
57
+ #[serde(default, skip_serializing_if = "Option::is_none")]
58
+ pub syscall: Option<String>,
59
+
60
+ #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
61
+ pub type_of: Option<serde_json::Value>,
62
+
63
+ #[serde(skip_serializing_if = "Option::is_none")]
64
+ pub param: Option<serde_json::Value>,
65
+ }
66
+
67
+ impl ErrorResponse {
68
+ /// Deeply introspects the error structure to determine the ErrorCode
69
+ pub fn get_code_deep(&self) -> Option<&ErrorCode> {
70
+ if let Some(ref code) = self.code {
71
+ return Some(code);
72
+ }
73
+ if let Some(ref error) = self.error {
74
+ return error.get_code_deep();
75
+ }
76
+ None
77
+ }
78
+ }
79
+
80
+ impl std::fmt::Display for ErrorResponse {
81
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82
+ serde_json::to_string(self)
83
+ .map_err(|_| std::fmt::Error)?
84
+ .fmt(f)
85
+ }
86
+ }
87
+
88
+ #[cfg(test)]
89
+ mod tests {
90
+ use pretty_assertions::assert_eq;
91
+
92
+ use super::*;
93
+
94
+ #[test]
95
+ fn test_error_code_as_number() {
96
+ // Test with numeric error code
97
+ let code_number = ErrorCode::Number(404);
98
+ assert_eq!(code_number.as_number(), Some(404));
99
+
100
+ // Test with string error code containing a valid number
101
+ let code_string_numeric = ErrorCode::String("500".to_string());
102
+ assert_eq!(code_string_numeric.as_number(), Some(500));
103
+
104
+ // Test with string error code containing a non-numeric value
105
+ let code_string_non_numeric = ErrorCode::String("ERR_STREAM_PREMATURE_CLOSE".to_string());
106
+ assert_eq!(code_string_non_numeric.as_number(), None);
107
+ }
108
+
109
+ #[test]
110
+ fn test_error_code_as_str() {
111
+ // Test with string error code
112
+ let code_string = ErrorCode::String("ERR_STREAM_PREMATURE_CLOSE".to_string());
113
+ assert_eq!(code_string.as_str(), Some("ERR_STREAM_PREMATURE_CLOSE"));
114
+
115
+ // Test with numeric error code
116
+ let code_number = ErrorCode::Number(404);
117
+ assert_eq!(code_number.as_str(), None);
118
+ }
119
+
120
+ #[test]
121
+ fn test_get_code_deep_direct() {
122
+ // Test with an error that has a direct code
123
+ let error_code = ErrorCode::Number(404);
124
+
125
+ // Use derived setters for a cleaner initialization
126
+ let fixture = ErrorResponse::default()
127
+ .message("Error message".to_string())
128
+ .code(error_code);
129
+
130
+ let actual = fixture.get_code_deep();
131
+
132
+ // Create a new ErrorCode to compare against
133
+ let expected_code = ErrorCode::Number(404);
134
+ assert_eq!(actual, Some(&expected_code));
135
+ }
136
+
137
+ #[test]
138
+ fn test_get_code_deep_nested() {
139
+ // Test with an error that has no direct code but has an inner error with a code
140
+ let error_code = ErrorCode::String("ERR_STREAM_PREMATURE_CLOSE".to_string());
141
+
142
+ // Use derived setters for cleaner initialization
143
+ let inner_error = ErrorResponse::default()
144
+ .message("Inner error".to_string())
145
+ .code(error_code);
146
+
147
+ let fixture = ErrorResponse::default()
148
+ .error(Box::new(inner_error))
149
+ .message("Outer error".to_string());
150
+
151
+ let actual = fixture.get_code_deep();
152
+
153
+ // Create a new ErrorCode to compare against
154
+ let expected_code = ErrorCode::String("ERR_STREAM_PREMATURE_CLOSE".to_string());
155
+ assert_eq!(actual, Some(&expected_code));
156
+ }
157
+
158
+ #[test]
159
+ fn test_get_code_deep_no_code() {
160
+ // Test with an error that has no code and no inner error
161
+ let fixture = ErrorResponse::default().message("Error message".to_string());
162
+
163
+ let actual = fixture.get_code_deep();
164
+ let expected = None;
165
+ assert_eq!(actual, expected);
166
+ }
167
+
168
+ #[test]
169
+ fn test_get_code_deep_multiple_nested() {
170
+ // Test with deeply nested errors
171
+ let error_code = ErrorCode::Number(500);
172
+
173
+ let deepest_error = ErrorResponse::default()
174
+ .message("Deepest error".to_string())
175
+ .code(error_code);
176
+
177
+ let middle_error = ErrorResponse::default()
178
+ .error(Box::new(deepest_error))
179
+ .message("Middle error".to_string());
180
+ // No code here, should find deepest
181
+
182
+ let fixture = ErrorResponse::default()
183
+ .error(Box::new(middle_error))
184
+ .message("Outer error".to_string());
185
+ // No code here, should find deepest
186
+
187
+ let actual = fixture.get_code_deep();
188
+
189
+ // Create a new ErrorCode to compare against
190
+ let expected_code = ErrorCode::Number(500);
191
+ assert_eq!(actual, Some(&expected_code));
192
+ }
193
+ }
crates/forge_app/src/dto/openai/fixtures/chutes_api_response.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "data": [
3
+ {
4
+ "id": "moonshotai/Kimi-K2-Instruct-75k",
5
+ "name": "Kimi K2 Instruct 75k",
6
+ "created": 1234567890,
7
+ "description": "Kimi K2 model with 75k context length",
8
+ "context_length": 75000,
9
+ "pricing": {
10
+ "prompt": 0.17992692,
11
+ "completion": 0.17992692
12
+ },
13
+ "supported_parameters": ["tools", "supports_parallel_tool_calls"]
14
+ }
15
+ ]
16
+ }
crates/forge_app/src/dto/openai/fixtures/chutes_completion_response.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "chatcmpl-a9db7cc9005c6568",
3
+ "object": "chat.completion.chunk",
4
+ "created": 1775233185,
5
+ "model": "moonshotai/Kimi-K2.5-TEE",
6
+ "choices": [
7
+ {
8
+ "index": 0,
9
+ "delta": { "reasoning": " The", "reasoning_content": " The" },
10
+ "logprobs": null,
11
+ "finish_reason": null,
12
+ "token_ids": null,
13
+ "hidden_states": null
14
+ }
15
+ ],
16
+ "usage": {
17
+ "prompt_tokens": 8764,
18
+ "total_tokens": 8765,
19
+ "completion_tokens": 1,
20
+ "reasoning_tokens": 1
21
+ },
22
+ "chutes_verification": "6bbeaf04d5800d774cc497b0a619acee"
23
+ }
crates/forge_app/src/dto/openai/fixtures/model_invalid_pricing.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "invalid-pricing-model",
3
+ "name": "Invalid Pricing Model",
4
+ "pricing": {
5
+ "prompt": "not-a-number",
6
+ "completion": 0.002
7
+ }
8
+ }
crates/forge_app/src/dto/openai/fixtures/model_mixed_pricing.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "mixed-model",
3
+ "name": "Mixed Pricing Model",
4
+ "pricing": {
5
+ "prompt": "0.001",
6
+ "completion": 0.002,
7
+ "image": null
8
+ }
9
+ }
crates/forge_app/src/dto/openai/fixtures/model_no_pricing.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "id": "no-pricing-model",
3
+ "name": "No Pricing Model"
4
+ }
crates/forge_app/src/dto/openai/fixtures/model_numeric_pricing.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "moonshotai/Kimi-K2-Instruct-75k",
3
+ "name": "Kimi K2 Instruct 75k",
4
+ "pricing": {
5
+ "prompt": 0.17992692,
6
+ "completion": 0.17992692
7
+ }
8
+ }
crates/forge_app/src/dto/openai/fixtures/model_scientific_notation.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "scientific-model",
3
+ "name": "Scientific Notation Model",
4
+ "pricing": {
5
+ "prompt": "1.5e-3",
6
+ "completion": "2.0E-4"
7
+ }
8
+ }
crates/forge_app/src/dto/openai/fixtures/model_string_pricing.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "test-model",
3
+ "name": "Test Model",
4
+ "pricing": {
5
+ "prompt": "0.001",
6
+ "completion": "0.002"
7
+ }
8
+ }
crates/forge_app/src/dto/openai/fixtures/orcarouter_api_response.json ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "data": [
3
+ {
4
+ "id": "openai/gpt-4o-mini",
5
+ "object": "model",
6
+ "created": 1626777600,
7
+ "owned_by": "OpenAI",
8
+ "supported_endpoint_types": ["openai", "openai-response"],
9
+ "name": "OpenAI: GPT-4o-mini",
10
+ "description": "GPT-4o mini is OpenAI's most advanced small model, supporting both text and image inputs with text outputs.",
11
+ "context_length": 128000,
12
+ "max_completion_tokens": 16384,
13
+ "architecture": {
14
+ "input_modalities": ["text", "image", "file"],
15
+ "output_modalities": ["text"]
16
+ },
17
+ "top_provider": {
18
+ "context_length": 128000,
19
+ "max_completion_tokens": 16384
20
+ },
21
+ "pricing": {
22
+ "prompt": "0.0000001500",
23
+ "completion": "0.0000006000",
24
+ "prompt_per_million": "0.150000",
25
+ "completion_per_million": "0.600000"
26
+ }
27
+ }
28
+ ],
29
+ "object": "list",
30
+ "success": true
31
+ }
crates/forge_app/src/dto/openai/fixtures/zai_api_delta_response.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"id":"20250822012952a2d93c459ed54892","created":1755797392,"model":"glm-4.5","choices":[{"index":0,"delta":{"role":"assistant","content":"Hi","reasoning_content":""}}]}
crates/forge_app/src/dto/openai/fixtures/zai_api_response.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"id":"20250822012952a2d93c459ed54892","created":1755797392,"model":"glm-4.5","choices":[{"index":0,"finish_reason":"stop","delta":{"role":"assistant","content":"","reasoning_content":""}}],"usage":{"prompt_tokens":8,"completion_tokens":14,"total_tokens":22,"prompt_tokens_details":{"cached_tokens":0}}}
crates/forge_app/src/dto/openai/mod.rs ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ mod copilot_model;
2
+ mod error;
3
+ mod model;
4
+ mod reasoning;
5
+ mod request;
6
+ mod response;
7
+ mod tool_choice;
8
+ mod transformers;
9
+
10
+ pub use copilot_model::*;
11
+ pub use error::*;
12
+ pub use model::*;
13
+ pub use reasoning::*;
14
+ pub use request::*;
15
+ pub use response::*;
16
+ pub use tool_choice::*;
17
+ pub use transformers::ProviderPipeline;
crates/forge_app/src/dto/openai/model.rs ADDED
@@ -0,0 +1,356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use forge_domain::ModelId;
2
+ use serde::{Deserialize, Serialize};
3
+
4
+ #[derive(Debug, Deserialize, Serialize, Clone)]
5
+ #[serde(untagged)]
6
+ enum PriceValue {
7
+ Number(f32),
8
+ String(String),
9
+ }
10
+
11
+ impl From<PriceValue> for Option<f32> {
12
+ fn from(value: PriceValue) -> Self {
13
+ match value {
14
+ PriceValue::Number(n) => Some(n),
15
+ PriceValue::String(s) => s.parse().ok(),
16
+ }
17
+ }
18
+ }
19
+
20
+ impl TryFrom<PriceValue> for f32 {
21
+ type Error = std::num::ParseFloatError;
22
+
23
+ fn try_from(value: PriceValue) -> Result<Self, Self::Error> {
24
+ match value {
25
+ PriceValue::Number(n) => Ok(n),
26
+ PriceValue::String(s) => s.parse(),
27
+ }
28
+ }
29
+ }
30
+
31
+ #[derive(Debug, Deserialize, Serialize, Clone)]
32
+ pub struct Model {
33
+ pub id: ModelId,
34
+ pub name: Option<String>,
35
+ pub created: Option<u64>,
36
+ pub description: Option<String>,
37
+ pub context_length: Option<u64>,
38
+ pub architecture: Option<Architecture>,
39
+ pub pricing: Option<Pricing>,
40
+ pub top_provider: Option<TopProvider>,
41
+ pub per_request_limits: Option<serde_json::Value>,
42
+ pub supported_parameters: Option<Vec<String>>,
43
+ }
44
+
45
+ #[derive(Debug, Deserialize, Serialize, Clone)]
46
+ pub struct Architecture {
47
+ // Some OpenAI-compatible providers (e.g. OrcaRouter) return an
48
+ // `architecture` object carrying only the modality arrays, so every
49
+ // field must tolerate being absent.
50
+ pub modality: Option<String>,
51
+ pub tokenizer: Option<String>,
52
+ pub instruct_type: Option<String>,
53
+ pub input_modalities: Option<Vec<String>>,
54
+ pub output_modalities: Option<Vec<String>>,
55
+ }
56
+
57
+ #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
58
+ pub struct Pricing {
59
+ #[serde(default, deserialize_with = "deserialize_optional_price")]
60
+ pub prompt: Option<f32>,
61
+ #[serde(default, deserialize_with = "deserialize_optional_price")]
62
+ pub completion: Option<f32>,
63
+ #[serde(default, deserialize_with = "deserialize_optional_price")]
64
+ pub image: Option<f32>,
65
+ #[serde(default, deserialize_with = "deserialize_optional_price")]
66
+ pub request: Option<f32>,
67
+ }
68
+
69
+ fn deserialize_optional_price<'de, D>(deserializer: D) -> Result<Option<f32>, D::Error>
70
+ where
71
+ D: serde::Deserializer<'de>,
72
+ {
73
+ use serde::de::Error;
74
+
75
+ match Option::<PriceValue>::deserialize(deserializer)? {
76
+ Some(price_value) => match f32::try_from(price_value) {
77
+ Ok(value) => Ok(Some(value)),
78
+ Err(_) => Err(Error::custom("invalid string format for pricing value")),
79
+ },
80
+ None => Ok(None),
81
+ }
82
+ }
83
+
84
+ #[derive(Debug, Deserialize, Serialize, Clone)]
85
+ pub struct TopProvider {
86
+ pub context_length: Option<u64>,
87
+ pub max_completion_tokens: Option<u64>,
88
+ #[serde(default)]
89
+ pub is_moderated: bool,
90
+ }
91
+
92
+ #[derive(Debug, Deserialize, Clone, Serialize)]
93
+ pub struct ListModelResponse {
94
+ pub data: Vec<Model>,
95
+ }
96
+
97
+ impl From<Model> for forge_domain::Model {
98
+ fn from(value: Model) -> Self {
99
+ let has_param = |name: &str| {
100
+ value
101
+ .supported_parameters
102
+ .as_ref()
103
+ .map(|params| params.iter().any(|p| p == name))
104
+ };
105
+
106
+ // Parse input modalities from OpenRouter's input_modalities field
107
+ let input_modalities = value
108
+ .architecture
109
+ .as_ref()
110
+ .and_then(|arch| arch.input_modalities.as_ref())
111
+ .map(|modalities| {
112
+ modalities
113
+ .iter()
114
+ .filter_map(|s| s.parse::<forge_domain::InputModality>().ok())
115
+ .collect::<Vec<_>>()
116
+ })
117
+ .unwrap_or_else(|| vec![forge_domain::InputModality::Text]);
118
+
119
+ let tools_supported = has_param("tools");
120
+ let supports_parallel_tool_calls = has_param("supports_parallel_tool_calls");
121
+ let supports_reasoning = has_param("reasoning");
122
+
123
+ forge_domain::Model {
124
+ id: value.id,
125
+ name: value.name,
126
+ description: value.description,
127
+ context_length: value.context_length,
128
+ tools_supported,
129
+ supports_parallel_tool_calls,
130
+ supports_reasoning,
131
+ input_modalities,
132
+ }
133
+ }
134
+ }
135
+
136
+ #[cfg(test)]
137
+ mod tests {
138
+ use pretty_assertions::assert_eq;
139
+
140
+ use super::*;
141
+
142
+ pub async fn load_fixture(filename: &str) -> serde_json::Value {
143
+ let fixture_path = format!("src/dto/openai/fixtures/{}", filename);
144
+ let fixture_content = tokio::fs::read_to_string(&fixture_path)
145
+ .await
146
+ .unwrap_or_else(|_| panic!("Failed to read fixture file: {}", fixture_path));
147
+ serde_json::from_str(&fixture_content)
148
+ .unwrap_or_else(|_| panic!("Failed to parse JSON fixture: {}", fixture_path))
149
+ }
150
+
151
+ #[tokio::test]
152
+ async fn test_deserialize_model_with_numeric_pricing() {
153
+ // This reproduces the issue where Chutes API returns numeric pricing instead of
154
+ // strings
155
+ let fixture = load_fixture("model_numeric_pricing.json").await;
156
+
157
+ let actual = serde_json::from_value::<Model>(fixture).unwrap();
158
+
159
+ // This should not fail - we should be able to handle numeric pricing
160
+ assert_eq!(actual.pricing.as_ref().unwrap().prompt, Some(0.17992692));
161
+ assert_eq!(
162
+ actual.pricing.as_ref().unwrap().completion,
163
+ Some(0.17992692)
164
+ );
165
+ }
166
+
167
+ #[tokio::test]
168
+ async fn test_deserialize_model_with_string_pricing() {
169
+ let fixture = load_fixture("model_string_pricing.json").await;
170
+
171
+ let actual = serde_json::from_value::<Model>(fixture).unwrap();
172
+ let expected = Model {
173
+ id: "test-model".into(),
174
+ name: Some("Test Model".to_string()),
175
+ created: None,
176
+ description: None,
177
+ context_length: None,
178
+ architecture: None,
179
+ pricing: Some(Pricing {
180
+ prompt: Some(0.001),
181
+ completion: Some(0.002),
182
+ image: None,
183
+ request: None,
184
+ }),
185
+ top_provider: None,
186
+ per_request_limits: None,
187
+ supported_parameters: None,
188
+ };
189
+
190
+ assert_eq!(actual.id, expected.id);
191
+ assert_eq!(actual.name, expected.name);
192
+ assert_eq!(
193
+ actual.pricing.as_ref().unwrap().prompt,
194
+ expected.pricing.as_ref().unwrap().prompt
195
+ );
196
+ assert_eq!(
197
+ actual.pricing.as_ref().unwrap().completion,
198
+ expected.pricing.as_ref().unwrap().completion
199
+ );
200
+ }
201
+
202
+ #[tokio::test]
203
+ async fn test_deserialize_model_with_mixed_pricing() {
204
+ // Test with mixed string, numeric, and null pricing values
205
+ let fixture = load_fixture("model_mixed_pricing.json").await;
206
+
207
+ let actual = serde_json::from_value::<Model>(fixture).unwrap();
208
+
209
+ assert_eq!(actual.pricing.as_ref().unwrap().prompt, Some(0.001));
210
+ assert_eq!(actual.pricing.as_ref().unwrap().completion, Some(0.002));
211
+ assert_eq!(actual.pricing.as_ref().unwrap().image, None);
212
+ assert_eq!(actual.pricing.as_ref().unwrap().request, None);
213
+ }
214
+
215
+ #[tokio::test]
216
+ async fn test_deserialize_model_without_pricing() {
217
+ // Test that models without pricing field work correctly
218
+ let fixture = load_fixture("model_no_pricing.json").await;
219
+
220
+ let actual = serde_json::from_value::<Model>(fixture).unwrap();
221
+
222
+ assert_eq!(actual.id.as_str(), "no-pricing-model");
223
+ assert_eq!(actual.name, Some("No Pricing Model".to_string()));
224
+ assert_eq!(actual.pricing, None);
225
+ }
226
+
227
+ #[tokio::test]
228
+ async fn test_chutes_api_response_format() {
229
+ // This simulates the actual Chutes API response format that was causing the
230
+ // issue
231
+ let fixture = load_fixture("chutes_api_response.json").await;
232
+
233
+ let actual = serde_json::from_value::<ListModelResponse>(fixture).unwrap();
234
+
235
+ assert_eq!(actual.data.len(), 1);
236
+ let model = &actual.data[0];
237
+ assert_eq!(model.id.as_str(), "moonshotai/Kimi-K2-Instruct-75k");
238
+ assert_eq!(model.name, Some("Kimi K2 Instruct 75k".to_string()));
239
+ assert_eq!(model.context_length, Some(75000));
240
+
241
+ let pricing = model.pricing.as_ref().unwrap();
242
+ assert_eq!(pricing.prompt, Some(0.17992692));
243
+ assert_eq!(pricing.completion, Some(0.17992692));
244
+ assert_eq!(pricing.image, None);
245
+ assert_eq!(pricing.request, None);
246
+ }
247
+
248
+ #[tokio::test]
249
+ async fn test_orcarouter_api_response_format() {
250
+ // OrcaRouter returns an `architecture` object with only the modality
251
+ // arrays (no `modality`/`tokenizer`) and a `top_provider` without
252
+ // `is_moderated`; all of these must deserialize.
253
+ let fixture = load_fixture("orcarouter_api_response.json").await;
254
+
255
+ let actual = serde_json::from_value::<ListModelResponse>(fixture).unwrap();
256
+
257
+ assert_eq!(actual.data.len(), 1);
258
+ let model = &actual.data[0];
259
+ assert_eq!(model.id.as_str(), "openai/gpt-4o-mini");
260
+ assert_eq!(model.name, Some("OpenAI: GPT-4o-mini".to_string()));
261
+ assert_eq!(model.context_length, Some(128000));
262
+
263
+ let architecture = model.architecture.as_ref().unwrap();
264
+ assert_eq!(architecture.modality, None);
265
+ assert_eq!(architecture.tokenizer, None);
266
+ assert_eq!(
267
+ architecture.input_modalities,
268
+ Some(vec![
269
+ "text".to_string(),
270
+ "image".to_string(),
271
+ "file".to_string()
272
+ ])
273
+ );
274
+
275
+ let top_provider = model.top_provider.as_ref().unwrap();
276
+ assert_eq!(top_provider.context_length, Some(128000));
277
+ assert_eq!(top_provider.is_moderated, false);
278
+
279
+ let pricing = model.pricing.as_ref().unwrap();
280
+ assert_eq!(pricing.prompt, Some(0.00000015));
281
+ assert_eq!(pricing.completion, Some(0.0000006));
282
+ }
283
+
284
+ #[tokio::test]
285
+ async fn test_deserialize_model_with_invalid_string_pricing() {
286
+ // Test that invalid string pricing formats fail gracefully
287
+ let fixture = load_fixture("model_invalid_pricing.json").await;
288
+
289
+ let actual = serde_json::from_value::<Model>(fixture);
290
+
291
+ // This should fail with a parsing error
292
+ assert!(actual.is_err());
293
+ let error_message = format!("{}", actual.unwrap_err());
294
+ assert!(error_message.contains("invalid string format for pricing value"));
295
+ }
296
+
297
+ #[tokio::test]
298
+ async fn test_deserialize_model_with_scientific_notation_string() {
299
+ // Test that scientific notation in strings works
300
+ let fixture = load_fixture("model_scientific_notation.json").await;
301
+
302
+ let actual = serde_json::from_value::<Model>(fixture).unwrap();
303
+
304
+ assert_eq!(actual.pricing.as_ref().unwrap().prompt, Some(0.0015));
305
+ assert_eq!(actual.pricing.as_ref().unwrap().completion, Some(0.0002));
306
+ }
307
+ #[tokio::test]
308
+ async fn test_model_conversion_without_supported_parameters() {
309
+ let model = Model {
310
+ id: "test-model".into(),
311
+ name: Some("Test Model".to_string()),
312
+ created: None,
313
+ description: Some("A test model".to_string()),
314
+ context_length: Some(4096),
315
+ architecture: None,
316
+ pricing: None,
317
+ top_provider: None,
318
+ per_request_limits: None,
319
+ supported_parameters: None, // No supported_parameters field
320
+ };
321
+
322
+ let domain_model: forge_domain::Model = model.into();
323
+
324
+ // When supported_parameters is None, capabilities should be None (unknown)
325
+ assert_eq!(domain_model.tools_supported, None);
326
+ assert_eq!(domain_model.supports_parallel_tool_calls, None);
327
+ assert_eq!(domain_model.supports_reasoning, None);
328
+ }
329
+
330
+ #[tokio::test]
331
+ async fn test_model_conversion_with_supported_parameters() {
332
+ let model = Model {
333
+ id: "test-model".into(),
334
+ name: Some("Test Model".to_string()),
335
+ created: None,
336
+ description: Some("A test model".to_string()),
337
+ context_length: Some(4096),
338
+ architecture: None,
339
+ pricing: None,
340
+ top_provider: None,
341
+ per_request_limits: None,
342
+ supported_parameters: Some(vec![
343
+ "tools".to_string(),
344
+ "reasoning".to_string(),
345
+ // Note: "supports_parallel_tool_calls" is not included
346
+ ]),
347
+ };
348
+
349
+ let domain_model: forge_domain::Model = model.into();
350
+
351
+ // Should reflect what's actually in supported_parameters
352
+ assert_eq!(domain_model.tools_supported, Some(true));
353
+ assert_eq!(domain_model.supports_parallel_tool_calls, Some(false));
354
+ assert_eq!(domain_model.supports_reasoning, Some(true));
355
+ }
356
+ }
crates/forge_app/src/dto/openai/reasoning.rs ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use serde::{Deserialize, Serialize};
2
+
3
+ #[derive(Debug, Deserialize, Serialize, Clone)]
4
+ pub struct ReasoningDetail {
5
+ pub r#type: String,
6
+ #[serde(skip_serializing_if = "Option::is_none")]
7
+ pub text: Option<String>,
8
+ #[serde(skip_serializing_if = "Option::is_none")]
9
+ pub signature: Option<String>,
10
+ #[serde(skip_serializing_if = "Option::is_none")]
11
+ pub data: Option<String>,
12
+ #[serde(skip_serializing_if = "Option::is_none")]
13
+ pub id: Option<String>,
14
+ #[serde(skip_serializing_if = "Option::is_none")]
15
+ pub format: Option<String>,
16
+ #[serde(skip_serializing_if = "Option::is_none")]
17
+ pub index: Option<i32>,
18
+ }
crates/forge_app/src/dto/openai/request.rs ADDED
@@ -0,0 +1,937 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::vec;
2
+
3
+ use derive_more::derive::Display;
4
+ use derive_setters::Setters;
5
+ use forge_json_repair::coerce_to_schema;
6
+ use serde::{Deserialize, Serialize};
7
+ use strum::IntoEnumIterator;
8
+
9
+ use super::response::{ExtraContent, FunctionCall, ToolCall};
10
+ use super::tool_choice::{FunctionType, ToolChoice};
11
+ use crate::domain::{
12
+ Context, ContextMessage, ModelId, ToolCallFull, ToolCallId, ToolCatalog, ToolDefinition,
13
+ ToolName, ToolResult, ToolValue,
14
+ };
15
+ use crate::dto::openai::ReasoningDetail;
16
+
17
+ #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
18
+ pub struct ImageUrl {
19
+ pub url: String,
20
+ #[serde(skip_serializing_if = "Option::is_none")]
21
+ pub detail: Option<String>,
22
+ }
23
+
24
+ #[derive(Debug, Deserialize, Serialize, Clone)]
25
+ pub struct Message {
26
+ pub role: Role,
27
+ #[serde(skip_serializing_if = "Option::is_none")]
28
+ pub content: Option<MessageContent>,
29
+ #[serde(skip_serializing_if = "Option::is_none")]
30
+ pub name: Option<ToolName>,
31
+ #[serde(skip_serializing_if = "Option::is_none")]
32
+ pub tool_call_id: Option<ToolCallId>,
33
+ #[serde(skip_serializing_if = "Option::is_none")]
34
+ pub tool_calls: Option<Vec<ToolCall>>,
35
+ #[serde(skip_serializing_if = "Option::is_none")]
36
+ pub reasoning_details: Option<Vec<ReasoningDetail>>,
37
+ // GitHub Copilot format (flat fields instead of array)
38
+ #[serde(skip_serializing_if = "Option::is_none")]
39
+ pub reasoning_text: Option<String>,
40
+ #[serde(skip_serializing_if = "Option::is_none")]
41
+ pub reasoning_opaque: Option<String>,
42
+ // kimi_k2 uses reasoning_content as flat string (similar to reasoning_text but aliased)
43
+ #[serde(skip_serializing_if = "Option::is_none", rename = "reasoning_content")]
44
+ pub reasoning_content: Option<String>,
45
+ #[serde(skip_serializing_if = "Option::is_none")]
46
+ pub extra_content: Option<ExtraContent>,
47
+ }
48
+
49
+ #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
50
+ #[serde(untagged)]
51
+ pub enum MessageContent {
52
+ Text(String),
53
+ Parts(Vec<ContentPart>),
54
+ }
55
+
56
+ impl MessageContent {
57
+ pub fn cached(self, enable_cache: bool) -> Self {
58
+ let cache_control =
59
+ enable_cache.then_some(CacheControl { type_: CacheControlType::Ephemeral });
60
+
61
+ match self {
62
+ MessageContent::Text(text) => {
63
+ if let Some(cc) = cache_control {
64
+ MessageContent::Parts(vec![ContentPart::Text { text, cache_control: Some(cc) }])
65
+ } else {
66
+ MessageContent::Text(text)
67
+ }
68
+ }
69
+ MessageContent::Parts(mut parts) => {
70
+ parts.iter_mut().for_each(ContentPart::reset_cache);
71
+ match cache_control {
72
+ Some(_) => {
73
+ // cache the last part of the message
74
+ if let Some(part) = parts.last_mut() {
75
+ part.cached(enable_cache)
76
+ }
77
+ MessageContent::Parts(parts)
78
+ }
79
+ None => MessageContent::Parts(parts),
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ pub fn is_cached(&self) -> bool {
86
+ match self {
87
+ MessageContent::Text(_) => false,
88
+ MessageContent::Parts(parts) => parts.iter().any(|part| {
89
+ if let ContentPart::Text { cache_control, .. } = part {
90
+ cache_control.is_some()
91
+ } else {
92
+ false
93
+ }
94
+ }),
95
+ }
96
+ }
97
+ }
98
+
99
+ #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
100
+ #[serde(tag = "type", rename_all = "snake_case")]
101
+ pub enum ContentPart {
102
+ Text {
103
+ text: String,
104
+ #[serde(skip_serializing_if = "Option::is_none")]
105
+ cache_control: Option<CacheControl>,
106
+ },
107
+ ImageUrl {
108
+ image_url: ImageUrl,
109
+ #[serde(skip_serializing_if = "Option::is_none")]
110
+ cache_control: Option<CacheControl>,
111
+ },
112
+ }
113
+
114
+ impl ContentPart {
115
+ pub fn reset_cache(&mut self) {
116
+ match self {
117
+ ContentPart::Text { cache_control, .. } => {
118
+ *cache_control = None;
119
+ }
120
+ ContentPart::ImageUrl { cache_control, .. } => {
121
+ *cache_control = None;
122
+ }
123
+ }
124
+ }
125
+
126
+ pub fn cached(&mut self, enable_cache: bool) {
127
+ let src_cache_control =
128
+ enable_cache.then_some(CacheControl { type_: CacheControlType::Ephemeral });
129
+ match self {
130
+ ContentPart::Text { cache_control, .. } => {
131
+ *cache_control = src_cache_control;
132
+ }
133
+ ContentPart::ImageUrl { cache_control, .. } => {
134
+ *cache_control = src_cache_control;
135
+ }
136
+ }
137
+ }
138
+ }
139
+
140
+ #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
141
+ pub struct CacheControl {
142
+ #[serde(rename = "type")]
143
+ pub type_: CacheControlType,
144
+ }
145
+
146
+ #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
147
+ #[serde(rename_all = "snake_case")]
148
+ pub enum CacheControlType {
149
+ Ephemeral,
150
+ }
151
+
152
+ #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
153
+ pub struct FunctionDescription {
154
+ pub description: Option<String>,
155
+ pub name: String,
156
+ pub parameters: serde_json::Value,
157
+ }
158
+
159
+ #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
160
+ pub struct Tool {
161
+ // TODO: should be an enum
162
+ pub r#type: FunctionType,
163
+ pub function: FunctionDescription,
164
+ }
165
+
166
+ /// Response format configuration for OpenAI API
167
+ #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
168
+ #[serde(tag = "type", content = "json_schema")]
169
+ pub enum ResponseFormat {
170
+ #[serde(rename = "text")]
171
+ Text,
172
+ #[serde(rename = "json_schema")]
173
+ JsonSchema {
174
+ name: String,
175
+ schema: Box<schemars::Schema>,
176
+ },
177
+ }
178
+
179
+ #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
180
+ pub struct Prediction {
181
+ pub r#type: String,
182
+ pub content: String,
183
+ }
184
+
185
+ #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
186
+ pub struct ProviderPreferences {
187
+ // Define fields as necessary
188
+ }
189
+
190
+ /// Z.ai-specific thinking type
191
+ ///
192
+ /// Represents the state of thinking for z.ai providers
193
+ #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
194
+ #[serde(rename_all = "lowercase")]
195
+ pub enum ThinkingType {
196
+ Enabled,
197
+ Disabled,
198
+ }
199
+
200
+ /// Z.ai-specific thinking configuration structure
201
+ ///
202
+ /// Z.ai uses a different format than standard OpenAI reasoning configuration.
203
+ /// This struct represents z.ai's thinking format: `{"type": "enabled"}` or
204
+ /// `{"type": "disabled"}`
205
+ #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
206
+ pub struct ThinkingConfig {
207
+ /// Type of thinking configuration - enabled or disabled
208
+ #[serde(rename = "type")]
209
+ pub r#type: ThinkingType,
210
+ }
211
+
212
+ #[derive(Debug, Deserialize, Serialize, Clone, Setters, Default)]
213
+ #[setters(strip_option)]
214
+ pub struct Request {
215
+ #[serde(skip_serializing_if = "Option::is_none")]
216
+ pub messages: Option<Vec<Message>>,
217
+ #[serde(skip_serializing_if = "Option::is_none")]
218
+ pub prompt: Option<String>,
219
+ #[serde(skip_serializing_if = "Option::is_none")]
220
+ pub model: Option<ModelId>,
221
+ #[serde(skip_serializing_if = "Option::is_none")]
222
+ pub response_format: Option<ResponseFormat>,
223
+ #[serde(skip_serializing_if = "Option::is_none")]
224
+ pub stop: Option<Vec<String>>,
225
+ #[serde(skip_serializing_if = "Option::is_none")]
226
+ pub stream: Option<bool>,
227
+ #[serde(skip_serializing_if = "Option::is_none")]
228
+ pub max_tokens: Option<u32>,
229
+ #[serde(skip_serializing_if = "Option::is_none")]
230
+ pub temperature: Option<f32>,
231
+ #[serde(skip_serializing_if = "Option::is_none")]
232
+ pub tools: Option<Vec<Tool>>,
233
+ #[serde(skip_serializing_if = "Option::is_none")]
234
+ pub tool_choice: Option<ToolChoice>,
235
+ #[serde(skip_serializing_if = "Option::is_none")]
236
+ pub seed: Option<u32>,
237
+ #[serde(skip_serializing_if = "Option::is_none")]
238
+ pub top_p: Option<f32>,
239
+ #[serde(skip_serializing_if = "Option::is_none")]
240
+ pub top_k: Option<u32>,
241
+ #[serde(skip_serializing_if = "Option::is_none")]
242
+ pub frequency_penalty: Option<f32>,
243
+ #[serde(skip_serializing_if = "Option::is_none")]
244
+ pub presence_penalty: Option<f32>,
245
+ #[serde(skip_serializing_if = "Option::is_none")]
246
+ pub repetition_penalty: Option<f32>,
247
+ #[serde(skip_serializing_if = "Option::is_none")]
248
+ pub logit_bias: Option<std::collections::HashMap<u32, f32>>,
249
+ #[serde(skip_serializing_if = "Option::is_none")]
250
+ pub top_logprobs: Option<u32>,
251
+ #[serde(skip_serializing_if = "Option::is_none")]
252
+ pub min_p: Option<f32>,
253
+ #[serde(skip_serializing_if = "Option::is_none")]
254
+ pub top_a: Option<f32>,
255
+ #[serde(skip_serializing_if = "Option::is_none")]
256
+ pub prediction: Option<Prediction>,
257
+ #[serde(skip_serializing_if = "Option::is_none")]
258
+ pub transforms: Option<Vec<Transform>>,
259
+ #[serde(skip_serializing_if = "Option::is_none")]
260
+ pub models: Option<Vec<String>>,
261
+ #[serde(skip_serializing_if = "Option::is_none")]
262
+ pub route: Option<String>,
263
+ #[serde(skip_serializing_if = "Option::is_none")]
264
+ pub provider: Option<ProviderPreferences>,
265
+ #[serde(skip_serializing_if = "Option::is_none")]
266
+ pub parallel_tool_calls: Option<bool>,
267
+ #[serde(skip_serializing_if = "Option::is_none")]
268
+ pub session_id: Option<String>,
269
+ /// Indicates who initiated the conversation: "user" or "agent".
270
+ /// Used for GitHub Copilot billing optimization. Not serialized to API.
271
+ #[serde(skip_serializing)]
272
+ pub initiator: Option<String>,
273
+ #[serde(skip_serializing_if = "Option::is_none")]
274
+ pub stream_options: Option<StreamOptions>,
275
+ #[serde(skip_serializing_if = "Option::is_none")]
276
+ pub reasoning: Option<forge_domain::ReasoningConfig>,
277
+ #[serde(skip_serializing_if = "Option::is_none")]
278
+ pub reasoning_effort: Option<String>,
279
+ #[serde(skip_serializing_if = "Option::is_none")]
280
+ pub max_completion_tokens: Option<u32>,
281
+ #[serde(skip_serializing_if = "Option::is_none")]
282
+ pub thinking: Option<ThinkingConfig>,
283
+ }
284
+
285
+ #[derive(Debug, Deserialize, Serialize, Clone, Default)]
286
+ pub struct StreamOptions {
287
+ pub include_usage: Option<bool>,
288
+ }
289
+
290
+ impl Request {
291
+ pub fn message_count(&self) -> usize {
292
+ self.messages
293
+ .as_ref()
294
+ .map(|messages| messages.len())
295
+ .unwrap_or(0)
296
+ }
297
+
298
+ pub fn message_cache_count(&self) -> usize {
299
+ self.messages
300
+ .iter()
301
+ .flatten()
302
+ .flat_map(|a| a.content.as_ref())
303
+ .enumerate()
304
+ .map(|(i, _)| i)
305
+ .max()
306
+ .unwrap_or(0)
307
+ }
308
+ }
309
+
310
+ /// ref: https://openrouter.ai/docs/transforms
311
+ #[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)]
312
+ pub enum Transform {
313
+ #[default]
314
+ #[serde(rename = "middle-out")]
315
+ MiddleOut,
316
+ }
317
+
318
+ impl From<ToolDefinition> for Tool {
319
+ fn from(value: ToolDefinition) -> Self {
320
+ Tool {
321
+ r#type: FunctionType,
322
+ function: FunctionDescription {
323
+ description: Some(value.description),
324
+ name: value.name.to_string(),
325
+ parameters: {
326
+ let mut params = serde_json::to_value(value.input_schema).unwrap();
327
+ // Ensure OpenAI compatibility by adding properties field if missing
328
+ if let Some(obj) = params.as_object_mut()
329
+ && obj.get("type") == Some(&serde_json::Value::String("object".to_string()))
330
+ && !obj.contains_key("properties")
331
+ {
332
+ obj.insert(
333
+ "properties".to_string(),
334
+ serde_json::Value::Object(serde_json::Map::new()),
335
+ );
336
+ }
337
+ params
338
+ },
339
+ },
340
+ }
341
+ }
342
+ }
343
+
344
+ impl From<Context> for Request {
345
+ fn from(context: Context) -> Self {
346
+ Request {
347
+ messages: {
348
+ let messages = context
349
+ .messages
350
+ .into_iter()
351
+ .map(|msg| Message::from(msg.message))
352
+ .collect::<Vec<_>>();
353
+
354
+ Some(messages)
355
+ },
356
+ tools: {
357
+ let tools = context
358
+ .tools
359
+ .into_iter()
360
+ .map(Tool::from)
361
+ .collect::<Vec<_>>();
362
+ if tools.is_empty() { None } else { Some(tools) }
363
+ },
364
+ model: None,
365
+ prompt: Default::default(),
366
+ response_format: context.response_format.map(|rf| match rf {
367
+ forge_domain::ResponseFormat::Text => ResponseFormat::Text,
368
+ forge_domain::ResponseFormat::JsonSchema(schema) => {
369
+ // Extract name from schema title
370
+ let name = schema
371
+ .as_value()
372
+ .as_object()
373
+ .and_then(|obj| obj.get("title"))
374
+ .and_then(|t| t.as_str())
375
+ .map(String::from)
376
+ .expect("Schema must have a title");
377
+
378
+ ResponseFormat::JsonSchema { name, schema }
379
+ }
380
+ }),
381
+ stop: Default::default(),
382
+ stream: Some(context.stream.unwrap_or(true)),
383
+ max_tokens: context.max_tokens.map(|t| t as u32),
384
+ temperature: context.temperature.map(|t| t.value()),
385
+ tool_choice: context.tool_choice.map(|tc| tc.into()),
386
+ seed: Default::default(),
387
+ top_p: context.top_p.map(|t| t.value()),
388
+ top_k: context.top_k.map(|t| t.value()),
389
+ frequency_penalty: Default::default(),
390
+ presence_penalty: Default::default(),
391
+ repetition_penalty: Default::default(),
392
+ logit_bias: Default::default(),
393
+ top_logprobs: Default::default(),
394
+ min_p: Default::default(),
395
+ top_a: Default::default(),
396
+ prediction: Default::default(),
397
+ // Since compaction is support on the client we don't need middle-out transforms any
398
+ // more
399
+ transforms: Default::default(),
400
+ models: Default::default(),
401
+ route: Default::default(),
402
+ provider: Default::default(),
403
+ parallel_tool_calls: Some(true), /* Default to true, transformers will adjust based
404
+ * on model capabilities */
405
+ stream_options: Some(StreamOptions { include_usage: Some(true) }),
406
+ session_id: context.conversation_id.map(|id| id.to_string()),
407
+ initiator: context.initiator,
408
+ reasoning: context.reasoning,
409
+ reasoning_effort: Default::default(),
410
+ max_completion_tokens: Default::default(),
411
+ thinking: Default::default(),
412
+ }
413
+ }
414
+ }
415
+
416
+ fn serialize_tool_call_arguments(tool_call: &ToolCallFull) -> String {
417
+ let serialized_arguments = || serde_json::to_string(&tool_call.arguments).unwrap();
418
+
419
+ let Ok(parsed_arguments) = tool_call.arguments.parse() else {
420
+ return serialized_arguments();
421
+ };
422
+
423
+ let normalized_arguments = ToolCatalog::iter()
424
+ .find(|tool| tool.definition().name == tool_call.name)
425
+ .map(|tool| coerce_to_schema(parsed_arguments.clone(), &tool.definition().input_schema))
426
+ .unwrap_or(parsed_arguments);
427
+
428
+ serde_json::to_string(&normalized_arguments).unwrap_or_else(|_| serialized_arguments())
429
+ }
430
+
431
+ impl From<ToolCallFull> for ToolCall {
432
+ fn from(value: ToolCallFull) -> Self {
433
+ let arguments = serialize_tool_call_arguments(&value);
434
+ let extra_content = value.thought_signature.map(ExtraContent::from);
435
+
436
+ Self {
437
+ id: value.call_id,
438
+ r#type: FunctionType,
439
+ function: FunctionCall { arguments, name: Some(value.name) },
440
+ extra_content,
441
+ }
442
+ }
443
+ }
444
+
445
+ impl From<ContextMessage> for Message {
446
+ fn from(value: ContextMessage) -> Self {
447
+ match value {
448
+ ContextMessage::Text(chat_message) => Message {
449
+ role: chat_message.role.into(),
450
+ content: Some(MessageContent::Text(chat_message.content)),
451
+ name: None,
452
+ tool_call_id: None,
453
+ tool_calls: chat_message
454
+ .tool_calls
455
+ .map(|tool_calls| tool_calls.into_iter().map(ToolCall::from).collect()),
456
+ reasoning_details: chat_message.reasoning_details.map(|details| {
457
+ details
458
+ .into_iter()
459
+ .map(|detail| ReasoningDetail {
460
+ r#type: detail
461
+ .type_of
462
+ .unwrap_or_else(|| "reasoning.text".to_string()),
463
+ text: detail.text,
464
+ signature: detail.signature,
465
+ data: detail.data,
466
+ id: detail.id,
467
+ format: detail.format,
468
+ index: detail.index,
469
+ })
470
+ .collect::<Vec<ReasoningDetail>>()
471
+ }),
472
+ reasoning_text: None,
473
+ reasoning_opaque: None,
474
+ reasoning_content: None,
475
+ extra_content: chat_message.thought_signature.map(ExtraContent::from),
476
+ },
477
+ ContextMessage::Tool(tool_result) => Message {
478
+ role: Role::Tool,
479
+ tool_call_id: tool_result.call_id.clone(),
480
+ name: Some(tool_result.name.clone()),
481
+ content: Some(tool_result.into()),
482
+ tool_calls: None,
483
+ reasoning_details: None,
484
+ reasoning_text: None,
485
+ reasoning_opaque: None,
486
+ reasoning_content: None,
487
+ extra_content: None,
488
+ },
489
+ ContextMessage::Image(img) => {
490
+ let content = vec![ContentPart::ImageUrl {
491
+ image_url: ImageUrl { url: img.url().clone(), detail: None },
492
+ cache_control: None,
493
+ }];
494
+ Message {
495
+ role: Role::User,
496
+ content: Some(MessageContent::Parts(content)),
497
+ name: None,
498
+ tool_call_id: None,
499
+ tool_calls: None,
500
+ reasoning_details: None,
501
+ reasoning_text: None,
502
+ reasoning_opaque: None,
503
+ reasoning_content: None,
504
+ extra_content: None,
505
+ }
506
+ }
507
+ }
508
+ }
509
+ }
510
+
511
+ impl From<ToolResult> for MessageContent {
512
+ fn from(result: ToolResult) -> Self {
513
+ if result.output.values.len() == 1
514
+ && let Some(text) = result.output.as_str()
515
+ {
516
+ return MessageContent::Text(text.to_string());
517
+ }
518
+ let mut parts = Vec::new();
519
+ for value in result.output.values.into_iter() {
520
+ match value {
521
+ ToolValue::Text(text) => {
522
+ parts.push(ContentPart::Text { text, cache_control: None });
523
+ }
524
+ ToolValue::Image(img) => {
525
+ let content = ContentPart::ImageUrl {
526
+ image_url: ImageUrl { url: img.url().clone(), detail: None },
527
+ cache_control: None,
528
+ };
529
+ parts.push(content);
530
+ }
531
+ ToolValue::Empty => {
532
+ // Handle empty case if needed
533
+ }
534
+ ToolValue::AI { value, .. } => {
535
+ parts.push(ContentPart::Text { text: value, cache_control: None })
536
+ }
537
+ }
538
+ }
539
+
540
+ MessageContent::Parts(parts)
541
+ }
542
+ }
543
+
544
+ impl From<forge_domain::Role> for Role {
545
+ fn from(role: forge_domain::Role) -> Self {
546
+ match role {
547
+ forge_domain::Role::System => Role::System,
548
+ forge_domain::Role::User => Role::User,
549
+ forge_domain::Role::Assistant => Role::Assistant,
550
+ }
551
+ }
552
+ }
553
+
554
+ #[derive(Debug, Deserialize, Display, Serialize, Clone, PartialEq, Eq)]
555
+ #[serde(rename_all = "snake_case")]
556
+ pub enum Role {
557
+ System,
558
+ User,
559
+ Assistant,
560
+ Tool,
561
+ }
562
+
563
+ #[cfg(test)]
564
+ mod tests {
565
+ use pretty_assertions::assert_eq;
566
+
567
+ use super::*;
568
+
569
+ #[test]
570
+ fn test_cached_text_true() {
571
+ let fixture = MessageContent::Text("hello".to_string());
572
+ let actual = fixture.cached(true);
573
+ let expected = MessageContent::Parts(vec![ContentPart::Text {
574
+ text: "hello".to_string(),
575
+ cache_control: Some(CacheControl { type_: CacheControlType::Ephemeral }),
576
+ }]);
577
+ assert_eq!(actual, expected);
578
+ }
579
+
580
+ #[test]
581
+ fn test_cached_text_false() {
582
+ let fixture = MessageContent::Text("hello".to_string());
583
+ let actual = fixture.cached(false);
584
+ let expected = MessageContent::Text("hello".to_string());
585
+ assert_eq!(actual, expected);
586
+ }
587
+
588
+ #[test]
589
+ fn test_cached_parts_true() {
590
+ let fixture = MessageContent::Parts(vec![
591
+ ContentPart::Text { text: "a".to_string(), cache_control: None },
592
+ ContentPart::ImageUrl {
593
+ image_url: ImageUrl { url: "http://example.com/a.png".to_string(), detail: None },
594
+ cache_control: Some(CacheControl { type_: CacheControlType::Ephemeral }),
595
+ },
596
+ ]);
597
+ let actual = fixture.cached(true);
598
+ let expected = MessageContent::Parts(vec![
599
+ ContentPart::Text { text: "a".to_string(), cache_control: None },
600
+ ContentPart::ImageUrl {
601
+ image_url: ImageUrl { url: "http://example.com/a.png".to_string(), detail: None },
602
+ cache_control: Some(CacheControl { type_: CacheControlType::Ephemeral }),
603
+ },
604
+ ]);
605
+ assert_eq!(actual, expected);
606
+ }
607
+
608
+ #[test]
609
+ fn test_cached_parts_multi_false() {
610
+ let fixture = MessageContent::Parts(vec![
611
+ ContentPart::Text {
612
+ text: "a".to_string(),
613
+ cache_control: Some(CacheControl { type_: CacheControlType::Ephemeral }),
614
+ },
615
+ ContentPart::Text {
616
+ text: "b".to_string(),
617
+ cache_control: Some(CacheControl { type_: CacheControlType::Ephemeral }),
618
+ },
619
+ ContentPart::ImageUrl {
620
+ image_url: ImageUrl { url: "http://example.com/a.png".to_string(), detail: None },
621
+ cache_control: Some(CacheControl { type_: CacheControlType::Ephemeral }),
622
+ },
623
+ ]);
624
+ let actual = fixture.cached(false);
625
+ let expected = MessageContent::Parts(vec![
626
+ ContentPart::Text { text: "a".to_string(), cache_control: None },
627
+ ContentPart::Text { text: "b".to_string(), cache_control: None },
628
+ ContentPart::ImageUrl {
629
+ image_url: ImageUrl { url: "http://example.com/a.png".to_string(), detail: None },
630
+ cache_control: None,
631
+ },
632
+ ]);
633
+ assert_eq!(actual, expected);
634
+ }
635
+
636
+ #[test]
637
+ fn test_cached_parts_already_true() {
638
+ let fixture = MessageContent::Parts(vec![
639
+ ContentPart::Text {
640
+ text: "a".to_string(),
641
+ cache_control: Some(CacheControl { type_: CacheControlType::Ephemeral }),
642
+ },
643
+ ContentPart::Text { text: "b".to_string(), cache_control: None },
644
+ ContentPart::ImageUrl {
645
+ image_url: ImageUrl { url: "http://example.com/a.png".to_string(), detail: None },
646
+ cache_control: None,
647
+ },
648
+ ]);
649
+ let actual = fixture.cached(true);
650
+ let expected = MessageContent::Parts(vec![
651
+ ContentPart::Text { text: "a".to_string(), cache_control: None },
652
+ ContentPart::Text { text: "b".to_string(), cache_control: None },
653
+ ContentPart::ImageUrl {
654
+ image_url: ImageUrl { url: "http://example.com/a.png".to_string(), detail: None },
655
+ cache_control: Some(CacheControl { type_: CacheControlType::Ephemeral }),
656
+ },
657
+ ]);
658
+ assert_eq!(actual, expected);
659
+ }
660
+
661
+ #[test]
662
+ fn test_cached_parts_multi_true() {
663
+ let fixture = MessageContent::Parts(vec![
664
+ ContentPart::Text { text: "a".to_string(), cache_control: None },
665
+ ContentPart::Text { text: "b".to_string(), cache_control: None },
666
+ ContentPart::ImageUrl {
667
+ image_url: ImageUrl { url: "http://example.com/a.png".to_string(), detail: None },
668
+ cache_control: None,
669
+ },
670
+ ]);
671
+ let actual = fixture.cached(true);
672
+ let expected = MessageContent::Parts(vec![
673
+ ContentPart::Text { text: "a".to_string(), cache_control: None },
674
+ ContentPart::Text { text: "b".to_string(), cache_control: None },
675
+ ContentPart::ImageUrl {
676
+ image_url: ImageUrl { url: "http://example.com/a.png".to_string(), detail: None },
677
+ cache_control: Some(CacheControl { type_: CacheControlType::Ephemeral }),
678
+ },
679
+ ]);
680
+ assert_eq!(actual, expected);
681
+ }
682
+
683
+ #[test]
684
+ fn test_cached_parts_false() {
685
+ let fixture = MessageContent::Parts(vec![
686
+ ContentPart::Text { text: "a".to_string(), cache_control: None },
687
+ ContentPart::ImageUrl {
688
+ image_url: ImageUrl { url: "http://example.com/a.png".to_string(), detail: None },
689
+ cache_control: None,
690
+ },
691
+ ]);
692
+ let actual = fixture.cached(false);
693
+ let expected = MessageContent::Parts(vec![
694
+ ContentPart::Text { text: "a".to_string(), cache_control: None },
695
+ ContentPart::ImageUrl {
696
+ image_url: ImageUrl { url: "http://example.com/a.png".to_string(), detail: None },
697
+ cache_control: None,
698
+ },
699
+ ]);
700
+ assert_eq!(actual, expected);
701
+ }
702
+
703
+ use forge_domain::{
704
+ ContextMessage, Role, TextMessage, ToolCallFull, ToolCallId, ToolCatalog, ToolName,
705
+ ToolResult,
706
+ };
707
+ use insta::assert_json_snapshot;
708
+
709
+ #[test]
710
+ fn test_user_message_conversion() {
711
+ let user_message = ContextMessage::Text(
712
+ TextMessage::new(Role::User, "Hello").model(ModelId::new("gpt-3.5-turbo")),
713
+ );
714
+ let router_message = Message::from(user_message);
715
+ assert_json_snapshot!(router_message);
716
+ }
717
+
718
+ #[test]
719
+ fn test_message_with_special_chars() {
720
+ let xml_content = r#"Here's some XML content:
721
+ <task>
722
+ <id>123</id>
723
+ <description>Test <special> characters</description>
724
+ <data key="value">
725
+ <item>1</item>
726
+ <item>2</item>
727
+ </data>
728
+ </task>"#;
729
+
730
+ let message = ContextMessage::Text(
731
+ TextMessage::new(Role::User, xml_content).model(ModelId::new("gpt-3.5-turbo")),
732
+ );
733
+ let router_message = Message::from(message);
734
+ assert_json_snapshot!(router_message);
735
+ }
736
+
737
+ #[test]
738
+ fn test_assistant_message_with_tool_call_conversion() {
739
+ let tool_call = ToolCallFull {
740
+ call_id: Some(ToolCallId::new("123")),
741
+ name: ToolName::new("test_tool"),
742
+ arguments: serde_json::json!({"key": "value"}).into(),
743
+ thought_signature: None,
744
+ };
745
+
746
+ let assistant_message = ContextMessage::Text(
747
+ TextMessage::new(Role::Assistant, "Using tool")
748
+ .tool_calls(vec![tool_call])
749
+ .model(ModelId::new("gpt-3.5-turbo")),
750
+ );
751
+ let router_message = Message::from(assistant_message);
752
+ assert_json_snapshot!(router_message);
753
+ }
754
+
755
+ #[test]
756
+ fn test_assistant_message_with_dump_style_tool_call_arguments_conversion() {
757
+ let fixture = ToolCatalog::tool_call_patch(
758
+ "/tmp/file.txt",
759
+ "new text",
760
+ "old text",
761
+ false,
762
+ )
763
+ .arguments(
764
+ serde_json::from_str::<forge_domain::ToolCallArguments>(
765
+ r#""{\"file_path\":\"/tmp/file.txt\",\"old_string\":\"old text\",\"new_string\":\"new text\",\"replace_all\":false}""#,
766
+ )
767
+ .unwrap(),
768
+ )
769
+ .call_id(ToolCallId::new("123"));
770
+
771
+ let assistant_message = ContextMessage::Text(
772
+ TextMessage::new(Role::Assistant, "Using tool")
773
+ .tool_calls(vec![fixture])
774
+ .model(ModelId::new("gpt-3.5-turbo")),
775
+ );
776
+ let actual = Message::from(assistant_message);
777
+ let actual =
778
+ serde_json::to_value(actual.tool_calls.expect("Tool calls should exist")).unwrap();
779
+ let expected = serde_json::json!([
780
+ {
781
+ "id": "123",
782
+ "type": "function",
783
+ "function": {
784
+ "arguments": "{\"file_path\":\"/tmp/file.txt\",\"new_string\":\"new text\",\"old_string\":\"old text\",\"replace_all\":false}",
785
+ "name": "patch"
786
+ }
787
+ }
788
+ ]);
789
+ assert_eq!(actual, expected);
790
+ }
791
+
792
+ #[test]
793
+ fn test_tool_message_conversion() {
794
+ let tool_result = ToolResult::new(ToolName::new("test_tool"))
795
+ .call_id(ToolCallId::new("123"))
796
+ .success(
797
+ r#"{
798
+ "user": "John",
799
+ "age": 30,
800
+ "address": [{"city": "New York"}, {"city": "San Francisco"}]
801
+ }"#,
802
+ );
803
+
804
+ let tool_message = ContextMessage::Tool(tool_result);
805
+ let router_message = Message::from(tool_message);
806
+ assert_json_snapshot!(router_message);
807
+ }
808
+
809
+ #[test]
810
+ fn test_tool_message_with_special_chars() {
811
+ let tool_result = ToolResult::new(ToolName::new("html_tool"))
812
+ .call_id(ToolCallId::new("456"))
813
+ .success(
814
+ r#"{
815
+ "html": "<div class=\"container\"><p>Hello <World></p></div>",
816
+ "elements": ["<span>", "<br/>", "<hr>"],
817
+ "attributes": {
818
+ "style": "color: blue; font-size: 12px;",
819
+ "data-test": "<test>&value</test>"
820
+ }
821
+ }"#,
822
+ );
823
+
824
+ let tool_message = ContextMessage::Tool(tool_result);
825
+ let router_message = Message::from(tool_message);
826
+ assert_json_snapshot!(router_message);
827
+ }
828
+
829
+ #[test]
830
+ fn test_tool_message_typescript_code() {
831
+ let tool_result = ToolResult::new(ToolName::new("rust_tool"))
832
+ .call_id(ToolCallId::new("456"))
833
+ .success(r#"{ "code": "fn main<T>(gt: T) {let b = &gt; }"}"#);
834
+
835
+ let tool_message = ContextMessage::Tool(tool_result);
836
+ let router_message = Message::from(tool_message);
837
+ assert_json_snapshot!(router_message);
838
+ }
839
+
840
+ #[test]
841
+ fn test_transform_display() {
842
+ assert_eq!(
843
+ serde_json::to_string(&Transform::MiddleOut).unwrap(),
844
+ "\"middle-out\""
845
+ );
846
+ }
847
+ #[test]
848
+ fn test_tool_definition_conversion_missing_properties() {
849
+ // Test case where input_schema is an object type but missing properties field
850
+ let fixture = {
851
+ // In schemars 1.0, Schema wraps serde_json::Value, so we create JSON directly
852
+ let schema_value = serde_json::json!({
853
+ "$schema": "http://json-schema.org/draft-07/schema#",
854
+ "title": "Null",
855
+ "type": "object",
856
+ "properties": {}
857
+ });
858
+ let schema = schemars::Schema::try_from(schema_value).unwrap();
859
+
860
+ ToolDefinition::new("test_tool")
861
+ .description("Test tool")
862
+ .input_schema(schema)
863
+ };
864
+
865
+ let actual = Tool::from(fixture);
866
+
867
+ let expected = Tool {
868
+ r#type: FunctionType,
869
+ function: FunctionDescription {
870
+ description: Some("Test tool".to_string()),
871
+ name: "test_tool".to_string(),
872
+ parameters: serde_json::json!({
873
+ "$schema": "http://json-schema.org/draft-07/schema#",
874
+ "properties": {},
875
+ "title": "Null",
876
+ "type": "object"
877
+ }),
878
+ },
879
+ };
880
+
881
+ assert_eq!(actual, expected);
882
+ }
883
+
884
+ #[test]
885
+ fn test_context_conversion_stream_defaults_to_true() {
886
+ let fixture = forge_domain::Context::default();
887
+ let actual = Request::from(fixture);
888
+
889
+ assert_eq!(actual.stream, Some(true));
890
+ }
891
+
892
+ #[test]
893
+ fn test_context_conversion_stream_explicit_true() {
894
+ let fixture = forge_domain::Context::default().stream(true);
895
+ let actual = Request::from(fixture);
896
+
897
+ assert_eq!(actual.stream, Some(true));
898
+ }
899
+
900
+ #[test]
901
+ fn test_context_conversion_stream_explicit_false() {
902
+ let fixture = forge_domain::Context::default().stream(false);
903
+ let actual = Request::from(fixture);
904
+
905
+ assert_eq!(actual.stream, Some(false));
906
+ }
907
+
908
+ #[test]
909
+ fn test_response_format_json_schema_serialization() {
910
+ use schemars::JsonSchema;
911
+ use serde::Deserialize;
912
+
913
+ #[derive(Deserialize, JsonSchema)]
914
+ #[allow(dead_code)]
915
+ #[schemars(title = "test_response")]
916
+ struct TestResponse {
917
+ message: String,
918
+ }
919
+
920
+ let schema = schemars::schema_for!(TestResponse);
921
+ let fixture = forge_domain::Context::default()
922
+ .response_format(forge_domain::ResponseFormat::JsonSchema(Box::new(schema)));
923
+
924
+ let actual = Request::from(fixture);
925
+
926
+ assert!(actual.response_format.is_some());
927
+ let rf = actual.response_format.unwrap();
928
+
929
+ // Serialize to JSON to verify the format
930
+ let json = serde_json::to_string(&rf).unwrap();
931
+ println!("Serialized response_format: {}", json);
932
+
933
+ // Should contain type and json_schema fields
934
+ assert!(json.contains("\"type\":\"json_schema\""));
935
+ assert!(json.contains("\"json_schema\""));
936
+ }
937
+ }
crates/forge_app/src/dto/openai/response.rs ADDED
@@ -0,0 +1,1120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::str::FromStr;
2
+
3
+ use forge_domain::{
4
+ ChatCompletionMessage, Content, FinishReason, TokenCount, ToolCallFull, ToolCallId,
5
+ ToolCallPart, ToolName, Usage,
6
+ };
7
+ use serde::{Deserialize, Serialize};
8
+
9
+ use super::tool_choice::FunctionType;
10
+ use crate::dto::openai::ReasoningDetail;
11
+ use crate::dto::openai::error::{Error, ErrorCode, ErrorResponse};
12
+
13
+ /// Represents a value that may be either a JSON number or a numeric string.
14
+ #[derive(Deserialize, Debug, Clone, PartialEq, derive_more::TryInto, Serialize)]
15
+ #[serde(untagged)]
16
+ pub enum StringOrF64 {
17
+ Number(f64),
18
+ String(String),
19
+ }
20
+
21
+ /// Epsilon for floating point comparison to handle near-zero costs
22
+ const COST_EPSILON: f64 = 1e-9;
23
+
24
+ /// Checks if a cost value is non-zero considering floating point precision
25
+ #[inline]
26
+ fn is_non_zero_cost(cost: &f64) -> bool {
27
+ cost.abs() > COST_EPSILON
28
+ }
29
+
30
+ #[derive(Debug, Deserialize, Serialize, Clone)]
31
+ #[serde(untagged)]
32
+ pub enum Response {
33
+ Success {
34
+ id: String,
35
+ provider: Option<String>,
36
+ #[serde(default)]
37
+ model: Option<String>,
38
+ choices: Vec<Choice>,
39
+ #[serde(default)]
40
+ created: u64,
41
+ object: Option<String>,
42
+ system_fingerprint: Option<String>,
43
+ usage: Option<ResponseUsage>,
44
+ #[serde(default)]
45
+ prompt_filter_results: Option<Vec<PromptFilterResult>>,
46
+ },
47
+ CostOnly {
48
+ choices: Vec<Choice>,
49
+ cost: Option<StringOrF64>,
50
+ },
51
+ Failure {
52
+ error: ErrorResponse,
53
+ },
54
+ }
55
+
56
+ #[derive(Debug, Deserialize, Serialize, Clone)]
57
+ pub struct PromptFilterResult {
58
+ pub prompt_index: u32,
59
+ pub content_filter_results: ContentFilterResults,
60
+ }
61
+
62
+ #[derive(Debug, Deserialize, Serialize, Clone)]
63
+ pub struct ContentFilterResults {
64
+ pub hate: Option<FilterResult>,
65
+ pub self_harm: Option<FilterResult>,
66
+ pub sexual: Option<FilterResult>,
67
+ pub violence: Option<FilterResult>,
68
+ }
69
+
70
+ #[derive(Debug, Deserialize, Serialize, Clone)]
71
+ pub struct FilterResult {
72
+ pub filtered: bool,
73
+ pub severity: String,
74
+ }
75
+
76
+ #[derive(Debug, Deserialize, Serialize, Clone)]
77
+ pub struct ResponseUsage {
78
+ pub prompt_tokens: usize,
79
+ pub completion_tokens: usize,
80
+ pub total_tokens: usize,
81
+ pub cost: Option<f64>,
82
+ pub prompt_tokens_details: Option<PromptTokenDetails>,
83
+ pub cost_details: Option<CostDetails>,
84
+ }
85
+
86
+ #[derive(Debug, Deserialize, Serialize, Clone)]
87
+ pub struct CostDetails {
88
+ pub upstream_inference_cost: Option<f64>,
89
+ pub upstream_inference_prompt_cost: Option<f64>,
90
+ pub upstream_inference_completions_cost: Option<f64>,
91
+ }
92
+
93
+ impl CostDetails {
94
+ fn total_cost(&self) -> Option<f64> {
95
+ self.upstream_inference_cost
96
+ .filter(is_non_zero_cost)
97
+ .or({
98
+ match (
99
+ self.upstream_inference_prompt_cost,
100
+ self.upstream_inference_completions_cost,
101
+ ) {
102
+ (None, None) => None,
103
+ (Some(p), None) => Some(p),
104
+ (None, Some(c)) => Some(c),
105
+ (Some(p), Some(c)) => Some(p + c),
106
+ }
107
+ })
108
+ .filter(is_non_zero_cost)
109
+ }
110
+ }
111
+
112
+ #[derive(Debug, Deserialize, Serialize, Clone)]
113
+ pub struct PromptTokenDetails {
114
+ pub cached_tokens: usize,
115
+ }
116
+
117
+ #[derive(Debug, Deserialize, Serialize, Clone)]
118
+ #[serde(untagged)]
119
+ pub enum Choice {
120
+ NonChat {
121
+ finish_reason: Option<String>,
122
+ text: String,
123
+ error: Option<ErrorResponse>,
124
+ },
125
+ NonStreaming {
126
+ logprobs: Option<serde_json::Value>,
127
+ index: u32,
128
+ finish_reason: Option<String>,
129
+ message: ResponseMessage,
130
+ error: Option<ErrorResponse>,
131
+ },
132
+ Streaming {
133
+ finish_reason: Option<String>,
134
+ delta: ResponseMessage,
135
+ error: Option<ErrorResponse>,
136
+ },
137
+ }
138
+
139
+ /// A message returned by a provider, used for both streaming deltas and
140
+ /// non-streaming responses.
141
+ ///
142
+ /// `reasoning` and `reasoning_content` are kept as separate private fields
143
+ /// because some providers (e.g. `moonshotai/Kimi-K2.5-TEE`) emit **both**
144
+ /// keys in the same delta object. Using `#[serde(alias)]` would cause a
145
+ /// `duplicate_field` error in that case. Use [`ResponseMessage::reasoning`]
146
+ /// to read the value in preference order.
147
+ #[derive(Debug, Deserialize, Serialize, Clone)]
148
+ pub struct ResponseMessage {
149
+ pub content: Option<String>,
150
+ // Private: some providers (e.g. moonshotai/Kimi-K2.5-TEE) emit both keys
151
+ // in the same delta object. Exposing them directly would let callers
152
+ // accidentally read only one and miss the other. Use `reasoning()` instead,
153
+ // which merges them in preference order.
154
+ reasoning: Option<String>,
155
+ reasoning_content: Option<String>,
156
+ pub role: Option<String>,
157
+ pub tool_calls: Option<Vec<ToolCall>>,
158
+ pub refusal: Option<String>,
159
+ pub reasoning_details: Option<Vec<ReasoningDetail>>,
160
+ // GitHub Copilot format (flat fields instead of array)
161
+ pub reasoning_text: Option<String>,
162
+ pub reasoning_opaque: Option<String>,
163
+ #[serde(skip_serializing_if = "Option::is_none")]
164
+ pub extra_content: Option<ExtraContent>,
165
+ }
166
+
167
+ impl ResponseMessage {
168
+ /// Returns the reasoning text. When both `reasoning` and
169
+ /// `reasoning_content` are present, the longer non-empty value is
170
+ /// returned; otherwise whichever is non-empty is used.
171
+ pub fn reasoning(&self) -> Option<&str> {
172
+ match (self.reasoning.as_deref(), self.reasoning_content.as_deref()) {
173
+ (Some(a), Some(b)) => {
174
+ let a = a.trim();
175
+ let b = b.trim();
176
+ match (a.is_empty(), b.is_empty()) {
177
+ (true, _) => Some(b).filter(|s| !s.is_empty()),
178
+ (_, true) => Some(a).filter(|s| !s.is_empty()),
179
+ _ => Some(if b.len() > a.len() { b } else { a }),
180
+ }
181
+ }
182
+ (Some(a), None) => Some(a).filter(|s| !s.trim().is_empty()),
183
+ (None, Some(b)) => Some(b).filter(|s| !s.trim().is_empty()),
184
+ (None, None) => None,
185
+ }
186
+ }
187
+ }
188
+
189
+ impl From<ReasoningDetail> for forge_domain::ReasoningDetail {
190
+ fn from(detail: ReasoningDetail) -> Self {
191
+ forge_domain::ReasoningDetail {
192
+ text: detail.text,
193
+ signature: detail.signature,
194
+ data: detail.data,
195
+ id: detail.id,
196
+ format: detail.format,
197
+ index: detail.index,
198
+ type_of: Some(detail.r#type),
199
+ }
200
+ }
201
+ }
202
+
203
+ /// Google-specific metadata for Vertex AI thought signatures
204
+ #[derive(Debug, Deserialize, Serialize, Clone)]
205
+ pub struct GoogleMetadata {
206
+ pub thought_signature: Option<String>,
207
+ }
208
+
209
+ /// Extra content that may be included by certain providers (e.g., Vertex AI)
210
+ #[derive(Debug, Deserialize, Serialize, Clone)]
211
+ pub struct ExtraContent {
212
+ pub google: Option<GoogleMetadata>,
213
+ }
214
+
215
+ impl ExtraContent {
216
+ /// Extracts the thought_signature from the extra content if present
217
+ pub fn thought_signature(&self) -> Option<String> {
218
+ self.google
219
+ .as_ref()
220
+ .and_then(|g| g.thought_signature.clone())
221
+ }
222
+ }
223
+
224
+ impl From<String> for ExtraContent {
225
+ fn from(thought_signature: String) -> Self {
226
+ Self {
227
+ google: Some(GoogleMetadata { thought_signature: Some(thought_signature) }),
228
+ }
229
+ }
230
+ }
231
+
232
+ #[derive(Debug, Deserialize, Serialize, Clone)]
233
+ pub struct ToolCall {
234
+ pub id: Option<ToolCallId>,
235
+ pub r#type: FunctionType,
236
+ pub function: FunctionCall,
237
+ #[serde(skip_serializing_if = "Option::is_none")]
238
+ pub extra_content: Option<ExtraContent>,
239
+ }
240
+
241
+ #[derive(Debug, Deserialize, Serialize, Clone)]
242
+ pub struct FunctionCall {
243
+ // Only the first event typically has the name of the function call
244
+ pub name: Option<ToolName>,
245
+ #[serde(default)]
246
+ pub arguments: String,
247
+ }
248
+
249
+ impl From<ResponseUsage> for Usage {
250
+ fn from(usage: ResponseUsage) -> Self {
251
+ let cost = usage
252
+ .cost
253
+ .filter(is_non_zero_cost)
254
+ .or_else(|| {
255
+ usage
256
+ .cost_details
257
+ .as_ref()
258
+ .and_then(CostDetails::total_cost)
259
+ })
260
+ .filter(is_non_zero_cost);
261
+
262
+ Usage {
263
+ prompt_tokens: TokenCount::Actual(usage.prompt_tokens),
264
+ completion_tokens: TokenCount::Actual(usage.completion_tokens),
265
+ total_tokens: TokenCount::Actual(usage.total_tokens),
266
+ cached_tokens: usage
267
+ .prompt_tokens_details
268
+ .map(|token_details| TokenCount::Actual(token_details.cached_tokens))
269
+ .unwrap_or_default(),
270
+ cost,
271
+ }
272
+ }
273
+ }
274
+
275
+ /// Intermediate representation of GitHub Copilot reasoning fields
276
+ struct GitHubCopilotReasoning {
277
+ text: Option<String>,
278
+ data: Option<String>,
279
+ r#type: Option<String>,
280
+ }
281
+
282
+ impl GitHubCopilotReasoning {
283
+ fn into_reasoning_detail(self) -> forge_domain::ReasoningDetail {
284
+ forge_domain::ReasoningDetail {
285
+ text: self.text,
286
+ data: self.data,
287
+ type_of: self.r#type,
288
+ ..Default::default()
289
+ }
290
+ }
291
+ }
292
+
293
+ /// Converts GitHub Copilot flat reasoning fields to structured reasoning
294
+ /// details
295
+ fn convert_github_copilot_reasoning(
296
+ reasoning_text: &Option<String>,
297
+ reasoning_opaque: &Option<String>,
298
+ ) -> Option<Vec<GitHubCopilotReasoning>> {
299
+ if reasoning_text.is_some() || reasoning_opaque.is_some() {
300
+ let mut details = Vec::new();
301
+ if let Some(text) = reasoning_text {
302
+ details.push(GitHubCopilotReasoning {
303
+ text: Some(text.clone()),
304
+ data: None,
305
+ r#type: Some("reasoning.text".to_string()),
306
+ });
307
+ }
308
+ if let Some(opaque) = reasoning_opaque {
309
+ details.push(GitHubCopilotReasoning {
310
+ text: None,
311
+ data: Some(opaque.clone()),
312
+ r#type: Some("reasoning.encrypted".to_string()),
313
+ });
314
+ }
315
+ Some(details)
316
+ } else {
317
+ None
318
+ }
319
+ }
320
+
321
+ impl TryFrom<Response> for ChatCompletionMessage {
322
+ type Error = anyhow::Error;
323
+
324
+ fn try_from(res: Response) -> Result<Self, Self::Error> {
325
+ match res {
326
+ Response::Success { choices, usage, prompt_filter_results, .. } => {
327
+ if let Some(choice) = choices.first() {
328
+ // Check if the choice has an error first
329
+ let error = match choice {
330
+ Choice::NonChat { error, .. } => error,
331
+ Choice::NonStreaming { error, .. } => error,
332
+ Choice::Streaming { error, .. } => error,
333
+ };
334
+
335
+ if let Some(error) = error {
336
+ return Err(Error::Response(error.clone()).into());
337
+ }
338
+
339
+ let mut response = match choice {
340
+ Choice::NonChat { text, finish_reason, .. } => {
341
+ ChatCompletionMessage::assistant(Content::full(text)).finish_reason_opt(
342
+ finish_reason
343
+ .clone()
344
+ .and_then(|s| FinishReason::from_str(&s).ok()),
345
+ )
346
+ }
347
+ Choice::NonStreaming { message, finish_reason, .. } => {
348
+ let mut resp = ChatCompletionMessage::assistant(Content::full(
349
+ message.content.clone().unwrap_or_default(),
350
+ ))
351
+ .finish_reason_opt(
352
+ finish_reason
353
+ .clone()
354
+ .and_then(|s| FinishReason::from_str(&s).ok()),
355
+ );
356
+ if let Some(reasoning) = message.reasoning() {
357
+ resp = resp.reasoning(Content::full(reasoning.to_owned()));
358
+ }
359
+
360
+ if let Some(thought_signature) = message
361
+ .extra_content
362
+ .as_ref()
363
+ .and_then(ExtraContent::thought_signature)
364
+ {
365
+ resp = resp.thought_signature(thought_signature);
366
+ }
367
+
368
+ if let Some(reasoning_details) = &message.reasoning_details {
369
+ let converted_details: Vec<forge_domain::ReasoningFull> =
370
+ reasoning_details
371
+ .clone()
372
+ .into_iter()
373
+ .map(forge_domain::ReasoningFull::from)
374
+ .collect();
375
+
376
+ resp = resp.add_reasoning_detail(forge_domain::Reasoning::Full(
377
+ converted_details,
378
+ ));
379
+ } else if let Some(details) = convert_github_copilot_reasoning(
380
+ &message.reasoning_text,
381
+ &message.reasoning_opaque,
382
+ ) {
383
+ resp = resp.add_reasoning_detail(forge_domain::Reasoning::Full(
384
+ details
385
+ .into_iter()
386
+ .map(GitHubCopilotReasoning::into_reasoning_detail)
387
+ .collect(),
388
+ ));
389
+ }
390
+
391
+ if let Some(tool_calls) = &message.tool_calls {
392
+ for tool_call in tool_calls {
393
+ let thought_signature = tool_call
394
+ .extra_content
395
+ .as_ref()
396
+ .and_then(ExtraContent::thought_signature);
397
+
398
+ resp = resp.add_tool_call(ToolCallFull {
399
+ call_id: tool_call.id.clone(),
400
+ name: tool_call
401
+ .function
402
+ .name
403
+ .clone()
404
+ .ok_or(forge_domain::Error::ToolCallMissingName)?,
405
+ arguments: serde_json::from_str(
406
+ &tool_call.function.arguments,
407
+ )?,
408
+ thought_signature,
409
+ });
410
+ }
411
+ }
412
+ resp
413
+ }
414
+ Choice::Streaming { delta, finish_reason, .. } => {
415
+ let mut resp = ChatCompletionMessage::assistant(Content::part(
416
+ delta.content.clone().unwrap_or_default(),
417
+ ))
418
+ .finish_reason_opt(
419
+ finish_reason
420
+ .clone()
421
+ .and_then(|s| FinishReason::from_str(&s).ok()),
422
+ );
423
+
424
+ if let Some(reasoning) = delta.reasoning() {
425
+ resp = resp.reasoning(Content::part(reasoning.to_owned()));
426
+ }
427
+
428
+ if let Some(thought_signature) = delta
429
+ .extra_content
430
+ .as_ref()
431
+ .and_then(ExtraContent::thought_signature)
432
+ {
433
+ resp = resp.thought_signature(thought_signature);
434
+ }
435
+
436
+ if let Some(reasoning_details) = &delta.reasoning_details {
437
+ let converted_details: Vec<forge_domain::ReasoningPart> =
438
+ reasoning_details
439
+ .clone()
440
+ .into_iter()
441
+ .map(forge_domain::ReasoningPart::from)
442
+ .collect();
443
+ resp = resp.add_reasoning_detail(forge_domain::Reasoning::Part(
444
+ converted_details,
445
+ ));
446
+ } else if let Some(details) = convert_github_copilot_reasoning(
447
+ &delta.reasoning_text,
448
+ &delta.reasoning_opaque,
449
+ ) {
450
+ resp = resp.add_reasoning_detail(forge_domain::Reasoning::Part(
451
+ details
452
+ .into_iter()
453
+ .map(GitHubCopilotReasoning::into_reasoning_detail)
454
+ .collect(),
455
+ ));
456
+ }
457
+
458
+ if let Some(tool_calls) = &delta.tool_calls {
459
+ for tool_call in tool_calls {
460
+ let thought_signature = tool_call
461
+ .extra_content
462
+ .as_ref()
463
+ .and_then(ExtraContent::thought_signature);
464
+
465
+ resp = resp.add_tool_call(ToolCallPart {
466
+ call_id: tool_call.id.clone(),
467
+ name: tool_call.function.name.clone(),
468
+ arguments_part: tool_call.function.arguments.clone(),
469
+ thought_signature,
470
+ });
471
+ }
472
+ }
473
+ resp
474
+ }
475
+ };
476
+
477
+ if let Some(usage) = usage {
478
+ response.usage = Some(usage.into());
479
+ }
480
+ Ok(response)
481
+ } else {
482
+ // Check if content was filtered
483
+ if let Some(filter_results) = prompt_filter_results
484
+ && let Some(filter_result) = filter_results.first()
485
+ {
486
+ let filtered_categories: Vec<String> = [
487
+ filter_result
488
+ .content_filter_results
489
+ .hate
490
+ .as_ref()
491
+ .filter(|f| f.filtered)
492
+ .map(|_| "hate"),
493
+ filter_result
494
+ .content_filter_results
495
+ .self_harm
496
+ .as_ref()
497
+ .filter(|f| f.filtered)
498
+ .map(|_| "self_harm"),
499
+ filter_result
500
+ .content_filter_results
501
+ .sexual
502
+ .as_ref()
503
+ .filter(|f| f.filtered)
504
+ .map(|_| "sexual"),
505
+ filter_result
506
+ .content_filter_results
507
+ .violence
508
+ .as_ref()
509
+ .filter(|f| f.filtered)
510
+ .map(|_| "violence"),
511
+ ]
512
+ .into_iter()
513
+ .flatten()
514
+ .map(String::from)
515
+ .collect();
516
+
517
+ if !filtered_categories.is_empty() {
518
+ let error = ErrorResponse::default()
519
+ .message(format!(
520
+ "Content was filtered due to: {}",
521
+ filtered_categories.join(", ")
522
+ ))
523
+ .code(ErrorCode::String("content_filter".to_string()));
524
+ return Err(Error::Response(error).into());
525
+ }
526
+ }
527
+
528
+ let mut default_response = ChatCompletionMessage::assistant(Content::full(""));
529
+ // No choices – this can happen with Ollama/LMStudio streaming where the final
530
+ // chunk only contains usage information.
531
+ if let Some(u) = usage {
532
+ default_response.usage = Some(u.into());
533
+ }
534
+ Ok(default_response)
535
+ }
536
+ }
537
+ Response::CostOnly { cost, .. } => {
538
+ let mut msg = ChatCompletionMessage::default();
539
+ if let Some(c) = cost {
540
+ let cost_value = match c {
541
+ StringOrF64::Number(n) => n,
542
+ StringOrF64::String(s) => s.parse().unwrap_or(0.0),
543
+ };
544
+ msg.usage = Some(Usage {
545
+ prompt_tokens: TokenCount::Actual(0),
546
+ completion_tokens: TokenCount::Actual(0),
547
+ total_tokens: TokenCount::Actual(0),
548
+ cached_tokens: TokenCount::Actual(0),
549
+ cost: Some(cost_value),
550
+ });
551
+ }
552
+ Ok(msg)
553
+ }
554
+ Response::Failure { error } => Err(Error::Response(error).into()),
555
+ }
556
+ }
557
+ }
558
+
559
+ #[cfg(test)]
560
+ mod tests {
561
+ use anyhow::Context;
562
+ use forge_domain::ChatCompletionMessage;
563
+
564
+ use super::*;
565
+
566
+ struct Fixture;
567
+
568
+ fn response_message(
569
+ reasoning: Option<&str>,
570
+ reasoning_content: Option<&str>,
571
+ ) -> ResponseMessage {
572
+ ResponseMessage {
573
+ content: None,
574
+ reasoning: reasoning.map(str::to_owned),
575
+ reasoning_content: reasoning_content.map(str::to_owned),
576
+ role: None,
577
+ tool_calls: None,
578
+ refusal: None,
579
+ reasoning_details: None,
580
+ reasoning_text: None,
581
+ reasoning_opaque: None,
582
+ extra_content: None,
583
+ }
584
+ }
585
+
586
+ #[test]
587
+ fn test_reasoning_only_reasoning_field() {
588
+ let fixture = response_message(Some("hello"), None);
589
+ assert_eq!(fixture.reasoning(), Some("hello"));
590
+ }
591
+
592
+ #[test]
593
+ fn test_reasoning_only_reasoning_content_field() {
594
+ let fixture = response_message(None, Some("hello"));
595
+ assert_eq!(fixture.reasoning(), Some("hello"));
596
+ }
597
+
598
+ #[test]
599
+ fn test_reasoning_both_returns_longer() {
600
+ let fixture = response_message(Some("short"), Some("much longer text"));
601
+ assert_eq!(fixture.reasoning(), Some("much longer text"));
602
+ }
603
+
604
+ #[test]
605
+ fn test_reasoning_both_equal_length_returns_reasoning() {
606
+ let fixture = response_message(Some("aaa"), Some("bbb"));
607
+ assert_eq!(fixture.reasoning(), Some("aaa"));
608
+ }
609
+
610
+ #[test]
611
+ fn test_reasoning_both_present_one_empty_returns_non_empty() {
612
+ let fixture = response_message(Some(""), Some("content"));
613
+ assert_eq!(fixture.reasoning(), Some("content"));
614
+ }
615
+
616
+ #[test]
617
+ fn test_reasoning_both_empty_returns_none() {
618
+ let fixture = response_message(Some(""), Some(""));
619
+ assert_eq!(fixture.reasoning(), None);
620
+ }
621
+
622
+ #[test]
623
+ fn test_reasoning_neither_present_returns_none() {
624
+ let fixture = response_message(None, None);
625
+ assert_eq!(fixture.reasoning(), None);
626
+ }
627
+
628
+ async fn load_fixture(filename: &str) -> serde_json::Value {
629
+ let fixture_path = format!("src/dto/openai/fixtures/{}", filename);
630
+ let fixture_content = tokio::fs::read_to_string(&fixture_path)
631
+ .await
632
+ .unwrap_or_else(|_| panic!("Failed to read fixture file: {}", fixture_path));
633
+ serde_json::from_str(&fixture_content)
634
+ .unwrap_or_else(|_| panic!("Failed to parse JSON fixture: {}", fixture_path))
635
+ }
636
+
637
+ impl Fixture {
638
+ // check if the response is compatible with the
639
+ fn test_response_compatibility(message: &str) -> bool {
640
+ let response = serde_json::from_str::<Response>(message)
641
+ .with_context(|| format!("Failed to parse response: {message}"))
642
+ .and_then(|event| {
643
+ ChatCompletionMessage::try_from(event.clone())
644
+ .with_context(|| "Failed to create completion message")
645
+ });
646
+ response.is_ok()
647
+ }
648
+ }
649
+
650
+ #[test]
651
+ fn test_open_ai_response_event() {
652
+ let event = "{\"id\":\"chatcmpl-B2YVxGR9TaLBrEcFMVCv2B4IcNe4g\",\"object\":\"chat.completion.chunk\",\"created\":1739949029,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_00428b782a\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_fmuXMsHhKD5eM2k0CvgNed53\",\"type\":\"function\",\"function\":{\"name\":\"shell\",\"arguments\":\"\"}}],\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}]}";
653
+ assert!(Fixture::test_response_compatibility(event));
654
+ }
655
+
656
+ #[test]
657
+ fn test_forge_response_event() {
658
+ let event = "{\"id\":\"gen-1739949430-JZMcABaj4fg8oFDtRNDZ\",\"provider\":\"OpenAI\",\"model\":\"openai/gpt-4o-mini\",\"object\":\"chat.completion.chunk\",\"created\":1739949430,\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_bhjvz9w48ov4DSRhM15qLMmh\",\"type\":\"function\",\"function\":{\"name\":\"shell\",\"arguments\":\"\"}}],\"refusal\":null},\"logprobs\":null,\"finish_reason\":null,\"native_finish_reason\":null}],\"system_fingerprint\":\"fp_00428b782a\"}";
659
+ assert!(Fixture::test_response_compatibility(event));
660
+ }
661
+
662
+ #[test]
663
+ fn test_reasoning_response_event() {
664
+ let event = "{\"id\":\"gen-1751626123-nYRpHzdA0thRXF0LoQi0\",\"provider\":\"Google\",\"model\":\"anthropic/claude-3.7-sonnet:thinking\",\"object\":\"chat.completion.chunk\",\"created\":1751626123,\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning\":\"I need to check\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"I need to check\"}]},\"finish_reason\":null,\"native_finish_reason\":null,\"logprobs\":null}]}";
665
+ assert!(Fixture::test_response_compatibility(event));
666
+ }
667
+
668
+ #[tokio::test]
669
+ async fn test_kimi_k2_both_reasoning_keys_event() {
670
+ // moonshotai/Kimi-K2.5-TEE emits both "reasoning" and "reasoning_content"
671
+ // in the same delta object. This must parse without a duplicate_field error.
672
+ let fixture = load_fixture("chutes_completion_response.json").await;
673
+ let actual = serde_json::from_value::<Response>(fixture);
674
+ assert!(actual.is_ok(), "Failed to parse: {:?}", actual.err());
675
+ let completion_result = ChatCompletionMessage::try_from(actual.unwrap());
676
+ assert!(completion_result.is_ok());
677
+ }
678
+
679
+ #[test]
680
+ fn test_fireworks_response_event_missing_arguments() {
681
+ let event = "{\"id\":\"gen-1749331907-SttL6PXleVHnrdLMABfU\",\"provider\":\"Fireworks\",\"model\":\"qwen/qwen3-235b-a22b\",\"object\":\"chat.completion.chunk\",\"created\":1749331907,\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_Wl2L8rrzHwrXSeiciIvU65IS\",\"type\":\"function\",\"function\":{\"name\":\"attempt_completion\"}}]},\"finish_reason\":null,\"native_finish_reason\":null,\"logprobs\":null}]}";
682
+ assert!(Fixture::test_response_compatibility(event));
683
+ }
684
+
685
+ #[tokio::test]
686
+ async fn test_responses() -> anyhow::Result<()> {
687
+ let content = forge_test_kit::fixture!("/src/dto/openai/responses.jsonl").await;
688
+
689
+ for (i, line) in content.split('\n').enumerate() {
690
+ let i = i + 1;
691
+ let _: Response = serde_json::from_str(line).with_context(|| {
692
+ format!("Failed to parse response [responses.jsonl:{i}]: {line}")
693
+ })?;
694
+ }
695
+
696
+ Ok(())
697
+ }
698
+ #[test]
699
+ fn test_choice_error_handling_non_chat() {
700
+ let error_response = ErrorResponse::default().message("Test error message".to_string());
701
+
702
+ let response = Response::Success {
703
+ id: "test-id".to_string(),
704
+ provider: Some("test".to_string()),
705
+ model: Some("test-model".to_string()),
706
+ choices: vec![Choice::NonChat {
707
+ text: "test content".to_string(),
708
+ finish_reason: None,
709
+ error: Some(error_response.clone()),
710
+ }],
711
+ created: 123456789,
712
+ object: Some("chat.completion".to_string()),
713
+ system_fingerprint: None,
714
+ usage: None,
715
+ prompt_filter_results: None,
716
+ };
717
+
718
+ let result = ChatCompletionMessage::try_from(response);
719
+ assert!(result.is_err());
720
+ let error = result.unwrap_err();
721
+ let error_string = format!("{:?}", error);
722
+ assert!(error_string.contains("Test error message"));
723
+ }
724
+
725
+ #[test]
726
+ fn test_choice_error_handling_non_streaming() {
727
+ let error_response = ErrorResponse::default().message("API limit exceeded".to_string());
728
+
729
+ let response = Response::Success {
730
+ id: "test-id".to_string(),
731
+ provider: Some("test".to_string()),
732
+ model: Some("test-model".to_string()),
733
+ choices: vec![Choice::NonStreaming {
734
+ logprobs: None,
735
+ index: 0,
736
+ finish_reason: None,
737
+ message: ResponseMessage {
738
+ content: Some("test content".to_string()),
739
+ reasoning: None,
740
+ reasoning_content: None,
741
+ role: Some("assistant".to_string()),
742
+ tool_calls: None,
743
+ refusal: None,
744
+ reasoning_details: None,
745
+ reasoning_text: None,
746
+ reasoning_opaque: None,
747
+ extra_content: None,
748
+ },
749
+ error: Some(error_response.clone()),
750
+ }],
751
+ created: 123456789,
752
+ object: Some("chat.completion".to_string()),
753
+ system_fingerprint: None,
754
+ usage: None,
755
+ prompt_filter_results: None,
756
+ };
757
+
758
+ let result = ChatCompletionMessage::try_from(response);
759
+ assert!(result.is_err());
760
+ let error = result.unwrap_err();
761
+ let error_string = format!("{:?}", error);
762
+ assert!(error_string.contains("API limit exceeded"));
763
+ }
764
+
765
+ #[test]
766
+ fn test_choice_error_handling_streaming() {
767
+ let error_response = ErrorResponse::default().message("Stream interrupted".to_string());
768
+
769
+ let response = Response::Success {
770
+ id: "test-id".to_string(),
771
+ provider: Some("test".to_string()),
772
+ model: Some("test-model".to_string()),
773
+ choices: vec![Choice::Streaming {
774
+ finish_reason: None,
775
+ delta: ResponseMessage {
776
+ content: Some("test content".to_string()),
777
+ reasoning: None,
778
+ reasoning_content: None,
779
+ role: Some("assistant".to_string()),
780
+ tool_calls: None,
781
+ refusal: None,
782
+ reasoning_details: None,
783
+ reasoning_text: None,
784
+ reasoning_opaque: None,
785
+ extra_content: None,
786
+ },
787
+ error: Some(error_response.clone()),
788
+ }],
789
+ created: 123456789,
790
+ object: Some("chat.completion".to_string()),
791
+ system_fingerprint: None,
792
+ usage: None,
793
+ prompt_filter_results: None,
794
+ };
795
+
796
+ let result = ChatCompletionMessage::try_from(response);
797
+ assert!(result.is_err());
798
+ let error = result.unwrap_err();
799
+ let error_string = format!("{:?}", error);
800
+ assert!(error_string.contains("Stream interrupted"));
801
+ }
802
+
803
+ #[test]
804
+ fn test_choice_no_error_success() {
805
+ let response = Response::Success {
806
+ id: "test-id".to_string(),
807
+ provider: Some("test".to_string()),
808
+ model: Some("test-model".to_string()),
809
+ choices: vec![Choice::NonStreaming {
810
+ logprobs: None,
811
+ index: 0,
812
+ finish_reason: Some("stop".to_string()),
813
+ message: ResponseMessage {
814
+ content: Some("Hello, world!".to_string()),
815
+ reasoning: None,
816
+ reasoning_content: None,
817
+ role: Some("assistant".to_string()),
818
+ tool_calls: None,
819
+ refusal: None,
820
+ reasoning_details: None,
821
+ reasoning_text: None,
822
+ reasoning_opaque: None,
823
+ extra_content: None,
824
+ },
825
+ error: None,
826
+ }],
827
+ created: 123456789,
828
+ object: Some("chat.completion".to_string()),
829
+ system_fingerprint: None,
830
+ usage: None,
831
+ prompt_filter_results: None,
832
+ };
833
+
834
+ let result = ChatCompletionMessage::try_from(response);
835
+ assert!(result.is_ok());
836
+ let message = result.unwrap();
837
+ assert_eq!(message.content.unwrap().as_str(), "Hello, world!");
838
+ }
839
+
840
+ #[test]
841
+ fn test_empty_choices_no_error() {
842
+ let response = Response::Success {
843
+ id: "test-id".to_string(),
844
+ provider: Some("test".to_string()),
845
+ model: Some("test-model".to_string()),
846
+ choices: vec![],
847
+ created: 123456789,
848
+ object: Some("chat.completion".to_string()),
849
+ system_fingerprint: None,
850
+ usage: None,
851
+ prompt_filter_results: None,
852
+ };
853
+
854
+ let result = ChatCompletionMessage::try_from(response);
855
+ assert!(result.is_ok());
856
+ let message = result.unwrap();
857
+ assert_eq!(message.content.unwrap().as_str(), "");
858
+ }
859
+
860
+ #[test]
861
+ fn test_cost_only_response_parses_and_returns_empty_message() {
862
+ let fixture = r#"{"choices":[],"cost":"0"}"#;
863
+ let actual = serde_json::from_str::<Response>(fixture).unwrap();
864
+
865
+ let actual = ChatCompletionMessage::try_from(actual).unwrap();
866
+
867
+ // CostOnly events now include the cost in the usage
868
+ let expected = ChatCompletionMessage::default().usage(Usage {
869
+ prompt_tokens: TokenCount::Actual(0),
870
+ completion_tokens: TokenCount::Actual(0),
871
+ total_tokens: TokenCount::Actual(0),
872
+ cached_tokens: TokenCount::Actual(0),
873
+ cost: Some(0.0),
874
+ });
875
+ assert_eq!(actual, expected);
876
+ }
877
+
878
+ #[test]
879
+ fn test_cost_only_response_numeric_cost_parses() {
880
+ let fixture = r#"{"choices":[],"cost":0.0}"#;
881
+ let actual = serde_json::from_str::<Response>(fixture);
882
+
883
+ assert!(actual.is_ok());
884
+ }
885
+
886
+ #[tokio::test]
887
+ async fn test_z_ai_response_compatibility() {
888
+ let fixture = load_fixture("zai_api_delta_response.json").await;
889
+ let actual = serde_json::from_value::<Response>(fixture);
890
+
891
+ assert!(actual.is_ok());
892
+
893
+ let response = actual.unwrap();
894
+ let completion_result = ChatCompletionMessage::try_from(response);
895
+ assert!(completion_result.is_ok());
896
+ }
897
+
898
+ #[tokio::test]
899
+ async fn test_z_ai_response_complete_with_usage() {
900
+ let fixture = load_fixture("zai_api_response.json").await;
901
+ let actual = serde_json::from_value::<Response>(fixture);
902
+
903
+ assert!(actual.is_ok());
904
+
905
+ let response = actual.unwrap();
906
+ let completion_result = ChatCompletionMessage::try_from(response);
907
+ assert!(completion_result.is_ok());
908
+ }
909
+
910
+ #[test]
911
+ fn test_response_usage_cost_priority_chain() {
912
+ // Priority 1: cost field (non-zero) beats everything
913
+ let fixture_cost_wins = ResponseUsage {
914
+ prompt_tokens: 100,
915
+ completion_tokens: 50,
916
+ total_tokens: 150,
917
+ cost: Some(0.001),
918
+ prompt_tokens_details: None,
919
+ cost_details: Some(CostDetails {
920
+ upstream_inference_cost: Some(0.005),
921
+ upstream_inference_prompt_cost: Some(0.003),
922
+ upstream_inference_completions_cost: Some(0.002),
923
+ }),
924
+ };
925
+
926
+ let actual: Usage = fixture_cost_wins.into();
927
+ assert_eq!(actual.cost, Some(0.001));
928
+
929
+ // Priority 2: upstream_inference_cost beats partial costs
930
+ let fixture_upstream_wins = ResponseUsage {
931
+ prompt_tokens: 100,
932
+ completion_tokens: 50,
933
+ total_tokens: 150,
934
+ cost: None,
935
+ prompt_tokens_details: None,
936
+ cost_details: Some(CostDetails {
937
+ upstream_inference_cost: Some(0.005),
938
+ upstream_inference_prompt_cost: Some(0.003),
939
+ upstream_inference_completions_cost: Some(0.002),
940
+ }),
941
+ };
942
+
943
+ let actual: Usage = fixture_upstream_wins.into();
944
+ assert_eq!(actual.cost, Some(0.005));
945
+
946
+ // Priority 3: partial costs are summed when upstream_inference_cost is None
947
+ let fixture_partial_sum = ResponseUsage {
948
+ prompt_tokens: 100,
949
+ completion_tokens: 50,
950
+ total_tokens: 150,
951
+ cost: None,
952
+ prompt_tokens_details: None,
953
+ cost_details: Some(CostDetails {
954
+ upstream_inference_cost: None,
955
+ upstream_inference_prompt_cost: Some(0.003),
956
+ upstream_inference_completions_cost: Some(0.002),
957
+ }),
958
+ };
959
+
960
+ let actual: Usage = fixture_partial_sum.into();
961
+ assert_eq!(actual.cost, Some(0.005));
962
+
963
+ // Priority 4: when upstream_inference_cost is 0 then compute it from other
964
+ // parameters.
965
+ let fixture = ResponseUsage {
966
+ prompt_tokens: 100,
967
+ completion_tokens: 50,
968
+ total_tokens: 150,
969
+ cost: None,
970
+ prompt_tokens_details: None,
971
+ cost_details: Some(CostDetails {
972
+ upstream_inference_cost: Some(0.0),
973
+ upstream_inference_prompt_cost: Some(0.003),
974
+ upstream_inference_completions_cost: Some(0.002),
975
+ }),
976
+ };
977
+
978
+ let actual: Usage = fixture.into();
979
+ assert_eq!(actual.cost, Some(0.005));
980
+ }
981
+
982
+ #[test]
983
+ fn test_zero_cost_should_fallback_to_cost_details() {
984
+ let fixture = ResponseUsage {
985
+ prompt_tokens: 100,
986
+ completion_tokens: 50,
987
+ total_tokens: 150,
988
+ cost: Some(0.0),
989
+ prompt_tokens_details: None,
990
+ cost_details: Some(CostDetails {
991
+ upstream_inference_cost: Some(0.005),
992
+ upstream_inference_prompt_cost: None,
993
+ upstream_inference_completions_cost: None,
994
+ }),
995
+ };
996
+
997
+ let actual: Usage = fixture.into();
998
+ assert_eq!(actual.cost, Some(0.005));
999
+ }
1000
+
1001
+ #[test]
1002
+ fn test_near_zero_cost_should_fallback_to_cost_details() {
1003
+ let fixture = ResponseUsage {
1004
+ prompt_tokens: 100,
1005
+ completion_tokens: 50,
1006
+ total_tokens: 150,
1007
+ cost: Some(1e-10),
1008
+ prompt_tokens_details: None,
1009
+ cost_details: Some(CostDetails {
1010
+ upstream_inference_cost: Some(0.005),
1011
+ upstream_inference_prompt_cost: None,
1012
+ upstream_inference_completions_cost: None,
1013
+ }),
1014
+ };
1015
+
1016
+ let actual: Usage = fixture.into();
1017
+ assert_eq!(actual.cost, Some(0.005));
1018
+ }
1019
+
1020
+ #[test]
1021
+ fn test_github_copilot_content_filter_response() {
1022
+ let response_json = r#"{
1023
+ "choices": [],
1024
+ "created": 0,
1025
+ "id": "",
1026
+ "prompt_filter_results": [{
1027
+ "content_filter_results": {
1028
+ "hate": {"filtered": false, "severity": "safe"},
1029
+ "self_harm": {"filtered": false, "severity": "safe"},
1030
+ "sexual": {"filtered": false, "severity": "safe"},
1031
+ "violence": {"filtered": false, "severity": "safe"}
1032
+ },
1033
+ "prompt_index": 0
1034
+ }]
1035
+ }"#;
1036
+
1037
+ let actual = serde_json::from_str::<Response>(response_json);
1038
+ assert!(
1039
+ actual.is_ok(),
1040
+ "Should parse GitHub Copilot filter response: {:?}",
1041
+ actual.err()
1042
+ );
1043
+ }
1044
+
1045
+ #[test]
1046
+ fn test_github_copilot_filtered_content_error() {
1047
+ let response = Response::Success {
1048
+ id: "".to_string(),
1049
+ provider: None,
1050
+ model: Some("gpt-5".to_string()),
1051
+ choices: vec![],
1052
+ created: 0,
1053
+ object: None,
1054
+ system_fingerprint: None,
1055
+ usage: None,
1056
+ prompt_filter_results: Some(vec![PromptFilterResult {
1057
+ prompt_index: 0,
1058
+ content_filter_results: ContentFilterResults {
1059
+ hate: Some(FilterResult { filtered: true, severity: "high".to_string() }),
1060
+ self_harm: Some(FilterResult { filtered: false, severity: "safe".to_string() }),
1061
+ sexual: Some(FilterResult { filtered: false, severity: "safe".to_string() }),
1062
+ violence: Some(FilterResult { filtered: false, severity: "safe".to_string() }),
1063
+ },
1064
+ }]),
1065
+ };
1066
+
1067
+ let result = ChatCompletionMessage::try_from(response);
1068
+ assert!(result.is_err());
1069
+ let error = result.unwrap_err();
1070
+ let error_string = format!("{:?}", error);
1071
+ assert!(error_string.contains("Content was filtered"));
1072
+ assert!(error_string.contains("hate"));
1073
+ }
1074
+
1075
+ #[test]
1076
+ fn test_nvidia_tool_call_streaming_chunk() {
1077
+ let response_json = r#"{"id":"chatcmpl-994182aa3bf1d873","object":"chat.completion.chunk","created":1775363363,"model":"qwen/qwen3.5-397b-a17b","choices":[{"index":0,"delta":{"content":null,"reasoning":null,"reasoning_content":null,"tool_calls":[{"index":1,"function":{"arguments":"}"}}]},"logprobs":null,"finish_reason":"tool_calls","stop_reason":null,"token_ids":null}]}"#;
1078
+
1079
+ let actual = serde_json::from_str::<Response>(response_json);
1080
+ assert!(
1081
+ actual.is_ok(),
1082
+ "Should parse NVIDIA tool call streaming chunk: {:?}",
1083
+ actual.err()
1084
+ );
1085
+ }
1086
+
1087
+ #[test]
1088
+ fn test_nvidia_tool_call_deserialization() {
1089
+ // NVIDIA sends tool calls without "id" and "type" fields
1090
+ let tool_call_json = r#"{"index":1,"function":{"arguments":"}"}}"#;
1091
+ let actual = serde_json::from_str::<ToolCall>(tool_call_json);
1092
+ assert!(
1093
+ actual.is_ok(),
1094
+ "Should parse NVIDIA tool call: {:?}",
1095
+ actual.err()
1096
+ );
1097
+ }
1098
+
1099
+ #[test]
1100
+ fn test_nvidia_response_message_deserialization() {
1101
+ let msg_json = r#"{"content":null,"reasoning":null,"reasoning_content":null,"tool_calls":[{"index":1,"function":{"arguments":"}"}}]}"#;
1102
+ let actual = serde_json::from_str::<ResponseMessage>(msg_json);
1103
+ assert!(
1104
+ actual.is_ok(),
1105
+ "Should parse NVIDIA response message: {:?}",
1106
+ actual.err()
1107
+ );
1108
+ }
1109
+
1110
+ #[test]
1111
+ fn test_nvidia_choice_deserialization() {
1112
+ let choice_json = r#"{"index":0,"delta":{"content":null,"reasoning":null,"reasoning_content":null,"tool_calls":[{"index":1,"function":{"arguments":"}"}}]},"logprobs":null,"finish_reason":"tool_calls","stop_reason":null,"token_ids":null}"#;
1113
+ let actual = serde_json::from_str::<Choice>(choice_json);
1114
+ assert!(
1115
+ actual.is_ok(),
1116
+ "Should parse NVIDIA choice: {:?}",
1117
+ actual.err()
1118
+ );
1119
+ }
1120
+ }
crates/forge_app/src/dto/openai/responses.jsonl ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {"error": {"code": "ERR_STREAM_PREMATURE_CLOSE"}}
2
+ {"error": {"errno": -104, "code": "ECONNRESET", "syscall": "read"}}
3
+ {"error": {"errno": -110, "code": "ETIMEDOUT", "syscall": "read"}}
4
+ {"error": {"error": {"code": "ERR_STREAM_PREMATURE_CLOSE"}}}
5
+ {"error": {"error": {"message": "Provider returned error", "code": 400, "metadata": {"raw": "{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"input length and `max_tokens` exceed context limit: MIN + MAX > 200000, decrease input length or `max_tokens` and try again\"}}", "provider_name": "Google"}}, "code": 400}}
6
+ {"error": {"error": {"message": "Provider returned error", "code": 413, "metadata": {"raw": "{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"Prompt is too long\"}}", "provider_name": "Google"}}, "code": 413}}
7
+ {"error": {"error": {"message": "Provider returned error", "code": 503, "metadata": {"raw": "{\n \"error\": {\n \"code\": 503,\n \"message\": \"The service is currently unavailable.\",\n \"status\": \"UNAVAILABLE\"\n }\n}\n", "provider_name": "Google AI Studio"}}, "code": 503}}
8
+ {"error": {"error": {"message": "Rate limit exceeded: limit_rpm/google/gemini-2.5-pro-exp-03-25/e5b7a905-dd02-4f6e-b61b-3aa1a1b8b9c8. ", "code": 429, "metadata": {"headers": {"X-RateLimit-Limit": "1", "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": "1746315180000"}, "provider_name": null}}, "code": 429}}
9
+ {"error": {"error": {"message": "Rate limit exceeded: limit_rpm/google/gemini-2.5-pro-exp-03-25/e5b7a905-dd02-4f6e-b61b-3aa1a1b8b9c8. High demand for google/gemini-2.5-pro-exp-03-25 on OpenRouter - limited to 1 requests per minute. Please retry shortly.", "code": 429, "metadata": {"headers": {"X-RateLimit-Limit": "1", "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": "1746937440000"}, "provider_name": null}}, "code": 429}}
10
+ {"error": {"error": {"message": "This endpoint's maximum context length is 200000 tokens. However, you requested about X tokens (Y of text input). Please reduce the length of either one, or use the \"middle-out\" transform to compress your prompt automatically.", "code": 400, "metadata": {"provider_name": null}}, "code": 400}}
11
+ {"error": {"error": {"message": "Upstream error from Chutes: Requested token count exceeds the model's maximum context length of 32768 tokens. You requested a total of 34689 tokens: 22892 tokens from the input messages and 11797 tokens for the completion. Please reduce the number of tokens in the input messages or the completion to fit within the limit.", "code": 502, "metadata": {"raw": {"object": "error", "message": "Requested token count exceeds the model's maximum context length of 32768 tokens. You requested a total of 34689 tokens: 22892 tokens from the input messages and 11797 tokens for the completion. Please reduce the number of tokens in the input messages or the completion to fit within the limit.", "type": "BadRequestError", "param": null, "code": 400}, "provider_name": "Chutes"}}, "code": 502}}
12
+ {"error": {"message": "400: An error occurred (ValidationException) when calling the ConverseStream operation: The text field in the ContentBlock object at messages.19.content.0 is blank. Add text to the text field, and try again."}}
13
+ {"error": {"message": "500: An error occurred (AccessDeniedException) when calling the ConverseStream operation: User: arn:aws:sts::275481790157:assumed-role/bedrockpartnerawsv1-ProxyApiHandlerServiceRoleBE71B-LZI8y019SUOG/bedrockpartnerawsv1-ProxyApiHandlerEC15A492-1JsCZPLohxs3 is not authorized to perform: bedrock:InvokeModelWithResponseStream on resource: arn:aws:bedrock:us-west-2::foundation-model/anthropic.claude-3-7-sonnet-20250219-v1:0 with an explicit deny in a service control policy"}}
14
+ {"error": {"message": "An error occurred (modelStreamErrorException) when calling the ConverseStream operation: The system encountered an unexpected error during processing. Try your request again."}}
15
+ {"error": {"message": "An error occurred (serviceUnavailableException) when calling the ConverseStream operation: Bedrock is unable to process your request."}}
16
+ {"error": {"message": "An error occurred (validationException) when calling the ConverseStream operation: The model returned the following errors: Input is too long for requested model."}}
17
+ {"error": {"message": "litellm.APIConnectionError: AnthropicException - ", "type": null, "param": null, "code": "500"}}
18
+ {"error": {"message": "litellm.BadRequestError: BedrockException - validationException {\"message\":\"The model returned the following errors: input length and `max_tokens` exceed context limit: 197717 + 8192 > 204698, decrease input length or `max_tokens` and try again\"}", "type": null, "param": null, "code": "400"}}
19
+ {"error": {"message": "litellm.ContextWindowExceededError: litellm.BadRequestError: BedrockException: Context Window Error - validationException {\"message\":\"The model returned the following errors: Input is too long for requested model.\"}", "type": null, "param": null, "code": "400"}}
20
+ {"error": {"message": "litellm.InternalServerError: AnthropicException - Overloaded. Handle with `litellm.InternalServerError`.", "type": null, "param": null, "code": "500"}}
crates/forge_app/src/dto/openai/snapshots/forge_app__dto__openai__request__tests__assistant_message_with_tool_call_conversion.snap ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ source: crates/forge_app/src/dto/openai/request.rs
3
+ expression: router_message
4
+ ---
5
+ {
6
+ "role": "assistant",
7
+ "content": "Using tool",
8
+ "tool_calls": [
9
+ {
10
+ "id": "123",
11
+ "type": "function",
12
+ "function": {
13
+ "name": "test_tool",
14
+ "arguments": "{\"key\":\"value\"}"
15
+ }
16
+ }
17
+ ]
18
+ }
crates/forge_app/src/dto/openai/snapshots/forge_app__dto__openai__request__tests__message_with_special_chars.snap ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ source: crates/forge_app/src/dto/openai/request.rs
3
+ expression: router_message
4
+ ---
5
+ {
6
+ "role": "user",
7
+ "content": "Here's some XML content:\n<task>\n <id>123</id>\n <description>Test <special> characters</description>\n <data key=\"value\">\n <item>1</item>\n <item>2</item>\n </data>\n</task>"
8
+ }
crates/forge_app/src/dto/openai/snapshots/forge_app__dto__openai__request__tests__tool_message_conversion.snap ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ source: crates/forge_app/src/dto/openai/request.rs
3
+ expression: router_message
4
+ ---
5
+ {
6
+ "role": "tool",
7
+ "content": "{\n \"user\": \"John\",\n \"age\": 30,\n \"address\": [{\"city\": \"New York\"}, {\"city\": \"San Francisco\"}]\n }",
8
+ "name": "test_tool",
9
+ "tool_call_id": "123"
10
+ }
crates/forge_app/src/dto/openai/snapshots/forge_app__dto__openai__request__tests__tool_message_typescript_code.snap ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ source: crates/forge_app/src/dto/openai/request.rs
3
+ expression: router_message
4
+ ---
5
+ {
6
+ "role": "tool",
7
+ "content": "{ \"code\": \"fn main<T>(gt: T) {let b = &gt; }\"}",
8
+ "name": "rust_tool",
9
+ "tool_call_id": "456"
10
+ }
crates/forge_app/src/dto/openai/snapshots/forge_app__dto__openai__request__tests__tool_message_with_special_chars.snap ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ source: crates/forge_app/src/dto/openai/request.rs
3
+ expression: router_message
4
+ ---
5
+ {
6
+ "role": "tool",
7
+ "content": "{\n \"html\": \"<div class=\\\"container\\\"><p>Hello <World></p></div>\",\n \"elements\": [\"<span>\", \"<br/>\", \"<hr>\"],\n \"attributes\": {\n \"style\": \"color: blue; font-size: 12px;\",\n \"data-test\": \"<test>&value</test>\"\n }\n }",
8
+ "name": "html_tool",
9
+ "tool_call_id": "456"
10
+ }
crates/forge_app/src/dto/openai/snapshots/forge_app__dto__openai__request__tests__user_message_conversion.snap ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ source: crates/forge_app/src/dto/openai/request.rs
3
+ expression: router_message
4
+ ---
5
+ {
6
+ "role": "user",
7
+ "content": "Hello"
8
+ }
crates/forge_app/src/dto/openai/tool_choice.rs ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use serde::{Deserialize, Serialize};
2
+
3
+ #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
4
+ #[serde(rename_all = "snake_case")]
5
+ pub enum ToolChoice {
6
+ None,
7
+ Auto,
8
+ Required,
9
+ #[serde(untagged)]
10
+ Function {
11
+ r#type: FunctionType,
12
+ function: FunctionName,
13
+ },
14
+ }
15
+
16
+ #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
17
+ #[serde(rename_all = "snake_case")]
18
+ pub struct FunctionName {
19
+ pub name: String,
20
+ }
21
+
22
+ #[derive(Default, Debug, Clone, PartialEq, Eq)]
23
+ pub struct FunctionType;
24
+
25
+ impl Serialize for FunctionType {
26
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
27
+ where
28
+ S: serde::Serializer,
29
+ {
30
+ serializer.serialize_str("function")
31
+ }
32
+ }
33
+
34
+ impl<'de> Deserialize<'de> for FunctionType {
35
+ fn deserialize<D>(_deserializer: D) -> Result<FunctionType, D::Error>
36
+ where
37
+ D: serde::Deserializer<'de>,
38
+ {
39
+ Ok(FunctionType)
40
+ }
41
+ }
42
+
43
+ impl From<forge_domain::ToolChoice> for ToolChoice {
44
+ fn from(value: forge_domain::ToolChoice) -> Self {
45
+ match value {
46
+ forge_domain::ToolChoice::None => ToolChoice::None,
47
+ forge_domain::ToolChoice::Auto => ToolChoice::Auto,
48
+ forge_domain::ToolChoice::Required => ToolChoice::Required,
49
+ forge_domain::ToolChoice::Call(tool_name) => ToolChoice::Function {
50
+ function: FunctionName { name: tool_name.to_string() },
51
+ r#type: FunctionType,
52
+ },
53
+ }
54
+ }
55
+ }
56
+
57
+ #[cfg(test)]
58
+ mod tests {
59
+ use super::*;
60
+
61
+ #[test]
62
+ fn test_tool_choice_serialization() {
63
+ // Test None variant
64
+ let choice_none = ToolChoice::None;
65
+ assert_eq!(serde_json::to_string(&choice_none).unwrap(), r#""none""#);
66
+
67
+ // Test Auto variant
68
+ let choice_auto = ToolChoice::Auto;
69
+ assert_eq!(serde_json::to_string(&choice_auto).unwrap(), r#""auto""#);
70
+
71
+ // Test Function variant
72
+ let choice_function = ToolChoice::Function {
73
+ function: FunctionName { name: "test_tool".to_string() },
74
+ r#type: FunctionType,
75
+ };
76
+ assert_eq!(
77
+ serde_json::to_string(&choice_function).unwrap(),
78
+ r#"{"type":"function","function":{"name":"test_tool"}}"#
79
+ );
80
+ }
81
+ }
crates/forge_app/src/dto/openai/transformers/default_reasoning_content.rs ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use forge_domain::Transformer;
2
+
3
+ use crate::dto::openai::Request;
4
+
5
+ /// Transformer that ensures every message has a `reasoning_content` field set.
6
+ ///
7
+ /// DeepSeek requires the `reasoning_content` field to be present on assistant
8
+ /// messages even when there is no reasoning text. When the field is `None` this
9
+ /// transformer falls back to an empty string.
10
+ pub struct DefaultReasoningContent;
11
+
12
+ impl Transformer for DefaultReasoningContent {
13
+ type Value = Request;
14
+
15
+ fn transform(&mut self, mut request: Self::Value) -> Self::Value {
16
+ if let Some(ref mut messages) = request.messages {
17
+ for message in messages.iter_mut() {
18
+ if message.reasoning_content.is_none() {
19
+ message.reasoning_content = Some(String::new());
20
+ }
21
+ }
22
+ }
23
+ request
24
+ }
25
+ }
26
+
27
+ #[cfg(test)]
28
+ mod tests {
29
+ use pretty_assertions::assert_eq;
30
+
31
+ use super::*;
32
+ use crate::dto::openai::{Message, MessageContent, Request, Role};
33
+
34
+ #[test]
35
+ fn test_sets_empty_string_when_reasoning_content_is_none() {
36
+ let fixture = Request::default().messages(vec![Message {
37
+ role: Role::Assistant,
38
+ content: Some(MessageContent::Text("test".to_string())),
39
+ name: None,
40
+ tool_call_id: None,
41
+ tool_calls: None,
42
+ reasoning_details: None,
43
+ reasoning_text: None,
44
+ reasoning_opaque: None,
45
+ reasoning_content: None,
46
+ extra_content: None,
47
+ }]);
48
+
49
+ let actual = DefaultReasoningContent.transform(fixture);
50
+
51
+ let messages = actual.messages.unwrap();
52
+ let msg = messages.first().unwrap();
53
+ assert_eq!(msg.reasoning_content, Some(String::new()));
54
+ }
55
+
56
+ #[test]
57
+ fn test_preserves_existing_reasoning_content() {
58
+ let fixture = Request::default().messages(vec![Message {
59
+ role: Role::Assistant,
60
+ content: Some(MessageContent::Text("test".to_string())),
61
+ name: None,
62
+ tool_call_id: None,
63
+ tool_calls: None,
64
+ reasoning_details: None,
65
+ reasoning_text: None,
66
+ reasoning_opaque: None,
67
+ reasoning_content: Some("thinking...".to_string()),
68
+ extra_content: None,
69
+ }]);
70
+
71
+ let actual = DefaultReasoningContent.transform(fixture);
72
+
73
+ let messages = actual.messages.unwrap();
74
+ let msg = messages.first().unwrap();
75
+ assert_eq!(msg.reasoning_content, Some("thinking...".to_string()));
76
+ }
77
+
78
+ #[test]
79
+ fn test_preserves_preserves_empty_string_when_already_set() {
80
+ let fixture = Request::default().messages(vec![Message {
81
+ role: Role::Assistant,
82
+ content: Some(MessageContent::Text("test".to_string())),
83
+ name: None,
84
+ tool_call_id: None,
85
+ tool_calls: None,
86
+ reasoning_details: None,
87
+ reasoning_text: None,
88
+ reasoning_opaque: None,
89
+ reasoning_content: Some(String::new()),
90
+ extra_content: None,
91
+ }]);
92
+
93
+ let actual = DefaultReasoningContent.transform(fixture);
94
+
95
+ let messages = actual.messages.unwrap();
96
+ let msg = messages.first().unwrap();
97
+ assert_eq!(msg.reasoning_content, Some(String::new()));
98
+ }
99
+ }
crates/forge_app/src/dto/openai/transformers/drop_tool_call.rs ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use forge_domain::Transformer;
2
+
3
+ use crate::dto::openai::{Request, Role};
4
+
5
+ /// Drops all tool call messages and converts them to user/assistant messages
6
+ pub struct DropToolCalls;
7
+
8
+ impl Transformer for DropToolCalls {
9
+ type Value = Request;
10
+
11
+ fn transform(&mut self, mut request: Self::Value) -> Self::Value {
12
+ if let Some(messages) = request.messages.as_mut() {
13
+ for message in messages.iter_mut() {
14
+ // Convert tool messages to user messages
15
+ if message.role == Role::Tool {
16
+ message.role = Role::User;
17
+ message.tool_calls = None;
18
+ message.tool_call_id = None;
19
+ message.name = None;
20
+ }
21
+ // Remove tool calls from assistant messages
22
+ if message.role == Role::Assistant {
23
+ message.tool_calls = None;
24
+ }
25
+ }
26
+ }
27
+
28
+ // Reset the tools field
29
+ request.tools = None;
30
+
31
+ request
32
+ }
33
+ }
34
+
35
+ #[cfg(test)]
36
+ mod tests {
37
+ use forge_domain::{
38
+ Context, ContextMessage, Role, TextMessage, ToolCallFull, ToolCallId, ToolName, ToolResult,
39
+ };
40
+
41
+ use super::*;
42
+
43
+ #[test]
44
+ fn test_mistral_transformer_tools_not_supported() {
45
+ let tool_call = ToolCallFull {
46
+ call_id: Some(ToolCallId::new("123")),
47
+ name: ToolName::new("test_tool"),
48
+ arguments: serde_json::json!({"key": "value"}).into(),
49
+ thought_signature: None,
50
+ };
51
+
52
+ let tool_result = ToolResult::new(ToolName::new("test_tool"))
53
+ .call_id(ToolCallId::new("123"))
54
+ .success("test result");
55
+
56
+ let context = Context {
57
+ conversation_id: None,
58
+ messages: vec![
59
+ ContextMessage::Text(
60
+ TextMessage::new(Role::Assistant, "Using tool").tool_calls(vec![tool_call]),
61
+ )
62
+ .into(),
63
+ ContextMessage::Tool(tool_result).into(),
64
+ ],
65
+ tools: vec![forge_domain::ToolDefinition::new("test_tool").description("A test tool")],
66
+ tool_choice: None,
67
+ max_tokens: None,
68
+ temperature: None,
69
+ top_p: None,
70
+ top_k: None,
71
+ reasoning: None,
72
+ stream: None,
73
+ response_format: None,
74
+ initiator: None,
75
+ };
76
+
77
+ let request = Request::from(context);
78
+ let mut transformer = DropToolCalls;
79
+ let transformed = transformer.transform(request);
80
+
81
+ let messages = transformed.messages.unwrap();
82
+ // Assistant message
83
+ assert!(messages[0].tool_calls.is_none());
84
+ // Converted tool message
85
+ assert_eq!(messages[1].role, Role::User.into());
86
+ // Tools field should be reset
87
+ assert!(transformed.tools.is_none());
88
+ }
89
+ }
crates/forge_app/src/dto/openai/transformers/ensure_system_first.rs ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use forge_domain::Transformer;
2
+
3
+ use crate::dto::openai::{Message, MessageContent, Request, Role};
4
+
5
+ /// Merges all system messages into a single system message at the beginning of
6
+ /// the messages array.
7
+ ///
8
+ /// Some providers (e.g. NVIDIA, vLLM) reject requests with multiple system
9
+ /// messages or system messages that are not positioned at the start of the
10
+ /// conversation.
11
+ pub struct MergeSystemMessages;
12
+
13
+ impl Transformer for MergeSystemMessages {
14
+ type Value = Request;
15
+
16
+ fn transform(&mut self, mut request: Self::Value) -> Self::Value {
17
+ if let Some(messages) = request.messages.take() {
18
+ let (system, rest): (Vec<_>, Vec<_>) =
19
+ messages.into_iter().partition(|m| m.role == Role::System);
20
+
21
+ let merged = if system.is_empty() {
22
+ rest
23
+ } else {
24
+ let combined_content = system
25
+ .iter()
26
+ .filter_map(|m| match &m.content {
27
+ Some(MessageContent::Text(text)) => Some(text.clone()),
28
+ Some(MessageContent::Parts(parts)) => Some(
29
+ parts
30
+ .iter()
31
+ .filter_map(|p| match p {
32
+ crate::dto::openai::ContentPart::Text { text, .. } => {
33
+ Some(text.clone())
34
+ }
35
+ _ => None,
36
+ })
37
+ .collect::<Vec<_>>()
38
+ .join(""),
39
+ ),
40
+ None => None,
41
+ })
42
+ .collect::<Vec<_>>()
43
+ .join("\n\n");
44
+
45
+ if combined_content.is_empty() {
46
+ // All system messages had no content, don't create empty system message
47
+ rest
48
+ } else {
49
+ let mut result = vec![Message {
50
+ role: Role::System,
51
+ content: Some(MessageContent::Text(combined_content)),
52
+ name: None,
53
+ tool_call_id: None,
54
+ tool_calls: None,
55
+ reasoning_details: None,
56
+ reasoning_text: None,
57
+ reasoning_opaque: None,
58
+ reasoning_content: None,
59
+ extra_content: None,
60
+ }];
61
+ result.extend(rest);
62
+ result
63
+ }
64
+ };
65
+
66
+ request.messages = Some(merged);
67
+ }
68
+ request
69
+ }
70
+ }
71
+
72
+ #[cfg(test)]
73
+ mod tests {
74
+ use pretty_assertions::assert_eq;
75
+
76
+ use super::*;
77
+ use crate::dto::openai::{Message, MessageContent, Role};
78
+
79
+ fn system_msg(content: &str) -> Message {
80
+ Message {
81
+ role: Role::System,
82
+ content: Some(MessageContent::Text(content.to_string())),
83
+ name: None,
84
+ tool_call_id: None,
85
+ tool_calls: None,
86
+ reasoning_details: None,
87
+ reasoning_text: None,
88
+ reasoning_opaque: None,
89
+ reasoning_content: None,
90
+ extra_content: None,
91
+ }
92
+ }
93
+
94
+ fn user_msg(content: &str) -> Message {
95
+ Message {
96
+ role: Role::User,
97
+ content: Some(MessageContent::Text(content.to_string())),
98
+ name: None,
99
+ tool_call_id: None,
100
+ tool_calls: None,
101
+ reasoning_details: None,
102
+ reasoning_text: None,
103
+ reasoning_opaque: None,
104
+ reasoning_content: None,
105
+ extra_content: None,
106
+ }
107
+ }
108
+
109
+ fn assistant_msg(content: &str) -> Message {
110
+ Message {
111
+ role: Role::Assistant,
112
+ content: Some(MessageContent::Text(content.to_string())),
113
+ name: None,
114
+ tool_call_id: None,
115
+ tool_calls: None,
116
+ reasoning_details: None,
117
+ reasoning_text: None,
118
+ reasoning_opaque: None,
119
+ reasoning_content: None,
120
+ extra_content: None,
121
+ }
122
+ }
123
+
124
+ fn get_text_content(msg: &Message) -> Option<&str> {
125
+ match msg.content.as_ref() {
126
+ Some(MessageContent::Text(text)) => Some(text.as_str()),
127
+ _ => None,
128
+ }
129
+ }
130
+
131
+ #[test]
132
+ fn test_multiple_system_messages_merged() {
133
+ let fixture = Request::default().messages(vec![
134
+ user_msg("hello"),
135
+ system_msg("you are helpful"),
136
+ assistant_msg("hi"),
137
+ system_msg("be concise"),
138
+ user_msg("how are you"),
139
+ ]);
140
+
141
+ let actual = MergeSystemMessages.transform(fixture);
142
+
143
+ let messages = actual.messages.unwrap();
144
+ assert_eq!(messages.len(), 4);
145
+ assert_eq!(messages[0].role, Role::System);
146
+ assert_eq!(
147
+ get_text_content(&messages[0]),
148
+ Some("you are helpful\n\nbe concise")
149
+ );
150
+ assert_eq!(messages[1].role, Role::User);
151
+ assert_eq!(messages[2].role, Role::Assistant);
152
+ assert_eq!(messages[3].role, Role::User);
153
+ }
154
+
155
+ #[test]
156
+ fn test_single_system_message_unchanged() {
157
+ let fixture = Request::default().messages(vec![
158
+ system_msg("you are helpful"),
159
+ user_msg("hello"),
160
+ assistant_msg("hi"),
161
+ ]);
162
+
163
+ let actual = MergeSystemMessages.transform(fixture);
164
+
165
+ let messages = actual.messages.unwrap();
166
+ assert_eq!(messages.len(), 3);
167
+ assert_eq!(messages[0].role, Role::System);
168
+ assert_eq!(get_text_content(&messages[0]), Some("you are helpful"));
169
+ assert_eq!(messages[1].role, Role::User);
170
+ assert_eq!(messages[2].role, Role::Assistant);
171
+ }
172
+
173
+ #[test]
174
+ fn test_no_system_messages_unchanged() {
175
+ let fixture = Request::default().messages(vec![
176
+ user_msg("hello"),
177
+ assistant_msg("hi"),
178
+ user_msg("how are you"),
179
+ ]);
180
+
181
+ let actual = MergeSystemMessages.transform(fixture);
182
+
183
+ let messages = actual.messages.unwrap();
184
+ assert_eq!(messages.len(), 3);
185
+ assert_eq!(messages[0].role, Role::User);
186
+ assert_eq!(messages[1].role, Role::Assistant);
187
+ assert_eq!(messages[2].role, Role::User);
188
+ }
189
+
190
+ #[test]
191
+ fn test_no_messages_unchanged() {
192
+ let fixture = Request::default();
193
+
194
+ let actual = MergeSystemMessages.transform(fixture);
195
+
196
+ assert!(actual.messages.is_none());
197
+ }
198
+ }
crates/forge_app/src/dto/openai/transformers/github_copilot_reasoning.rs ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use forge_domain::Transformer;
2
+
3
+ use crate::dto::openai::Request;
4
+
5
+ /// Transformer that converts reasoning_details to GitHub Copilot's flat format.
6
+ ///
7
+ /// GitHub Copilot uses `reasoning_text` and `reasoning_opaque` fields directly
8
+ /// in messages instead of the OpenRouter-style `reasoning_details` array.
9
+ ///
10
+ /// This transformer:
11
+ /// 1. Extracts `reasoning.text` type entries → `reasoning_text`
12
+ /// 2. Extracts `reasoning.encrypted` type entries → `reasoning_opaque`
13
+ /// 3. Removes `reasoning_details` array
14
+ pub struct GitHubCopilotReasoning;
15
+
16
+ impl Transformer for GitHubCopilotReasoning {
17
+ type Value = Request;
18
+
19
+ fn transform(&mut self, mut request: Self::Value) -> Self::Value {
20
+ if let Some(ref mut messages) = request.messages {
21
+ for message in messages.iter_mut() {
22
+ if let Some(reasoning_details) = &message.reasoning_details {
23
+ // Extract reasoning_text (type: "reasoning.text")
24
+ let reasoning_text = reasoning_details
25
+ .iter()
26
+ .find(|d| d.r#type == "reasoning.text")
27
+ .and_then(|d| d.text.clone());
28
+
29
+ // Extract reasoning_opaque (type: "reasoning.encrypted")
30
+ let reasoning_opaque = reasoning_details
31
+ .iter()
32
+ .find(|d| d.r#type == "reasoning.encrypted")
33
+ .and_then(|d| d.data.clone());
34
+
35
+ // Set flat fields
36
+ message.reasoning_text = reasoning_text;
37
+ message.reasoning_opaque = reasoning_opaque;
38
+
39
+ // Remove reasoning_details array (GitHub Copilot doesn't accept it)
40
+ message.reasoning_details = None;
41
+ }
42
+ }
43
+ }
44
+ request
45
+ }
46
+ }
47
+
48
+ #[cfg(test)]
49
+ mod tests {
50
+ use pretty_assertions::assert_eq;
51
+
52
+ use super::*;
53
+ use crate::dto::openai::{Message, MessageContent, ReasoningDetail, Request, Role};
54
+
55
+ #[test]
56
+ fn test_converts_reasoning_details_to_flat_format() {
57
+ // Setup
58
+ let fixture = Request::default().messages(vec![Message {
59
+ role: Role::Assistant,
60
+ content: Some(MessageContent::Text("test".to_string())),
61
+ reasoning_details: Some(vec![
62
+ ReasoningDetail {
63
+ r#type: "reasoning.text".to_string(),
64
+ text: Some("thinking...".to_string()),
65
+ signature: None,
66
+ data: None,
67
+ id: None,
68
+ format: None,
69
+ index: None,
70
+ },
71
+ ReasoningDetail {
72
+ r#type: "reasoning.encrypted".to_string(),
73
+ text: None,
74
+ signature: None,
75
+ data: Some("encrypted_data".to_string()),
76
+ id: None,
77
+ format: None,
78
+ index: None,
79
+ },
80
+ ]),
81
+ reasoning_text: None,
82
+ reasoning_opaque: None,
83
+ reasoning_content: None,
84
+ tool_calls: None,
85
+ tool_call_id: None,
86
+ name: None,
87
+ extra_content: None,
88
+ }]);
89
+
90
+ // Execute
91
+ let actual = GitHubCopilotReasoning.transform(fixture);
92
+
93
+ // Verify transformation
94
+ let messages = actual.messages.unwrap();
95
+ let msg = messages.first().unwrap();
96
+ assert_eq!(msg.reasoning_text, Some("thinking...".to_string()));
97
+ assert_eq!(msg.reasoning_opaque, Some("encrypted_data".to_string()));
98
+ assert!(msg.reasoning_details.is_none());
99
+ }
100
+
101
+ #[test]
102
+ fn test_handles_missing_reasoning_details() {
103
+ // Setup
104
+ let fixture = Request::default().messages(vec![Message {
105
+ role: Role::User,
106
+ content: Some(MessageContent::Text("test".to_string())),
107
+ reasoning_details: None,
108
+ reasoning_text: None,
109
+ reasoning_opaque: None,
110
+ reasoning_content: None,
111
+ tool_calls: None,
112
+ tool_call_id: None,
113
+ name: None,
114
+ extra_content: None,
115
+ }]);
116
+
117
+ // Execute
118
+ let actual = GitHubCopilotReasoning.transform(fixture.clone());
119
+
120
+ // Verify - no change since there were no reasoning_details
121
+ let messages = actual.messages.unwrap();
122
+ let msg = messages.first().unwrap();
123
+ assert!(msg.reasoning_text.is_none());
124
+ assert!(msg.reasoning_opaque.is_none());
125
+ }
126
+ }
crates/forge_app/src/dto/openai/transformers/make_cerebras_compat.rs ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use forge_domain::Transformer;
2
+
3
+ use crate::dto::openai::Request;
4
+
5
+ /// makes the Request compatible with the OpenAI API.
6
+ pub struct MakeCerebrasCompat;
7
+
8
+ impl Transformer for MakeCerebrasCompat {
9
+ type Value = Request;
10
+
11
+ fn transform(&mut self, mut request: Self::Value) -> Self::Value {
12
+ // remove fields that are not supported by cerebras.
13
+ request.parallel_tool_calls = None;
14
+ request
15
+ }
16
+ }
17
+
18
+ #[cfg(test)]
19
+ mod tests {
20
+ use pretty_assertions::assert_eq;
21
+
22
+ use super::*;
23
+
24
+ #[test]
25
+ fn test_parallel_tool_calls_dropped() {
26
+ use crate::dto::openai::{FunctionDescription, FunctionType, Request, Tool};
27
+
28
+ let fixture = Request::default()
29
+ .tools(vec![Tool {
30
+ r#type: FunctionType,
31
+ function: FunctionDescription {
32
+ description: Some("test".to_string()),
33
+ name: "test".to_string(),
34
+ parameters: serde_json::json!({}),
35
+ },
36
+ }])
37
+ .parallel_tool_calls(true);
38
+ let mut transformer = MakeCerebrasCompat;
39
+ let actual = transformer.transform(fixture);
40
+ let expected = None;
41
+ assert_eq!(actual.parallel_tool_calls, expected);
42
+ }
43
+ }
crates/forge_app/src/dto/openai/transformers/make_openai_compat.rs ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use forge_domain::Transformer;
2
+
3
+ use crate::dto::openai::Request;
4
+
5
+ /// makes the Request compatible with the OpenAI API.
6
+ pub struct MakeOpenAiCompat;
7
+
8
+ impl Transformer for MakeOpenAiCompat {
9
+ type Value = Request;
10
+
11
+ fn transform(&mut self, mut request: Self::Value) -> Self::Value {
12
+ // remove fields that are not supported by open-ai.
13
+ request.provider = None;
14
+ request.transforms = None;
15
+ request.prompt = None;
16
+ request.models = None;
17
+ request.route = None;
18
+ request.top_k = None;
19
+ request.top_p = None;
20
+ request.repetition_penalty = None;
21
+ request.min_p = None;
22
+ request.top_a = None;
23
+ request.session_id = None;
24
+ request.reasoning = None;
25
+
26
+ let tools_present = request
27
+ .tools
28
+ .as_ref()
29
+ .is_some_and(|tools| !tools.is_empty());
30
+
31
+ if !tools_present {
32
+ // drop `parallel_tool_calls` field if tools are not passed to the request.
33
+ request.parallel_tool_calls = None;
34
+ }
35
+
36
+ // OpenAI has deprecated `max_tokens`, now it is `max_completion_tokens`.
37
+ request.max_completion_tokens = request.max_tokens.take();
38
+
39
+ request
40
+ }
41
+ }
42
+
43
+ #[cfg(test)]
44
+ mod tests {
45
+ use pretty_assertions::assert_eq;
46
+
47
+ use super::*;
48
+
49
+ #[test]
50
+ fn test_parallel_tool_calls_removed_when_no_tools() {
51
+ use crate::dto::openai::Request;
52
+
53
+ let fixture = Request::default().parallel_tool_calls(true);
54
+ let mut transformer = MakeOpenAiCompat;
55
+ let actual = transformer.transform(fixture);
56
+ let expected = None;
57
+ assert_eq!(actual.parallel_tool_calls, expected);
58
+ }
59
+
60
+ #[test]
61
+ fn test_parallel_tool_calls_removed_when_empty_tools() {
62
+ let fixture = Request::default().tools(vec![]).parallel_tool_calls(true);
63
+ let mut transformer = MakeOpenAiCompat;
64
+ let actual = transformer.transform(fixture);
65
+ let expected = None;
66
+ assert_eq!(actual.parallel_tool_calls, expected);
67
+ }
68
+
69
+ #[test]
70
+ fn test_parallel_tool_calls_preserved_when_tools_present() {
71
+ use crate::dto::openai::{FunctionDescription, FunctionType, Request, Tool};
72
+
73
+ let fixture = Request::default()
74
+ .tools(vec![Tool {
75
+ r#type: FunctionType,
76
+ function: FunctionDescription {
77
+ description: Some("test".to_string()),
78
+ name: "test".to_string(),
79
+ parameters: serde_json::json!({}),
80
+ },
81
+ }])
82
+ .parallel_tool_calls(true);
83
+ let mut transformer = MakeOpenAiCompat;
84
+ let actual = transformer.transform(fixture);
85
+ let expected = Some(true);
86
+ assert_eq!(actual.parallel_tool_calls, expected);
87
+ }
88
+
89
+ #[test]
90
+ fn test_reasoning_removed() {
91
+ let fixture = Request::default().reasoning(forge_domain::ReasoningConfig {
92
+ enabled: Some(true),
93
+ effort: None,
94
+ max_tokens: None,
95
+ exclude: None,
96
+ });
97
+ let mut transformer = MakeOpenAiCompat;
98
+ let actual = transformer.transform(fixture);
99
+ let expected = None;
100
+ assert_eq!(actual.reasoning, expected);
101
+ }
102
+
103
+ #[test]
104
+ fn test_max_tokens_mapped_correctly() {
105
+ let fixture = Request::default().max_tokens(100);
106
+ let mut transformer = MakeOpenAiCompat;
107
+ let actual = transformer.transform(fixture);
108
+ let expected = Some(100);
109
+ assert_eq!(actual.max_completion_tokens, expected);
110
+ assert_eq!(actual.max_tokens, None);
111
+ }
112
+ }