1use super::*;
4
5impl MergeGatewayProvider {
6 pub fn new(api_key: String) -> Self {
7 Self::with_model(api_key, models::merge_gateway::DEFAULT_MODEL.to_string())
8 }
9
10 pub fn with_model(api_key: String, model: String) -> Self {
11 let timeouts = TimeoutsConfig::default();
12 let http_client = HttpClientFactory::for_llm(&timeouts);
13 Self::from_runtime_parts(api_key, model, http_client, urls::MERGE_GATEWAY_NATIVE_API_BASE.to_string())
14 }
15
16 pub fn new_with_client(
17 api_key: String,
18 model: String,
19 http_client: HttpClient,
20 base_url: String,
21 _timeouts: TimeoutsConfig,
22 ) -> Self {
23 Self::from_runtime_parts(api_key, model, http_client, base_url)
24 }
25
26 pub fn from_config(
27 api_key: Option<String>,
28 model: Option<String>,
29 base_url: Option<String>,
30 prompt_cache: Option<PromptCachingConfig>,
31 timeouts: Option<TimeoutsConfig>,
32 _anthropic: Option<AnthropicConfig>,
33 model_behavior: Option<ModelConfig>,
34 ) -> Self {
35 let api_key = <MergeGatewaySpec as OpenAiCompatSpec>::resolve_api_key(api_key);
36 let model = resolve_model(model, models::merge_gateway::DEFAULT_MODEL);
37 let base_url =
38 override_base_url(urls::MERGE_GATEWAY_NATIVE_API_BASE, base_url, Some(env_vars::MERGE_GATEWAY_BASE_URL));
39 let timeouts = timeouts.unwrap_or_default();
40 let http_client = HttpClientFactory::for_llm(&timeouts);
41
42 let legacy_core = if is_legacy_openai_base_url(&base_url) {
43 Some(OpenAiCompatCore::from_config(
44 Some(api_key.clone()),
45 Some(model.clone()),
46 Some(base_url.clone()),
47 prompt_cache,
48 Some(timeouts),
49 model_behavior.clone(),
50 ))
51 } else {
52 None
53 };
54
55 Self {
56 native: NativeMergeGatewayCore {
57 api_key,
58 http_client,
59 base_url,
60 model,
61 model_behavior,
62 },
63 legacy_core,
64 no_tool_vendor_cache: Mutex::new(HashSet::new()),
65 }
66 }
67
68 fn from_runtime_parts(api_key: String, model: String, http_client: HttpClient, base_url: String) -> Self {
69 let legacy_core = if is_legacy_openai_base_url(&base_url) {
70 Some(OpenAiCompatCore::from_parts(api_key.clone(), model.clone(), http_client.clone(), base_url.clone()))
71 } else {
72 None
73 };
74
75 Self {
76 native: NativeMergeGatewayCore {
77 api_key,
78 http_client,
79 base_url,
80 model,
81 model_behavior: None,
82 },
83 legacy_core,
84 no_tool_vendor_cache: Mutex::new(HashSet::new()),
85 }
86 }
87
88 fn prepare_native_request(&self, request: &mut LLMRequest) {
89 if request.model.trim().is_empty() {
90 request.model = self.native.model.clone();
91 }
92 }
93
94 fn tool_vendor_known_missing(&self, model: &str) -> bool {
95 self.no_tool_vendor_cache
96 .lock()
97 .map(|guard| guard.contains(model))
98 .unwrap_or(false)
99 }
100
101 pub(crate) fn mark_tool_vendor_missing(&self, model: &str) {
102 if let Ok(mut guard) = self.no_tool_vendor_cache.lock() {
103 guard.insert(model.to_owned());
104 }
105 }
106
107 fn no_tool_vendor_cached_error(model: &str) -> LLMError {
108 provider_error(format!(
109 "Model '{model}' has no vendor serving tools on Merge Gateway (cached from an earlier capability_unavailable rejection). Hint: use default_routing or another model until the route gains a tool-capable vendor."
110 ))
111 }
112
113 fn responses_url(&self) -> String {
114 format!("{}/responses", self.native.base_url.trim_end_matches('/'))
115 }
116
117 fn build_native_tools(&self, tools: &[ToolDefinition], model: &str) -> Option<Vec<Value>> {
118 let gemini_compatible = model.starts_with("google/gemini-");
119 let serialized: Vec<Value> = tools
120 .iter()
121 .filter_map(|tool| {
122 tool.function.as_ref().map(|func| {
123 let parameters = if gemini_compatible {
124 sanitize_function_parameters(func.parameters.clone())
125 } else {
126 func.parameters.clone()
127 };
128 json!({
129 "type": "function",
130 "name": func.name,
131 "description": func.description,
132 "parameters": parameters,
133 })
134 })
135 })
136 .collect();
137
138 if serialized.is_empty() { None } else { Some(serialized) }
139 }
140
141 fn native_tool_use_value(&self, call: &ToolCall) -> Result<Value, LLMError> {
142 call.validate()
143 .map_err(|err| provider_error(format!("Invalid Merge Gateway tool call: {err}")))?;
144 let name = call
145 .tool_name()
146 .ok_or_else(|| provider_error("Merge Gateway tool call is missing a function name"))?;
147 let input = call
148 .execution_arguments()
149 .map_err(|err| provider_error(format!("Failed to serialize Merge Gateway tool call arguments: {err}")))?;
150 Ok(json!({
151 "type": "tool_use",
152 "id": call.id,
153 "name": name,
154 "input": input,
155 }))
156 }
157
158 fn native_message_content_value(&self, message: &Message, model: &str) -> Result<Value, LLMError> {
159 let content = serialize_message_content_openai_for_model(message, model);
160 if message.tool_calls.as_ref().is_none_or(Vec::is_empty) {
161 return Ok(content);
162 }
163
164 let mut parts = match content {
165 Value::String(text) => {
166 if text.trim().is_empty() {
167 Vec::new()
168 } else {
169 vec![json!({"type": "text", "text": text})]
170 }
171 }
172 Value::Array(parts) => parts,
173 other => vec![other],
174 };
175
176 if let Some(tool_calls) = &message.tool_calls {
177 for call in tool_calls {
178 parts.push(self.native_tool_use_value(call)?);
179 }
180 }
181
182 Ok(Value::Array(parts))
183 }
184
185 fn native_input_item_for_message(&self, message: &Message, model: &str) -> Result<Value, LLMError> {
186 if message.role.is_tool_response() {
187 let tool_call_id = message
188 .tool_call_id
189 .clone()
190 .ok_or_else(|| provider_error("Merge Gateway tool result messages must include a tool_call_id"))?;
191 return Ok(json!({
192 "type": "tool_result",
193 "tool_use_id": tool_call_id,
194 "content": function_output_value_from_message_content(&message.content),
195 }));
196 }
197
198 Ok(json!({
199 "type": "message",
200 "role": message.role.as_generic_str(),
201 "content": self.native_message_content_value(message, model)?,
202 }))
203 }
204
205 fn native_stop_sequences(request: &LLMRequest) -> Option<Vec<Value>> {
206 let stop: Vec<Value> = request
207 .stop_sequences
208 .as_ref()
209 .into_iter()
210 .flatten()
211 .filter_map(|stop| {
212 let trimmed = stop.trim();
213 if trimmed.is_empty() {
214 None
215 } else {
216 Some(Value::String(trimmed.to_string()))
217 }
218 })
219 .collect();
220
221 if stop.is_empty() { None } else { Some(stop) }
222 }
223
224 pub(crate) fn build_native_payload(&self, request: &LLMRequest, stream: bool) -> Result<Value, LLMError> {
225 let mut input = Vec::new();
226
227 if let Some(system) = &request.system_prompt {
228 let trimmed = system.trim();
229 if !trimmed.is_empty() {
230 input.push(json!({
231 "type": "message",
232 "role": "system",
233 "content": trimmed,
234 }));
235 }
236 }
237
238 for message in request.messages.iter() {
239 input.push(self.native_input_item_for_message(message, &request.model)?);
240 }
241
242 let mut payload = Map::new();
243 payload.insert("model".to_owned(), Value::String(request.model.clone()));
244 payload.insert("input".to_owned(), Value::Array(input));
245
246 let tools_disabled = matches!(request.tool_choice, Some(ToolChoice::None));
249 if !tools_disabled
250 && let Some(tools) = request
251 .tools
252 .as_ref()
253 .and_then(|tools| self.build_native_tools(tools, &request.model))
254 {
255 payload.insert("tools".to_owned(), Value::Array(tools));
256 }
257
258 if let Some(max_tokens) = request.max_tokens {
259 payload.insert("max_tokens".to_owned(), json!(max_tokens));
260 }
261 if let Some(temperature) = request.temperature {
262 payload.insert("temperature".to_owned(), json!(crate::providers::common::sampling_param_f64(temperature)));
263 }
264 if let Some(top_p) = request.top_p {
265 payload.insert("top_p".to_owned(), json!(crate::providers::common::sampling_param_f64(top_p)));
266 }
267 if let Some(stop) = Self::native_stop_sequences(request) {
268 payload.insert("stop".to_owned(), Value::Array(stop));
269 }
270 if !tools_disabled && let Some(choice) = &request.tool_choice {
271 payload.insert("tool_choice".to_owned(), choice.to_provider_format("merge-gateway"));
272 }
273 if let Some(output_format) = &request.output_format {
274 payload.insert("response_format".to_owned(), output_format.clone());
275 }
276 if let Some(service_tier) = request.service_tier.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
277 match map_openai_service_tier_for_merge(service_tier) {
278 Some(mapped) => {
279 payload.insert("service_tier".to_owned(), Value::String(mapped.to_owned()));
280 }
281 None => {
282 tracing::warn!(
283 model = %request.model,
284 service_tier = %service_tier,
285 "Merge Gateway has no equivalent for service_tier; omitting it and using gateway default routing"
286 );
287 }
288 }
289 }
290 if let Some(cache_key) = request
291 .prompt_cache_key
292 .as_deref()
293 .map(str::trim)
294 .filter(|value| !value.is_empty())
295 {
296 payload.insert("prompt_cache_key".to_owned(), Value::String(cache_key.to_owned()));
297 }
298 if let Some(session_id) = merge_session_identity(request) {
299 payload.insert("session_id".to_owned(), Value::String(session_id));
300 }
301 if stream {
302 payload.insert("stream".to_owned(), Value::Bool(true));
303 }
304
305 if let Some(effort) = request.reasoning_effort
306 && is_active_reasoning_effort(effort)
307 && let Some(control) = merge_reasoning_control_for_model(&request.model)
308 {
309 match control {
310 MergeReasoningControl::ReasoningEffort => {
311 payload.insert("reasoning_effort".to_owned(), Value::String(effort.as_str().to_owned()));
312 }
313 MergeReasoningControl::ThinkingBudget => {
314 if let Some(thinking) = merge_thinking_payload(effort, request.max_tokens) {
315 payload.insert("thinking".to_owned(), thinking);
316 }
317 }
318 }
319 }
320
321 Ok(Value::Object(payload))
322 }
323
324 async fn generate_native(&self, request: LLMRequest) -> Result<LLMResponse, LLMError> {
325 self.generate_native_with_streaming_retry(request, true).await
326 }
327
328 async fn generate_native_with_streaming_retry(
329 &self,
330 mut request: LLMRequest,
331 allow_streaming_retry: bool,
332 ) -> Result<LLMResponse, LLMError> {
333 self.prepare_native_request(&mut request);
334 LLMProvider::validate_request(self, &request)?;
335 if self.tool_vendor_known_missing(&request.model) && native_request_sends_tools(&request) {
339 return Err(Self::no_tool_vendor_cached_error(&request.model));
340 }
341 let payload = self.build_native_payload(&request, false)?;
342 let session_id = merge_session_identity(&request);
343 let mut http = self
344 .native
345 .http_client
346 .post(self.responses_url())
347 .bearer_auth(&self.native.api_key);
348 if let Some(session_id) = session_id.as_deref() {
349 http = http.header("X-Session-Id", session_id);
350 }
351 let response = http
352 .json(&payload)
353 .send()
354 .await
355 .map_err(|e| format_network_error("Merge Gateway", &e))?;
356
357 if !response.status().is_success() {
358 let status = response.status();
359 let body = crate::providers::common::read_provider_error_body(response).await;
360 if is_merge_tier_pricing_rejection(status, &body) && request.service_tier.is_some() {
364 tracing::warn!(
365 model = %request.model,
366 status = %status,
367 "Merge Gateway route is not priced for service_tier; retrying once without it"
368 );
369 request.service_tier = None;
370 return Box::pin(self.generate_native_with_streaming_retry(request, allow_streaming_retry)).await;
371 }
372 if allow_streaming_retry && is_streaming_only_rejection(status, &body) {
378 tracing::warn!(
379 model = %request.model,
380 status = %status,
381 "Merge Gateway route requires streaming; retrying once with streaming"
382 );
383 return Box::pin(self.collect_streamed_response(request)).await;
384 }
385 let sent_reasoning = request.reasoning_effort.is_some_and(is_active_reasoning_effort);
393 if is_capability_unavailable(status, &body)
394 && native_request_sends_tools(&request)
395 && !(sent_reasoning && is_reasoning_capability_rejection(&body))
396 {
397 self.mark_tool_vendor_missing(&request.model);
398 }
399 return Err(merge_request_error(status, &body));
400 }
401
402 let json: Value = response.json().await.map_err(|e| format_parse_error("Merge Gateway", &e))?;
403 Self::parse_native_response_payload(json, request.model)
404 }
405
406 async fn collect_streamed_response(&self, request: LLMRequest) -> Result<LLMResponse, LLMError> {
407 let mut stream = self.stream_native_normalized_inner(request, false).await?;
408 let mut completed = None;
409 while let Some(event) = stream.next().await {
410 match event? {
411 NormalizedStreamEvent::Done { response } => {
412 completed = Some(*response);
413 break;
414 }
415 NormalizedStreamEvent::Usage { .. }
416 | NormalizedStreamEvent::TextDelta { .. }
417 | NormalizedStreamEvent::ReasoningDelta { .. }
418 | NormalizedStreamEvent::ReasoningStage { .. }
419 | NormalizedStreamEvent::ToolCallStart { .. }
420 | NormalizedStreamEvent::ToolCallDelta { .. } => {}
421 }
422 }
423 completed.ok_or_else(|| provider_error("Merge Gateway streamed fallback ended without a completed response"))
424 }
425
426 async fn stream_native_normalized(&self, request: LLMRequest) -> Result<LLMNormalizedStream, LLMError> {
427 self.stream_native_normalized_inner(request, true).await
428 }
429
430 async fn stream_native_normalized_inner(
431 &self,
432 mut request: LLMRequest,
433 allow_non_streaming_fallback: bool,
434 ) -> Result<LLMNormalizedStream, LLMError> {
435 self.prepare_native_request(&mut request);
436 LLMProvider::validate_request(self, &request)?;
437 if self.tool_vendor_known_missing(&request.model) && native_request_sends_tools(&request) {
438 return Err(Self::no_tool_vendor_cached_error(&request.model));
439 }
440 request.stream = true;
441
442 let payload = self.build_native_payload(&request, true)?;
443 let session_id = merge_session_identity(&request);
444 let mut http = self
445 .native
446 .http_client
447 .post(self.responses_url())
448 .bearer_auth(&self.native.api_key);
449 if let Some(session_id) = session_id.as_deref() {
450 http = http.header("X-Session-Id", session_id);
451 }
452 let response = http
453 .json(&payload)
454 .send()
455 .await
456 .map_err(|e| format_network_error("Merge Gateway", &e))?;
457
458 if !response.status().is_success() {
459 let status = response.status();
460 let body = crate::providers::common::read_provider_error_body(response).await;
461 if allow_non_streaming_fallback
471 && is_capability_unavailable(status, &body)
472 && !is_streaming_only_model(&request.model)
473 {
474 tracing::warn!(
475 model = %request.model,
476 status = %status,
477 "Merge Gateway rejected streaming capabilities; retrying once without streaming"
478 );
479 request.stream = false;
480 let fallback = Box::pin(self.generate_native_with_streaming_retry(request, false)).await?;
481 let completed = LLMStreamEvent::Completed { response: Box::new(fallback) };
482 let stream = try_stream! {
483 for event in completed.into_normalized() {
484 yield event;
485 }
486 };
487 return Ok(Box::pin(stream));
488 }
489 if is_merge_tier_pricing_rejection(status, &body) && request.service_tier.is_some() {
492 tracing::warn!(
493 model = %request.model,
494 status = %status,
495 "Merge Gateway route is not priced for service_tier; retrying once without it"
496 );
497 request.service_tier = None;
498 return Box::pin(self.stream_native_normalized_inner(request, allow_non_streaming_fallback)).await;
499 }
500 return Err(merge_request_error(status, &body));
501 }
502
503 let model = request.model.clone();
504 let stream = try_stream! {
505 let mut body_stream = response.bytes_stream();
506 let mut decoder = Utf8StreamDecoder::new();
507 let mut buffer: Vec<u8> = Vec::new();
508 let mut offset = 0usize;
509 let mut state = MergeStreamState::new(model.clone());
510
511 while let Some(chunk_result) = body_stream.next().await {
512 let chunk = chunk_result.map_err(|e| format_network_error("Merge Gateway", &e))?;
513 decoder.push_bytes(&chunk, &mut buffer);
514
515 while let Some(raw_event) = next_sse_event(&buffer, &mut offset)
516 .map_err(|error| format_parse_error("Merge Gateway", &error))?
517 {
518
519 let payload_text = match extract_data_payload(raw_event) {
520 Some(payload) => payload.into_owned(),
521 None => {
522 let trimmed = raw_event.trim();
523 if trimmed.starts_with('{') || trimmed.starts_with('[') {
524 trimmed.to_string()
525 } else {
526 continue;
527 }
528 }
529 };
530
531 if payload_text.trim() == "[DONE]" {
532 state.done = true;
533 break;
534 }
535
536 let events = Self::handle_native_stream_payload(raw_event, &payload_text, &mut state)?;
537 for event in events {
538 yield event;
539 }
540
541 if state.done {
542 break;
543 }
544 }
545
546 if offset > 0 {
547 buffer.drain(..offset);
548 offset = 0;
549 }
550
551 if state.done {
552 break;
553 }
554 }
555
556 if !state.done {
557 Err(provider_error("Merge Gateway stream ended before a terminal response event"))?;
558 }
559
560 if let Some(response) = state.native_snapshot.take() {
561 for event in state.apply_native_snapshot(response)? {
562 yield event;
563 }
564 }
565
566 let response = state.finish()?;
567 if let Some(usage) = response.usage.clone() {
568 yield NormalizedStreamEvent::Usage { usage };
569 }
570 yield NormalizedStreamEvent::Done { response: Box::new(response) };
571 };
572
573 Ok(Box::pin(stream))
574 }
575
576 fn handle_native_stream_payload(
577 raw_event: &str,
578 payload_text: &str,
579 state: &mut MergeStreamState,
580 ) -> Result<Vec<NormalizedStreamEvent>, LLMError> {
581 let payload: Value = serde_json::from_str(payload_text)
582 .map_err(|e| provider_error(format!("Invalid Merge Gateway SSE payload: {e}")))?;
583 let event_name = Self::merge_event_name(raw_event, &payload);
584 let data = payload.get("data").cloned().unwrap_or(payload.clone());
585 let mut events = Vec::new();
586
587 let fallback_restart = data
588 .get("fallback_restart")
589 .or_else(|| payload.get("fallback_restart"))
590 .and_then(Value::as_bool)
591 .unwrap_or(false);
592 if fallback_restart {
593 let model = data.get("model").or_else(|| payload.get("model")).and_then(Value::as_str);
594 state.reset_for_fallback_restart(model);
595 return Ok(events);
596 }
597
598 if event_name.is_empty() {
599 if data.get("response").is_some()
600 || data.get("output").is_some()
601 || data.get("content").is_some()
602 || data.get("text").is_some()
603 {
604 let response_value = data.get("response").cloned().unwrap_or_else(|| data.clone());
605 let response = Self::parse_native_response_payload(response_value, state.model.clone())?;
606 state.native_snapshot = None;
607 events.extend(state.apply_native_snapshot(response)?);
608 state.done = true;
609 }
610 return Ok(events);
611 }
612
613 match event_name.as_str() {
614 "response.stream" | "response.done" => {
619 let response_value = data.get("response").cloned().or_else(|| {
620 if data.get("output").is_some() {
621 Some(data.clone())
622 } else {
623 None
624 }
625 });
626
627 let response = match response_value {
628 Some(response_value) => Self::parse_native_response_payload(response_value, state.model.clone())?,
629 None if event_name == "response.done" => state
630 .native_snapshot
631 .take()
632 .ok_or_else(|| provider_error("Merge Gateway response.done event was missing the response"))?,
633 None => return Ok(events),
634 };
635
636 if event_name == "response.done" {
637 state.native_snapshot = None;
638 events.extend(state.apply_native_snapshot(response)?);
639 state.done = true;
640 } else {
641 if let Some(previous) = state.native_snapshot.take() {
642 if Self::native_snapshots_are_cumulative(&previous, &response) {
643 if !state.native_snapshot_streaming {
644 events.extend(state.apply_native_snapshot(previous)?);
645 state.native_snapshot_streaming = true;
646 }
647 events.extend(state.apply_native_snapshot(response.clone())?);
648 } else if state.native_snapshot_streaming {
649 state.reset_native_snapshot_accumulator();
653 }
654 }
655 state.remember_native_snapshot(response);
656 }
657 }
658 "response.output_text.delta" | "response.output_text.done" => {
659 if let Some(fragment) = Self::stream_text_fragment(&data) {
660 if let Some(delta) = state.apply_text_fragment(fragment, event_name.ends_with(".done")) {
661 if !delta.is_empty() {
662 events.push(NormalizedStreamEvent::TextDelta { delta });
663 }
664 }
665 }
666 }
667 "response.output_item.added" => {
668 if let Some(item) = Self::stream_output_item(&data) {
669 events.extend(state.record_tool_use_item(item, false)?);
670 }
671 }
672 "response.output_item.done" => {
673 if let Some(item) = Self::stream_output_item(&data) {
674 events.extend(state.record_tool_use_item(item, true)?);
675 }
676 }
677 "response.function_call_arguments.delta" => {
678 if let Some((call_id, name, fragment)) = Self::stream_tool_arguments(&data) {
679 events.extend(state.record_tool_arguments(call_id, name, fragment, false)?);
680 }
681 }
682 "response.function_call_arguments.done" => {
683 if let Some((call_id, name, fragment)) = Self::stream_tool_arguments(&data) {
684 events.extend(state.record_tool_arguments(call_id, name, fragment, true)?);
685 }
686 }
687 "response.usage" => {
688 let usage_value = data.get("usage").unwrap_or(&data);
689 state.usage = Self::parse_native_usage(Some(usage_value));
690 }
691 "response.completed" | "response.incomplete" => {
692 if event_name == "response.incomplete" {
693 state.incomplete = true;
694 }
695 let response_value = data.get("response").cloned().or_else(|| {
696 if data.get("output").is_some() {
697 Some(data.clone())
698 } else {
699 None
700 }
701 });
702
703 if let Some(response_value) = response_value {
704 match Self::parse_native_response_payload(response_value, state.model.clone()) {
705 Ok(response) => {
706 state.final_response = Some(response);
707 state.done = true;
708 }
709 Err(err) if state.has_streamed_output() => {
710 state.request_id = Self::extract_request_id(&data);
711 state.done = true;
712 if matches!(event_name.as_str(), "response.completed") {
713 return Err(err);
714 }
715 }
716 Err(err) => return Err(err),
717 }
718 } else if state.has_streamed_output() {
719 state.request_id = Self::extract_request_id(&data);
720 state.done = true;
721 }
722 }
723 "response.failed" | "response.error" | "error" => {
724 let message = data
725 .get("error")
726 .and_then(Value::as_object)
727 .and_then(|error| error.get("message"))
728 .and_then(Value::as_str)
729 .or_else(|| data.get("error").and_then(Value::as_str))
730 .or_else(|| data.get("message").and_then(Value::as_str))
731 .unwrap_or("Merge Gateway stream failed")
732 .to_string();
733 return Err(provider_error(message));
734 }
735 _ => {}
736 }
737
738 if state.done && state.final_response.is_none() && !state.has_streamed_output() {
739 state.final_response = Some(state.synthesize_response());
740 }
741
742 Ok(events)
743 }
744
745 fn merge_event_name(raw_event: &str, payload: &Value) -> String {
746 for line in raw_event.lines() {
747 if let Some(event) = line.strip_prefix("event:") {
748 return event.trim().to_string();
749 }
750 }
751 if let Some(event) = payload.get("event").and_then(Value::as_str) {
752 return event.to_string();
753 }
754 if let Some(event) = payload.get("type").and_then(Value::as_str) {
755 return event.to_string();
756 }
757 if let Some(event) = payload.get("object").and_then(Value::as_str)
758 && (event.starts_with("response.") || event == "error")
759 {
760 return event.to_string();
761 }
762 String::new()
763 }
764
765 fn native_snapshots_are_cumulative(previous: &LLMResponse, current: &LLMResponse) -> bool {
766 let content_is_cumulative = match (previous.content.as_deref(), current.content.as_deref()) {
767 (Some(previous), Some(current)) => current.starts_with(previous),
768 (Some(_), None) => false,
769 (None, _) => true,
770 };
771 if !content_is_cumulative {
772 return false;
773 }
774
775 let previous_calls = previous.tool_calls.as_deref().unwrap_or_default();
776 let current_calls = current.tool_calls.as_deref().unwrap_or_default();
777 previous_calls.iter().all(|previous_call| {
778 let Some(current_call) = current_calls.iter().find(|call| call.id == previous_call.id) else {
779 return false;
780 };
781
782 let previous_name = previous_call.tool_name().unwrap_or_default();
783 let current_name = current_call.tool_name().unwrap_or_default();
784 if previous_name != current_name {
785 return false;
786 }
787
788 let previous_arguments = previous_call.raw_input().unwrap_or_default();
789 previous_arguments.is_empty()
790 || previous_arguments == "{}"
791 || current_call.raw_input().unwrap_or_default().starts_with(previous_arguments)
792 })
793 }
794
795 fn stream_text_fragment(data: &Value) -> Option<String> {
796 data.get("delta")
797 .or_else(|| data.get("output_text"))
798 .or_else(|| data.get("text"))
799 .and_then(Value::as_str)
800 .map(ToOwned::to_owned)
801 }
802
803 fn stream_output_item(data: &Value) -> Option<&Value> {
804 data.get("output_item").or_else(|| data.get("item")).or_else(|| {
805 if data.get("type").is_some() || data.get("content").is_some() {
806 Some(data)
807 } else {
808 None
809 }
810 })
811 }
812
813 fn stream_tool_arguments(data: &Value) -> Option<(String, Option<String>, String)> {
814 let call_id = data
815 .get("call_id")
816 .or_else(|| data.get("item_id"))
817 .or_else(|| data.get("tool_use_id"))
818 .or_else(|| data.get("tool_call_id"))
819 .or_else(|| data.get("id"))
820 .and_then(Value::as_str)
821 .map(ToOwned::to_owned)?;
822 let name = data.get("name").and_then(Value::as_str).map(ToOwned::to_owned).or_else(|| {
823 Self::stream_output_item(data)
824 .and_then(|item| item.get("name"))
825 .and_then(Value::as_str)
826 .map(ToOwned::to_owned)
827 });
828 let fragment = data
829 .get("delta")
830 .or_else(|| data.get("arguments"))
831 .or_else(|| data.get("input"))
832 .and_then(|value| Self::value_to_arguments(value).ok())
833 .unwrap_or_default();
834 Some((call_id, name, fragment))
835 }
836
837 fn value_to_arguments(value: &Value) -> Result<String, LLMError> {
838 match value {
839 Value::String(text) => Ok(text.clone()),
840 _ => serde_json::to_string(value)
841 .map_err(|e| provider_error(format!("Failed to serialize Merge Gateway tool arguments: {e}"))),
842 }
843 }
844
845 fn parse_native_response_payload(json: Value, model: String) -> Result<LLMResponse, LLMError> {
846 let mut content = String::new();
847 let mut tool_calls = Vec::new();
848 let mut finish_reason = FinishReason::Stop;
849
850 if let Some(output) = json.get("output").and_then(Value::as_array) {
851 for item in output {
852 Self::parse_native_output_item(item, &mut content, &mut tool_calls, &mut finish_reason)?;
853 }
854 } else if json.get("content").is_some() || json.get("text").is_some() || json.get("tool_use").is_some() {
855 Self::parse_native_output_item(&json, &mut content, &mut tool_calls, &mut finish_reason)?;
856 } else {
857 return Err(provider_error("Invalid response from Merge Gateway: missing output"));
858 }
859
860 if matches!(finish_reason, FinishReason::Stop) && !tool_calls.is_empty() {
861 finish_reason = FinishReason::ToolCalls;
862 }
863
864 Ok(LLMResponse {
865 content: if content.is_empty() { None } else { Some(content) },
866 tool_calls: if tool_calls.is_empty() { None } else { Some(tool_calls) },
867 model: json
868 .get("model")
869 .and_then(Value::as_str)
870 .map(ToOwned::to_owned)
871 .filter(|value| !value.trim().is_empty())
872 .unwrap_or(model),
873 usage: Self::parse_native_usage(json.get("usage")),
874 finish_reason,
875 reasoning: None,
876 reasoning_details: None,
877 tool_references: Vec::new(),
878 request_id: Self::extract_request_id(&json),
879 organization_id: None,
880 compaction: None,
881 })
882 }
883
884 fn parse_native_output_item(
885 item: &Value,
886 content: &mut String,
887 tool_calls: &mut Vec<ToolCall>,
888 finish_reason: &mut FinishReason,
889 ) -> Result<(), LLMError> {
890 let item_type = item.get("type").and_then(Value::as_str).unwrap_or("");
891 match item_type {
892 "message" | "" => {
893 if let Some(role) = item.get("role").and_then(Value::as_str)
894 && role != "assistant"
895 {
896 return Ok(());
897 }
898
899 if let Some(reason) = item.get("finish_reason").and_then(Value::as_str) {
900 *finish_reason = Self::map_finish_reason(reason);
901 }
902
903 if let Some(content_value) = item.get("content") {
904 Self::parse_native_content_value(content_value, content, tool_calls, finish_reason)?;
905 } else {
906 Self::parse_native_content_value(item, content, tool_calls, finish_reason)?;
907 }
908 }
909 "tool_use" | "function_call" => {
910 if let Some(tool_call) = Self::tool_call_from_value(item)? {
911 tool_calls.push(tool_call);
912 if matches!(finish_reason, FinishReason::Stop) {
913 *finish_reason = FinishReason::ToolCalls;
914 }
915 }
916 }
917 "refusal" => {
918 if let Some(text) = item.get("refusal").and_then(Value::as_str) {
919 content.push_str(text);
920 *finish_reason = FinishReason::Refusal;
921 }
922 }
923 _ => {
924 Self::parse_native_content_value(item, content, tool_calls, finish_reason)?;
925 }
926 }
927 Ok(())
928 }
929
930 fn parse_native_content_value(
931 value: &Value,
932 content: &mut String,
933 tool_calls: &mut Vec<ToolCall>,
934 finish_reason: &mut FinishReason,
935 ) -> Result<(), LLMError> {
936 match value {
937 Value::String(text) => content.push_str(text),
938 Value::Array(parts) => {
939 for part in parts {
940 Self::parse_native_content_part(part, content, tool_calls, finish_reason)?;
941 }
942 }
943 Value::Object(_) => {
944 Self::parse_native_content_part(value, content, tool_calls, finish_reason)?;
945 }
946 _ => {}
947 }
948 Ok(())
949 }
950
951 fn parse_native_content_part(
952 part: &Value,
953 content: &mut String,
954 tool_calls: &mut Vec<ToolCall>,
955 finish_reason: &mut FinishReason,
956 ) -> Result<(), LLMError> {
957 let part_type = part.get("type").and_then(Value::as_str).unwrap_or("");
958 match part_type {
959 "text" => {
960 if let Some(text) = part.get("text").and_then(Value::as_str) {
961 content.push_str(text);
962 } else if let Some(text) = part.get("output_text").and_then(Value::as_str) {
963 content.push_str(text);
964 }
965 }
966 "tool_use" | "function_call" => {
967 if let Some(tool_call) = Self::tool_call_from_value(part)? {
968 tool_calls.push(tool_call);
969 *finish_reason = FinishReason::ToolCalls;
970 }
971 }
972 "refusal" => {
973 if let Some(text) = part.get("refusal").and_then(Value::as_str) {
974 content.push_str(text);
975 *finish_reason = FinishReason::Refusal;
976 }
977 }
978 _ => {
979 if let Some(text) = part.get("text").and_then(Value::as_str) {
980 content.push_str(text);
981 } else if let Some(text) = part.get("output_text").and_then(Value::as_str) {
982 content.push_str(text);
983 } else if let Some(text) = part.as_str() {
984 content.push_str(text);
985 }
986
987 if let Some(inner) = part.get("content") {
988 Self::parse_native_content_value(inner, content, tool_calls, finish_reason)?;
989 }
990
991 if let Some(tool_call) = Self::tool_call_from_value(part)? {
992 tool_calls.push(tool_call);
993 *finish_reason = FinishReason::ToolCalls;
994 }
995 }
996 }
997 Ok(())
998 }
999
1000 fn tool_call_from_value(value: &Value) -> Result<Option<ToolCall>, LLMError> {
1001 let id = value
1002 .get("id")
1003 .or_else(|| value.get("call_id"))
1004 .or_else(|| value.get("tool_use_id"))
1005 .or_else(|| value.get("tool_call_id"))
1006 .and_then(Value::as_str)
1007 .map(ToOwned::to_owned)
1008 .unwrap_or_else(generate_tool_call_id);
1009
1010 let name = value
1011 .get("name")
1012 .or_else(|| value.get("function").and_then(|func| func.get("name")))
1013 .and_then(Value::as_str)
1014 .unwrap_or("")
1015 .trim()
1016 .to_string();
1017 if name.is_empty() {
1018 return Ok(None);
1019 }
1020
1021 let arguments = match value.get("input").or_else(|| value.get("arguments")) {
1022 Some(Value::Null) | None => "{}".to_string(),
1023 Some(input) => Self::value_to_arguments(input)?,
1024 };
1025
1026 Ok(Some(ToolCall::function(id, name, arguments)))
1027 }
1028
1029 fn map_finish_reason(reason: &str) -> FinishReason {
1030 match reason.to_ascii_lowercase().as_str() {
1031 "stop" => FinishReason::Stop,
1032 "length" | "max_tokens" => FinishReason::Length,
1033 "tool_use" | "tool_calls" => FinishReason::ToolCalls,
1034 "content_filter" => FinishReason::ContentFilter,
1035 "refusal" => FinishReason::Refusal,
1036 "error" => FinishReason::Error("Merge Gateway reported an error".to_string()),
1037 _ => FinishReason::Stop,
1038 }
1039 }
1040
1041 pub(crate) fn parse_native_usage(value: Option<&Value>) -> Option<Usage> {
1042 let usage = value?;
1043 let prompt_tokens = usage_u32_from_keys(usage, RESPONSES_PROMPT_TOKEN_KEYS);
1044 let completion_tokens = usage_u32_from_keys(usage, RESPONSES_COMPLETION_TOKEN_KEYS);
1045 let total_tokens = usage
1046 .get("total_tokens")
1047 .and_then(Value::as_u64)
1048 .and_then(|value| u32::try_from(value).ok())
1049 .unwrap_or_else(|| prompt_tokens.saturating_add(completion_tokens));
1050
1051 let openai_cached_prompt_tokens = parse_cached_prompt_tokens_from_usage(usage, true);
1060 let cache_creation_tokens = parse_cache_write_tokens_from_usage(usage, true)
1061 .or_else(|| {
1062 usage
1063 .get("prompt_cache_write_tokens")
1064 .and_then(Value::as_u64)
1065 .and_then(|value| u32::try_from(value).ok())
1066 })
1067 .or_else(|| {
1068 usage
1069 .get("cache_creation_input_tokens")
1070 .and_then(Value::as_u64)
1071 .and_then(|value| u32::try_from(value).ok())
1072 });
1073 let cache_read_tokens = usage
1074 .get("cache_read_tokens")
1075 .or_else(|| usage.get("prompt_cache_read_tokens"))
1076 .or_else(|| usage.get("cache_read_input_tokens"))
1077 .and_then(Value::as_u64)
1078 .and_then(|value| u32::try_from(value).ok())
1079 .or(openai_cached_prompt_tokens);
1080 let cached_prompt_tokens = openai_cached_prompt_tokens.or(cache_read_tokens);
1086
1087 Some(Usage {
1088 prompt_tokens,
1089 completion_tokens,
1090 total_tokens,
1091 cached_prompt_tokens,
1092 cache_creation_tokens,
1093 cache_read_tokens,
1094 iterations: None,
1095 })
1096 }
1097
1098 fn extract_request_id(value: &Value) -> Option<String> {
1099 value
1100 .get("id")
1101 .or_else(|| value.get("request_id"))
1102 .or_else(|| value.get("provider_request_id"))
1103 .or_else(|| value.get("routing").and_then(|routing| routing.get("request_id")))
1104 .and_then(Value::as_str)
1105 .map(ToOwned::to_owned)
1106 }
1107}
1108
1109#[async_trait]
1112impl LLMProvider for MergeGatewayProvider {
1113 fn name(&self) -> &str {
1114 "merge-gateway"
1115 }
1116
1117 async fn generate(&self, mut request: LLMRequest) -> Result<LLMResponse, LLMError> {
1118 if let Some(core) = &self.legacy_core {
1119 core.prepare(&mut request);
1120 return core.generate_prepared(request).await;
1121 }
1122
1123 self.generate_native(request).await
1124 }
1125
1126 async fn stream(&self, request: LLMRequest) -> Result<LLMStream, LLMError> {
1127 if let Some(core) = &self.legacy_core {
1128 let mut request = request;
1129 core.prepare(&mut request);
1130 LLMProvider::validate_request(self, &request)?;
1131 request.stream = true;
1132 return core.stream_prepared(request).await;
1133 }
1134
1135 let mut normalized = self.stream_native_normalized(request).await?;
1136 let stream = try_stream! {
1137 let mut completed = None;
1138 while let Some(event) = normalized.next().await {
1139 match event? {
1140 NormalizedStreamEvent::TextDelta { delta } => yield LLMStreamEvent::Token { delta },
1141 NormalizedStreamEvent::ReasoningDelta { delta, .. } => yield LLMStreamEvent::Reasoning { delta },
1142 NormalizedStreamEvent::ReasoningStage { stage } => yield LLMStreamEvent::ReasoningStage { stage },
1143 NormalizedStreamEvent::ToolCallStart { .. }
1144 | NormalizedStreamEvent::ToolCallDelta { .. }
1145 | NormalizedStreamEvent::Usage { .. } => {}
1146 NormalizedStreamEvent::Done { response } => {
1147 completed = Some(response);
1148 break;
1149 }
1150 }
1151 }
1152
1153 if let Some(response) = completed {
1154 yield LLMStreamEvent::Completed { response };
1155 }
1156 };
1157
1158 Ok(Box::pin(stream))
1159 }
1160
1161 async fn stream_normalized(&self, request: LLMRequest) -> Result<LLMNormalizedStream, LLMError> {
1162 if self.legacy_core.is_some() {
1163 let mut legacy_stream = self.stream(request).await?;
1164 let stream = try_stream! {
1165 while let Some(event) = legacy_stream.next().await {
1166 for normalized in event?.into_normalized() {
1167 yield normalized;
1168 }
1169 }
1170 };
1171 return Ok(Box::pin(stream));
1172 }
1173
1174 self.stream_native_normalized(request).await
1175 }
1176
1177 fn supported_models(&self) -> Vec<String> {
1178 models::merge_gateway::SUPPORTED_MODELS
1179 .iter()
1180 .map(|model| (*model).to_string())
1181 .collect()
1182 }
1183
1184 fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
1185 validate_request_common(request, "Merge Gateway", "merge-gateway", None)
1186 }
1187
1188 fn supports_streaming(&self) -> bool {
1189 true
1190 }
1191
1192 fn supports_non_streaming(&self, model: &str) -> bool {
1193 !is_streaming_only_model(model)
1201 }
1202
1203 fn supports_structured_output(&self, _model: &str) -> bool {
1204 false
1205 }
1206
1207 fn supports_reasoning(&self, model: &str) -> bool {
1208 self.native
1209 .model_behavior
1210 .as_ref()
1211 .and_then(|behavior| behavior.model_supports_reasoning)
1212 .unwrap_or_else(|| merge_reasoning_control_for_model(model).is_some())
1213 }
1214
1215 fn supports_reasoning_effort(&self, model: &str) -> bool {
1216 self.native
1217 .model_behavior
1218 .as_ref()
1219 .and_then(|behavior| behavior.model_supports_reasoning_effort)
1220 .unwrap_or_else(|| merge_reasoning_control_for_model(model).is_some())
1221 }
1222
1223 fn supports_vision(&self, model: &str) -> bool {
1224 matches!(
1225 model,
1226 models::merge_gateway::ANTHROPIC_CLAUDE_OPUS_5
1227 | models::merge_gateway::ANTHROPIC_CLAUDE_OPUS_5_5
1228 | models::merge_gateway::ANTHROPIC_CLAUDE_SONNET_5
1229 | models::merge_gateway::ANTHROPIC_CLAUDE_SONNET_5_5
1230 | models::merge_gateway::ANTHROPIC_CLAUDE_HAIKU_4_5_20251001
1231 | models::merge_gateway::ANTHROPIC_CLAUDE_HAIKU_5_5
1232 | models::merge_gateway::ANTHROPIC_CLAUDE_FABLE_5_1
1233 | models::merge_gateway::GOOGLE_GEMINI_3_6_FLASH
1234 | models::merge_gateway::GOOGLE_GEMINI_3_7_FLASH
1235 | models::merge_gateway::GOOGLE_GEMINI_3_8_FLASH
1236 | models::merge_gateway::GOOGLE_GEMINI_4_ARGON
1237 | models::merge_gateway::MOONSHOT_KIMI_K3
1238 | models::merge_gateway::ZAI_GLM_5_3_FLASH
1239 | models::merge_gateway::OPENAI_GPT_5_5
1240 | models::merge_gateway::OPENAI_GPT_5_6_LUNA
1241 | models::merge_gateway::OPENAI_GPT_5_6_SOL
1242 | models::merge_gateway::OPENAI_GPT_5_6_TERRA
1243 | models::merge_gateway::OPENAI_GPT_6_ASTRA
1244 | models::merge_gateway::META_MUSE_SPARK_OPEN
1245 )
1246 }
1247
1248 fn effective_context_size(&self, model: &str) -> usize {
1249 vtcode_config::models::model_catalog_entry(MergeGatewaySpec::KEY, model)
1250 .map(|entry| entry.context_window)
1251 .filter(|capacity| *capacity > 0)
1252 .unwrap_or(128_000)
1253 }
1254}
1255
1256#[async_trait]
1257impl crate::client::LLMClient for MergeGatewayProvider {
1258 async fn generate(&mut self, prompt: &str) -> Result<LLMResponse, LLMError> {
1259 let request = crate::providers::common::make_default_request(prompt, &self.native.model);
1260 Ok(<Self as LLMProvider>::generate(self, request).await?)
1261 }
1262
1263 fn model_id(&self) -> &str {
1264 &self.native.model
1265 }
1266}