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;