Skip to main content

vtcode_llm/providers/merge_gateway/
provider_impl.rs

1//! `MergeGatewayProvider` behavior: native payload building, streaming, parsing.
2
3use 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        // Bedrock-backed routes reject tool_choice=none, so omit callable
247        // definitions when synthesis disables tools. Preserve historical input.
248        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        // Fail fast only when this request would put tools on the wire and the
336        // route has no tool vendor. Recovery (ToolChoice::None) can still
337        // synthesize without tools.
338        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            // The route accepts the tier vocabulary but is not priced for this
361            // tier: retry once without it and serve standard. Terminates:
362            // re-entry has no tier so this branch cannot refire.
363            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            // Some vendors behind the gateway (e.g. `zai/`) only serve
373            // streaming requests: retry once via the streaming transport and
374            // collect the terminal response. Terminates: the streaming path
375            // never falls back to non-streaming when the retry flag is
376            // cleared, so this branch cannot refire.
377            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            // Non-streaming tool requests fail here only when no vendor serves
386            // tools at all: remember the verdict so later turns fail fast.
387            // Streaming-capability misses never reach this point (the stream
388            // path downgrades first), and tool-free requests bypass the cache.
389            // A rejection naming `reasoning` blames the reasoning+tools
390            // combination rather than tools alone, so it must not poison the
391            // verdict: a tools-only retry may still route.
392            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            // A streaming request can fail capability routing while a plain
462            // non-streaming request with the same tools routes fine (the
463            // failing combo is `streaming_tools`, not `tools`). Retry once
464            // without streaming; tool definitions are preserved. If the route
465            // has no tool vendor at all the retry fails too and its error —
466            // naming exactly what is missing — is surfaced. Streaming-only
467            // routes (e.g. `zai/`) never take this path: non-streaming is
468            // known to fail there, so surface the capability error instead of
469            // ping-ponging between transports.
470            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            // Same pricing fallback as the non-streaming path, preserving
490            // streaming: re-entry has no tier so this branch cannot refire.
491            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            // Native `/v1/responses` streams cumulative snapshots. The
615            // OpenAI-compatible `/v1/openai/responses` surface uses the
616            // delta-oriented events handled below, so keep both protocols
617            // explicit at this boundary.
618            "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                            // A provider replacement cannot retract already-emitted
650                            // deltas, but it must not leak the old snapshot into the
651                            // terminal response or subsequent deltas.
652                            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        // Surface gateway cache signals instead of hard-coding None. The
1052        // shared Responses helpers cover OpenAI-style
1053        // `input_tokens_details.cached_tokens` /
1054        // `prompt_tokens_details.cached_tokens` / `prompt_cache_hit_tokens` /
1055        // `cached_tokens` shapes; the explicit fallbacks cover gateway
1056        // variants (`cache_read_tokens`, OpenRouter-style
1057        // `prompt_cache_read_tokens`, Anthropic-style
1058        // `cache_read_input_tokens`).
1059        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        // Merge native Responses often reports only Anthropic-style cache-read
1081        // fields. Surface that count in `cached_prompt_tokens` so trajectory
1082        // and status metrics are not misleading zeros when the gateway cached.
1083        // Cost accounting already prefers `cache_read_tokens` via
1084        // `Usage::billable_totals`; this mapping is observability only.
1085        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/// Stable opaque session identity for Merge Gateway cache-aware routing.
1110
1111#[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        // The native `/responses` surface services non-streaming generation on
1194        // most routes, and the harness's stream-timeout retry falls back to
1195        // non-streaming only when this capability is advertised. Streaming-only
1196        // routes (e.g. `zai/`) reject non-streaming with `streaming_only`, so
1197        // they must not advertise the fallback: `collect_single_response` then
1198        // uses the streaming transport directly and the timeout retry cannot
1199        // re-hit the known-failing mode.
1200        !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}