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.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 !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 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 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 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
688fn 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 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 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 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}