// 流式示例:SSE 报文 → 录制(逐帧递给应用)→ 落盘 → 离线逐帧回放 → 成本汇总。
//
// 运行:
// moon run examples/streaming_demo --target js
//
// 本示例用 ScriptedHttpSender 代替真实网络,因此无需密钥、无需联网。
// 换成真实客户端时,只需要替换 `sender` 一个对象,其余代码一行都不用改。
//
// 它要展示的核心点:录制时应用是「逐块」拿到数据的,回放时也应该逐块拿到 ——
// 这样「逐块渲染」那段最容易出错的代码在离线测试里也会被真正跑到。
///|
let output_path : String = "examples/streaming.cassette.json"
///|
/// 一段真实形状的 OpenAI 流式响应(SSE)。
///
/// 包含:角色增量、两段文本增量、结束帧、以及只在最后一帧出现的 `usage`。
/// 最后一帧的 `choices` 是空数组 —— OpenAI 把用量放在一个没有内容的帧里。
fn openai_stream() -> String {
let chunks : Array[String] = [
"{\"id\":\"chatcmpl-stream\",\"object\":\"chat.completion.chunk\",\"created\":1677652288,\"model\":\"gpt-4o\",\"system_fingerprint\":\"fp_demo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}",
"{\"id\":\"chatcmpl-stream\",\"object\":\"chat.completion.chunk\",\"created\":1677652288,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"你好\"},\"finish_reason\":null}]}",
"{\"id\":\"chatcmpl-stream\",\"object\":\"chat.completion.chunk\",\"created\":1677652288,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!我是月兔助手。\"},\"finish_reason\":null}]}",
"{\"id\":\"chatcmpl-stream\",\"object\":\"chat.completion.chunk\",\"created\":1677652288,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}",
"{\"id\":\"chatcmpl-stream\",\"object\":\"chat.completion.chunk\",\"created\":1677652288,\"model\":\"gpt-4o\",\"choices\":[],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":5,\"total_tokens\":14}}",
]
let sb = StringBuilder::new()
for chunk in chunks {
sb.write_string("data: ")
sb.write_string(chunk)
sb.write_string("\n\n")
}
sb.write_string("data: [DONE]\n\n")
sb.to_string()
}
///|
fn chat_request(content : String) -> @core.Request {
let body : Map[String, Json] = Map([])
body.set("model", Json::string("gpt-4o"))
body.set("stream", Json::boolean(true))
let messages : Array[Json] = []
let message : Map[String, Json] = Map([])
message.set("role", Json::string("user"))
message.set("content", Json::string(content))
messages.push(Json::object(message))
body.set("messages", Json::array(messages))
@core.Request::new("openai", "gpt-4o", Json::object(body))
}
///|
fn main {
// ── 第一步:协议 + HTTP 执行器 ────────────────────────────────────
// api_key 只进入 HTTP 头,因此永远不会进入 cassette。
let protocol : &@providers.Protocol = @providers.OpenAiChat::new(
"sk-demo-key",
)
let sender = @providers.ScriptedHttpSender::new()
let request = chat_request("你好,请介绍一下自己")
let outgoing = protocol.encode(request) catch {
error => {
println("encode failed: " + error.to_string())
return
}
}
println("http request : " + outgoing.method_name() + " " + outgoing.url)
sender.on(outgoing, @providers.HttpResponse::new(200, openai_stream()))
// ── 第二步:录制,并逐帧递给应用 ──────────────────────────────────
// `replay_stream` 在三种模式下语义一致:先拿到响应,再重放其中的帧。
// 递帧的顺序就是它们在线上到达的顺序。
let transport = @providers.ProviderTransport::new(protocol, sender)
let session = @mooncassette.record_session(
@core.Cassette::new("streaming-demo", recorded_at="2026-09-12T11:00:00Z"),
transport,
)
let delivered : Array[String] = []
let response = session.replay_stream(request, fn(frame) {
let label = "chunk " + (delivered.length() + 1).to_string() + ": "
let event = match frame.event {
Some(name) => "event=" + name + " "
None => ""
}
println(" " + label + event + frame.data)
delivered.push(frame.data)
}) catch {
error => {
println("send failed: " + error.to_string())
return
}
}
println("http status : " + response.status.to_string())
println("frames : " + delivered.length().to_string())
println("answer : " + @canon.to_canonical_string(response.body))
// ── 第三步:落盘 + 成本汇总 ───────────────────────────────────────
let text = @mooncassette.save(session)
@fs.write_string_to_file(output_path, text) catch {
_ => println("failed to write " + output_path)
}
println("written : " + output_path)
// 价目表由使用方提供(单位:微美元 / 百万 token),库里刻意不内置价格。
let table = @cost.PriceTable::new()
table.set("gpt-4o", @cost.Price::new(2_500_000, 10_000_000))
println(
"cost : " +
@cost.estimate_cassette(table, session.cassette()).render(),
)
// ── 第四步:离线逐帧回放 ─────────────────────────────────────────
// 回放会话不持有任何 HttpSender,因此不可能发起网络调用。
let playback = @mooncassette.replay_session(text) catch {
error => {
println("load failed: " + error.to_string())
return
}
}
let replayed : Array[String] = []
let _ = playback.replay_stream(request, fn(frame) {
replayed.push(frame.data)
}) catch {
error => {
println("replay failed: " + error.to_string())
return
}
}
println("http calls : " + sender.call_count().to_string())
// 录制时递给应用的帧与回放时递给应用的帧必须逐帧一致,否则「回放」只是近似。
if replayed == delivered {
println(
"frames equal : yes (" + replayed.length().to_string() + " frames)",
)
} else {
println("frames equal : NO")
}
println("ok")
}