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        // Some Merge routes terminate at Anthropic Bedrock, which rejects
247        // `tool_choice: "none"`. Keep tool definitions on the wire even when
248        // tools are disabled so the rendered prefix stays cache-stable across
249        // recovery turns; only the choice field is omitted. If the route has
250        // no tool vendor at all, omit definitions so synthesis can still run.
251        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        // Fail fast only when this request would put tools on the wire and the
340        // route has no tool vendor. Recovery (ToolChoice::None) can still
341        // synthesize without tools.
342        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            // The route accepts the tier vocabulary but is not priced for this
367            // tier: retry once without it and serve standard. Terminates:
368            // re-entry has no tier so this branch cannot refire.
369            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            // Some vendors behind the gateway (e.g. `zai/`) only serve
379            // streaming requests: retry once via the streaming transport and
380            // collect the terminal response. Terminates: the streaming path
381            // never falls back to non-streaming when the retry flag is
382            // cleared, so this branch cannot refire.
383            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            // Non-streaming tool requests fail here only when no vendor serves
392            // tools at all: remember the verdict so later turns fail fast.
393            // Streaming-capability misses never reach this point (the stream
394            // path downgrades first), and tool-free requests bypass the cache.
395            // A rejection naming `reasoning` blames the reasoning+tools
396            // combination rather than tools alone, so it must not poison the
397            // verdict: a tools-only retry may still route.
398            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            // A streaming request can fail capability routing while a plain
470            // non-streaming request with the same tools routes fine (the
471            // failing combo is `streaming_tools`, not `tools`). Retry once
472            // without streaming; tool definitions are preserved. If the route
473            // has no tool vendor at all the retry fails too and its error —
474            // naming exactly what is missing — is surfaced. Streaming-only
475            // routes (e.g. `zai/`) never take this path: non-streaming is
476            // known to fail there, so surface the capability error instead of
477            // ping-ponging between transports.
478            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            // Same pricing fallback as the non-streaming path, preserving
498            // streaming: re-entry has no tier so this branch cannot refire.
499            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            // Native `/v1/responses` streams cumulative snapshots. The
623            // OpenAI-compatible `/v1/openai/responses` surface uses the
624            // delta-oriented events handled below, so keep both protocols
625            // explicit at this boundary.
626            "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                            // A provider replacement cannot retract already-emitted
658                            // deltas, but it must not leak the old snapshot into the
659                            // terminal response or subsequent deltas.
660                            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        // Surface gateway cache signals instead of hard-coding None. The
1060        // shared Responses helpers cover OpenAI-style
1061        // `input_tokens_details.cached_tokens` /
1062        // `prompt_tokens_details.cached_tokens` / `prompt_cache_hit_tokens` /
1063        // `cached_tokens` shapes; the explicit fallbacks cover gateway
1064        // variants (`cache_read_tokens`, OpenRouter-style
1065        // `prompt_cache_read_tokens`, Anthropic-style
1066        // `cache_read_input_tokens`).
1067        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        // Merge native Responses often reports only Anthropic-style cache-read
1089        // fields. Surface that count in `cached_prompt_tokens` so trajectory
1090        // and status metrics are not misleading zeros when the gateway cached.
1091        // Cost accounting already prefers `cache_read_tokens` via
1092        // `Usage::billable_totals`; this mapping is observability only.
1093        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/// Stable opaque session identity for Merge Gateway cache-aware routing.
1118
1119#[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        // The native `/responses` surface services non-streaming generation on
1202        // most routes, and the harness's stream-timeout retry falls back to
1203        // non-streaming only when this capability is advertised. Streaming-only
1204        // routes (e.g. `zai/`) reject non-streaming with `streaming_only`, so
1205        // they must not advertise the fallback: `collect_single_response` then
1206        // uses the streaming transport directly and the timeout retry cannot
1207        // re-hit the known-failing mode.
1208        !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}