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.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 {
811    use super::StepFunProvider;
812    use crate::provider::{
813        LLMProvider, LLMRequest, Message, NormalizedStreamEvent, ToolCall, ToolChoice, ToolDefinition,
814    };
815    use futures::StreamExt;
816    use serde_json::{Value, json};
817    use std::sync::Arc;
818    use vtcode_config::TimeoutsConfig;
819    use vtcode_config::constants::models;
820    use vtcode_config::types::ReasoningEffortLevel;
821    use wiremock::matchers::{body_partial_json, method, path};
822    use wiremock::{Mock, ResponseTemplate};
823
824    fn build_payload(request: &LLMRequest) -> Value {
825        let provider = StepFunProvider::new("test-key".to_string());
826        provider.build_payload(request, request.stream).expect("payload should build")
827    }
828
829    fn mock_provider(base_url: &str) -> StepFunProvider {
830        let http_client = reqwest::Client::builder().no_proxy().build().expect("test client should build");
831        StepFunProvider::new_with_client(
832            "test-key".to_string(),
833            models::stepfun::STEP_3_7_FLASH.to_string(),
834            http_client,
835            base_url.to_string(),
836            TimeoutsConfig::default(),
837        )
838    }
839
840    use crate::providers::test_support::start_mock_server_or_skip;
841
842    fn completed_response_body() -> Value {
843        json!({
844            "id": "resp_step_1",
845            "object": "response",
846            "created_at": 1772624997,
847            "completed_at": 1772624998,
848            "model": models::stepfun::STEP_3_7_FLASH,
849            "status": "completed",
850            "error": null,
851            "incomplete_details": null,
852            "output": [
853                {
854                    "type": "reasoning",
855                    "id": "rs_1",
856                    "summary": [],
857                    "content": null,
858                    "encrypted_content": null,
859                    "status": null
860                },
861                {
862                    "type": "message",
863                    "id": "msg_1",
864                    "status": "completed",
865                    "role": "assistant",
866                    "content": [{"type": "output_text", "text": "hello from stepfun", "annotations": []}]
867                },
868                {
869                    "type": "function_call",
870                    "id": "fc_1",
871                    "call_id": "call_1",
872                    "name": "get_weather",
873                    "arguments": "{\"city\":\"Beijing\"}",
874                    "status": "completed"
875                }
876            ],
877            "usage": {
878                "input_tokens": 14,
879                "input_tokens_details": {"cached_tokens": 0},
880                "output_tokens": 52,
881                "output_tokens_details": {"reasoning_tokens": 0, "tool_output_tokens": 0},
882                "total_tokens": 66
883            }
884        })
885    }
886
887    #[tokio::test]
888    async fn generate_posts_responses_request_and_parses_output() {
889        let Some(server) = start_mock_server_or_skip().await else {
890            return;
891        };
892
893        Mock::given(method("POST"))
894            .and(path("/v1/responses"))
895            .and(body_partial_json(json!({
896                "model": models::stepfun::STEP_3_7_FLASH,
897                "instructions": "system guidance",
898                "stream": false,
899                "max_output_tokens": 512,
900                "reasoning": {"effort": "high"},
901                "tool_choice": "auto",
902                "input": [{"role": "user", "content": [{"type": "input_text", "text": "hello"}]}],
903                "tools": [{"type": "function", "name": "get_weather"}]
904            })))
905            .respond_with(ResponseTemplate::new(200).set_body_json(completed_response_body()))
906            .expect(1)
907            .mount(&server)
908            .await;
909
910        let provider = mock_provider(&format!("{}/v1", server.uri()));
911        let response = provider
912            .generate(LLMRequest {
913                model: models::stepfun::STEP_3_7_FLASH.to_string(),
914                messages: vec![Message::user("hello".to_string())].into(),
915                system_prompt: Some(Arc::from("system guidance")),
916                max_tokens: Some(512),
917                reasoning_effort: Some(ReasoningEffortLevel::XHigh),
918                tools: Some(Arc::new(vec![ToolDefinition::function(
919                    "get_weather".to_string(),
920                    "Get the weather".to_string(),
921                    json!({"type": "object"}),
922                )])),
923                tool_choice: Some(ToolChoice::Auto),
924                ..Default::default()
925            })
926            .await
927            .expect("generate should succeed");
928
929        assert_eq!(response.content.as_deref(), Some("hello from stepfun"));
930        assert_eq!(response.request_id.as_deref(), Some("resp_step_1"));
931        assert_eq!(response.finish_reason, crate::provider::FinishReason::ToolCalls);
932        let tool_calls = response.tool_calls.expect("tool calls should exist");
933        assert_eq!(tool_calls[0].id, "call_1");
934        assert_eq!(tool_calls[0].function.as_ref().map(|f| f.name.as_str()), Some("get_weather"));
935        let usage = response.usage.expect("usage should exist");
936        assert_eq!(usage.prompt_tokens, 14);
937        assert_eq!(usage.completion_tokens, 52);
938        assert_eq!(usage.total_tokens, 66);
939    }
940
941    #[tokio::test]
942    async fn generate_surfaces_provider_error_body() {
943        let Some(server) = start_mock_server_or_skip().await else {
944            return;
945        };
946
947        Mock::given(method("POST"))
948            .and(path("/v1/responses"))
949            .respond_with(ResponseTemplate::new(400).set_body_json(json!({
950                "error": {"code": "invalid_request", "message": "bad input"}
951            })))
952            .expect(1)
953            .mount(&server)
954            .await;
955
956        let provider = mock_provider(&format!("{}/v1", server.uri()));
957        let error = provider
958            .generate(LLMRequest {
959                model: models::stepfun::STEP_3_7_FLASH.to_string(),
960                messages: vec![Message::user("hello".to_string())].into(),
961                ..Default::default()
962            })
963            .await
964            .expect_err("HTTP 400 should fail");
965
966        assert!(error.to_string().contains("StepFun"), "error should name the provider: {error}");
967        assert!(error.to_string().contains("400"), "error should include the status: {error}");
968    }
969
970    #[tokio::test]
971    async fn stream_normalized_tolerates_stepfun_reasoning_boundaries() {
972        let Some(server) = start_mock_server_or_skip().await else {
973            return;
974        };
975
976        // Mirrors the documented StepFun SSE shape, including the non-summary
977        // `response.reasoning_part.added`/`.done` boundaries.
978        let sse_body = concat!(
979            "event: response.created\n",
980            "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",
981            "event: response.output_item.added\n",
982            "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",
983            "event: response.reasoning_part.added\n",
984            "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",
985            "event: response.reasoning_text.delta\n",
986            "data: {\"type\":\"response.reasoning_text.delta\",\"sequence_number\":3,\"output_index\":0,\"item_id\":\"rs_1\",\"content_index\":0,\"delta\":\"thinking\"}\n\n",
987            "event: response.reasoning_text.done\n",
988            "data: {\"type\":\"response.reasoning_text.done\",\"sequence_number\":4,\"output_index\":0,\"item_id\":\"rs_1\",\"content_index\":0,\"text\":\"thinking\"}\n\n",
989            "event: response.reasoning_part.done\n",
990            "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",
991            "event: response.output_item.done\n",
992            "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",
993            "event: response.output_item.added\n",
994            "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",
995            "event: response.content_part.added\n",
996            "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",
997            "event: response.output_text.delta\n",
998            "data: {\"type\":\"response.output_text.delta\",\"sequence_number\":9,\"item_id\":\"msg_1\",\"output_index\":1,\"content_index\":0,\"delta\":\"Hello\"}\n\n",
999            "event: response.output_text.done\n",
1000            "data: {\"type\":\"response.output_text.done\",\"sequence_number\":10,\"item_id\":\"msg_1\",\"output_index\":1,\"content_index\":0,\"text\":\"Hello\"}\n\n",
1001            "event: response.content_part.done\n",
1002            "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",
1003            "event: response.output_item.done\n",
1004            "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",
1005            "event: response.completed\n",
1006            "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",
1007        );
1008
1009        Mock::given(method("POST"))
1010            .and(path("/v1/responses"))
1011            .respond_with(ResponseTemplate::new(200).set_body_raw(sse_body, "text/event-stream"))
1012            .expect(1)
1013            .mount(&server)
1014            .await;
1015
1016        let provider = mock_provider(&format!("{}/v1", server.uri()));
1017        let mut stream = provider
1018            .stream_normalized(LLMRequest {
1019                model: models::stepfun::STEP_3_7_FLASH.to_string(),
1020                messages: vec![Message::user("hello".to_string())].into(),
1021                ..Default::default()
1022            })
1023            .await
1024            .expect("stream_normalized should start");
1025
1026        let mut text = String::new();
1027        let mut reasoning = String::new();
1028        let mut done = None;
1029        while let Some(event) = stream.next().await {
1030            match event.expect("stream event should not error") {
1031                NormalizedStreamEvent::TextDelta { delta } => text.push_str(&delta),
1032                NormalizedStreamEvent::ReasoningDelta { delta, .. } => reasoning.push_str(&delta),
1033                NormalizedStreamEvent::Done { response } => {
1034                    done = Some(response);
1035                    break;
1036                }
1037                _ => {}
1038            }
1039        }
1040
1041        assert_eq!(text, "Hello");
1042        assert_eq!(reasoning, "thinking");
1043        let response = done.expect("stream should complete");
1044        assert_eq!(response.content.as_deref(), Some("Hello"));
1045        assert_eq!(response.request_id.as_deref(), Some("resp_step"));
1046        assert_eq!(response.usage.map(|usage| usage.total_tokens), Some(12));
1047    }
1048
1049    #[test]
1050    fn step_5_preview_uses_1m_context_and_reasoning_effort() {
1051        let provider = StepFunProvider::new("test-key".to_string());
1052        assert_eq!(provider.effective_context_size(models::stepfun::STEP_5_PREVIEW), 1_048_576);
1053        assert_eq!(provider.effective_context_size(models::stepfun::STEP_3_7_FLASH), 262_144);
1054        assert!(provider.supports_reasoning(models::stepfun::STEP_5_PREVIEW));
1055        assert!(provider.supports_reasoning_effort(models::stepfun::STEP_5_PREVIEW));
1056        assert!(provider.supports_vision(models::stepfun::STEP_5_PREVIEW));
1057    }
1058
1059    #[test]
1060    fn payload_maps_reasoning_effort_and_suppresses_sampling() {
1061        let payload = build_payload(&LLMRequest {
1062            model: models::stepfun::STEP_3_7_FLASH.to_string(),
1063            messages: vec![Message::user("hello".to_string())].into(),
1064            reasoning_effort: Some(ReasoningEffortLevel::XHigh),
1065            temperature: Some(0.5),
1066            ..Default::default()
1067        });
1068
1069        assert_eq!(payload["reasoning"]["effort"], "high");
1070        assert!(payload.get("temperature").is_none());
1071        assert!(payload.get("top_p").is_none());
1072    }
1073
1074    #[test]
1075    fn unknown_effort_omits_reasoning_but_still_suppresses_sampling() {
1076        let payload = build_payload(&LLMRequest {
1077            model: models::stepfun::STEP_3_7_FLASH.to_string(),
1078            messages: vec![Message::user("hello".to_string())].into(),
1079            temperature: Some(0.5),
1080            reasoning_effort: Some(ReasoningEffortLevel::Unknown),
1081            ..Default::default()
1082        });
1083
1084        assert!(payload.get("reasoning").is_none());
1085        assert!(payload.get("temperature").is_none());
1086
1087        let payload = build_payload(&LLMRequest {
1088            model: models::stepfun::STEP_3_7_FLASH.to_string(),
1089            messages: vec![Message::user("hello".to_string())].into(),
1090            temperature: Some(0.5),
1091            reasoning_effort: Some(ReasoningEffortLevel::Low),
1092            ..Default::default()
1093        });
1094
1095        assert_eq!(payload["reasoning"]["effort"], "low");
1096        assert!(payload.get("temperature").is_none());
1097    }
1098
1099    #[test]
1100    fn golden_payload_basic_shape() {
1101        use crate::provider::ToolChoice;
1102
1103        let payload = build_payload(&LLMRequest {
1104            model: models::stepfun::STEP_3_7_FLASH.to_string(),
1105            messages: vec![Message::user("hello".to_string())].into(),
1106            system_prompt: Some(Arc::from("system guidance")),
1107            max_tokens: Some(512),
1108            temperature: Some(0.5),
1109            top_p: Some(0.25),
1110            stream: true,
1111            tool_choice: Some(ToolChoice::Auto),
1112            metadata: Some(json!({"user_id": "user-42"})),
1113            ..Default::default()
1114        });
1115
1116        assert_eq!(payload["model"], models::stepfun::STEP_3_7_FLASH);
1117        assert_eq!(payload["instructions"], "system guidance");
1118        let input = payload["input"].as_array().unwrap();
1119        assert_eq!(input.len(), 1);
1120        assert_eq!(input[0]["role"], "user");
1121        assert_eq!(input[0]["content"][0]["type"], "input_text");
1122        assert_eq!(input[0]["content"][0]["text"], "hello");
1123        assert_eq!(payload["max_output_tokens"], 512);
1124        assert_eq!(payload["temperature"], 0.5);
1125        assert_eq!(payload["top_p"], 0.25);
1126        assert_eq!(payload["stream"], true);
1127        // StepFun does not accept user metadata or chat-completions fields.
1128        assert!(payload.get("messages").is_none());
1129        assert!(payload.get("user_id").is_none());
1130        assert!(payload.get("max_tokens").is_none());
1131    }
1132
1133    #[test]
1134    fn payload_inlines_vision_parts_as_input_images() {
1135        use crate::provider::ContentPart;
1136
1137        let payload = build_payload(&LLMRequest {
1138            model: models::stepfun::STEP_3_7_FLASH.to_string(),
1139            messages: vec![Message {
1140                role: crate::provider::MessageRole::User,
1141                content: crate::provider::MessageContent::Parts(vec![
1142                    ContentPart::Text { text: "describe".to_string() },
1143                    ContentPart::Image {
1144                        data: "abc".to_string(),
1145                        mime_type: "image/png".to_string(),
1146                        content_type: "image".to_string(),
1147                        detail: None,
1148                        image_url: None,
1149                    },
1150                ]),
1151                ..Default::default()
1152            }]
1153            .into(),
1154            ..Default::default()
1155        });
1156
1157        let content = payload["input"][0]["content"].as_array().unwrap();
1158        assert_eq!(content[0]["type"], "input_text");
1159        assert_eq!(content[1]["type"], "input_image");
1160        assert_eq!(content[1]["image_url"], "data:image/png;base64,abc");
1161    }
1162
1163    #[test]
1164    fn payload_flattens_tools_into_responses_shape() {
1165        let payload = build_payload(&LLMRequest {
1166            model: models::stepfun::STEP_3_7_FLASH.to_string(),
1167            messages: vec![Message::user("hello".to_string())].into(),
1168            tools: Some(Arc::new(vec![ToolDefinition::function(
1169                "get_weather".to_string(),
1170                "Get the weather".to_string(),
1171                json!({"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}),
1172            )])),
1173            tool_choice: Some(ToolChoice::Auto),
1174            ..Default::default()
1175        });
1176
1177        let tool = &payload["tools"][0];
1178        assert_eq!(tool["type"], "function");
1179        assert_eq!(tool["name"], "get_weather");
1180        assert_eq!(tool["description"], "Get the weather");
1181        assert!(tool.get("function").is_none(), "Responses tools are flat, not nested");
1182        assert_eq!(payload["tool_choice"], "auto");
1183    }
1184
1185    #[test]
1186    fn payload_omits_callable_tools_for_tool_choice_none() {
1187        for stream in [false, true] {
1188            let mut request = 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"}),
1195                )])),
1196                tool_choice: Some(ToolChoice::None),
1197                stream,
1198                ..Default::default()
1199            };
1200            let payload = build_payload(&request);
1201            assert!(payload.get("tools").is_none());
1202            assert!(payload.get("tool_choice").is_none());
1203            assert_eq!(payload["stream"], stream);
1204            request.tool_choice = Some(ToolChoice::Auto);
1205            let enabled = build_payload(&request);
1206            assert_eq!(enabled["tools"][0]["name"], "get_weather");
1207            assert_eq!(enabled["tool_choice"], "auto");
1208        }
1209    }
1210
1211    #[test]
1212    fn payload_replays_tool_calls_and_outputs() {
1213        let payload = build_payload(&LLMRequest {
1214            model: models::stepfun::STEP_3_7_FLASH.to_string(),
1215            messages: vec![
1216                Message::user("weather?".to_string()),
1217                Message::assistant_with_tools(
1218                    String::new(),
1219                    vec![ToolCall::function(
1220                        "call_1".to_string(),
1221                        "get_weather".to_string(),
1222                        "{\"city\":\"Beijing\"}".to_string(),
1223                    )],
1224                ),
1225                Message::tool_response("call_1".to_string(), "{\"temperature\":22}".to_string()),
1226            ]
1227            .into(),
1228            ..Default::default()
1229        });
1230
1231        let input = payload["input"].as_array().unwrap();
1232        assert_eq!(input[0]["role"], "user");
1233        assert_eq!(input[1]["type"], "function_call");
1234        assert_eq!(input[1]["call_id"], "call_1");
1235        assert_eq!(input[1]["name"], "get_weather");
1236        assert_eq!(input[1]["arguments"], "{\"city\":\"Beijing\"}");
1237        assert_eq!(input[2]["type"], "function_call_output");
1238        assert_eq!(input[2]["call_id"], "call_1");
1239        assert_eq!(input[2]["output"], "{\"temperature\":22}");
1240    }
1241
1242    #[test]
1243    fn payload_synthesizes_aborted_output_for_orphan_call() {
1244        let payload = build_payload(&LLMRequest {
1245            model: models::stepfun::STEP_3_7_FLASH.to_string(),
1246            messages: vec![
1247                Message::user("weather?".to_string()),
1248                Message::assistant_with_tools(
1249                    String::new(),
1250                    vec![ToolCall::function(
1251                        "call_orphan".to_string(),
1252                        "get_weather".to_string(),
1253                        "{}".to_string(),
1254                    )],
1255                ),
1256            ]
1257            .into(),
1258            ..Default::default()
1259        });
1260
1261        let input = payload["input"].as_array().unwrap();
1262        assert_eq!(input[1]["type"], "function_call");
1263        assert_eq!(input[2]["type"], "function_call_output");
1264        assert_eq!(input[2]["call_id"], "call_orphan");
1265        assert_eq!(input[2]["output"], "aborted");
1266    }
1267
1268    #[test]
1269    fn parse_response_extracts_reasoning_text_and_tool_calls() {
1270        let response = json!({
1271            "id": "resp_1",
1272            "status": "completed",
1273            "output": [
1274                {"type": "reasoning", "id": "rs_1", "summary": [], "content": null, "encrypted_content": null},
1275                {"type": "message", "id": "msg_1", "role": "assistant", "status": "completed",
1276                 "content": [{"type": "output_text", "text": "done", "annotations": []}]},
1277                {"type": "function_call", "id": "fc_1", "call_id": "call_1", "name": "get_weather",
1278                 "arguments": "{\"city\":\"Beijing\"}", "status": "completed"}
1279            ],
1280            "usage": {
1281                "input_tokens": 14,
1282                "output_tokens": 52,
1283                "total_tokens": 66,
1284                "input_tokens_details": {"cached_tokens": 0},
1285                "output_tokens_details": {"reasoning_tokens": 0, "tool_output_tokens": 0}
1286            }
1287        });
1288
1289        let parsed = StepFunProvider::parse_response(response, models::stepfun::STEP_3_7_FLASH.to_string())
1290            .expect("response should parse");
1291
1292        assert_eq!(parsed.content.as_deref(), Some("done"));
1293        assert_eq!(parsed.request_id.as_deref(), Some("resp_1"));
1294        let tool_calls = parsed.tool_calls.expect("tool calls should exist");
1295        assert_eq!(tool_calls.len(), 1);
1296        assert_eq!(tool_calls[0].id, "call_1");
1297        assert_eq!(tool_calls[0].function.as_ref().map(|f| f.name.as_str()), Some("get_weather"));
1298        assert_eq!(parsed.finish_reason, crate::provider::FinishReason::ToolCalls);
1299        let usage = parsed.usage.expect("usage should exist");
1300        assert_eq!(usage.prompt_tokens, 14);
1301        assert_eq!(usage.completion_tokens, 52);
1302        assert_eq!(usage.total_tokens, 66);
1303    }
1304
1305    #[test]
1306    fn parse_response_maps_incomplete_status_to_length() {
1307        let response = json!({
1308            "id": "resp_incomplete",
1309            "status": "incomplete",
1310            "incomplete_details": {"reason": "max_output_tokens"},
1311            "output": [
1312                {"type": "reasoning", "id": "rs_1", "summary": [], "content": "thinking", "encrypted_content": null}
1313            ]
1314        });
1315
1316        let parsed = StepFunProvider::parse_response(response, models::stepfun::STEP_3_7_FLASH.to_string())
1317            .expect("response should parse");
1318        assert_eq!(parsed.finish_reason, crate::provider::FinishReason::Length);
1319        assert_eq!(parsed.reasoning.as_deref(), Some("thinking"));
1320    }
1321
1322    #[test]
1323    fn parse_response_surfaces_failed_status() {
1324        let response = json!({
1325            "id": "resp_failed",
1326            "status": "failed",
1327            "error": {"code": "server_error", "message": "backend failed"},
1328            "output": []
1329        });
1330
1331        let error = StepFunProvider::parse_response(response, models::stepfun::STEP_3_7_FLASH.to_string())
1332            .expect_err("failed response should error");
1333        assert!(error.to_string().contains("backend failed"));
1334    }
1335
1336    #[test]
1337    fn validate_request_rejects_unknown_models() {
1338        let provider = StepFunProvider::new("test-key".to_string());
1339        let error = provider
1340            .validate_request(&LLMRequest {
1341                model: "not-a-stepfun-model".to_string(),
1342                messages: vec![Message::user("hello".to_string())].into(),
1343                ..Default::default()
1344            })
1345            .expect_err("unknown model should be rejected");
1346        assert!(error.to_string().contains("not-a-stepfun-model"));
1347    }
1348}