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));
252 let omit_tools_for_missing_vendor = tools_disabled && self.tool_vendor_known_missing(&request.model);
253 if !omit_tools_for_missing_vendor
254 && let Some(tools) = request
255 .tools
256 .as_ref()
257 .and_then(|tools| self.build_native_tools(tools, &request.model))
258 {
259 payload.insert("tools".to_owned(), Value::Array(tools));
260 }
261
262 if let Some(max_tokens) = request.max_tokens {
263 payload.insert("max_tokens".to_owned(), json!(max_tokens));
264 }
265 if let Some(temperature) = request.temperature {
266 payload.insert("temperature".to_owned(), json!(crate::providers::common::sampling_param_f64(temperature)));
267 }
268 if let Some(top_p) = request.top_p {
269 payload.insert("top_p".to_owned(), json!(crate::providers::common::sampling_param_f64(top_p)));
270 }
271 if let Some(stop) = Self::native_stop_sequences(request) {
272 payload.insert("stop".to_owned(), Value::Array(stop));
273 }
274 if !tools_disabled && let Some(choice) = &request.tool_choice {
275 payload.insert("tool_choice".to_owned(), choice.to_provider_format("merge-gateway"));
276 }
277 if let Some(output_format) = &request.output_format {
278 payload.insert("response_format".to_owned(), output_format.clone());
279 }
280 if let Some(service_tier) = request.service_tier.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
281 match map_openai_service_tier_for_merge(service_tier) {
282 Some(mapped) => {
283 payload.insert("service_tier".to_owned(), Value::String(mapped.to_owned()));
284 }
285 None => {
286 tracing::warn!(
287 model = %request.model,
288 service_tier = %service_tier,
289 "Merge Gateway has no equivalent for service_tier; omitting it and using gateway default routing"
290 );
291 }
292 }
293 }
294 if let Some(cache_key) = request
295 .prompt_cache_key
296 .as_deref()
297 .map(str::trim)
298 .filter(|value| !value.is_empty())
299 {
300 payload.insert("prompt_cache_key".to_owned(), Value::String(cache_key.to_owned()));
301 }
302 if let Some(session_id) = merge_session_identity(request) {
303 payload.insert("session_id".to_owned(), Value::String(session_id));
304 }
305 if stream {
306 payload.insert("stream".to_owned(), Value::Bool(true));
307 }
308
309 if let Some(effort) = request.reasoning_effort
310 && is_active_reasoning_effort(effort)
311 && let Some(control) = merge_reasoning_control_for_model(&request.model)
312 {
313 match control {
314 MergeReasoningControl::ReasoningEffort => {
315 payload.insert("reasoning_effort".to_owned(), Value::String(effort.as_str().to_owned()));
316 }
317 MergeReasoningControl::ThinkingBudget => {
318 if let Some(thinking) = merge_thinking_payload(effort, request.max_tokens) {
319 payload.insert("thinking".to_owned(), thinking);
320 }
321 }
322 }
323 }
324
325 Ok(Value::Object(payload))
326 }
327
328 async fn generate_native(&self, request: LLMRequest) -> Result<LLMResponse, LLMError> {
329 self.generate_native_with_streaming_retry(request, true).await
330 }
331
332 async fn generate_native_with_streaming_retry(
333 &self,
334 mut request: LLMRequest,
335 allow_streaming_retry: bool,
336 ) -> Result<LLMResponse, LLMError> {
337 self.prepare_native_request(&mut request);
338 LLMProvider::validate_request(self, &request)?;
339 if self.tool_vendor_known_missing(&request.model)
343 && native_request_sends_tools(&request, self.tool_vendor_known_missing(&request.model))
344 {
345 return Err(Self::no_tool_vendor_cached_error(&request.model));
346 }
347 let payload = self.build_native_payload(&request, false)?;
348 let session_id = merge_session_identity(&request);
349 let mut http = self
350 .native
351 .http_client
352 .post(self.responses_url())
353 .bearer_auth(&self.native.api_key);
354 if let Some(session_id) = session_id.as_deref() {
355 http = http.header("X-Session-Id", session_id);
356 }
357 let response = http
358 .json(&payload)
359 .send()
360 .await
361 .map_err(|e| format_network_error("Merge Gateway", &e))?;
362
363 if !response.status().is_success() {
364 let status = response.status();
365 let body = crate::providers::common::read_provider_error_body(response).await;
366 if is_merge_tier_pricing_rejection(status, &body) && request.service_tier.is_some() {
370 tracing::warn!(
371 model = %request.model,
372 status = %status,
373 "Merge Gateway route is not priced for service_tier; retrying once without it"
374 );
375 request.service_tier = None;
376 return Box::pin(self.generate_native_with_streaming_retry(request, allow_streaming_retry)).await;
377 }
378 if allow_streaming_retry && is_streaming_only_rejection(status, &body) {
384 tracing::warn!(
385 model = %request.model,
386 status = %status,
387 "Merge Gateway route requires streaming; retrying once with streaming"
388 );
389 return Box::pin(self.collect_streamed_response(request)).await;
390 }
391 let sent_reasoning = request.reasoning_effort.is_some_and(is_active_reasoning_effort);
399 if is_capability_unavailable(status, &body)
400 && native_request_sends_tools(&request, self.tool_vendor_known_missing(&request.model))
401 && !(sent_reasoning && is_reasoning_capability_rejection(&body))
402 {
403 self.mark_tool_vendor_missing(&request.model);
404 }
405 return Err(merge_request_error(status, &body));
406 }
407
408 let json: Value = response.json().await.map_err(|e| format_parse_error("Merge Gateway", &e))?;
409 Self::parse_native_response_payload(json, request.model)
410 }
411
412 async fn collect_streamed_response(&self, request: LLMRequest) -> Result<LLMResponse, LLMError> {
413 let mut stream = self.stream_native_normalized_inner(request, false).await?;
414 let mut completed = None;
415 while let Some(event) = stream.next().await {
416 match event? {
417 NormalizedStreamEvent::Done { response } => {
418 completed = Some(*response);
419 break;
420 }
421 NormalizedStreamEvent::Usage { .. }
422 | NormalizedStreamEvent::TextDelta { .. }
423 | NormalizedStreamEvent::ReasoningDelta { .. }
424 | NormalizedStreamEvent::ReasoningStage { .. }
425 | NormalizedStreamEvent::ToolCallStart { .. }
426 | NormalizedStreamEvent::ToolCallDelta { .. } => {}
427 }
428 }
429 completed.ok_or_else(|| provider_error("Merge Gateway streamed fallback ended without a completed response"))
430 }
431
432 async fn stream_native_normalized(&self, request: LLMRequest) -> Result<LLMNormalizedStream, LLMError> {
433 self.stream_native_normalized_inner(request, true).await
434 }
435
436 async fn stream_native_normalized_inner(
437 &self,
438 mut request: LLMRequest,
439 allow_non_streaming_fallback: bool,
440 ) -> Result<LLMNormalizedStream, LLMError> {
441 self.prepare_native_request(&mut request);
442 LLMProvider::validate_request(self, &request)?;
443 if self.tool_vendor_known_missing(&request.model)
444 && native_request_sends_tools(&request, self.tool_vendor_known_missing(&request.model))
445 {
446 return Err(Self::no_tool_vendor_cached_error(&request.model));
447 }
448 request.stream = true;
449
450 let payload = self.build_native_payload(&request, true)?;
451 let session_id = merge_session_identity(&request);
452 let mut http = self
453 .native
454 .http_client
455 .post(self.responses_url())
456 .bearer_auth(&self.native.api_key);
457 if let Some(session_id) = session_id.as_deref() {
458 http = http.header("X-Session-Id", session_id);
459 }
460 let response = http
461 .json(&payload)
462 .send()
463 .await
464 .map_err(|e| format_network_error("Merge Gateway", &e))?;
465
466 if !response.status().is_success() {
467 let status = response.status();
468 let body = crate::providers::common::read_provider_error_body(response).await;
469 if allow_non_streaming_fallback
479 && is_capability_unavailable(status, &body)
480 && !is_streaming_only_model(&request.model)
481 {
482 tracing::warn!(
483 model = %request.model,
484 status = %status,
485 "Merge Gateway rejected streaming capabilities; retrying once without streaming"
486 );
487 request.stream = false;
488 let fallback = Box::pin(self.generate_native_with_streaming_retry(request, false)).await?;
489 let completed = LLMStreamEvent::Completed { response: Box::new(fallback) };
490 let stream = try_stream! {
491 for event in completed.into_normalized() {
492 yield event;
493 }
494 };
495 return Ok(Box::pin(stream));
496 }
497 if is_merge_tier_pricing_rejection(status, &body) && request.service_tier.is_some() {
500 tracing::warn!(
501 model = %request.model,
502 status = %status,
503 "Merge Gateway route is not priced for service_tier; retrying once without it"
504 );
505 request.service_tier = None;
506 return Box::pin(self.stream_native_normalized_inner(request, allow_non_streaming_fallback)).await;
507 }
508 return Err(merge_request_error(status, &body));
509 }
510
511 let model = request.model.clone();
512 let stream = try_stream! {
513 let mut body_stream = response.bytes_stream();
514 let mut decoder = Utf8StreamDecoder::new();
515 let mut buffer: Vec<u8> = Vec::new();
516 let mut offset = 0usize;
517 let mut state = MergeStreamState::new(model.clone());
518
519 while let Some(chunk_result) = body_stream.next().await {
520 let chunk = chunk_result.map_err(|e| format_network_error("Merge Gateway", &e))?;
521 decoder.push_bytes(&chunk, &mut buffer);
522
523 while let Some(raw_event) = next_sse_event(&buffer, &mut offset)
524 .map_err(|error| format_parse_error("Merge Gateway", &error))?
525 {
526
527 let payload_text = match extract_data_payload(raw_event) {
528 Some(payload) => payload.into_owned(),
529 None => {
530 let trimmed = raw_event.trim();
531 if trimmed.starts_with('{') || trimmed.starts_with('[') {
532 trimmed.to_string()
533 } else {
534 continue;
535 }
536 }
537 };
538
539 if payload_text.trim() == "[DONE]" {
540 state.done = true;
541 break;
542 }
543
544 let events = Self::handle_native_stream_payload(raw_event, &payload_text, &mut state)?;
545 for event in events {
546 yield event;
547 }
548
549 if state.done {
550 break;
551 }
552 }
553
554 if offset > 0 {
555 buffer.drain(..offset);
556 offset = 0;
557 }
558
559 if state.done {
560 break;
561 }
562 }
563
564 if !state.done {
565 Err(provider_error("Merge Gateway stream ended before a terminal response event"))?;
566 }
567
568 if let Some(response) = state.native_snapshot.take() {
569 for event in state.apply_native_snapshot(response)? {
570 yield event;
571 }
572 }
573
574 let response = state.finish()?;
575 if let Some(usage) = response.usage.clone() {
576 yield NormalizedStreamEvent::Usage { usage };
577 }
578 yield NormalizedStreamEvent::Done { response: Box::new(response) };
579 };
580
581 Ok(Box::pin(stream))
582 }
583
584 fn handle_native_stream_payload(
585 raw_event: &str,
586 payload_text: &str,
587 state: &mut MergeStreamState,
588 ) -> Result<Vec<NormalizedStreamEvent>, LLMError> {
589 let payload: Value = serde_json::from_str(payload_text)
590 .map_err(|e| provider_error(format!("Invalid Merge Gateway SSE payload: {e}")))?;
591 let event_name = Self::merge_event_name(raw_event, &payload);
592 let data = payload.get("data").cloned().unwrap_or(payload.clone());
593 let mut events = Vec::new();
594
595 let fallback_restart = data
596 .get("fallback_restart")
597 .or_else(|| payload.get("fallback_restart"))
598 .and_then(Value::as_bool)
599 .unwrap_or(false);
600 if fallback_restart {
601 let model = data.get("model").or_else(|| payload.get("model")).and_then(Value::as_str);
602 state.reset_for_fallback_restart(model);
603 return Ok(events);
604 }
605
606 if event_name.is_empty() {
607 if data.get("response").is_some()
608 || data.get("output").is_some()
609 || data.get("content").is_some()
610 || data.get("text").is_some()
611 {
612 let response_value = data.get("response").cloned().unwrap_or_else(|| data.clone());
613 let response = Self::parse_native_response_payload(response_value, state.model.clone())?;
614 state.native_snapshot = None;
615 events.extend(state.apply_native_snapshot(response)?);
616 state.done = true;
617 }
618 return Ok(events);
619 }
620
621 match event_name.as_str() {
622 "response.stream" | "response.done" => {
627 let response_value = data.get("response").cloned().or_else(|| {
628 if data.get("output").is_some() {
629 Some(data.clone())
630 } else {
631 None
632 }
633 });
634
635 let response = match response_value {
636 Some(response_value) => Self::parse_native_response_payload(response_value, state.model.clone())?,
637 None if event_name == "response.done" => state
638 .native_snapshot
639 .take()
640 .ok_or_else(|| provider_error("Merge Gateway response.done event was missing the response"))?,
641 None => return Ok(events),
642 };
643
644 if event_name == "response.done" {
645 state.native_snapshot = None;
646 events.extend(state.apply_native_snapshot(response)?);
647 state.done = true;
648 } else {
649 if let Some(previous) = state.native_snapshot.take() {
650 if Self::native_snapshots_are_cumulative(&previous, &response) {
651 if !state.native_snapshot_streaming {
652 events.extend(state.apply_native_snapshot(previous)?);
653 state.native_snapshot_streaming = true;
654 }
655 events.extend(state.apply_native_snapshot(response.clone())?);
656 } else if state.native_snapshot_streaming {
657 state.reset_native_snapshot_accumulator();
661 }
662 }
663 state.remember_native_snapshot(response);
664 }
665 }
666 "response.output_text.delta" | "response.output_text.done" => {
667 if let Some(fragment) = Self::stream_text_fragment(&data) {
668 if let Some(delta) = state.apply_text_fragment(fragment, event_name.ends_with(".done")) {
669 if !delta.is_empty() {
670 events.push(NormalizedStreamEvent::TextDelta { delta });
671 }
672 }
673 }
674 }
675 "response.output_item.added" => {
676 if let Some(item) = Self::stream_output_item(&data) {
677 events.extend(state.record_tool_use_item(item, false)?);
678 }
679 }
680 "response.output_item.done" => {
681 if let Some(item) = Self::stream_output_item(&data) {
682 events.extend(state.record_tool_use_item(item, true)?);
683 }
684 }
685 "response.function_call_arguments.delta" => {
686 if let Some((call_id, name, fragment)) = Self::stream_tool_arguments(&data) {
687 events.extend(state.record_tool_arguments(call_id, name, fragment, false)?);
688 }
689 }
690 "response.function_call_arguments.done" => {
691 if let Some((call_id, name, fragment)) = Self::stream_tool_arguments(&data) {
692 events.extend(state.record_tool_arguments(call_id, name, fragment, true)?);
693 }
694 }
695 "response.usage" => {
696 let usage_value = data.get("usage").unwrap_or(&data);
697 state.usage = Self::parse_native_usage(Some(usage_value));
698 }
699 "response.completed" | "response.incomplete" => {
700 if event_name == "response.incomplete" {
701 state.incomplete = true;
702 }
703 let response_value = data.get("response").cloned().or_else(|| {
704 if data.get("output").is_some() {
705 Some(data.clone())
706 } else {
707 None
708 }
709 });
710
711 if let Some(response_value) = response_value {
712 match Self::parse_native_response_payload(response_value, state.model.clone()) {
713 Ok(response) => {
714 state.final_response = Some(response);
715 state.done = true;
716 }
717 Err(err) if state.has_streamed_output() => {
718 state.request_id = Self::extract_request_id(&data);
719 state.done = true;
720 if matches!(event_name.as_str(), "response.completed") {
721 return Err(err);
722 }
723 }
724 Err(err) => return Err(err),
725 }
726 } else if state.has_streamed_output() {
727 state.request_id = Self::extract_request_id(&data);
728 state.done = true;
729 }
730 }
731 "response.failed" | "response.error" | "error" => {
732 let message = data
733 .get("error")
734 .and_then(Value::as_object)
735 .and_then(|error| error.get("message"))
736 .and_then(Value::as_str)
737 .or_else(|| data.get("error").and_then(Value::as_str))
738 .or_else(|| data.get("message").and_then(Value::as_str))
739 .unwrap_or("Merge Gateway stream failed")
740 .to_string();
741 return Err(provider_error(message));
742 }
743 _ => {}
744 }
745
746 if state.done && state.final_response.is_none() && !state.has_streamed_output() {
747 state.final_response = Some(state.synthesize_response());
748 }
749
750 Ok(events)
751 }
752
753 fn merge_event_name(raw_event: &str, payload: &Value) -> String {
754 for line in raw_event.lines() {
755 if let Some(event) = line.strip_prefix("event:") {
756 return event.trim().to_string();
757 }
758 }
759 if let Some(event) = payload.get("event").and_then(Value::as_str) {
760 return event.to_string();
761 }
762 if let Some(event) = payload.get("type").and_then(Value::as_str) {
763 return event.to_string();
764 }
765 if let Some(event) = payload.get("object").and_then(Value::as_str)
766 && (event.starts_with("response.") || event == "error")
767 {
768 return event.to_string();
769 }
770 String::new()
771 }
772
773 fn native_snapshots_are_cumulative(previous: &LLMResponse, current: &LLMResponse) -> bool {
774 let content_is_cumulative = match (previous.content.as_deref(), current.content.as_deref()) {
775 (Some(previous), Some(current)) => current.starts_with(previous),
776 (Some(_), None) => false,
777 (None, _) => true,
778 };
779 if !content_is_cumulative {
780 return false;
781 }
782
783 let previous_calls = previous.tool_calls.as_deref().unwrap_or_default();
784 let current_calls = current.tool_calls.as_deref().unwrap_or_default();
785 previous_calls.iter().all(|previous_call| {
786 let Some(current_call) = current_calls.iter().find(|call| call.id == previous_call.id) else {
787 return false;
788 };
789
790 let previous_name = previous_call.tool_name().unwrap_or_default();
791 let current_name = current_call.tool_name().unwrap_or_default();
792 if previous_name != current_name {
793 return false;
794 }
795
796 let previous_arguments = previous_call.raw_input().unwrap_or_default();
797 previous_arguments.is_empty()
798 || previous_arguments == "{}"
799 || current_call.raw_input().unwrap_or_default().starts_with(previous_arguments)
800 })
801 }
802
803 fn stream_text_fragment(data: &Value) -> Option<String> {
804 data.get("delta")
805 .or_else(|| data.get("output_text"))
806 .or_else(|| data.get("text"))
807 .and_then(Value::as_str)
808 .map(ToOwned::to_owned)
809 }
810
811 fn stream_output_item(data: &Value) -> Option<&Value> {
812 data.get("output_item").or_else(|| data.get("item")).or_else(|| {
813 if data.get("type").is_some() || data.get("content").is_some() {
814 Some(data)
815 } else {
816 None
817 }
818 })
819 }
820
821 fn stream_tool_arguments(data: &Value) -> Option<(String, Option<String>, String)> {
822 let call_id = data
823 .get("call_id")
824 .or_else(|| data.get("item_id"))
825 .or_else(|| data.get("tool_use_id"))
826 .or_else(|| data.get("tool_call_id"))
827 .or_else(|| data.get("id"))
828 .and_then(Value::as_str)
829 .map(ToOwned::to_owned)?;
830 let name = data.get("name").and_then(Value::as_str).map(ToOwned::to_owned).or_else(|| {
831 Self::stream_output_item(data)
832 .and_then(|item| item.get("name"))
833 .and_then(Value::as_str)
834 .map(ToOwned::to_owned)
835 });
836 let fragment = data
837 .get("delta")
838 .or_else(|| data.get("arguments"))
839 .or_else(|| data.get("input"))
840 .and_then(|value| Self::value_to_arguments(value).ok())
841 .unwrap_or_default();
842 Some((call_id, name, fragment))
843 }
844
845 fn value_to_arguments(value: &Value) -> Result<String, LLMError> {
846 match value {
847 Value::String(text) => Ok(text.clone()),
848 _ => serde_json::to_string(value)
849 .map_err(|e| provider_error(format!("Failed to serialize Merge Gateway tool arguments: {e}"))),
850 }
851 }
852
853 fn parse_native_response_payload(json: Value, model: String) -> Result<LLMResponse, LLMError> {
854 let mut content = String::new();
855 let mut tool_calls = Vec::new();
856 let mut finish_reason = FinishReason::Stop;
857
858 if let Some(output) = json.get("output").and_then(Value::as_array) {
859 for item in output {
860 Self::parse_native_output_item(item, &mut content, &mut tool_calls, &mut finish_reason)?;
861 }
862 } else if json.get("content").is_some() || json.get("text").is_some() || json.get("tool_use").is_some() {
863 Self::parse_native_output_item(&json, &mut content, &mut tool_calls, &mut finish_reason)?;
864 } else {
865 return Err(provider_error("Invalid response from Merge Gateway: missing output"));
866 }
867
868 if matches!(finish_reason, FinishReason::Stop) && !tool_calls.is_empty() {
869 finish_reason = FinishReason::ToolCalls;
870 }
871
872 Ok(LLMResponse {
873 content: if content.is_empty() { None } else { Some(content) },
874 tool_calls: if tool_calls.is_empty() { None } else { Some(tool_calls) },
875 model: json
876 .get("model")
877 .and_then(Value::as_str)
878 .map(ToOwned::to_owned)
879 .filter(|value| !value.trim().is_empty())
880 .unwrap_or(model),
881 usage: Self::parse_native_usage(json.get("usage")),
882 finish_reason,
883 reasoning: None,
884 reasoning_details: None,
885 tool_references: Vec::new(),
886 request_id: Self::extract_request_id(&json),
887 organization_id: None,
888 compaction: None,
889 })
890 }
891
892 fn parse_native_output_item(
893 item: &Value,
894 content: &mut String,
895 tool_calls: &mut Vec<ToolCall>,
896 finish_reason: &mut FinishReason,
897 ) -> Result<(), LLMError> {
898 let item_type = item.get("type").and_then(Value::as_str).unwrap_or("");
899 match item_type {
900 "message" | "" => {
901 if let Some(role) = item.get("role").and_then(Value::as_str)
902 && role != "assistant"
903 {
904 return Ok(());
905 }
906
907 if let Some(reason) = item.get("finish_reason").and_then(Value::as_str) {
908 *finish_reason = Self::map_finish_reason(reason);
909 }
910
911 if let Some(content_value) = item.get("content") {
912 Self::parse_native_content_value(content_value, content, tool_calls, finish_reason)?;
913 } else {
914 Self::parse_native_content_value(item, content, tool_calls, finish_reason)?;
915 }
916 }
917 "tool_use" | "function_call" => {
918 if let Some(tool_call) = Self::tool_call_from_value(item)? {
919 tool_calls.push(tool_call);
920 if matches!(finish_reason, FinishReason::Stop) {
921 *finish_reason = FinishReason::ToolCalls;
922 }
923 }
924 }
925 "refusal" => {
926 if let Some(text) = item.get("refusal").and_then(Value::as_str) {
927 content.push_str(text);
928 *finish_reason = FinishReason::Refusal;
929 }
930 }
931 _ => {
932 Self::parse_native_content_value(item, content, tool_calls, finish_reason)?;
933 }
934 }
935 Ok(())
936 }
937
938 fn parse_native_content_value(
939 value: &Value,
940 content: &mut String,
941 tool_calls: &mut Vec<ToolCall>,
942 finish_reason: &mut FinishReason,
943 ) -> Result<(), LLMError> {
944 match value {
945 Value::String(text) => content.push_str(text),
946 Value::Array(parts) => {
947 for part in parts {
948 Self::parse_native_content_part(part, content, tool_calls, finish_reason)?;
949 }
950 }
951 Value::Object(_) => {
952 Self::parse_native_content_part(value, content, tool_calls, finish_reason)?;
953 }
954 _ => {}
955 }
956 Ok(())
957 }
958
959 fn parse_native_content_part(
960 part: &Value,
961 content: &mut String,
962 tool_calls: &mut Vec<ToolCall>,
963 finish_reason: &mut FinishReason,
964 ) -> Result<(), LLMError> {
965 let part_type = part.get("type").and_then(Value::as_str).unwrap_or("");
966 match part_type {
967 "text" => {
968 if let Some(text) = part.get("text").and_then(Value::as_str) {
969 content.push_str(text);
970 } else if let Some(text) = part.get("output_text").and_then(Value::as_str) {
971 content.push_str(text);
972 }
973 }
974 "tool_use" | "function_call" => {
975 if let Some(tool_call) = Self::tool_call_from_value(part)? {
976 tool_calls.push(tool_call);
977 *finish_reason = FinishReason::ToolCalls;
978 }
979 }
980 "refusal" => {
981 if let Some(text) = part.get("refusal").and_then(Value::as_str) {
982 content.push_str(text);
983 *finish_reason = FinishReason::Refusal;
984 }
985 }
986 _ => {
987 if let Some(text) = part.get("text").and_then(Value::as_str) {
988 content.push_str(text);
989 } else if let Some(text) = part.get("output_text").and_then(Value::as_str) {
990 content.push_str(text);
991 } else if let Some(text) = part.as_str() {
992 content.push_str(text);
993 }
994
995 if let Some(inner) = part.get("content") {
996 Self::parse_native_content_value(inner, content, tool_calls, finish_reason)?;
997 }
998
999 if let Some(tool_call) = Self::tool_call_from_value(part)? {
1000 tool_calls.push(tool_call);
1001 *finish_reason = FinishReason::ToolCalls;
1002 }
1003 }
1004 }
1005 Ok(())
1006 }
1007
1008 fn tool_call_from_value(value: &Value) -> Result<Option<ToolCall>, LLMError> {
1009 let id = value
1010 .get("id")
1011 .or_else(|| value.get("call_id"))
1012 .or_else(|| value.get("tool_use_id"))
1013 .or_else(|| value.get("tool_call_id"))
1014 .and_then(Value::as_str)
1015 .map(ToOwned::to_owned)
1016 .unwrap_or_else(generate_tool_call_id);
1017
1018 let name = value
1019 .get("name")
1020 .or_else(|| value.get("function").and_then(|func| func.get("name")))
1021 .and_then(Value::as_str)
1022 .unwrap_or("")
1023 .trim()
1024 .to_string();
1025 if name.is_empty() {
1026 return Ok(None);
1027 }
1028
1029 let arguments = match value.get("input").or_else(|| value.get("arguments")) {
1030 Some(Value::Null) | None => "{}".to_string(),
1031 Some(input) => Self::value_to_arguments(input)?,
1032 };
1033
1034 Ok(Some(ToolCall::function(id, name, arguments)))
1035 }
1036
1037 fn map_finish_reason(reason: &str) -> FinishReason {
1038 match reason.to_ascii_lowercase().as_str() {
1039 "stop" => FinishReason::Stop,
1040 "length" | "max_tokens" => FinishReason::Length,
1041 "tool_use" | "tool_calls" => FinishReason::ToolCalls,
1042 "content_filter" => FinishReason::ContentFilter,
1043 "refusal" => FinishReason::Refusal,
1044 "error" => FinishReason::Error("Merge Gateway reported an error".to_string()),
1045 _ => FinishReason::Stop,
1046 }
1047 }
1048
1049 pub(crate) fn parse_native_usage(value: Option<&Value>) -> Option<Usage> {
1050 let usage = value?;
1051 let prompt_tokens = usage_u32_from_keys(usage, RESPONSES_PROMPT_TOKEN_KEYS);
1052 let completion_tokens = usage_u32_from_keys(usage, RESPONSES_COMPLETION_TOKEN_KEYS);
1053 let total_tokens = usage
1054 .get("total_tokens")
1055 .and_then(Value::as_u64)
1056 .and_then(|value| u32::try_from(value).ok())
1057 .unwrap_or_else(|| prompt_tokens.saturating_add(completion_tokens));
1058
1059 let openai_cached_prompt_tokens = parse_cached_prompt_tokens_from_usage(usage, true);
1068 let cache_creation_tokens = parse_cache_write_tokens_from_usage(usage, true)
1069 .or_else(|| {
1070 usage
1071 .get("prompt_cache_write_tokens")
1072 .and_then(Value::as_u64)
1073 .and_then(|value| u32::try_from(value).ok())
1074 })
1075 .or_else(|| {
1076 usage
1077 .get("cache_creation_input_tokens")
1078 .and_then(Value::as_u64)
1079 .and_then(|value| u32::try_from(value).ok())
1080 });
1081 let cache_read_tokens = usage
1082 .get("cache_read_tokens")
1083 .or_else(|| usage.get("prompt_cache_read_tokens"))
1084 .or_else(|| usage.get("cache_read_input_tokens"))
1085 .and_then(Value::as_u64)
1086 .and_then(|value| u32::try_from(value).ok())
1087 .or(openai_cached_prompt_tokens);
1088 let cached_prompt_tokens = openai_cached_prompt_tokens.or(cache_read_tokens);
1094
1095 Some(Usage {
1096 prompt_tokens,
1097 completion_tokens,
1098 total_tokens,
1099 cached_prompt_tokens,
1100 cache_creation_tokens,
1101 cache_read_tokens,
1102 iterations: None,
1103 })
1104 }
1105
1106 fn extract_request_id(value: &Value) -> Option<String> {
1107 value
1108 .get("id")
1109 .or_else(|| value.get("request_id"))
1110 .or_else(|| value.get("provider_request_id"))
1111 .or_else(|| value.get("routing").and_then(|routing| routing.get("request_id")))
1112 .and_then(Value::as_str)
1113 .map(ToOwned::to_owned)
1114 }
1115}
1116
1117#[async_trait]
1120impl LLMProvider for MergeGatewayProvider {
1121 fn name(&self) -> &str {
1122 "merge-gateway"
1123 }
1124
1125 async fn generate(&self, mut request: LLMRequest) -> Result<LLMResponse, LLMError> {
1126 if let Some(core) = &self.legacy_core {
1127 core.prepare(&mut request);
1128 return core.generate_prepared(request).await;
1129 }
1130
1131 self.generate_native(request).await
1132 }
1133
1134 async fn stream(&self, request: LLMRequest) -> Result<LLMStream, LLMError> {
1135 if let Some(core) = &self.legacy_core {
1136 let mut request = request;
1137 core.prepare(&mut request);
1138 LLMProvider::validate_request(self, &request)?;
1139 request.stream = true;
1140 return core.stream_prepared(request).await;
1141 }
1142
1143 let mut normalized = self.stream_native_normalized(request).await?;
1144 let stream = try_stream! {
1145 let mut completed = None;
1146 while let Some(event) = normalized.next().await {
1147 match event? {
1148 NormalizedStreamEvent::TextDelta { delta } => yield LLMStreamEvent::Token { delta },
1149 NormalizedStreamEvent::ReasoningDelta { delta, .. } => yield LLMStreamEvent::Reasoning { delta },
1150 NormalizedStreamEvent::ReasoningStage { stage } => yield LLMStreamEvent::ReasoningStage { stage },
1151 NormalizedStreamEvent::ToolCallStart { .. }
1152 | NormalizedStreamEvent::ToolCallDelta { .. }
1153 | NormalizedStreamEvent::Usage { .. } => {}
1154 NormalizedStreamEvent::Done { response } => {
1155 completed = Some(response);
1156 break;
1157 }
1158 }
1159 }
1160
1161 if let Some(response) = completed {
1162 yield LLMStreamEvent::Completed { response };
1163 }
1164 };
1165
1166 Ok(Box::pin(stream))
1167 }
1168
1169 async fn stream_normalized(&self, request: LLMRequest) -> Result<LLMNormalizedStream, LLMError> {
1170 if self.legacy_core.is_some() {
1171 let mut legacy_stream = self.stream(request).await?;
1172 let stream = try_stream! {
1173 while let Some(event) = legacy_stream.next().await {
1174 for normalized in event?.into_normalized() {
1175 yield normalized;
1176 }
1177 }
1178 };
1179 return Ok(Box::pin(stream));
1180 }
1181
1182 self.stream_native_normalized(request).await
1183 }
1184
1185 fn supported_models(&self) -> Vec<String> {
1186 models::merge_gateway::SUPPORTED_MODELS
1187 .iter()
1188 .map(|model| (*model).to_string())
1189 .collect()
1190 }
1191
1192 fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
1193 validate_request_common(request, "Merge Gateway", "merge-gateway", None)
1194 }
1195
1196 fn supports_streaming(&self) -> bool {
1197 true
1198 }
1199
1200 fn supports_non_streaming(&self, model: &str) -> bool {
1201 !is_streaming_only_model(model)
1209 }
1210
1211 fn supports_structured_output(&self, _model: &str) -> bool {
1212 false
1213 }
1214
1215 fn supports_reasoning(&self, model: &str) -> bool {
1216 self.native
1217 .model_behavior
1218 .as_ref()
1219 .and_then(|behavior| behavior.model_supports_reasoning)
1220 .unwrap_or_else(|| merge_reasoning_control_for_model(model).is_some())
1221 }
1222
1223 fn supports_reasoning_effort(&self, model: &str) -> bool {
1224 self.native
1225 .model_behavior
1226 .as_ref()
1227 .and_then(|behavior| behavior.model_supports_reasoning_effort)
1228 .unwrap_or_else(|| merge_reasoning_control_for_model(model).is_some())
1229 }
1230
1231 fn supports_vision(&self, model: &str) -> bool {
1232 matches!(
1233 model,
1234 models::merge_gateway::ANTHROPIC_CLAUDE_OPUS_5
1235 | models::merge_gateway::ANTHROPIC_CLAUDE_OPUS_5_5
1236 | models::merge_gateway::ANTHROPIC_CLAUDE_SONNET_5
1237 | models::merge_gateway::ANTHROPIC_CLAUDE_SONNET_5_5
1238 | models::merge_gateway::ANTHROPIC_CLAUDE_HAIKU_4_5_20251001
1239 | models::merge_gateway::ANTHROPIC_CLAUDE_HAIKU_5_5
1240 | models::merge_gateway::ANTHROPIC_CLAUDE_FABLE_5_1
1241 | models::merge_gateway::GOOGLE_GEMINI_3_6_FLASH
1242 | models::merge_gateway::GOOGLE_GEMINI_3_7_FLASH
1243 | models::merge_gateway::GOOGLE_GEMINI_3_8_FLASH
1244 | models::merge_gateway::MOONSHOT_KIMI_K3
1245 | models::merge_gateway::ZAI_GLM_5_3_FLASH
1246 | models::merge_gateway::OPENAI_GPT_5_5
1247 | models::merge_gateway::OPENAI_GPT_5_6_LUNA
1248 | models::merge_gateway::OPENAI_GPT_5_6_SOL
1249 | models::merge_gateway::OPENAI_GPT_5_6_TERRA
1250 | models::merge_gateway::OPENAI_GPT_6_ASTRA
1251 )
1252 }
1253
1254 fn effective_context_size(&self, model: &str) -> usize {
1255 vtcode_config::models::model_catalog_entry(MergeGatewaySpec::KEY, model)
1256 .map(|entry| entry.context_window)
1257 .filter(|capacity| *capacity > 0)
1258 .unwrap_or(128_000)
1259 }
1260}
1261
1262#[async_trait]
1263impl crate::client::LLMClient for MergeGatewayProvider {
1264 async fn generate(&mut self, prompt: &str) -> Result<LLMResponse, LLMError> {
1265 let request = crate::providers::common::make_default_request(prompt, &self.native.model);
1266 Ok(<Self as LLMProvider>::generate(self, request).await?)
1267 }
1268
1269 fn model_id(&self) -> &str {
1270 &self.native.model
1271 }
1272}