use std::pin::Pin;
use async_trait::async_trait;
use futures::Stream;
use super::{
Backend, Capability, ChatChunk, ChatRequest, ChatResponse, ChatStream, FinishReason,
Usage,
};
use super::error::BackendError;
pub const STUB_PROVIDER_NAME: &str = "stub";
pub const STUB_DEFAULT_MODEL: &str = "stub-model";
pub const STUB_CONTENT: &str = "(stub)";
#[derive(Debug, Default, Clone)]
pub struct StubBackend {
chunk_content: Option<String>,
}
impl StubBackend {
pub fn new() -> Self {
Self { chunk_content: None }
}
pub fn with_chunk_content(mut self, content: impl Into<String>) -> Self {
self.chunk_content = Some(content.into());
self
}
fn effective_chunk(&self) -> &str {
self.chunk_content.as_deref().unwrap_or(STUB_CONTENT)
}
}
#[async_trait]
impl Backend for StubBackend {
fn name(&self) -> &str {
STUB_PROVIDER_NAME
}
fn default_model(&self) -> &str {
STUB_DEFAULT_MODEL
}
async fn complete(&self, request: ChatRequest) -> Result<ChatResponse, BackendError> {
let trace_id = request.trace_id.unwrap_or_else(|| "stub-trace".to_string());
Ok(ChatResponse {
content: self.effective_chunk().to_string(),
model_name: STUB_DEFAULT_MODEL.to_string(),
provider_name: STUB_PROVIDER_NAME.to_string(),
finish_reason: FinishReason::Stop,
usage: Usage::default(),
retry_count: 0,
trace_id,
})
}
async fn stream(&self, request: ChatRequest) -> Result<ChatStream, BackendError> {
let chunk = ChatChunk {
delta: self.effective_chunk().to_string(),
finish_reason: Some(FinishReason::Stop),
usage: Some(Usage::default()),
};
let inner: Pin<Box<dyn Stream<Item = Result<ChatChunk, BackendError>> + Send>> =
Box::pin(futures::stream::iter(vec![Ok(chunk)]));
Ok(super::sse_streaming::cancel_aware(inner, request.cancel.clone()))
}
fn supports(&self, capability: Capability, _model: &str) -> bool {
match capability {
Capability::Streaming => true,
Capability::ToolUse
| Capability::Vision
| Capability::PromptCaching
| Capability::SafetySettings
| Capability::StructuredOutput
| Capability::LockedParams => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use futures::StreamExt;
#[test]
fn name_is_canonical_stub() {
let s = StubBackend::new();
assert_eq!(s.name(), STUB_PROVIDER_NAME);
assert_eq!(s.name(), "stub");
}
#[test]
fn default_model_is_canonical_slug() {
let s = StubBackend::new();
assert_eq!(s.default_model(), STUB_DEFAULT_MODEL);
assert_eq!(s.default_model(), "stub-model");
}
#[tokio::test]
async fn complete_returns_canonical_stub_content() {
let s = StubBackend::new();
let req = ChatRequest::default();
let resp = s.complete(req).await.expect("stub complete never fails");
assert_eq!(resp.content, STUB_CONTENT);
assert_eq!(resp.content, "(stub)");
assert_eq!(resp.model_name, "stub-model");
assert_eq!(resp.provider_name, "stub");
assert_eq!(resp.finish_reason, FinishReason::Stop);
assert_eq!(resp.retry_count, 0);
assert_eq!(resp.usage, Usage::default());
}
#[tokio::test]
async fn complete_echoes_trace_id_when_provided() {
let s = StubBackend::new();
let req = ChatRequest {
trace_id: Some("flow-42".to_string()),
..Default::default()
};
let resp = s.complete(req).await.unwrap();
assert_eq!(resp.trace_id, "flow-42");
}
#[tokio::test]
async fn complete_synthesizes_trace_id_when_absent() {
let s = StubBackend::new();
let req = ChatRequest::default();
let resp = s.complete(req).await.unwrap();
assert!(!resp.trace_id.is_empty());
}
#[tokio::test]
async fn stream_emits_exactly_one_chunk_with_stub_content() {
let s = StubBackend::new();
let req = ChatRequest { stream: true, ..Default::default() };
let mut chunks: Vec<ChatChunk> = Vec::new();
let mut stream = s.stream(req).await.expect("stub stream never fails");
while let Some(item) = stream.next().await {
chunks.push(item.expect("stub never errors"));
}
assert_eq!(chunks.len(), 1, "v1.24.0 byte-compat: exactly one chunk");
let chunk = &chunks[0];
assert_eq!(chunk.delta, STUB_CONTENT);
assert_eq!(chunk.finish_reason, Some(FinishReason::Stop));
assert_eq!(chunk.usage, Some(Usage::default()));
}
#[tokio::test]
async fn stream_with_chunk_content_override_emits_custom_delta() {
let s = StubBackend::new().with_chunk_content("hello world");
let req = ChatRequest::default();
let mut stream = s.stream(req).await.unwrap();
let chunk = stream.next().await.unwrap().unwrap();
assert_eq!(chunk.delta, "hello world");
assert!(stream.next().await.is_none(), "single-chunk semantics preserved");
}
#[test]
fn supports_streaming_capability() {
let s = StubBackend::new();
assert!(s.supports(Capability::Streaming, "any-model"));
}
#[test]
fn supports_false_for_non_streaming_capabilities() {
let s = StubBackend::new();
for cap in [
Capability::ToolUse,
Capability::Vision,
Capability::PromptCaching,
Capability::SafetySettings,
Capability::StructuredOutput,
Capability::LockedParams,
] {
assert!(!s.supports(cap, "any-model"), "{:?}", cap);
}
}
#[tokio::test]
async fn stream_chunk_delta_byte_compat_with_v1_24_0_wire() {
let s = StubBackend::new();
let req = ChatRequest::default();
let chunk = s
.stream(req)
.await
.unwrap()
.next()
.await
.unwrap()
.unwrap();
assert_eq!(chunk.delta.as_bytes(), b"(stub)");
}
#[test]
fn stub_is_clone_send_sync() {
fn assert_traits<T: Clone + Send + Sync>() {}
assert_traits::<StubBackend>();
}
#[tokio::test]
async fn dyn_backend_dispatch_through_box() {
let b: Box<dyn Backend> = Box::new(StubBackend::new());
let req = ChatRequest::default();
let resp = b.complete(req).await.unwrap();
assert_eq!(resp.content, "(stub)");
}
}