1use 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
46fn 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
56fn 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 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
107fn function_output_string(value: Value) -> String {
109 match value {
110 Value::String(text) => text,
111 other => other.to_string(),
112 }
113}
114
115fn 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
135fn 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 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 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 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 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 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 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 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
691fn 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 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 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 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}