open-standards/eoc/eoc-rs/crates/eoc-streaming/src/stream.rs
Transaction Science 7ef14c2ea9 ingest wave B: 3 crates · op-payouts + smart-byte-nostr + eoc-streaming
OpenPay (1 crate, 2174 LOC, 14 tests)
  op-payouts          Multi-rail payout engine. Visa Direct, Mastercard Send,
                      ACH credits (NACHA), Fedwire + SWIFT MT103 + ISO 20022
                      pacs.008, SEPA SCT + SCT Inst (mod-97 IBAN), UK FPS,
                      FedNow, RTP, PayPal, Wise. Crypto: USDC/USDT on
                      ETH/Polygon/Base/Solana + BTC + Lightning with real
                      ERC-20 transfer(address,uint256) calldata. Offline-pure
                      drivers (emit wire payloads — operators transmit).

Smart Byte (1 crate, 2605 LOC, 38 tests)
  smart-byte-nostr    Nostr adapter. NIP-01 canonical event id + BIP-340
                      Schnorr sign/verify; from-scratch bech32 + NIP-19 TLV
                      (npub/nsec/note/nprofile/nevent/nrelay); NIP-04 legacy
                      AES-256-CBC DMs; NIP-44 v2 HKDF-SHA256→ChaCha20+HMAC
                      with bucketed padding; NIP-17 gift-wrap (kind 1059) +
                      seal (kind 13) + rumor (kind 14); NIP-05/11/13/26/65.
                      Relay client message vocabulary (REQ/EVENT/CLOSE/AUTH/
                      EOSE/OK/NOTICE/CLOSED) + in-memory router.

EOC (1 crate, 2024 LOC, 30 tests)
  eoc-streaming       Token-by-token + tool-call streaming primitives.
                      SSE framing (data: lines); Anthropic event mapper
                      (message_start/content_block_*/message_delta/stop/ping);
                      OpenAI delta mapper; Vercel AI SDK mapper. Bounded-channel
                      backpressure with watermarks; CancelToken propagation;
                      Last-Event-ID resumable streams; per-stream joule meter.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 13:39:09 -04:00

224 lines
6.6 KiB
Rust

//! The normalized event vocabulary every provider mapper targets.
//!
//! [`Event`] is the lingua franca: providers emit framed bytes, mappers
//! turn those into [`Event`]s, and downstream consumers (cascade,
//! agent, HTTP/WS bridge) only ever see [`Event`]. The [`TokenStream`]
//! is a thin wrapper around a bounded MPSC channel so the sink end can
//! exert backpressure and the receiver end can poll like any other
//! tokio stream.
use serde::{Deserialize, Serialize};
use crate::error::{StreamError, StreamResult};
/// Speaker role for a content block.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
/// System / instruction author.
System,
/// End user.
User,
/// Model output.
Assistant,
/// Tool / function response.
Tool,
}
/// Why the stream stopped.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FinishReason {
/// Model produced an end-of-message token.
EndTurn,
/// Model hit `max_tokens` / `max_output_tokens`.
MaxTokens,
/// Model emitted a tool call and is awaiting a response.
ToolUse,
/// Content was filtered by safety policy.
ContentFilter,
/// The stream was cancelled.
Cancelled,
/// Provider-specific reason.
Other(String),
}
/// Normalized stream event.
///
/// Every provider mapper produces zero or more of these per upstream
/// frame. Consumers should treat unknown fields as forward-compatible.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Event {
/// The model started a new message.
MessageStart {
/// Optional message id (Anthropic, OpenAI both expose one).
id: Option<String>,
/// Speaker role for the message.
role: Role,
},
/// A token-level text delta.
TextDelta {
/// Index of the content block this delta belongs to.
index: u32,
/// The new text chunk.
delta: String,
},
/// A tool/function call was emitted by the model.
ToolCallStart {
/// Index of the content block.
index: u32,
/// Provider-issued call id.
id: String,
/// Tool name.
name: String,
},
/// Streaming arguments for an in-progress tool call.
ToolCallDelta {
/// Index of the content block.
index: u32,
/// Provider-issued call id.
id: String,
/// Argument JSON fragment.
arguments_delta: String,
},
/// A tool call is complete.
ToolCallEnd {
/// Index of the content block.
index: u32,
/// Provider-issued call id.
id: String,
},
/// A tool-execution result was injected into the stream.
ToolResult {
/// Provider-issued call id this result satisfies.
id: String,
/// Result payload (opaque JSON-as-string).
result: String,
},
/// A content block (text or tool call) closed.
ContentBlockStop {
/// Index of the content block that just closed.
index: u32,
},
/// Provider liveness ping (Anthropic sends these every ~15s).
Ping,
/// The stream is finishing.
MessageDelta {
/// Optional partial reason — Anthropic emits this before stop.
stop_reason: Option<FinishReason>,
},
/// Final event — the model has stopped emitting.
MessageStop {
/// Why the stream stopped.
reason: FinishReason,
},
/// Provider raised an error mid-stream.
Error {
/// Provider-supplied error code.
code: String,
/// Human-readable message.
message: String,
},
}
/// Anything that can absorb [`Event`]s.
///
/// Implementors decide whether to block, error on backpressure, or drop.
#[async_trait::async_trait]
pub trait Sink: Send + Sync {
/// Send an event. Returns `Closed` if the sink is shut down.
async fn send(&self, event: Event) -> StreamResult<()>;
}
/// A bounded MPSC stream of normalized events.
///
/// Construct with [`TokenStream::bounded`]; clone the sink and hand it
/// to mappers; consume via [`TokenStream::recv`].
#[derive(Debug)]
pub struct TokenStream {
rx: tokio::sync::mpsc::Receiver<Event>,
tx: tokio::sync::mpsc::Sender<Event>,
}
impl TokenStream {
/// Construct a bounded stream with the given capacity.
pub fn bounded(capacity: usize) -> Self {
let cap = capacity.max(1);
let (tx, rx) = tokio::sync::mpsc::channel(cap);
Self { rx, tx }
}
/// Clone a [`Sink`] handle for this stream.
pub fn sink(&self) -> StreamSink {
StreamSink {
tx: self.tx.clone(),
}
}
/// Receive the next event, or `None` once all sinks are dropped.
pub async fn recv(&mut self) -> Option<Event> {
self.rx.recv().await
}
/// Close the receive end. Any further sends will fail with `Closed`.
pub fn close(&mut self) {
self.rx.close();
}
}
/// A clonable sink handle returned by [`TokenStream::sink`].
#[derive(Clone, Debug)]
pub struct StreamSink {
tx: tokio::sync::mpsc::Sender<Event>,
}
impl StreamSink {
/// Non-blocking send. Returns `Backpressure` if the channel is full.
pub fn try_send(&self, event: Event) -> StreamResult<()> {
use tokio::sync::mpsc::error::TrySendError;
self.tx.try_send(event).map_err(|e| match e {
TrySendError::Full(_) => StreamError::Backpressure,
TrySendError::Closed(_) => StreamError::Closed,
})
}
}
#[async_trait::async_trait]
impl Sink for StreamSink {
async fn send(&self, event: Event) -> StreamResult<()> {
self.tx
.send(event)
.await
.map_err(|_| StreamError::Closed)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn roundtrip_text_delta() {
let mut s = TokenStream::bounded(4);
let sink = s.sink();
sink.send(Event::MessageStart {
id: Some("m1".into()),
role: Role::Assistant,
})
.await
.expect("send start");
sink.send(Event::TextDelta {
index: 0,
delta: "hi".into(),
})
.await
.expect("send delta");
assert!(matches!(s.recv().await, Some(Event::MessageStart { .. })));
assert!(matches!(s.recv().await, Some(Event::TextDelta { .. })));
// Closing the receiver forces `recv` to drain to `None` without
// requiring every cloned sender to be dropped.
s.close();
assert!(s.recv().await.is_none());
}
}