Skip to main content

vtcode_llm/providers/stepfun/
mod.rs

1//! StepFun provider backed by the native Responses API
2//! (`POST {base}/responses`).
3//!
4//! StepFun's `/responses` surface is wire-compatible with the OpenAI Responses
5//! API: history is replayed as `input` items (`message`, `function_call`,
6//! `function_call_output`), the system prompt travels as top-level
7//! `instructions`, reasoning effort is encoded as `reasoning.effort`, and the
8//! output-token budget uses `max_output_tokens`.
9//!
10//! This replaces the previous chat-completions implementation. The provider
11//! keeps the registration contract expected by `impl_standard_provider_constructor!`
12//! (type name + 7-argument `from_config`) and reuses the shared Responses
13//! streaming adapter so normalized stream events stay consistent with the native
14//! OpenAI provider.
15
16use crate::error_display;
17use crate::provider::{
18    FinishReason, LLMError, LLMNormalizedStream, LLMProvider, LLMRequest, LLMResponse, LLMStream, LLMStreamEvent,
19    MessageContent, MessageRole, ToolChoice, ToolDefinition, Usage,
20};
21use crate::providers::common::{
22    ensure_model, impl_llm_client, override_base_url, parse_json_response, read_provider_error_body, resolve_model,
23    sampling_param_f64, validate_supported_models,
24};
25use crate::providers::error_handling::{format_network_error, format_parse_error};
26use crate::providers::openai::tool_serialization::sanitize_openai_function_parameters;
27use crate::providers::shared::{
28    ResponsesNormalizedStreamOptions, StreamAggregator, Utf8StreamDecoder, create_responses_normalized_stream,
29    extract_data_payload, find_sse_boundary_bytes, function_output_value_from_message_content,
30};
31use async_stream::try_stream;
32use async_trait::async_trait;
33use futures::StreamExt;
34use reqwest::Client as HttpClient;
35use serde_json::{Value, json};
36use std::collections::{HashMap, HashSet};
37use vtcode_config::TimeoutsConfig;
38use vtcode_config::constants::{env_vars, models, urls};
39use vtcode_config::core::{AnthropicConfig, ModelConfig, PromptCachingConfig};
40use vtcode_config::types::ReasoningEffortLevel;
41
42const PROVIDER_NAME: &str = "StepFun";
43const PROVIDER_KEY: &str = "stepfun";
44const LEGACY_API_KEY_ENV: &str = "STEP_API_KEY";
45
46/// Maps VT Code's effort ladder onto StepFun's `reasoning.effort` vocabulary.
47fn reasoning_effort_value(effort: ReasoningEffortLevel) -> Option<&'static str> {
48    match effort {
49        ReasoningEffortLevel::None | ReasoningEffortLevel::Unknown => None,
50        ReasoningEffortLevel::Minimal | ReasoningEffortLevel::Low => Some("low"),
51        ReasoningEffortLevel::Medium => Some("medium"),
52        ReasoningEffortLevel::High | ReasoningEffortLevel::XHigh | ReasoningEffortLevel::Max => Some("high"),
53    }
54}
55
56/// Render one user/assistant content block list for the `input` array.
57fn user_content_parts(content: &MessageContent) -> Vec<Value> {
58    use crate::provider::ContentPart;
59
60    let mut parts = Vec::new();
61    match content {
62        MessageContent::Text(text) => {
63            if !text.trim().is_empty() {
64                parts.push(json!({"type": "input_text", "text": text}));
65            }
66        }
67        MessageContent::Parts(content_parts) => {
68            for part in content_parts {
69                match part {
70                    ContentPart::Text { text } => {
71                        if !text.trim().is_empty() {
72                            parts.push(json!({"type": "input_text", "text": text}));
73                        }
74                    }
75                    ContentPart::Image { data, mime_type, image_url, detail, .. } => {
76                        let image_url = match image_url {
77                            Some(url) => url.clone(),
78                            None => format!("data:{mime_type};base64,{data}"),
79                        };
80                        let mut image_part = json!({"type": "input_image", "image_url": image_url});
81                        if let Some(detail) = detail {
82                            image_part["detail"] = json!(detail.as_str());
83                        }
84                        parts.push(image_part);
85                    }
86                    ContentPart::File { filename, file_id, file_url, .. } => {
87                        // StepFun documents no file input block for the Responses
88                        // API; degrade to a text note rather than sending an
89                        // unsupported payload the server would reject.
90                        let fallback = filename
91                            .clone()
92                            .or_else(|| file_id.clone())
93                            .or_else(|| file_url.clone())
94                            .unwrap_or_else(|| "attached file".to_string());
95                        parts.push(json!({
96                            "type": "input_text",
97                            "text": format!("[File input not directly supported: {fallback}]")
98                        }));
99                    }
100                }
101            }
102        }
103    }
104    parts
105}
106
107/// `function_call_output.output` must be a string on the StepFun wire.
108fn function_output_string(value: Value) -> String {
109    match value {
110        Value::String(text) => text,
111        other => other.to_string(),
112    }
113}
114
115/// Flatten VT Code tool definitions into StepFun's `function` tool shape.
116fn serialize_tools(tools: &[ToolDefinition]) -> Vec<Value> {
117    tools
118        .iter()
119        .filter_map(|tool| {
120            let function = tool.function.as_ref()?;
121            let mut serialized = json!({
122                "type": "function",
123                "name": function.name,
124                "description": function.description,
125                "parameters": sanitize_openai_function_parameters(function.parameters.clone(), true),
126            });
127            if tool.strict == Some(true) {
128                serialized["strict"] = json!(true);
129            }
130            Some(serialized)
131        })
132        .collect()
133}
134
135/// Map VT Code's provider-agnostic `output_format` onto StepFun's
136/// `text.format` descriptor.
137fn text_format_from_output_format(output_format: &Value) -> Value {
138    if let Some(json_schema) = output_format.get("json_schema") {
139        let name = json_schema.get("name").and_then(Value::as_str).unwrap_or("response");
140        let schema = json_schema.get("schema").cloned().unwrap_or_else(|| json!({"type": "object"}));
141        return json!({"type": "json_schema", "name": name, "schema": schema});
142    }
143
144    if let Some(format_type) = output_format.get("type").and_then(Value::as_str) {
145        if format_type == "json_schema"
146            && let Some(schema) = output_format.get("schema")
147        {
148            let name = output_format.get("name").and_then(Value::as_str).unwrap_or("response");
149            return json!({"type": "json_schema", "name": name, "schema": schema});
150        }
151        if format_type == "json_object" {
152            return json!({"type": "json_object"});
153        }
154    }
155
156    json!({"type": "json_object"})
157}
158
159pub struct StepFunProvider {
160    http_client: HttpClient,
161    base_url: String,
162    model: String,
163    api_key: String,
164    model_behavior: Option<ModelConfig>,
165}
166
167impl StepFunProvider {
168    pub fn new(api_key: String) -> Self {
169        Self::with_model(api_key, models::stepfun::DEFAULT_MODEL.to_string())
170    }
171
172    pub fn with_model(api_key: String, model: String) -> Self {
173        Self::with_model_internal(api_key, model, None, TimeoutsConfig::default(), None)
174    }
175
176    pub fn new_with_client(
177        api_key: String,
178        model: String,
179        http_client: HttpClient,
180        base_url: String,
181        _timeouts: TimeoutsConfig,
182    ) -> Self {
183        Self {
184            http_client,
185            base_url,
186            model,
187            api_key,
188            model_behavior: None,
189        }
190    }
191
192    pub fn from_config(
193        api_key: Option<String>,
194        model: Option<String>,
195        base_url: Option<String>,
196        _prompt_cache: Option<PromptCachingConfig>,
197        timeouts: Option<TimeoutsConfig>,
198        _anthropic: Option<AnthropicConfig>,
199        model_behavior: Option<ModelConfig>,
200    ) -> Self {
201        let api_key = resolve_api_key(api_key);
202        let resolved_model = resolve_model(model, models::stepfun::DEFAULT_MODEL);
203        Self::with_model_internal(api_key, resolved_model, base_url, timeouts.unwrap_or_default(), model_behavior)
204    }
205
206    fn with_model_internal(
207        api_key: String,
208        model: String,
209        base_url: Option<String>,
210        timeouts: TimeoutsConfig,
211        model_behavior: Option<ModelConfig>,
212    ) -> Self {
213        use crate::http_client::HttpClientFactory;
214
215        Self {
216            http_client: HttpClientFactory::for_llm(&timeouts),
217            base_url: override_base_url(urls::STEPFUN_API_BASE, base_url, Some(env_vars::STEPFUN_BASE_URL)),
218            model,
219            api_key,
220            model_behavior,
221        }
222    }
223
224    fn responses_url(&self) -> String {
225        format!("{}/responses", self.base_url.trim_end_matches('/'))
226    }
227
228    fn reasoning_enabled(model: &str) -> bool {
229        models::stepfun::REASONING_MODELS.contains(&model)
230    }
231
232    fn model_behavior_flag(
233        model_behavior: Option<&ModelConfig>,
234        select: impl Fn(&ModelConfig) -> Option<bool>,
235    ) -> bool {
236        model_behavior.and_then(select).unwrap_or(false)
237    }
238
239    /// Builds the `/responses` request payload.
240    fn build_payload(&self, request: &LLMRequest, stream: bool) -> Result<Value, LLMError> {
241        let mut instructions_segments: Vec<String> = Vec::new();
242        if let Some(system_prompt) = &request.system_prompt {
243            let trimmed = system_prompt.trim();
244            if !trimmed.is_empty() {
245                instructions_segments.push(trimmed.to_owned());
246            }
247        }
248
249        let mut input: Vec<Value> = Vec::new();
250        let mut active_tool_calls: HashSet<String> = HashSet::new();
251        let mut deferred_tool_outputs: HashMap<String, String> = HashMap::new();
252
253        for (index, message) in request.messages.iter().enumerate() {
254            match message.role {
255                MessageRole::System => {
256                    let text = message.content.as_text();
257                    let trimmed = text.trim();
258                    if !trimmed.is_empty() {
259                        instructions_segments.push(trimmed.to_owned());
260                    }
261                }
262                MessageRole::User => {
263                    let parts = user_content_parts(&message.content);
264                    if !parts.is_empty() {
265                        input.push(json!({"role": "user", "content": parts}));
266                    }
267                }
268                MessageRole::Assistant => {
269                    let text = message.content.as_text();
270                    if !text.trim().is_empty() {
271                        input.push(json!({"role": "assistant", "content": text.to_string()}));
272                    }
273
274                    if let Some(tool_calls) = &message.tool_calls {
275                        for (call_index, call) in tool_calls.iter().enumerate() {
276                            let Some(function) = &call.function else {
277                                continue;
278                            };
279
280                            input.push(json!({
281                                "type": "function_call",
282                                "id": format!("fc_{index}_{call_index}"),
283                                "call_id": call.id,
284                                "name": function.name,
285                                "arguments": function.arguments,
286                            }));
287                            active_tool_calls.insert(call.id.clone());
288
289                            if let Some(output) = deferred_tool_outputs.remove(&call.id) {
290                                active_tool_calls.remove(&call.id);
291                                input.push(json!({
292                                    "type": "function_call_output",
293                                    "call_id": call.id,
294                                    "output": output,
295                                }));
296                            }
297                        }
298                    }
299                }
300                MessageRole::Tool => {
301                    let Some(call_id) = message.tool_call_id.as_ref() else {
302                        return Err(LLMError::InvalidRequest {
303                            message: error_display::format_llm_error(
304                                PROVIDER_NAME,
305                                "Tool messages must include tool_call_id for the Responses API",
306                            ),
307                            metadata: None,
308                        });
309                    };
310                    let output = function_output_string(function_output_value_from_message_content(&message.content));
311
312                    if active_tool_calls.remove(call_id) {
313                        input.push(json!({
314                            "type": "function_call_output",
315                            "call_id": call_id,
316                            "output": output,
317                        }));
318                    } else {
319                        deferred_tool_outputs.insert(call_id.clone(), output);
320                    }
321                }
322            }
323        }
324
325        // Every replayed `function_call` needs a paired output; synthesize one
326        // so a partially paired history cannot fail replay.
327        for call_id in active_tool_calls {
328            input.push(json!({
329                "type": "function_call_output",
330                "call_id": call_id,
331                "output": "aborted",
332            }));
333        }
334
335        let mut payload = json!({
336            "model": request.model,
337            "input": input,
338            "stream": stream,
339        });
340
341        if !instructions_segments.is_empty() {
342            payload["instructions"] = json!(instructions_segments.join("\n\n"));
343        }
344
345        let reasoning_active = request
346            .reasoning_effort
347            .is_some_and(|effort| effort != ReasoningEffortLevel::None);
348        if !reasoning_active {
349            if let Some(temperature) = request.temperature {
350                payload["temperature"] = json!(sampling_param_f64(temperature));
351            }
352            if let Some(top_p) = request.top_p {
353                payload["top_p"] = json!(sampling_param_f64(top_p));
354            }
355        }
356
357        if let Some(max_tokens) = request.max_tokens {
358            payload["max_output_tokens"] = json!(max_tokens);
359        }
360
361        if let Some(effort) = request.reasoning_effort.and_then(reasoning_effort_value) {
362            payload["reasoning"] = json!({"effort": effort});
363        }
364
365        // StepFun accepts only tool_choice=auto. Omit callable definitions
366        // for tool-free synthesis instead of silently enabling tool use.
367        if !matches!(request.tool_choice, Some(ToolChoice::None))
368            && let Some(tools) = &request.tools
369        {
370            let serialized = serialize_tools(tools);
371            if !serialized.is_empty() {
372                payload["tools"] = Value::Array(serialized);
373                payload["tool_choice"] = json!("auto");
374            }
375        }
376
377        if let Some(output_format) = &request.output_format {
378            payload["text"] = json!({"format": text_format_from_output_format(output_format)});
379        }
380
381        Ok(payload)
382    }
383
384    /// Parses a non-streaming `/responses` body (also used as the final-event
385    /// parser for normalized streaming).
386    fn parse_response(response_json: Value, model: String) -> Result<LLMResponse, LLMError> {
387        let output = response_json
388            .get("output")
389            .and_then(Value::as_array)
390            .ok_or_else(|| LLMError::Provider {
391                message: error_display::format_llm_error(PROVIDER_NAME, "Invalid response: missing output array"),
392                metadata: None,
393            })?;
394
395        let status = response_json.get("status").and_then(Value::as_str).unwrap_or("completed");
396        if status == "failed" {
397            let message = response_json
398                .get("error")
399                .and_then(|error| error.get("message"))
400                .and_then(Value::as_str)
401                .unwrap_or("Response generation failed");
402            return Err(LLMError::Provider {
403                message: error_display::format_llm_error(PROVIDER_NAME, message),
404                metadata: None,
405            });
406        }
407
408        let mut content = String::new();
409        let mut reasoning = String::new();
410        let mut tool_calls = Vec::new();
411        let mut tool_references = Vec::new();
412
413        for item in output {
414            let item_type = item.get("type").and_then(Value::as_str).unwrap_or("");
415            match item_type {
416                "message" => {
417                    if let Some(content_parts) = item.get("content").and_then(Value::as_array) {
418                        for part in content_parts {
419                            if let Some(text) = part.get("text").and_then(Value::as_str) {
420                                content.push_str(text);
421                            }
422                        }
423                    }
424                }
425                "function_call" => {
426                    let call_id = item
427                        .get("call_id")
428                        .and_then(Value::as_str)
429                        .or_else(|| item.get("id").and_then(Value::as_str))
430                        .unwrap_or("")
431                        .to_string();
432                    let name = item.get("name").and_then(Value::as_str).unwrap_or("").to_string();
433                    let arguments = item
434                        .get("arguments")
435                        .map(|value| {
436                            if value.is_string() {
437                                value.as_str().unwrap_or("{}").to_string()
438                            } else {
439                                value.to_string()
440                            }
441                        })
442                        .unwrap_or_else(|| "{}".to_string());
443                    tool_calls.push(crate::provider::ToolCall::function(call_id, name, arguments));
444                }
445                "reasoning" => {
446                    if let Some(text) = item.get("content").and_then(Value::as_str) {
447                        content_reasoning_append(&mut reasoning, text);
448                    }
449                    if let Some(summary) = item.get("summary").and_then(Value::as_array) {
450                        for part in summary {
451                            if let Some(text) = part.get("text").and_then(Value::as_str) {
452                                content_reasoning_append(&mut reasoning, text);
453                            }
454                        }
455                    }
456                }
457                "tool_search_output" => {
458                    crate::providers::shared::collect_tool_references_from_tool_search_output(
459                        item,
460                        &mut tool_references,
461                    );
462                }
463                _ => {}
464            }
465        }
466
467        let finish_reason = if !tool_calls.is_empty() {
468            FinishReason::ToolCalls
469        } else {
470            match status {
471                "incomplete" => FinishReason::Length,
472                _ => FinishReason::Stop,
473            }
474        };
475
476        let usage = response_json.get("usage").map(|usage_value| Usage {
477            prompt_tokens: usage_value
478                .get("input_tokens")
479                .or_else(|| usage_value.get("prompt_tokens"))
480                .and_then(Value::as_u64)
481                .and_then(|value| u32::try_from(value).ok())
482                .unwrap_or(0),
483            completion_tokens: usage_value
484                .get("output_tokens")
485                .or_else(|| usage_value.get("completion_tokens"))
486                .and_then(Value::as_u64)
487                .and_then(|value| u32::try_from(value).ok())
488                .unwrap_or(0),
489            total_tokens: usage_value
490                .get("total_tokens")
491                .and_then(Value::as_u64)
492                .and_then(|value| u32::try_from(value).ok())
493                .unwrap_or(0),
494            // StepFun advertises no prompt-cache metrics; do not surface them.
495            cached_prompt_tokens: None,
496            cache_creation_tokens: None,
497            cache_read_tokens: None,
498            iterations: None,
499        });
500
501        Ok(LLMResponse {
502            content: (!content.is_empty()).then_some(content),
503            tool_calls: (!tool_calls.is_empty()).then_some(tool_calls),
504            model,
505            usage,
506            finish_reason,
507            reasoning: (!reasoning.is_empty()).then_some(reasoning),
508            reasoning_details: None,
509            tool_references,
510            request_id: response_json.get("id").and_then(Value::as_str).map(ToOwned::to_owned),
511            organization_id: None,
512            compaction: None,
513        })
514    }
515
516    async fn generate_request(&self, request: &LLMRequest) -> Result<LLMResponse, LLMError> {
517        let model = request.model.clone();
518        let payload = self.build_payload(request, false)?;
519
520        let response = self
521            .http_client
522            .post(self.responses_url())
523            .bearer_auth(&self.api_key)
524            .json(&payload)
525            .send()
526            .await
527            .map_err(|error| format_network_error(PROVIDER_NAME, &error))?;
528
529        if !response.status().is_success() {
530            let status = response.status();
531            let body = read_provider_error_body(response).await;
532            let formatted_error = error_display::format_llm_error(PROVIDER_NAME, &format!("HTTP {status}: {body}"));
533            return Err(LLMError::Provider { message: formatted_error, metadata: None });
534        }
535
536        let json = parse_json_response(response, PROVIDER_NAME).await?;
537        Self::parse_response(json, model)
538    }
539
540    /// Legacy `LLMStream` path over the StepFun Responses SSE wire.
541    async fn stream_request(&self, request: &LLMRequest) -> Result<LLMStream, LLMError> {
542        let model = request.model.clone();
543        let payload = self.build_payload(request, true)?;
544
545        let response = self
546            .http_client
547            .post(self.responses_url())
548            .bearer_auth(&self.api_key)
549            .json(&payload)
550            .send()
551            .await
552            .map_err(|error| format_network_error(PROVIDER_NAME, &error))?;
553
554        if !response.status().is_success() {
555            let status = response.status();
556            let body = read_provider_error_body(response).await;
557            let formatted_error = error_display::format_llm_error(PROVIDER_NAME, &format!("HTTP {status}: {body}"));
558            return Err(LLMError::Provider { message: formatted_error, metadata: None });
559        }
560
561        let stream = try_stream! {
562            let mut body_stream = response.bytes_stream();
563            let mut buffer: Vec<u8> = Vec::new();
564            let mut offset = 0usize;
565            let mut decoder = Utf8StreamDecoder::new();
566            let mut aggregator = StreamAggregator::new(model.clone());
567
568            while let Some(chunk_result) = body_stream.next().await {
569                let chunk = chunk_result.map_err(|error| format_network_error(PROVIDER_NAME, &error))?;
570                decoder.push_bytes(&chunk, &mut buffer);
571
572                while let Some((split_idx, delimiter_len)) = find_sse_boundary_bytes(&buffer, offset) {
573                    let event = std::str::from_utf8(&buffer[offset..split_idx]).expect("valid utf-8 stream data");
574                    offset = split_idx + delimiter_len;
575
576                    let Some(data_payload) = extract_data_payload(event) else {
577                        continue;
578                    };
579                    let trimmed = data_payload.trim();
580                    if trimmed.is_empty() || trimmed == "[DONE]" {
581                        continue;
582                    }
583
584                    let Ok(event) = serde_json::from_str::<Value>(trimmed) else {
585                        continue;
586                    };
587                    let event_type = event.get("type").and_then(Value::as_str).unwrap_or("");
588
589                    match event_type {
590                        "response.output_text.delta" => {
591                            if let Some(delta) = event.get("delta").and_then(Value::as_str) {
592                                for stream_event in aggregator.handle_content(delta) {
593                                    yield stream_event;
594                                }
595                            }
596                        }
597                        "response.reasoning.delta"
598                        | "response.reasoning_content.delta"
599                        | "response.reasoning_text.delta" => {
600                            if let Some(delta) = event.get("delta").and_then(Value::as_str)
601                                && let Some(delta) = aggregator.handle_reasoning(delta)
602                            {
603                                yield LLMStreamEvent::Reasoning { delta };
604                            }
605                        }
606                        "response.reasoning_text.done" => {
607                            let text = event
608                                .get("text")
609                                .and_then(Value::as_str)
610                                .or_else(|| event.get("delta").and_then(Value::as_str));
611                            if let Some(text) = text
612                                && let Some(delta) = aggregator.handle_reasoning(text)
613                            {
614                                yield LLMStreamEvent::Reasoning { delta };
615                            }
616                        }
617                        "response.function_call_arguments.delta" => {
618                            if let Some(delta) = event.get("delta").and_then(Value::as_str) {
619                                let call_id = event
620                                    .get("item_id")
621                                    .or_else(|| event.get("call_id"))
622                                    .and_then(Value::as_str)
623                                    .unwrap_or("");
624                                aggregator.handle_tool_calls(&[json!({
625                                    "index": event.get("output_index").and_then(Value::as_u64).unwrap_or(0),
626                                    "id": call_id,
627                                    "function": {"arguments": delta},
628                                })]);
629                            }
630                        }
631                        "response.output_item.done" => {
632                            if let Some(item) = event.get("item")
633                                && item.get("type").and_then(Value::as_str) == Some("compaction")
634                            {
635                                aggregator.append_reasoning_detail(item);
636                            }
637                        }
638                        "response.completed" => {
639                            let streamed = aggregator.finalize();
640                            let response = match event.get("response") {
641                                Some(response_value) => Self::parse_response(response_value.clone(), model.clone())
642                                    .unwrap_or(streamed),
643                                None => streamed,
644                            };
645                            yield LLMStreamEvent::Completed { response: Box::new(response) };
646                            return;
647                        }
648                        "response.incomplete" | "response.failed" => {
649                            let message = event
650                                .get("response")
651                                .and_then(|response| response.get("error"))
652                                .and_then(|error| error.get("message"))
653                                .and_then(Value::as_str)
654                                .unwrap_or("Response generation failed");
655                            Err(LLMError::Provider {
656                                message: error_display::format_llm_error(PROVIDER_NAME, message),
657                                metadata: None,
658                            })?;
659                        }
660                        "error" => {
661                            let message = event
662                                .get("error")
663                                .and_then(|error| error.get("message"))
664                                .and_then(Value::as_str)
665                                .unwrap_or("Unknown error from StepFun Responses API");
666                            Err(LLMError::Provider {
667                                message: error_display::format_llm_error(PROVIDER_NAME, message),
668                                metadata: None,
669                            })?;
670                        }
671                        _ => {}
672                    }
673                }
674
675                if offset > 0 {
676                    buffer.drain(..offset);
677                    offset = 0;
678                }
679            }
680
681            yield LLMStreamEvent::Completed { response: Box::new(aggregator.finalize()) };
682        };
683
684        Ok(Box::pin(stream))
685    }
686}
687
688/// Appends a reasoning fragment, separating fragments with a blank line.
689fn content_reasoning_append(reasoning: &mut String, text: &str) {
690    if text.trim().is_empty() {
691        return;
692    }
693    if !reasoning.is_empty() {
694        reasoning.push_str("\n\n");
695    }
696    reasoning.push_str(text);
697}
698
699fn resolve_api_key(api_key: Option<String>) -> String {
700    api_key
701        .or_else(|| std::env::var("STEPFUN_API_KEY").ok().filter(|key| !key.trim().is_empty()))
702        .or_else(|| std::env::var(LEGACY_API_KEY_ENV).ok().filter(|key| !key.trim().is_empty()))
703        .unwrap_or_default()
704}
705
706#[async_trait]
707impl LLMProvider for StepFunProvider {
708    fn name(&self) -> &str {
709        PROVIDER_KEY
710    }
711
712    fn supports_streaming(&self) -> bool {
713        true
714    }
715
716    fn supports_non_streaming(&self, _model: &str) -> bool {
717        // `/responses` services `stream: false`; pinned so the runloop's
718        // stream-timeout fallback to non-streaming cannot silently regress.
719        true
720    }
721
722    fn supports_reasoning(&self, model: &str) -> bool {
723        let requested = if model.trim().is_empty() { &self.model } else { model };
724        Self::model_behavior_flag(self.model_behavior.as_ref(), |behavior| behavior.model_supports_reasoning)
725            || Self::reasoning_enabled(requested)
726    }
727
728    fn supports_reasoning_effort(&self, model: &str) -> bool {
729        let requested = if model.trim().is_empty() { &self.model } else { model };
730        Self::model_behavior_flag(self.model_behavior.as_ref(), |behavior| behavior.model_supports_reasoning_effort)
731            || Self::reasoning_enabled(requested)
732    }
733
734    fn supports_structured_output(&self, _model: &str) -> bool {
735        true
736    }
737
738    fn supports_vision(&self, _model: &str) -> bool {
739        true
740    }
741
742    fn effective_context_size(&self, model: &str) -> usize {
743        crate::provider::catalog_context_window(PROVIDER_KEY, model, 262_144)
744    }
745
746    fn supported_models(&self) -> Vec<String> {
747        models::stepfun::SUPPORTED_MODELS
748            .iter()
749            .map(|model| (*model).to_string())
750            .collect()
751    }
752
753    fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
754        validate_supported_models(request, PROVIDER_NAME, PROVIDER_KEY, models::stepfun::SUPPORTED_MODELS)
755    }
756
757    async fn generate(&self, mut request: LLMRequest) -> Result<LLMResponse, LLMError> {
758        ensure_model(&mut request, &self.model);
759        self.validate_request(&request)?;
760        self.generate_request(&request).await
761    }
762
763    async fn stream(&self, mut request: LLMRequest) -> Result<LLMStream, LLMError> {
764        ensure_model(&mut request, &self.model);
765        self.validate_request(&request)?;
766        request.stream = true;
767        self.stream_request(&request).await
768    }
769
770    async fn stream_normalized(&self, mut request: LLMRequest) -> Result<LLMNormalizedStream, LLMError> {
771        ensure_model(&mut request, &self.model);
772        self.validate_request(&request)?;
773        request.stream = true;
774        let model = request.model.clone();
775        let payload = self.build_payload(&request, true)?;
776
777        let response = self
778            .http_client
779            .post(self.responses_url())
780            .bearer_auth(&self.api_key)
781            .json(&payload)
782            .send()
783            .await
784            .map_err(|error| format_network_error(PROVIDER_NAME, &error))?;
785
786        if !response.status().is_success() {
787            let status = response.status();
788            let body = read_provider_error_body(response).await;
789            let formatted_error = error_display::format_llm_error(PROVIDER_NAME, &format!("HTTP {status}: {body}"));
790            return Err(LLMError::Provider { message: formatted_error, metadata: None });
791        }
792
793        let emit_reasoning = self.supports_reasoning(&model);
794        Ok(create_responses_normalized_stream(
795            response,
796            ResponsesNormalizedStreamOptions {
797                provider_name: PROVIDER_NAME,
798                model: model.clone(),
799                emit_reasoning,
800                include_cached_prompt_metrics: false,
801            },
802            move |value| Self::parse_response(value, model.clone()),
803        ))
804    }
805}
806
807impl_llm_client!(StepFunProvider);
808
809#[cfg(test)]
810mod tests;