Skip to main content

vtcode_llm/providers/merge_gateway/provider_impl/
mod.rs

1//! `MergeGatewayProvider` behavior: native payload building, streaming, parsing.
2
3use super::*;
4
5mod parsing;
6mod streaming;
7
8impl MergeGatewayProvider {
9    pub fn new(api_key: String) -> Self {
10        Self::with_model(api_key, models::merge_gateway::DEFAULT_MODEL.to_string())
11    }
12
13    pub fn with_model(api_key: String, model: String) -> Self {
14        let timeouts = TimeoutsConfig::default();
15        let http_client = HttpClientFactory::for_llm(&timeouts);
16        Self::from_runtime_parts(api_key, model, http_client, urls::MERGE_GATEWAY_NATIVE_API_BASE.to_string())
17    }
18
19    pub fn new_with_client(
20        api_key: String,
21        model: String,
22        http_client: HttpClient,
23        base_url: String,
24        _timeouts: TimeoutsConfig,
25    ) -> Self {
26        Self::from_runtime_parts(api_key, model, http_client, base_url)
27    }
28
29    pub fn from_config(
30        api_key: Option<String>,
31        model: Option<String>,
32        base_url: Option<String>,
33        prompt_cache: Option<PromptCachingConfig>,
34        timeouts: Option<TimeoutsConfig>,
35        _anthropic: Option<AnthropicConfig>,
36        model_behavior: Option<ModelConfig>,
37    ) -> Self {
38        let api_key = <MergeGatewaySpec as OpenAiCompatSpec>::resolve_api_key(api_key);
39        let model = resolve_model(model, models::merge_gateway::DEFAULT_MODEL);
40        let base_url =
41            override_base_url(urls::MERGE_GATEWAY_NATIVE_API_BASE, base_url, Some(env_vars::MERGE_GATEWAY_BASE_URL));
42        let timeouts = timeouts.unwrap_or_default();
43        let http_client = HttpClientFactory::for_llm(&timeouts);
44
45        let legacy_core = if is_legacy_openai_base_url(&base_url) {
46            Some(OpenAiCompatCore::from_config(
47                Some(api_key.clone()),
48                Some(model.clone()),
49                Some(base_url.clone()),
50                prompt_cache,
51                Some(timeouts),
52                model_behavior.clone(),
53            ))
54        } else {
55            None
56        };
57
58        Self {
59            native: NativeMergeGatewayCore {
60                api_key,
61                http_client,
62                base_url,
63                model,
64                model_behavior,
65            },
66            legacy_core,
67            no_tool_vendor_cache: Mutex::new(HashSet::new()),
68        }
69    }
70
71    fn from_runtime_parts(api_key: String, model: String, http_client: HttpClient, base_url: String) -> Self {
72        let legacy_core = if is_legacy_openai_base_url(&base_url) {
73            Some(OpenAiCompatCore::from_parts(api_key.clone(), model.clone(), http_client.clone(), base_url.clone()))
74        } else {
75            None
76        };
77
78        Self {
79            native: NativeMergeGatewayCore {
80                api_key,
81                http_client,
82                base_url,
83                model,
84                model_behavior: None,
85            },
86            legacy_core,
87            no_tool_vendor_cache: Mutex::new(HashSet::new()),
88        }
89    }
90
91    fn prepare_native_request(&self, request: &mut LLMRequest) {
92        if request.model.trim().is_empty() {
93            request.model = self.native.model.clone();
94        }
95    }
96
97    fn tool_vendor_known_missing(&self, model: &str) -> bool {
98        self.no_tool_vendor_cache
99            .lock()
100            .map(|guard| guard.contains(model))
101            .unwrap_or(false)
102    }
103
104    pub(crate) fn mark_tool_vendor_missing(&self, model: &str) {
105        if let Ok(mut guard) = self.no_tool_vendor_cache.lock() {
106            guard.insert(model.to_owned());
107        }
108    }
109
110    fn no_tool_vendor_cached_error(model: &str) -> LLMError {
111        provider_error(format!(
112            "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."
113        ))
114    }
115
116    fn responses_url(&self) -> String {
117        format!("{}/responses", self.native.base_url.trim_end_matches('/'))
118    }
119
120    fn build_native_tools(&self, tools: &[ToolDefinition], model: &str) -> Option<Vec<Value>> {
121        let gemini_compatible = model.starts_with("google/gemini-");
122        let serialized: Vec<Value> = tools
123            .iter()
124            .filter_map(|tool| {
125                tool.function.as_ref().map(|func| {
126                    let parameters = if gemini_compatible {
127                        sanitize_function_parameters(func.parameters.clone())
128                    } else {
129                        func.parameters.clone()
130                    };
131                    json!({
132                        "type": "function",
133                        "name": func.name,
134                        "description": func.description,
135                        "parameters": parameters,
136                    })
137                })
138            })
139            .collect();
140
141        if serialized.is_empty() { None } else { Some(serialized) }
142    }
143
144    fn native_tool_use_value(&self, call: &ToolCall) -> Result<Value, LLMError> {
145        call.validate()
146            .map_err(|err| provider_error(format!("Invalid Merge Gateway tool call: {err}")))?;
147        let name = call
148            .tool_name()
149            .ok_or_else(|| provider_error("Merge Gateway tool call is missing a function name"))?;
150        let input = call
151            .execution_arguments()
152            .map_err(|err| provider_error(format!("Failed to serialize Merge Gateway tool call arguments: {err}")))?;
153        Ok(json!({
154            "type": "tool_use",
155            "id": call.id,
156            "name": name,
157            "input": input,
158        }))
159    }
160
161    fn native_message_content_value(&self, message: &Message, model: &str) -> Result<Value, LLMError> {
162        let content = serialize_message_content_openai_for_model(message, model);
163        if message.tool_calls.as_ref().is_none_or(Vec::is_empty) {
164            return Ok(content);
165        }
166
167        let mut parts = match content {
168            Value::String(text) => {
169                if text.trim().is_empty() {
170                    Vec::new()
171                } else {
172                    vec![json!({"type": "text", "text": text})]
173                }
174            }
175            Value::Array(parts) => parts,
176            other => vec![other],
177        };
178
179        if let Some(tool_calls) = &message.tool_calls {
180            for call in tool_calls {
181                parts.push(self.native_tool_use_value(call)?);
182            }
183        }
184
185        Ok(Value::Array(parts))
186    }
187
188    fn native_input_item_for_message(&self, message: &Message, model: &str) -> Result<Value, LLMError> {
189        if message.role.is_tool_response() {
190            let tool_call_id = message
191                .tool_call_id
192                .clone()
193                .ok_or_else(|| provider_error("Merge Gateway tool result messages must include a tool_call_id"))?;
194            return Ok(json!({
195                "type": "tool_result",
196                "tool_use_id": tool_call_id,
197                "content": function_output_value_from_message_content(&message.content),
198            }));
199        }
200
201        Ok(json!({
202            "type": "message",
203            "role": message.role.as_generic_str(),
204            "content": self.native_message_content_value(message, model)?,
205        }))
206    }
207
208    fn native_stop_sequences(request: &LLMRequest) -> Option<Vec<Value>> {
209        let stop: Vec<Value> = request
210            .stop_sequences
211            .as_ref()
212            .into_iter()
213            .flatten()
214            .filter_map(|stop| {
215                let trimmed = stop.trim();
216                if trimmed.is_empty() {
217                    None
218                } else {
219                    Some(Value::String(trimmed.to_string()))
220                }
221            })
222            .collect();
223
224        if stop.is_empty() { None } else { Some(stop) }
225    }
226
227    pub(crate) fn build_native_payload(&self, request: &LLMRequest, stream: bool) -> Result<Value, LLMError> {
228        let mut input = Vec::new();
229
230        if let Some(system) = &request.system_prompt {
231            let trimmed = system.trim();
232            if !trimmed.is_empty() {
233                input.push(json!({
234                    "type": "message",
235                    "role": "system",
236                    "content": trimmed,
237                }));
238            }
239        }
240
241        for message in request.messages.iter() {
242            input.push(self.native_input_item_for_message(message, &request.model)?);
243        }
244
245        let mut payload = Map::new();
246        payload.insert("model".to_owned(), Value::String(request.model.clone()));
247        payload.insert("input".to_owned(), Value::Array(input));
248
249        // Bedrock-backed routes reject tool_choice=none, so omit callable
250        // definitions when synthesis disables tools. Preserve historical input.
251        let tools_disabled = matches!(request.tool_choice, Some(ToolChoice::None));
252        if !tools_disabled
253            && let Some(tools) = request
254                .tools
255                .as_ref()
256                .and_then(|tools| self.build_native_tools(tools, &request.model))
257        {
258            payload.insert("tools".to_owned(), Value::Array(tools));
259        }
260
261        if let Some(max_tokens) = request.max_tokens {
262            payload.insert("max_tokens".to_owned(), json!(max_tokens));
263        }
264        if let Some(temperature) = request.temperature {
265            payload.insert("temperature".to_owned(), json!(crate::providers::common::sampling_param_f64(temperature)));
266        }
267        if let Some(top_p) = request.top_p {
268            payload.insert("top_p".to_owned(), json!(crate::providers::common::sampling_param_f64(top_p)));
269        }
270        if let Some(stop) = Self::native_stop_sequences(request) {
271            payload.insert("stop".to_owned(), Value::Array(stop));
272        }
273        if !tools_disabled && let Some(choice) = &request.tool_choice {
274            payload.insert("tool_choice".to_owned(), choice.to_provider_format("merge-gateway"));
275        }
276        if let Some(output_format) = &request.output_format {
277            payload.insert("response_format".to_owned(), output_format.clone());
278        }
279        if let Some(service_tier) = request.service_tier.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
280            match map_openai_service_tier_for_merge(service_tier) {
281                Some(mapped) => {
282                    payload.insert("service_tier".to_owned(), Value::String(mapped.to_owned()));
283                }
284                None => {
285                    tracing::warn!(
286                        model = %request.model,
287                        service_tier = %service_tier,
288                        "Merge Gateway has no equivalent for service_tier; omitting it and using gateway default routing"
289                    );
290                }
291            }
292        }
293        if let Some(cache_key) = request
294            .prompt_cache_key
295            .as_deref()
296            .map(str::trim)
297            .filter(|value| !value.is_empty())
298        {
299            payload.insert("prompt_cache_key".to_owned(), Value::String(cache_key.to_owned()));
300        }
301        if let Some(session_id) = merge_session_identity(request) {
302            payload.insert("session_id".to_owned(), Value::String(session_id));
303        }
304        if stream {
305            payload.insert("stream".to_owned(), Value::Bool(true));
306        }
307
308        if let Some(effort) = request.reasoning_effort
309            && is_active_reasoning_effort(effort)
310            && let Some(control) = merge_reasoning_control_for_model(&request.model)
311        {
312            match control {
313                MergeReasoningControl::ReasoningEffort => {
314                    payload.insert("reasoning_effort".to_owned(), Value::String(effort.as_str().to_owned()));
315                }
316                MergeReasoningControl::ThinkingBudget => {
317                    if let Some(thinking) = merge_thinking_payload(effort, request.max_tokens) {
318                        payload.insert("thinking".to_owned(), thinking);
319                    }
320                }
321            }
322        }
323
324        Ok(Value::Object(payload))
325    }
326
327    async fn generate_native(&self, request: LLMRequest) -> Result<LLMResponse, LLMError> {
328        self.generate_native_with_streaming_retry(request, true).await
329    }
330
331    async fn generate_native_with_streaming_retry(
332        &self,
333        mut request: LLMRequest,
334        allow_streaming_retry: bool,
335    ) -> Result<LLMResponse, LLMError> {
336        self.prepare_native_request(&mut request);
337        LLMProvider::validate_request(self, &request)?;
338        // Fail fast only when this request would put tools on the wire and the
339        // route has no tool vendor. Recovery (ToolChoice::None) can still
340        // synthesize without tools.
341        if self.tool_vendor_known_missing(&request.model) && native_request_sends_tools(&request) {
342            return Err(Self::no_tool_vendor_cached_error(&request.model));
343        }
344        let payload = self.build_native_payload(&request, false)?;
345        let session_id = merge_session_identity(&request);
346        let mut http = self
347            .native
348            .http_client
349            .post(self.responses_url())
350            .bearer_auth(&self.native.api_key);
351        if let Some(session_id) = session_id.as_deref() {
352            http = http.header("X-Session-Id", session_id);
353        }
354        let response = http
355            .json(&payload)
356            .send()
357            .await
358            .map_err(|e| format_network_error("Merge Gateway", &e))?;
359
360        if !response.status().is_success() {
361            let status = response.status();
362            let body = crate::providers::common::read_provider_error_body(response).await;
363            // The route accepts the tier vocabulary but is not priced for this
364            // tier: retry once without it and serve standard. Terminates:
365            // re-entry has no tier so this branch cannot refire.
366            if is_merge_tier_pricing_rejection(status, &body) && request.service_tier.is_some() {
367                tracing::warn!(
368                    model = %request.model,
369                    status = %status,
370                    "Merge Gateway route is not priced for service_tier; retrying once without it"
371                );
372                request.service_tier = None;
373                return Box::pin(self.generate_native_with_streaming_retry(request, allow_streaming_retry)).await;
374            }
375            // Some vendors behind the gateway (e.g. `zai/`) only serve
376            // streaming requests: retry once via the streaming transport and
377            // collect the terminal response. Terminates: the streaming path
378            // never falls back to non-streaming when the retry flag is
379            // cleared, so this branch cannot refire.
380            if allow_streaming_retry && is_streaming_only_rejection(status, &body) {
381                tracing::warn!(
382                    model = %request.model,
383                    status = %status,
384                    "Merge Gateway route requires streaming; retrying once with streaming"
385                );
386                return Box::pin(self.collect_streamed_response(request)).await;
387            }
388            // Non-streaming tool requests fail here only when no vendor serves
389            // tools at all: remember the verdict so later turns fail fast.
390            // Streaming-capability misses never reach this point (the stream
391            // path downgrades first), and tool-free requests bypass the cache.
392            // A rejection naming `reasoning` blames the reasoning+tools
393            // combination rather than tools alone, so it must not poison the
394            // verdict: a tools-only retry may still route.
395            let sent_reasoning = request.reasoning_effort.is_some_and(is_active_reasoning_effort);
396            if is_capability_unavailable(status, &body)
397                && native_request_sends_tools(&request)
398                && !(sent_reasoning && is_reasoning_capability_rejection(&body))
399            {
400                self.mark_tool_vendor_missing(&request.model);
401            }
402            return Err(merge_request_error(status, &body));
403        }
404
405        let json: Value = response.json().await.map_err(|e| format_parse_error("Merge Gateway", &e))?;
406        Self::parse_native_response_payload(json, request.model)
407    }
408
409    async fn collect_streamed_response(&self, request: LLMRequest) -> Result<LLMResponse, LLMError> {
410        let mut stream = self.stream_native_normalized_inner(request, false).await?;
411        let mut completed = None;
412        while let Some(event) = stream.next().await {
413            match event? {
414                NormalizedStreamEvent::Done { response } => {
415                    completed = Some(*response);
416                    break;
417                }
418                NormalizedStreamEvent::Usage { .. }
419                | NormalizedStreamEvent::TextDelta { .. }
420                | NormalizedStreamEvent::ReasoningDelta { .. }
421                | NormalizedStreamEvent::ReasoningStage { .. }
422                | NormalizedStreamEvent::ToolCallStart { .. }
423                | NormalizedStreamEvent::ToolCallDelta { .. } => {}
424            }
425        }
426        completed.ok_or_else(|| provider_error("Merge Gateway streamed fallback ended without a completed response"))
427    }
428
429    async fn stream_native_normalized(&self, request: LLMRequest) -> Result<LLMNormalizedStream, LLMError> {
430        self.stream_native_normalized_inner(request, true).await
431    }
432
433    async fn stream_native_normalized_inner(
434        &self,
435        mut request: LLMRequest,
436        allow_non_streaming_fallback: bool,
437    ) -> Result<LLMNormalizedStream, LLMError> {
438        self.prepare_native_request(&mut request);
439        LLMProvider::validate_request(self, &request)?;
440        if self.tool_vendor_known_missing(&request.model) && native_request_sends_tools(&request) {
441            return Err(Self::no_tool_vendor_cached_error(&request.model));
442        }
443        request.stream = true;
444
445        let payload = self.build_native_payload(&request, true)?;
446        let session_id = merge_session_identity(&request);
447        let mut http = self
448            .native
449            .http_client
450            .post(self.responses_url())
451            .bearer_auth(&self.native.api_key);
452        if let Some(session_id) = session_id.as_deref() {
453            http = http.header("X-Session-Id", session_id);
454        }
455        let response = http
456            .json(&payload)
457            .send()
458            .await
459            .map_err(|e| format_network_error("Merge Gateway", &e))?;
460
461        if !response.status().is_success() {
462            let status = response.status();
463            let body = crate::providers::common::read_provider_error_body(response).await;
464            // A streaming request can fail capability routing while a plain
465            // non-streaming request with the same tools routes fine (the
466            // failing combo is `streaming_tools`, not `tools`). Retry once
467            // without streaming; tool definitions are preserved. If the route
468            // has no tool vendor at all the retry fails too and its error —
469            // naming exactly what is missing — is surfaced. Streaming-only
470            // routes (e.g. `zai/`) never take this path: non-streaming is
471            // known to fail there, so surface the capability error instead of
472            // ping-ponging between transports.
473            if allow_non_streaming_fallback
474                && is_capability_unavailable(status, &body)
475                && !is_streaming_only_model(&request.model)
476            {
477                tracing::warn!(
478                    model = %request.model,
479                    status = %status,
480                    "Merge Gateway rejected streaming capabilities; retrying once without streaming"
481                );
482                request.stream = false;
483                let fallback = Box::pin(self.generate_native_with_streaming_retry(request, false)).await?;
484                let completed = LLMStreamEvent::Completed { response: Box::new(fallback) };
485                let stream = try_stream! {
486                    for event in completed.into_normalized() {
487                        yield event;
488                    }
489                };
490                return Ok(Box::pin(stream));
491            }
492            // Same pricing fallback as the non-streaming path, preserving
493            // streaming: re-entry has no tier so this branch cannot refire.
494            if is_merge_tier_pricing_rejection(status, &body) && request.service_tier.is_some() {
495                tracing::warn!(
496                    model = %request.model,
497                    status = %status,
498                    "Merge Gateway route is not priced for service_tier; retrying once without it"
499                );
500                request.service_tier = None;
501                return Box::pin(self.stream_native_normalized_inner(request, allow_non_streaming_fallback)).await;
502            }
503            return Err(merge_request_error(status, &body));
504        }
505
506        let model = request.model.clone();
507        let stream = try_stream! {
508            let mut body_stream = response.bytes_stream();
509            let mut decoder = Utf8StreamDecoder::new();
510            let mut buffer: Vec<u8> = Vec::new();
511            let mut offset = 0usize;
512            let mut state = MergeStreamState::new(model.clone());
513
514            while let Some(chunk_result) = body_stream.next().await {
515                let chunk = chunk_result.map_err(|e| format_network_error("Merge Gateway", &e))?;
516                decoder.push_bytes(&chunk, &mut buffer);
517
518                while let Some(raw_event) = next_sse_event(&buffer, &mut offset)
519                    .map_err(|error| format_parse_error("Merge Gateway", &error))?
520                {
521
522                    let payload_text = match extract_data_payload(raw_event) {
523                        Some(payload) => payload.into_owned(),
524                        None => {
525                            let trimmed = raw_event.trim();
526                            if trimmed.starts_with('{') || trimmed.starts_with('[') {
527                                trimmed.to_string()
528                            } else {
529                                continue;
530                            }
531                        }
532                    };
533
534                    if payload_text.trim() == "[DONE]" {
535                        state.done = true;
536                        break;
537                    }
538
539                    let events = Self::handle_native_stream_payload(raw_event, &payload_text, &mut state)?;
540                    for event in events {
541                        yield event;
542                    }
543
544                    if state.done {
545                        break;
546                    }
547                }
548
549                if offset > 0 {
550                    buffer.drain(..offset);
551                    offset = 0;
552                }
553
554                if state.done {
555                    break;
556                }
557            }
558
559            if !state.done {
560                Err(provider_error("Merge Gateway stream ended before a terminal response event"))?;
561            }
562
563            if let Some(response) = state.native_snapshot.take() {
564                for event in state.apply_native_snapshot(response)? {
565                    yield event;
566                }
567            }
568
569            let response = state.finish()?;
570            if let Some(usage) = response.usage.clone() {
571                yield NormalizedStreamEvent::Usage { usage };
572            }
573            yield NormalizedStreamEvent::Done { response: Box::new(response) };
574        };
575
576        Ok(Box::pin(stream))
577    }
578}
579
580/// Stable opaque session identity for Merge Gateway cache-aware routing.
581
582#[async_trait]
583impl LLMProvider for MergeGatewayProvider {
584    fn name(&self) -> &str {
585        "merge-gateway"
586    }
587
588    async fn generate(&self, mut request: LLMRequest) -> Result<LLMResponse, LLMError> {
589        if let Some(core) = &self.legacy_core {
590            core.prepare(&mut request);
591            return core.generate_prepared(request).await;
592        }
593
594        self.generate_native(request).await
595    }
596
597    async fn stream(&self, request: LLMRequest) -> Result<LLMStream, LLMError> {
598        if let Some(core) = &self.legacy_core {
599            let mut request = request;
600            core.prepare(&mut request);
601            LLMProvider::validate_request(self, &request)?;
602            request.stream = true;
603            return core.stream_prepared(request).await;
604        }
605
606        let mut normalized = self.stream_native_normalized(request).await?;
607        let stream = try_stream! {
608            let mut completed = None;
609            while let Some(event) = normalized.next().await {
610                match event? {
611                    NormalizedStreamEvent::TextDelta { delta } => yield LLMStreamEvent::Token { delta },
612                    NormalizedStreamEvent::ReasoningDelta { delta, .. } => yield LLMStreamEvent::Reasoning { delta },
613                    NormalizedStreamEvent::ReasoningStage { stage } => yield LLMStreamEvent::ReasoningStage { stage },
614                    NormalizedStreamEvent::ToolCallStart { .. }
615                    | NormalizedStreamEvent::ToolCallDelta { .. }
616                    | NormalizedStreamEvent::Usage { .. } => {}
617                    NormalizedStreamEvent::Done { response } => {
618                        completed = Some(response);
619                        break;
620                    }
621                }
622            }
623
624            if let Some(response) = completed {
625                yield LLMStreamEvent::Completed { response };
626            }
627        };
628
629        Ok(Box::pin(stream))
630    }
631
632    async fn stream_normalized(&self, request: LLMRequest) -> Result<LLMNormalizedStream, LLMError> {
633        if self.legacy_core.is_some() {
634            let mut legacy_stream = self.stream(request).await?;
635            let stream = try_stream! {
636                while let Some(event) = legacy_stream.next().await {
637                    for normalized in event?.into_normalized() {
638                        yield normalized;
639                    }
640                }
641            };
642            return Ok(Box::pin(stream));
643        }
644
645        self.stream_native_normalized(request).await
646    }
647
648    fn supported_models(&self) -> Vec<String> {
649        models::merge_gateway::SUPPORTED_MODELS
650            .iter()
651            .map(|model| (*model).to_string())
652            .collect()
653    }
654
655    fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
656        validate_request_common(request, "Merge Gateway", "merge-gateway", None)
657    }
658
659    fn supports_streaming(&self) -> bool {
660        true
661    }
662
663    fn supports_non_streaming(&self, model: &str) -> bool {
664        // The native `/responses` surface services non-streaming generation on
665        // most routes, and the harness's stream-timeout retry falls back to
666        // non-streaming only when this capability is advertised. Streaming-only
667        // routes (e.g. `zai/`) reject non-streaming with `streaming_only`, so
668        // they must not advertise the fallback: `collect_single_response` then
669        // uses the streaming transport directly and the timeout retry cannot
670        // re-hit the known-failing mode.
671        !is_streaming_only_model(model)
672    }
673
674    fn supports_structured_output(&self, _model: &str) -> bool {
675        false
676    }
677
678    fn supports_reasoning(&self, model: &str) -> bool {
679        self.native
680            .model_behavior
681            .as_ref()
682            .and_then(|behavior| behavior.model_supports_reasoning)
683            .unwrap_or_else(|| merge_reasoning_control_for_model(model).is_some())
684    }
685
686    fn supports_reasoning_effort(&self, model: &str) -> bool {
687        self.native
688            .model_behavior
689            .as_ref()
690            .and_then(|behavior| behavior.model_supports_reasoning_effort)
691            .unwrap_or_else(|| merge_reasoning_control_for_model(model).is_some())
692    }
693
694    fn supports_vision(&self, model: &str) -> bool {
695        matches!(
696            model,
697            models::merge_gateway::ANTHROPIC_CLAUDE_OPUS_5
698                | models::merge_gateway::ANTHROPIC_CLAUDE_OPUS_5_5
699                | models::merge_gateway::ANTHROPIC_CLAUDE_SONNET_5
700                | models::merge_gateway::ANTHROPIC_CLAUDE_SONNET_5_5
701                | models::merge_gateway::ANTHROPIC_CLAUDE_HAIKU_4_5_20251001
702                | models::merge_gateway::ANTHROPIC_CLAUDE_HAIKU_5_5
703                | models::merge_gateway::ANTHROPIC_CLAUDE_FABLE_5_1
704                | models::merge_gateway::GOOGLE_GEMINI_3_6_FLASH
705                | models::merge_gateway::GOOGLE_GEMINI_3_7_FLASH
706                | models::merge_gateway::GOOGLE_GEMINI_3_8_FLASH
707                | models::merge_gateway::GOOGLE_GEMINI_4_ARGON
708                | models::merge_gateway::MOONSHOT_KIMI_K3
709                | models::merge_gateway::MOONSHOT_KIMI_K3_1_PRO
710                | models::merge_gateway::ZAI_GLM_5_3_FLASH
711                | models::merge_gateway::OPENAI_GPT_5_5
712                | models::merge_gateway::OPENAI_GPT_5_6_LUNA
713                | models::merge_gateway::OPENAI_GPT_5_6_SOL
714                | models::merge_gateway::OPENAI_GPT_5_6_TERRA
715                | models::merge_gateway::OPENAI_GPT_6_ASTRA
716                | models::merge_gateway::OPENAI_GPT_6_SOL
717                | models::merge_gateway::OPENAI_GPT_6_1_SOL
718                | models::merge_gateway::OPENAI_GPT_6_1_TERRA
719                | models::merge_gateway::OPENAI_GPT_6_LUNA
720                | models::merge_gateway::META_MUSE_SPARK_OPEN
721        )
722    }
723
724    fn effective_context_size(&self, model: &str) -> usize {
725        vtcode_config::models::model_catalog_entry(MergeGatewaySpec::KEY, model)
726            .map(|entry| entry.context_window)
727            .filter(|capacity| *capacity > 0)
728            .unwrap_or(128_000)
729    }
730}
731
732#[async_trait]
733impl crate::client::LLMClient for MergeGatewayProvider {
734    async fn generate(&mut self, prompt: &str) -> Result<LLMResponse, LLMError> {
735        let request = crate::providers::common::make_default_request(prompt, &self.native.model);
736        Ok(<Self as LLMProvider>::generate(self, request).await?)
737    }
738
739    fn model_id(&self) -> &str {
740        &self.native.model
741    }
742}