Skip to main content

vtcode_llm/providers/
stepfun.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.as_ref().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        // Keep tool definitions on the wire even when tools are disabled so the
366        // rendered prefix stays cache-stable. StepFun accepts only
367        // `tool_choice: "auto"`, so omit the choice field for ToolChoice::None
368        // instead of removing the catalog (OpenAI caching guidance).
369        if let Some(tools) = &request.tools {
370            let serialized = serialize_tools(tools);
371            if !serialized.is_empty() {
372                payload["tools"] = Value::Array(serialized);
373                if !matches!(request.tool_choice, Some(ToolChoice::None)) {
374                    // StepFun currently accepts only the string "auto".
375                    payload["tool_choice"] = json!("auto");
376                }
377            }
378        }
379
380        if let Some(output_format) = &request.output_format {
381            payload["text"] = json!({"format": text_format_from_output_format(output_format)});
382        }
383
384        Ok(payload)
385    }
386
387    /// Parses a non-streaming `/responses` body (also used as the final-event
388    /// parser for normalized streaming).
389    fn parse_response(response_json: Value, model: String) -> Result<LLMResponse, LLMError> {
390        let output = response_json
391            .get("output")
392            .and_then(Value::as_array)
393            .ok_or_else(|| LLMError::Provider {
394                message: error_display::format_llm_error(PROVIDER_NAME, "Invalid response: missing output array"),
395                metadata: None,
396            })?;
397
398        let status = response_json.get("status").and_then(Value::as_str).unwrap_or("completed");
399        if status == "failed" {
400            let message = response_json
401                .get("error")
402                .and_then(|error| error.get("message"))
403                .and_then(Value::as_str)
404                .unwrap_or("Response generation failed");
405            return Err(LLMError::Provider {
406                message: error_display::format_llm_error(PROVIDER_NAME, message),
407                metadata: None,
408            });
409        }
410
411        let mut content = String::new();
412        let mut reasoning = String::new();
413        let mut tool_calls = Vec::new();
414        let mut tool_references = Vec::new();
415
416        for item in output {
417            let item_type = item.get("type").and_then(Value::as_str).unwrap_or("");
418            match item_type {
419                "message" => {
420                    if let Some(content_parts) = item.get("content").and_then(Value::as_array) {
421                        for part in content_parts {
422                            if let Some(text) = part.get("text").and_then(Value::as_str) {
423                                content.push_str(text);
424                            }
425                        }
426                    }
427                }
428                "function_call" => {
429                    let call_id = item
430                        .get("call_id")
431                        .and_then(Value::as_str)
432                        .or_else(|| item.get("id").and_then(Value::as_str))
433                        .unwrap_or("")
434                        .to_string();
435                    let name = item.get("name").and_then(Value::as_str).unwrap_or("").to_string();
436                    let arguments = item
437                        .get("arguments")
438                        .map(|value| {
439                            if value.is_string() {
440                                value.as_str().unwrap_or("{}").to_string()
441                            } else {
442                                value.to_string()
443                            }
444                        })
445                        .unwrap_or_else(|| "{}".to_string());
446                    tool_calls.push(crate::provider::ToolCall::function(call_id, name, arguments));
447                }
448                "reasoning" => {
449                    if let Some(text) = item.get("content").and_then(Value::as_str) {
450                        content_reasoning_append(&mut reasoning, text);
451                    }
452                    if let Some(summary) = item.get("summary").and_then(Value::as_array) {
453                        for part in summary {
454                            if let Some(text) = part.get("text").and_then(Value::as_str) {
455                                content_reasoning_append(&mut reasoning, text);
456                            }
457                        }
458                    }
459                }
460                "tool_search_output" => {
461                    crate::providers::shared::collect_tool_references_from_tool_search_output(
462                        item,
463                        &mut tool_references,
464                    );
465                }
466                _ => {}
467            }
468        }
469
470        let finish_reason = if !tool_calls.is_empty() {
471            FinishReason::ToolCalls
472        } else {
473            match status {
474                "incomplete" => FinishReason::Length,
475                _ => FinishReason::Stop,
476            }
477        };
478
479        let usage = response_json.get("usage").map(|usage_value| Usage {
480            prompt_tokens: usage_value
481                .get("input_tokens")
482                .or_else(|| usage_value.get("prompt_tokens"))
483                .and_then(Value::as_u64)
484                .and_then(|value| u32::try_from(value).ok())
485                .unwrap_or(0),
486            completion_tokens: usage_value
487                .get("output_tokens")
488                .or_else(|| usage_value.get("completion_tokens"))
489                .and_then(Value::as_u64)
490                .and_then(|value| u32::try_from(value).ok())
491                .unwrap_or(0),
492            total_tokens: usage_value
493                .get("total_tokens")
494                .and_then(Value::as_u64)
495                .and_then(|value| u32::try_from(value).ok())
496                .unwrap_or(0),
497            // StepFun advertises no prompt-cache metrics; do not surface them.
498            cached_prompt_tokens: None,
499            cache_creation_tokens: None,
500            cache_read_tokens: None,
501            iterations: None,
502        });
503
504        Ok(LLMResponse {
505            content: (!content.is_empty()).then_some(content),
506            tool_calls: (!tool_calls.is_empty()).then_some(tool_calls),
507            model,
508            usage,
509            finish_reason,
510            reasoning: (!reasoning.is_empty()).then_some(reasoning),
511            reasoning_details: None,
512            tool_references,
513            request_id: response_json.get("id").and_then(Value::as_str).map(ToOwned::to_owned),
514            organization_id: None,
515            compaction: None,
516        })
517    }
518
519    async fn generate_request(&self, request: &LLMRequest) -> Result<LLMResponse, LLMError> {
520        let model = request.model.clone();
521        let payload = self.build_payload(request, false)?;
522
523        let response = self
524            .http_client
525            .post(self.responses_url())
526            .bearer_auth(&self.api_key)
527            .json(&payload)
528            .send()
529            .await
530            .map_err(|error| format_network_error(PROVIDER_NAME, &error))?;
531
532        if !response.status().is_success() {
533            let status = response.status();
534            let body = read_provider_error_body(response).await;
535            let formatted_error = error_display::format_llm_error(PROVIDER_NAME, &format!("HTTP {status}: {body}"));
536            return Err(LLMError::Provider { message: formatted_error, metadata: None });
537        }
538
539        let json = parse_json_response(response, PROVIDER_NAME).await?;
540        Self::parse_response(json, model)
541    }
542
543    /// Legacy `LLMStream` path over the StepFun Responses SSE wire.
544    async fn stream_request(&self, request: &LLMRequest) -> Result<LLMStream, LLMError> {
545        let model = request.model.clone();
546        let payload = self.build_payload(request, true)?;
547
548        let response = self
549            .http_client
550            .post(self.responses_url())
551            .bearer_auth(&self.api_key)
552            .json(&payload)
553            .send()
554            .await
555            .map_err(|error| format_network_error(PROVIDER_NAME, &error))?;
556
557        if !response.status().is_success() {
558            let status = response.status();
559            let body = read_provider_error_body(response).await;
560            let formatted_error = error_display::format_llm_error(PROVIDER_NAME, &format!("HTTP {status}: {body}"));
561            return Err(LLMError::Provider { message: formatted_error, metadata: None });
562        }
563
564        let stream = try_stream! {
565            let mut body_stream = response.bytes_stream();
566            let mut buffer: Vec<u8> = Vec::new();
567            let mut offset = 0usize;
568            let mut decoder = Utf8StreamDecoder::new();
569            let mut aggregator = StreamAggregator::new(model.clone());
570
571            while let Some(chunk_result) = body_stream.next().await {
572                let chunk = chunk_result.map_err(|error| format_network_error(PROVIDER_NAME, &error))?;
573                decoder.push_bytes(&chunk, &mut buffer);
574
575                while let Some((split_idx, delimiter_len)) = find_sse_boundary_bytes(&buffer, offset) {
576                    let event = std::str::from_utf8(&buffer[offset..split_idx]).expect("valid utf-8 stream data");
577                    offset = split_idx + delimiter_len;
578
579                    let Some(data_payload) = extract_data_payload(event) else {
580                        continue;
581                    };
582                    let trimmed = data_payload.trim();
583                    if trimmed.is_empty() || trimmed == "[DONE]" {
584                        continue;
585                    }
586
587                    let Ok(event) = serde_json::from_str::<Value>(trimmed) else {
588                        continue;
589                    };
590                    let event_type = event.get("type").and_then(Value::as_str).unwrap_or("");
591
592                    match event_type {
593                        "response.output_text.delta" => {
594                            if let Some(delta) = event.get("delta").and_then(Value::as_str) {
595                                for stream_event in aggregator.handle_content(delta) {
596                                    yield stream_event;
597                                }
598                            }
599                        }
600                        "response.reasoning.delta"
601                        | "response.reasoning_content.delta"
602                        | "response.reasoning_text.delta" => {
603                            if let Some(delta) = event.get("delta").and_then(Value::as_str)
604                                && let Some(delta) = aggregator.handle_reasoning(delta)
605                            {
606                                yield LLMStreamEvent::Reasoning { delta };
607                            }
608                        }
609                        "response.reasoning_text.done" => {
610                            let text = event
611                                .get("text")
612                                .and_then(Value::as_str)
613                                .or_else(|| event.get("delta").and_then(Value::as_str));
614                            if let Some(text) = text
615                                && let Some(delta) = aggregator.handle_reasoning(text)
616                            {
617                                yield LLMStreamEvent::Reasoning { delta };
618                            }
619                        }
620                        "response.function_call_arguments.delta" => {
621                            if let Some(delta) = event.get("delta").and_then(Value::as_str) {
622                                let call_id = event
623                                    .get("item_id")
624                                    .or_else(|| event.get("call_id"))
625                                    .and_then(Value::as_str)
626                                    .unwrap_or("");
627                                aggregator.handle_tool_calls(&[json!({
628                                    "index": event.get("output_index").and_then(Value::as_u64).unwrap_or(0),
629                                    "id": call_id,
630                                    "function": {"arguments": delta},
631                                })]);
632                            }
633                        }
634                        "response.output_item.done" => {
635                            if let Some(item) = event.get("item")
636                                && item.get("type").and_then(Value::as_str) == Some("compaction")
637                            {
638                                aggregator.append_reasoning_detail(item);
639                            }
640                        }
641                        "response.completed" => {
642                            let streamed = aggregator.finalize();
643                            let response = match event.get("response") {
644                                Some(response_value) => Self::parse_response(response_value.clone(), model.clone())
645                                    .unwrap_or(streamed),
646                                None => streamed,
647                            };
648                            yield LLMStreamEvent::Completed { response: Box::new(response) };
649                            return;
650                        }
651                        "response.incomplete" | "response.failed" => {
652                            let message = event
653                                .get("response")
654                                .and_then(|response| response.get("error"))
655                                .and_then(|error| error.get("message"))
656                                .and_then(Value::as_str)
657                                .unwrap_or("Response generation failed");
658                            Err(LLMError::Provider {
659                                message: error_display::format_llm_error(PROVIDER_NAME, message),
660                                metadata: None,
661                            })?;
662                        }
663                        "error" => {
664                            let message = event
665                                .get("error")
666                                .and_then(|error| error.get("message"))
667                                .and_then(Value::as_str)
668                                .unwrap_or("Unknown error from StepFun Responses API");
669                            Err(LLMError::Provider {
670                                message: error_display::format_llm_error(PROVIDER_NAME, message),
671                                metadata: None,
672                            })?;
673                        }
674                        _ => {}
675                    }
676                }
677
678                if offset > 0 {
679                    buffer.drain(..offset);
680                    offset = 0;
681                }
682            }
683
684            yield LLMStreamEvent::Completed { response: Box::new(aggregator.finalize()) };
685        };
686
687        Ok(Box::pin(stream))
688    }
689}
690
691/// Appends a reasoning fragment, separating fragments with a blank line.
692fn content_reasoning_append(reasoning: &mut String, text: &str) {
693    if text.trim().is_empty() {
694        return;
695    }
696    if !reasoning.is_empty() {
697        reasoning.push_str("\n\n");
698    }
699    reasoning.push_str(text);
700}
701
702fn resolve_api_key(api_key: Option<String>) -> String {
703    api_key
704        .or_else(|| std::env::var("STEPFUN_API_KEY").ok().filter(|key| !key.trim().is_empty()))
705        .or_else(|| std::env::var(LEGACY_API_KEY_ENV).ok().filter(|key| !key.trim().is_empty()))
706        .unwrap_or_default()
707}
708
709#[async_trait]
710impl LLMProvider for StepFunProvider {
711    fn name(&self) -> &str {
712        PROVIDER_KEY
713    }
714
715    fn supports_streaming(&self) -> bool {
716        true
717    }
718
719    fn supports_non_streaming(&self, _model: &str) -> bool {
720        // `/responses` services `stream: false`; pinned so the runloop's
721        // stream-timeout fallback to non-streaming cannot silently regress.
722        true
723    }
724
725    fn supports_reasoning(&self, model: &str) -> bool {
726        let requested = if model.trim().is_empty() { &self.model } else { model };
727        Self::model_behavior_flag(&self.model_behavior, |behavior| behavior.model_supports_reasoning)
728            || Self::reasoning_enabled(requested)
729    }
730
731    fn supports_reasoning_effort(&self, model: &str) -> bool {
732        let requested = if model.trim().is_empty() { &self.model } else { model };
733        Self::model_behavior_flag(&self.model_behavior, |behavior| behavior.model_supports_reasoning_effort)
734            || Self::reasoning_enabled(requested)
735    }
736
737    fn supports_structured_output(&self, _model: &str) -> bool {
738        true
739    }
740
741    fn supports_vision(&self, _model: &str) -> bool {
742        true
743    }
744
745    fn effective_context_size(&self, model: &str) -> usize {
746        crate::provider::catalog_context_window(PROVIDER_KEY, model, 262_144)
747    }
748
749    fn supported_models(&self) -> Vec<String> {
750        models::stepfun::SUPPORTED_MODELS
751            .iter()
752            .map(|model| (*model).to_string())
753            .collect()
754    }
755
756    fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
757        validate_supported_models(request, PROVIDER_NAME, PROVIDER_KEY, models::stepfun::SUPPORTED_MODELS)
758    }
759
760    async fn generate(&self, mut request: LLMRequest) -> Result<LLMResponse, LLMError> {
761        ensure_model(&mut request, &self.model);
762        self.validate_request(&request)?;
763        self.generate_request(&request).await
764    }
765
766    async fn stream(&self, mut request: LLMRequest) -> Result<LLMStream, LLMError> {
767        ensure_model(&mut request, &self.model);
768        self.validate_request(&request)?;
769        request.stream = true;
770        self.stream_request(&request).await
771    }
772
773    async fn stream_normalized(&self, mut request: LLMRequest) -> Result<LLMNormalizedStream, LLMError> {
774        ensure_model(&mut request, &self.model);
775        self.validate_request(&request)?;
776        request.stream = true;
777        let model = request.model.clone();
778        let payload = self.build_payload(&request, true)?;
779
780        let response = self
781            .http_client
782            .post(self.responses_url())
783            .bearer_auth(&self.api_key)
784            .json(&payload)
785            .send()
786            .await
787            .map_err(|error| format_network_error(PROVIDER_NAME, &error))?;
788
789        if !response.status().is_success() {
790            let status = response.status();
791            let body = read_provider_error_body(response).await;
792            let formatted_error = error_display::format_llm_error(PROVIDER_NAME, &format!("HTTP {status}: {body}"));
793            return Err(LLMError::Provider { message: formatted_error, metadata: None });
794        }
795
796        let emit_reasoning = self.supports_reasoning(&model);
797        Ok(create_responses_normalized_stream(
798            response,
799            ResponsesNormalizedStreamOptions {
800                provider_name: PROVIDER_NAME,
801                model: model.clone(),
802                emit_reasoning,
803                include_cached_prompt_metrics: false,
804            },
805            move |value| Self::parse_response(value, model.clone()),
806        ))
807    }
808}
809
810impl_llm_client!(StepFunProvider);
811
812#[cfg(test)]
813mod tests {
814    use super::StepFunProvider;
815    use crate::provider::{LLMProvider, LLMRequest, Message, NormalizedStreamEvent, ToolCall, ToolDefinition};
816    use futures::StreamExt;
817    use serde_json::{Value, json};
818    use std::sync::Arc;
819    use vtcode_config::TimeoutsConfig;
820    use vtcode_config::constants::models;
821    use vtcode_config::types::ReasoningEffortLevel;
822    use wiremock::matchers::{body_partial_json, method, path};
823    use wiremock::{Mock, ResponseTemplate};
824
825    fn build_payload(request: &LLMRequest) -> Value {
826        let provider = StepFunProvider::new("test-key".to_string());
827        provider.build_payload(request, request.stream).expect("payload should build")
828    }
829
830    fn mock_provider(base_url: &str) -> StepFunProvider {
831        let http_client = reqwest::Client::builder().no_proxy().build().expect("test client should build");
832        StepFunProvider::new_with_client(
833            "test-key".to_string(),
834            models::stepfun::STEP_3_7_FLASH.to_string(),
835            http_client,
836            base_url.to_string(),
837            TimeoutsConfig::default(),
838        )
839    }
840
841    use crate::providers::test_support::start_mock_server_or_skip;
842
843    fn completed_response_body() -> Value {
844        json!({
845            "id": "resp_step_1",
846            "object": "response",
847            "created_at": 1772624997,
848            "completed_at": 1772624998,
849            "model": models::stepfun::STEP_3_7_FLASH,
850            "status": "completed",
851            "error": null,
852            "incomplete_details": null,
853            "output": [
854                {
855                    "type": "reasoning",
856                    "id": "rs_1",
857                    "summary": [],
858                    "content": null,
859                    "encrypted_content": null,
860                    "status": null
861                },
862                {
863                    "type": "message",
864                    "id": "msg_1",
865                    "status": "completed",
866                    "role": "assistant",
867                    "content": [{"type": "output_text", "text": "hello from stepfun", "annotations": []}]
868                },
869                {
870                    "type": "function_call",
871                    "id": "fc_1",
872                    "call_id": "call_1",
873                    "name": "get_weather",
874                    "arguments": "{\"city\":\"Beijing\"}",
875                    "status": "completed"
876                }
877            ],
878            "usage": {
879                "input_tokens": 14,
880                "input_tokens_details": {"cached_tokens": 0},
881                "output_tokens": 52,
882                "output_tokens_details": {"reasoning_tokens": 0, "tool_output_tokens": 0},
883                "total_tokens": 66
884            }
885        })
886    }
887
888    #[tokio::test]
889    async fn generate_posts_responses_request_and_parses_output() {
890        let Some(server) = start_mock_server_or_skip().await else {
891            return;
892        };
893
894        Mock::given(method("POST"))
895            .and(path("/v1/responses"))
896            .and(body_partial_json(json!({
897                "model": models::stepfun::STEP_3_7_FLASH,
898                "instructions": "system guidance",
899                "stream": false,
900                "max_output_tokens": 512,
901                "reasoning": {"effort": "high"},
902                "tool_choice": "auto",
903                "input": [{"role": "user", "content": [{"type": "input_text", "text": "hello"}]}],
904                "tools": [{"type": "function", "name": "get_weather"}]
905            })))
906            .respond_with(ResponseTemplate::new(200).set_body_json(completed_response_body()))
907            .expect(1)
908            .mount(&server)
909            .await;
910
911        let provider = mock_provider(&format!("{}/v1", server.uri()));
912        let response = provider
913            .generate(LLMRequest {
914                model: models::stepfun::STEP_3_7_FLASH.to_string(),
915                messages: vec![Message::user("hello".to_string())].into(),
916                system_prompt: Some(Arc::from("system guidance")),
917                max_tokens: Some(512),
918                reasoning_effort: Some(ReasoningEffortLevel::XHigh),
919                tools: Some(Arc::new(vec![ToolDefinition::function(
920                    "get_weather".to_string(),
921                    "Get the weather".to_string(),
922                    json!({"type": "object"}),
923                )])),
924                tool_choice: Some(crate::provider::ToolChoice::Auto),
925                ..Default::default()
926            })
927            .await
928            .expect("generate should succeed");
929
930        assert_eq!(response.content.as_deref(), Some("hello from stepfun"));
931        assert_eq!(response.request_id.as_deref(), Some("resp_step_1"));
932        assert_eq!(response.finish_reason, crate::provider::FinishReason::ToolCalls);
933        let tool_calls = response.tool_calls.expect("tool calls should exist");
934        assert_eq!(tool_calls[0].id, "call_1");
935        assert_eq!(tool_calls[0].function.as_ref().map(|f| f.name.as_str()), Some("get_weather"));
936        let usage = response.usage.expect("usage should exist");
937        assert_eq!(usage.prompt_tokens, 14);
938        assert_eq!(usage.completion_tokens, 52);
939        assert_eq!(usage.total_tokens, 66);
940    }
941
942    #[tokio::test]
943    async fn generate_surfaces_provider_error_body() {
944        let Some(server) = start_mock_server_or_skip().await else {
945            return;
946        };
947
948        Mock::given(method("POST"))
949            .and(path("/v1/responses"))
950            .respond_with(ResponseTemplate::new(400).set_body_json(json!({
951                "error": {"code": "invalid_request", "message": "bad input"}
952            })))
953            .expect(1)
954            .mount(&server)
955            .await;
956
957        let provider = mock_provider(&format!("{}/v1", server.uri()));
958        let error = provider
959            .generate(LLMRequest {
960                model: models::stepfun::STEP_3_7_FLASH.to_string(),
961                messages: vec![Message::user("hello".to_string())].into(),
962                ..Default::default()
963            })
964            .await
965            .expect_err("HTTP 400 should fail");
966
967        assert!(error.to_string().contains("StepFun"), "error should name the provider: {error}");
968        assert!(error.to_string().contains("400"), "error should include the status: {error}");
969    }
970
971    #[tokio::test]
972    async fn stream_normalized_tolerates_stepfun_reasoning_boundaries() {
973        let Some(server) = start_mock_server_or_skip().await else {
974            return;
975        };
976
977        // Mirrors the documented StepFun SSE shape, including the non-summary
978        // `response.reasoning_part.added`/`.done` boundaries.
979        let sse_body = concat!(
980            "event: response.created\n",
981            "data: {\"type\":\"response.created\",\"sequence_number\":0,\"response\":{\"id\":\"resp_step\",\"object\":\"response\",\"created_at\":1772624997,\"model\":\"step-3.7-flash\",\"status\":\"in_progress\",\"output\":[]}}\n\n",
982            "event: response.output_item.added\n",
983            "data: {\"type\":\"response.output_item.added\",\"sequence_number\":1,\"output_index\":0,\"item\":{\"id\":\"rs_1\",\"type\":\"reasoning\",\"summary\":[],\"content\":null,\"encrypted_content\":null,\"status\":\"in_progress\"}}\n\n",
984            "event: response.reasoning_part.added\n",
985            "data: {\"type\":\"response.reasoning_part.added\",\"sequence_number\":2,\"output_index\":0,\"item_id\":\"rs_1\",\"content_index\":0,\"part\":{\"type\":\"reasoning_text\",\"text\":\"\"}}\n\n",
986            "event: response.reasoning_text.delta\n",
987            "data: {\"type\":\"response.reasoning_text.delta\",\"sequence_number\":3,\"output_index\":0,\"item_id\":\"rs_1\",\"content_index\":0,\"delta\":\"thinking\"}\n\n",
988            "event: response.reasoning_text.done\n",
989            "data: {\"type\":\"response.reasoning_text.done\",\"sequence_number\":4,\"output_index\":0,\"item_id\":\"rs_1\",\"content_index\":0,\"text\":\"thinking\"}\n\n",
990            "event: response.reasoning_part.done\n",
991            "data: {\"type\":\"response.reasoning_part.done\",\"sequence_number\":5,\"output_index\":0,\"item_id\":\"rs_1\",\"content_index\":0,\"part\":{\"type\":\"reasoning_text\",\"text\":\"thinking\"}}\n\n",
992            "event: response.output_item.done\n",
993            "data: {\"type\":\"response.output_item.done\",\"sequence_number\":6,\"output_index\":0,\"item\":{\"id\":\"rs_1\",\"type\":\"reasoning\",\"summary\":[],\"content\":null,\"encrypted_content\":null,\"status\":\"completed\"}}\n\n",
994            "event: response.output_item.added\n",
995            "data: {\"type\":\"response.output_item.added\",\"sequence_number\":7,\"output_index\":1,\"item\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"status\":\"in_progress\",\"content\":[]}}\n\n",
996            "event: response.content_part.added\n",
997            "data: {\"type\":\"response.content_part.added\",\"sequence_number\":8,\"item_id\":\"msg_1\",\"output_index\":1,\"content_index\":0,\"part\":{\"type\":\"output_text\",\"text\":\"\",\"annotations\":[]}}\n\n",
998            "event: response.output_text.delta\n",
999            "data: {\"type\":\"response.output_text.delta\",\"sequence_number\":9,\"item_id\":\"msg_1\",\"output_index\":1,\"content_index\":0,\"delta\":\"Hello\"}\n\n",
1000            "event: response.output_text.done\n",
1001            "data: {\"type\":\"response.output_text.done\",\"sequence_number\":10,\"item_id\":\"msg_1\",\"output_index\":1,\"content_index\":0,\"text\":\"Hello\"}\n\n",
1002            "event: response.content_part.done\n",
1003            "data: {\"type\":\"response.content_part.done\",\"sequence_number\":11,\"item_id\":\"msg_1\",\"output_index\":1,\"content_index\":0,\"part\":{\"type\":\"output_text\",\"text\":\"Hello\",\"annotations\":[]}}\n\n",
1004            "event: response.output_item.done\n",
1005            "data: {\"type\":\"response.output_item.done\",\"sequence_number\":12,\"output_index\":1,\"item\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello\",\"annotations\":[]}]}}\n\n",
1006            "event: response.completed\n",
1007            "data: {\"type\":\"response.completed\",\"sequence_number\":13,\"response\":{\"id\":\"resp_step\",\"object\":\"response\",\"status\":\"completed\",\"output\":[{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello\",\"annotations\":[]}]}],\"usage\":{\"input_tokens\":10,\"output_tokens\":2,\"total_tokens\":12}}}\n\n",
1008        );
1009
1010        Mock::given(method("POST"))
1011            .and(path("/v1/responses"))
1012            .respond_with(ResponseTemplate::new(200).set_body_raw(sse_body, "text/event-stream"))
1013            .expect(1)
1014            .mount(&server)
1015            .await;
1016
1017        let provider = mock_provider(&format!("{}/v1", server.uri()));
1018        let mut stream = provider
1019            .stream_normalized(LLMRequest {
1020                model: models::stepfun::STEP_3_7_FLASH.to_string(),
1021                messages: vec![Message::user("hello".to_string())].into(),
1022                ..Default::default()
1023            })
1024            .await
1025            .expect("stream_normalized should start");
1026
1027        let mut text = String::new();
1028        let mut reasoning = String::new();
1029        let mut done = None;
1030        while let Some(event) = stream.next().await {
1031            match event.expect("stream event should not error") {
1032                NormalizedStreamEvent::TextDelta { delta } => text.push_str(&delta),
1033                NormalizedStreamEvent::ReasoningDelta { delta, .. } => reasoning.push_str(&delta),
1034                NormalizedStreamEvent::Done { response } => {
1035                    done = Some(response);
1036                    break;
1037                }
1038                _ => {}
1039            }
1040        }
1041
1042        assert_eq!(text, "Hello");
1043        assert_eq!(reasoning, "thinking");
1044        let response = done.expect("stream should complete");
1045        assert_eq!(response.content.as_deref(), Some("Hello"));
1046        assert_eq!(response.request_id.as_deref(), Some("resp_step"));
1047        assert_eq!(response.usage.map(|usage| usage.total_tokens), Some(12));
1048    }
1049
1050    #[test]
1051    fn step_5_preview_uses_1m_context_and_reasoning_effort() {
1052        let provider = StepFunProvider::new("test-key".to_string());
1053        assert_eq!(provider.effective_context_size(models::stepfun::STEP_5_PREVIEW), 1_048_576);
1054        assert_eq!(provider.effective_context_size(models::stepfun::STEP_3_7_FLASH), 262_144);
1055        assert!(provider.supports_reasoning(models::stepfun::STEP_5_PREVIEW));
1056        assert!(provider.supports_reasoning_effort(models::stepfun::STEP_5_PREVIEW));
1057        assert!(provider.supports_vision(models::stepfun::STEP_5_PREVIEW));
1058    }
1059
1060    #[test]
1061    fn payload_maps_reasoning_effort_and_suppresses_sampling() {
1062        let payload = build_payload(&LLMRequest {
1063            model: models::stepfun::STEP_3_7_FLASH.to_string(),
1064            messages: vec![Message::user("hello".to_string())].into(),
1065            reasoning_effort: Some(ReasoningEffortLevel::XHigh),
1066            temperature: Some(0.5),
1067            ..Default::default()
1068        });
1069
1070        assert_eq!(payload["reasoning"]["effort"], "high");
1071        assert!(payload.get("temperature").is_none());
1072        assert!(payload.get("top_p").is_none());
1073    }
1074
1075    #[test]
1076    fn unknown_effort_omits_reasoning_but_still_suppresses_sampling() {
1077        let payload = build_payload(&LLMRequest {
1078            model: models::stepfun::STEP_3_7_FLASH.to_string(),
1079            messages: vec![Message::user("hello".to_string())].into(),
1080            temperature: Some(0.5),
1081            reasoning_effort: Some(ReasoningEffortLevel::Unknown),
1082            ..Default::default()
1083        });
1084
1085        assert!(payload.get("reasoning").is_none());
1086        assert!(payload.get("temperature").is_none());
1087
1088        let payload = build_payload(&LLMRequest {
1089            model: models::stepfun::STEP_3_7_FLASH.to_string(),
1090            messages: vec![Message::user("hello".to_string())].into(),
1091            temperature: Some(0.5),
1092            reasoning_effort: Some(ReasoningEffortLevel::Low),
1093            ..Default::default()
1094        });
1095
1096        assert_eq!(payload["reasoning"]["effort"], "low");
1097        assert!(payload.get("temperature").is_none());
1098    }
1099
1100    #[test]
1101    fn golden_payload_basic_shape() {
1102        use crate::provider::ToolChoice;
1103
1104        let payload = build_payload(&LLMRequest {
1105            model: models::stepfun::STEP_3_7_FLASH.to_string(),
1106            messages: vec![Message::user("hello".to_string())].into(),
1107            system_prompt: Some(Arc::from("system guidance")),
1108            max_tokens: Some(512),
1109            temperature: Some(0.5),
1110            top_p: Some(0.25),
1111            stream: true,
1112            tool_choice: Some(ToolChoice::Auto),
1113            metadata: Some(json!({"user_id": "user-42"})),
1114            ..Default::default()
1115        });
1116
1117        assert_eq!(payload["model"], models::stepfun::STEP_3_7_FLASH);
1118        assert_eq!(payload["instructions"], "system guidance");
1119        let input = payload["input"].as_array().unwrap();
1120        assert_eq!(input.len(), 1);
1121        assert_eq!(input[0]["role"], "user");
1122        assert_eq!(input[0]["content"][0]["type"], "input_text");
1123        assert_eq!(input[0]["content"][0]["text"], "hello");
1124        assert_eq!(payload["max_output_tokens"], 512);
1125        assert_eq!(payload["temperature"], 0.5);
1126        assert_eq!(payload["top_p"], 0.25);
1127        assert_eq!(payload["stream"], true);
1128        // StepFun does not accept user metadata or chat-completions fields.
1129        assert!(payload.get("messages").is_none());
1130        assert!(payload.get("user_id").is_none());
1131        assert!(payload.get("max_tokens").is_none());
1132    }
1133
1134    #[test]
1135    fn payload_inlines_vision_parts_as_input_images() {
1136        use crate::provider::ContentPart;
1137
1138        let payload = build_payload(&LLMRequest {
1139            model: models::stepfun::STEP_3_7_FLASH.to_string(),
1140            messages: vec![Message {
1141                role: crate::provider::MessageRole::User,
1142                content: crate::provider::MessageContent::Parts(vec![
1143                    ContentPart::Text { text: "describe".to_string() },
1144                    ContentPart::Image {
1145                        data: "abc".to_string(),
1146                        mime_type: "image/png".to_string(),
1147                        content_type: "image".to_string(),
1148                        detail: None,
1149                        image_url: None,
1150                    },
1151                ]),
1152                ..Default::default()
1153            }]
1154            .into(),
1155            ..Default::default()
1156        });
1157
1158        let content = payload["input"][0]["content"].as_array().unwrap();
1159        assert_eq!(content[0]["type"], "input_text");
1160        assert_eq!(content[1]["type"], "input_image");
1161        assert_eq!(content[1]["image_url"], "data:image/png;base64,abc");
1162    }
1163
1164    #[test]
1165    fn payload_flattens_tools_into_responses_shape() {
1166        let payload = build_payload(&LLMRequest {
1167            model: models::stepfun::STEP_3_7_FLASH.to_string(),
1168            messages: vec![Message::user("hello".to_string())].into(),
1169            tools: Some(Arc::new(vec![ToolDefinition::function(
1170                "get_weather".to_string(),
1171                "Get the weather".to_string(),
1172                json!({"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}),
1173            )])),
1174            tool_choice: Some(crate::provider::ToolChoice::Auto),
1175            ..Default::default()
1176        });
1177
1178        let tool = &payload["tools"][0];
1179        assert_eq!(tool["type"], "function");
1180        assert_eq!(tool["name"], "get_weather");
1181        assert_eq!(tool["description"], "Get the weather");
1182        assert!(tool.get("function").is_none(), "Responses tools are flat, not nested");
1183        assert_eq!(payload["tool_choice"], "auto");
1184    }
1185
1186    #[test]
1187    fn payload_keeps_tools_but_omits_choice_for_tool_choice_none() {
1188        let payload = build_payload(&LLMRequest {
1189            model: models::stepfun::STEP_3_7_FLASH.to_string(),
1190            messages: vec![Message::user("hello".to_string())].into(),
1191            tools: Some(Arc::new(vec![ToolDefinition::function(
1192                "get_weather".to_string(),
1193                "Get the weather".to_string(),
1194                json!({"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}),
1195            )])),
1196            tool_choice: Some(crate::provider::ToolChoice::None),
1197            ..Default::default()
1198        });
1199
1200        assert!(payload.get("tools").is_some(), "tool definitions stay on the wire for cache stability");
1201        assert!(
1202            payload.get("tool_choice").is_none(),
1203            "StepFun accepts only tool_choice=auto, so none must omit the field"
1204        );
1205    }
1206
1207    #[test]
1208    fn payload_replays_tool_calls_and_outputs() {
1209        let payload = build_payload(&LLMRequest {
1210            model: models::stepfun::STEP_3_7_FLASH.to_string(),
1211            messages: vec![
1212                Message::user("weather?".to_string()),
1213                Message::assistant_with_tools(
1214                    String::new(),
1215                    vec![ToolCall::function(
1216                        "call_1".to_string(),
1217                        "get_weather".to_string(),
1218                        "{\"city\":\"Beijing\"}".to_string(),
1219                    )],
1220                ),
1221                Message::tool_response("call_1".to_string(), "{\"temperature\":22}".to_string()),
1222            ]
1223            .into(),
1224            ..Default::default()
1225        });
1226
1227        let input = payload["input"].as_array().unwrap();
1228        assert_eq!(input[0]["role"], "user");
1229        assert_eq!(input[1]["type"], "function_call");
1230        assert_eq!(input[1]["call_id"], "call_1");
1231        assert_eq!(input[1]["name"], "get_weather");
1232        assert_eq!(input[1]["arguments"], "{\"city\":\"Beijing\"}");
1233        assert_eq!(input[2]["type"], "function_call_output");
1234        assert_eq!(input[2]["call_id"], "call_1");
1235        assert_eq!(input[2]["output"], "{\"temperature\":22}");
1236    }
1237
1238    #[test]
1239    fn payload_synthesizes_aborted_output_for_orphan_call() {
1240        let payload = build_payload(&LLMRequest {
1241            model: models::stepfun::STEP_3_7_FLASH.to_string(),
1242            messages: vec![
1243                Message::user("weather?".to_string()),
1244                Message::assistant_with_tools(
1245                    String::new(),
1246                    vec![ToolCall::function(
1247                        "call_orphan".to_string(),
1248                        "get_weather".to_string(),
1249                        "{}".to_string(),
1250                    )],
1251                ),
1252            ]
1253            .into(),
1254            ..Default::default()
1255        });
1256
1257        let input = payload["input"].as_array().unwrap();
1258        assert_eq!(input[1]["type"], "function_call");
1259        assert_eq!(input[2]["type"], "function_call_output");
1260        assert_eq!(input[2]["call_id"], "call_orphan");
1261        assert_eq!(input[2]["output"], "aborted");
1262    }
1263
1264    #[test]
1265    fn parse_response_extracts_reasoning_text_and_tool_calls() {
1266        let response = json!({
1267            "id": "resp_1",
1268            "status": "completed",
1269            "output": [
1270                {"type": "reasoning", "id": "rs_1", "summary": [], "content": null, "encrypted_content": null},
1271                {"type": "message", "id": "msg_1", "role": "assistant", "status": "completed",
1272                 "content": [{"type": "output_text", "text": "done", "annotations": []}]},
1273                {"type": "function_call", "id": "fc_1", "call_id": "call_1", "name": "get_weather",
1274                 "arguments": "{\"city\":\"Beijing\"}", "status": "completed"}
1275            ],
1276            "usage": {
1277                "input_tokens": 14,
1278                "output_tokens": 52,
1279                "total_tokens": 66,
1280                "input_tokens_details": {"cached_tokens": 0},
1281                "output_tokens_details": {"reasoning_tokens": 0, "tool_output_tokens": 0}
1282            }
1283        });
1284
1285        let parsed = StepFunProvider::parse_response(response, models::stepfun::STEP_3_7_FLASH.to_string())
1286            .expect("response should parse");
1287
1288        assert_eq!(parsed.content.as_deref(), Some("done"));
1289        assert_eq!(parsed.request_id.as_deref(), Some("resp_1"));
1290        let tool_calls = parsed.tool_calls.expect("tool calls should exist");
1291        assert_eq!(tool_calls.len(), 1);
1292        assert_eq!(tool_calls[0].id, "call_1");
1293        assert_eq!(tool_calls[0].function.as_ref().map(|f| f.name.as_str()), Some("get_weather"));
1294        assert_eq!(parsed.finish_reason, crate::provider::FinishReason::ToolCalls);
1295        let usage = parsed.usage.expect("usage should exist");
1296        assert_eq!(usage.prompt_tokens, 14);
1297        assert_eq!(usage.completion_tokens, 52);
1298        assert_eq!(usage.total_tokens, 66);
1299    }
1300
1301    #[test]
1302    fn parse_response_maps_incomplete_status_to_length() {
1303        let response = json!({
1304            "id": "resp_incomplete",
1305            "status": "incomplete",
1306            "incomplete_details": {"reason": "max_output_tokens"},
1307            "output": [
1308                {"type": "reasoning", "id": "rs_1", "summary": [], "content": "thinking", "encrypted_content": null}
1309            ]
1310        });
1311
1312        let parsed = StepFunProvider::parse_response(response, models::stepfun::STEP_3_7_FLASH.to_string())
1313            .expect("response should parse");
1314        assert_eq!(parsed.finish_reason, crate::provider::FinishReason::Length);
1315        assert_eq!(parsed.reasoning.as_deref(), Some("thinking"));
1316    }
1317
1318    #[test]
1319    fn parse_response_surfaces_failed_status() {
1320        let response = json!({
1321            "id": "resp_failed",
1322            "status": "failed",
1323            "error": {"code": "server_error", "message": "backend failed"},
1324            "output": []
1325        });
1326
1327        let error = StepFunProvider::parse_response(response, models::stepfun::STEP_3_7_FLASH.to_string())
1328            .expect_err("failed response should error");
1329        assert!(error.to_string().contains("backend failed"));
1330    }
1331
1332    #[test]
1333    fn validate_request_rejects_unknown_models() {
1334        let provider = StepFunProvider::new("test-key".to_string());
1335        let error = provider
1336            .validate_request(&LLMRequest {
1337                model: "not-a-stepfun-model".to_string(),
1338                messages: vec![Message::user("hello".to_string())].into(),
1339                ..Default::default()
1340            })
1341            .expect_err("unknown model should be rejected");
1342        assert!(error.to_string().contains("not-a-stepfun-model"));
1343    }
1344}