From 896d6e99ca2a9cfddc7aff0acbdb7e4f57669bec Mon Sep 17 00:00:00 2001 From: onn Date: Sun, 25 Oct 2026 00:20:40 +0800 Subject: [PATCH 01/16] fix(provider): propagate OpenAI Responses stream failures - Handle error and response.failed events as terminal failures - Classify exhausted credits as quota errors instead of retryable failures - Preserve partial output without reporting successful completion - Split Responses handling into focused modules - Add provider, server replay, and TUI quota reporting regression tests - Document conversation errors and quota recovery --- .../assistant-message-quota.test.ts | 55 + .../src/native-to-pi/quota-exceeded.test.ts | 162 ++ crates/provider/src/openai/error_payload.rs | 52 + crates/provider/src/openai/responses.rs | 1429 +---------------- .../provider/src/openai/responses/request.rs | 193 +++ .../provider/src/openai/responses/response.rs | 278 ++++ .../provider/src/openai/responses/stream.rs | 494 ++++++ .../src/openai/responses/stream_error.rs | 41 + .../src/openai/responses/stream_tools.rs | 51 + crates/provider/src/openai/responses/tests.rs | 437 +++++ .../provider/tests/openai_responses_errors.rs | 626 ++++++++ .../server/tests/provider_quota_reporting.rs | 374 +++++ docs/usage.md | 14 + 13 files changed, 2793 insertions(+), 1413 deletions(-) create mode 100644 apps/tui/lib/coding-agent/src/modes/interactive/components/assistant-message-quota.test.ts create mode 100644 apps/tui/src/native-to-pi/quota-exceeded.test.ts create mode 100644 crates/provider/src/openai/responses/request.rs create mode 100644 crates/provider/src/openai/responses/response.rs create mode 100644 crates/provider/src/openai/responses/stream.rs create mode 100644 crates/provider/src/openai/responses/stream_error.rs create mode 100644 crates/provider/src/openai/responses/stream_tools.rs create mode 100644 crates/provider/src/openai/responses/tests.rs create mode 100644 crates/provider/tests/openai_responses_errors.rs create mode 100644 crates/server/tests/provider_quota_reporting.rs diff --git a/apps/tui/lib/coding-agent/src/modes/interactive/components/assistant-message-quota.test.ts b/apps/tui/lib/coding-agent/src/modes/interactive/components/assistant-message-quota.test.ts new file mode 100644 index 00000000..dba90682 --- /dev/null +++ b/apps/tui/lib/coding-agent/src/modes/interactive/components/assistant-message-quota.test.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { stripVTControlCharacters } from "node:util"; +import type { AssistantMessage } from "@earendil-works/pi-ai"; +import { initTheme } from "../theme/theme.js"; +import { AssistantMessageComponent } from "./assistant-message.js"; + +initTheme("dark"); + +const quotaMessage = "You have no credits remaining. Please add credits to continue."; + +for (const text of ["", "The answer so far."]) { + test(`quota Error stays singular through streaming, redraw, and restore (${text ? "text" : "empty"})`, () => { + const streaming: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text }], + api: "openai-responses", + provider: "mock", + model: "quota-test", + timestamp: 1, + stopReason: "stop", + usage: { + input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }; + const failed: AssistantMessage = { ...streaming, stopReason: "error", errorMessage: quotaMessage }; + const component = new AssistantMessageComponent(); + const visibleLines = () => component.render(160) + .map((line) => stripVTControlCharacters(line).trim()).filter(Boolean); + const body = text ? [text] : []; + const finalLines = [...body, `Error: ${quotaMessage}`]; + + component.updateContent(streaming, true); + assert.deepEqual(visibleLines(), body); + component.updateContent(failed, false); + assert.deepEqual(visibleLines(), finalLines); + assert.equal(component.getSpacingContent(), "visible"); + // Stable signatures reuse child components; invalidation rebuilds them. + component.updateContent(failed, false); + assert.deepEqual(visibleLines(), finalLines); + component.invalidate(); + assert.deepEqual(visibleLines(), finalLines); + component.setExpanded(true); + assert.deepEqual(visibleLines(), finalLines); + component.setExpanded(false); + assert.deepEqual(visibleLines(), finalLines); + + const restored = new AssistantMessageComponent(failed); + assert.deepEqual(restored.render(160).map((line) => stripVTControlCharacters(line).trim()).filter(Boolean), finalLines); + // Updating the same row to a later successful message must clear stale Error chrome. + component.updateContent(streaming, false); + assert.deepEqual(visibleLines(), body); + }); +} diff --git a/apps/tui/src/native-to-pi/quota-exceeded.test.ts b/apps/tui/src/native-to-pi/quota-exceeded.test.ts new file mode 100644 index 00000000..0bb964a7 --- /dev/null +++ b/apps/tui/src/native-to-pi/quota-exceeded.test.ts @@ -0,0 +1,162 @@ +/** + * Trace: L2-DES-APP-010 + * Verifies: quota failures survive Native projection, rendering, and restore. + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { stripVTControlCharacters } from "node:util"; +import type { AssistantMessage } from "@earendil-works/pi-ai"; +import { AssistantMessageComponent } from "../../lib/coding-agent/src/modes/interactive/components/assistant-message.js"; +import { initTheme } from "../../lib/coding-agent/src/modes/interactive/theme/theme.js"; +import { NativeAgentConnection } from "../native-agent-connection.js"; +import { createStreamState, nativeNotificationToPiEvents } from "./events.js"; +import { applyFailedTurnsToAgentMessages, nativeItemsToAgentMessages } from "./messages.js"; + +initTheme("dark"); + +const quotaMessage = "You have no credits remaining. Please add credits to continue."; +const partialText = "The answer so far."; +const completedAt = "2026-10-24T00:00:00.000Z"; +const failedTurn = { + id: "turn-quota", + status: "failed", + error: { errorCode: "QUOTA_EXCEEDED_ERROR", message: quotaMessage }, + completedAt, +}; +const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 }; + +function renderedLines(messages: Array>): string[] { + return messages.flatMap((message) => + new AssistantMessageComponent(message as unknown as AssistantMessage) + .render(160) + .map((line) => stripVTControlCharacters(line).trim()) + .filter(Boolean), + ); +} + +for (const textState of ["no tokens", "streaming text", "completed text"] as const) { + for (const statusNotification of ["absent", "before completion", "after completion"] as const) { + test(`quota failure: ${textState}, failed status ${statusNotification}`, () => { + const state = createStreamState(); + assert.deepEqual(nativeNotificationToPiEvents("turn/started", { turn: { id: failedTurn.id } }, state), [ + { type: "agent_start" }, + ]); + const endedMessages: Array> = []; + if (textState !== "no tokens") { + nativeNotificationToPiEvents("item/assistantMessage/delta", { itemId: "a1", delta: partialText }, state); + if (textState === "completed text") { + const itemEvents = nativeNotificationToPiEvents("item/completed", { + id: "a1", item: { type: "assistantMessage", text: partialText }, + }, state); + endedMessages.push(...itemEvents.filter((event) => event.type === "message_end") + .map((event) => event.message as Record)); + } + } + if (statusNotification === "before completion") { + assert.deepEqual(nativeNotificationToPiEvents("turn/statusChanged", { + turnId: failedTurn.id, status: "failed", + }, state), [{ type: "agent_end" }]); + } + + const events = nativeNotificationToPiEvents("turn/completed", { turn: failedTurn }, state); + const message = events[0]?.message as Record; + assert.equal(typeof message?.timestamp, "number"); + assert.deepEqual(events, [ + { + type: "message_end", + message: { + role: "assistant", + content: [{ type: "text", text: textState === "streaming text" ? partialText : "" }], + timestamp: message.timestamp, + stopReason: "error", + errorMessage: quotaMessage, + usage, + }, + }, + { type: "agent_end" }, + ]); + endedMessages.push(message); + assert.deepEqual(renderedLines(endedMessages), [ + ...(textState === "no tokens" ? [] : [partialText]), + `Error: ${quotaMessage}`, + ]); + assert.deepEqual(state, { + ...createStreamState(), + assistantTextByItem: new Map(textState === "streaming text" ? [["a1", partialText]] : []), + }); + if (statusNotification === "after completion") { + assert.deepEqual(nativeNotificationToPiEvents("turn/statusChanged", { + turnId: failedTurn.id, status: "failed", + }, state), [{ type: "agent_end" }]); + } + // The next turn must get a new stream, not reuse the failed one. + assert.deepEqual(nativeNotificationToPiEvents("turn/started", { turn: { id: "next-turn" } }, state), [ + { type: "agent_start" }, + ]); + const nextEvents = nativeNotificationToPiEvents("item/assistantMessage/delta", { + itemId: "a2", delta: "Next answer.", + }, state); + assert.deepEqual(nextEvents.map((event) => event.type), ["message_start", "message_update"]); + assert.deepEqual(renderedLines([nextEvents[1].message as Record]), ["Next answer."]); + }); + } +} + +for (const text of ["", partialText]) { + test(`quota failure ends connection activity with ${text ? "existing text" : "zero text"}`, async () => { + const connection = NativeAgentConnection.create({ writeLine: () => assert.fail("unexpected RPC") }); + const events: Array> = []; + const unsubscribe = connection.subscribe((event) => { + if (event.type === "session_event") events.push(event.event as unknown as Record); + }); + try { + const initialState = await connection.getState(); + connection.pushChunk(`${JSON.stringify({ jsonrpc: "2.0", method: "turn/started", params: { + turn: { id: failedTurn.id }, + } })}\n`); + assert.deepEqual(await connection.getState(), { ...initialState, isStreaming: true }); + if (text) { + connection.pushChunk(`${JSON.stringify({ jsonrpc: "2.0", method: "item/assistantMessage/delta", + params: { itemId: "a1", delta: text }, + })}\n`); + } + connection.pushChunk(`${JSON.stringify({ jsonrpc: "2.0", method: "turn/completed", + params: { turn: failedTurn }, + })}\n`); + assert.deepEqual(await connection.getState(), initialState); + const terminalEvents = events.filter((event) => event.type === "message_end" || event.type === "agent_end"); + assert.deepEqual(terminalEvents.map((event) => event.type), ["message_end", "agent_end"]); + assert.deepEqual(renderedLines([terminalEvents[0].message as Record]), [ + ...(text ? [text] : []), `Error: ${quotaMessage}`, + ]); + } finally { + unsubscribe(); + connection.dispose(); + } + }); + + for (const durableWarning of ["present", "absent"] as const) { + test(`quota failure restore: ${text ? "existing text" : "zero text"}, warning ${durableWarning}`, () => { + const timestamp = Date.parse(completedAt); + const items = [ + ...(text ? [{ id: "a1", createdAt: completedAt, item: { type: "assistantMessage", text } }] : []), + ...(durableWarning === "present" ? [{ + id: "quota-warning", createdAt: completedAt, + item: { type: "warning", code: failedTurn.error.errorCode, message: quotaMessage }, + }] : []), + ]; + const restored = applyFailedTurnsToAgentMessages(nativeItemsToAgentMessages(items), [failedTurn]); + const expected: Array> = durableWarning === "present" && text ? [{ + role: "assistant", content: [{ type: "text", text }], timestamp, + stopReason: null, errorMessage: null, usage, + }] : []; + expected.push({ + role: "assistant", content: [{ type: "text", text: durableWarning === "present" ? "" : text }], + timestamp, stopReason: "error", errorMessage: quotaMessage, usage, + }); + assert.deepEqual(restored, expected); + assert.deepEqual(renderedLines(restored), [...(text ? [text] : []), `Error: ${quotaMessage}`]); + assert.deepEqual(applyFailedTurnsToAgentMessages(restored, [failedTurn]), restored); + }); + } +} diff --git a/crates/provider/src/openai/error_payload.rs b/crates/provider/src/openai/error_payload.rs index b9a72436..76f8caa1 100644 --- a/crates/provider/src/openai/error_payload.rs +++ b/crates/provider/src/openai/error_payload.rs @@ -40,6 +40,24 @@ pub(super) fn provider_error_from_payload( return Some(error); } + // A depleted balance is not a transient rate limit, even when the + // payload also carries numeric code 429. Keep this distinct from authentication. + if [ + payload.kind.as_deref(), + payload.code.as_ref().and_then(Value::as_str), + ] + .into_iter() + .flatten() + .any(|value| { + value.eq_ignore_ascii_case("insufficient_quota") + || value.eq_ignore_ascii_case("credit_balance_exhausted") + }) { + return Some(ProviderError::QuotaExceededError { + message, + provider_name, + }); + } + Some(match status_code { Some(401 | 403) => ProviderError::AuthenticationError { message, @@ -223,4 +241,38 @@ mod tests { }) ); } + + #[test] + fn quota_metadata_takes_precedence_over_retryable_status_codes() { + let request = ModelRequest { + model_slug: ModelProfileKey::Generic, + model: "provider-model".to_string(), + system: None, + messages: Vec::new(), + max_tokens: 16, + tools: None, + hosted_tools: Vec::new(), + sampling: Default::default(), + request_thinking: None, + reasoning_effort: None, + extra_body: None, + }; + let expected = json!({ + "error_kind": "quota_exceeded_error", + "message": "No credits remain", + "provider_name": "openai", + }); + for payload in [ + json!({"message": "No credits remain", "type": "insufficient_quota"}), + json!({"message": "No credits remain", "code": "credit_balance_exhausted"}), + json!({"message": "No credits remain", "type": "insufficient_quota", "code": 429}), + ] { + let error = provider_error_from_payload(&json!({"error": payload}), &request) + .expect("quota provider error"); + assert_eq!( + serde_json::to_value(error).expect("serialize quota error"), + expected + ); + } + } } diff --git a/crates/provider/src/openai/responses.rs b/crates/provider/src/openai/responses.rs index 5405af05..4b63a15a 100644 --- a/crates/provider/src/openai/responses.rs +++ b/crates/provider/src/openai/responses.rs @@ -1,29 +1,27 @@ -use std::{collections::HashMap, pin::Pin}; +use std::pin::Pin; use anyhow::Result; use async_trait::async_trait; -use devo_protocol::{ - ModelRequest, ModelResponse, RequestContent, ResponseContent, ResponseExtra, ResponseMetadata, - StopReason, StreamEvent, Usage, -}; -use futures::Stream; -use futures::StreamExt; +use devo_protocol::{ModelRequest, ModelResponse, StreamEvent}; +use futures::{Stream, StreamExt}; use reqwest::Client; use reqwest::header::{AUTHORIZATION, CONTENT_TYPE}; -use serde_json::{Value, json}; +use serde_json::Value; use tracing::debug; use crate::error::stream_error; -use crate::hosted_tools::append_openai_responses_hosted_tools; use crate::http::invalid_status_error; -use crate::text_normalization::{TaggedTextFragment, TaggedTextParser, split_tagged_text}; -use crate::{ModelProviderSDK, ProviderHttpOptions, merge_extra_body}; +use crate::{ModelProviderSDK, ProviderHttpOptions}; -use super::capabilities::{OpenAITransport, resolve_request_profile}; -use super::{ - OpenAIRole, - shared::{request_role, responses_tool_definitions}, -}; +mod request; +mod response; +mod stream; +mod stream_error; +mod stream_tools; +#[cfg(test)] +mod tests; + +use request::build_request; /// OpenAI Responses API provider. /// @@ -93,64 +91,6 @@ impl OpenAIResponsesProvider { } } -/// Builds the exact OpenAI Responses request body used by this provider. -fn build_request(request: &ModelRequest, stream: bool) -> Value { - let profile = resolve_request_profile(&request.model_slug, OpenAITransport::Responses); - // `max_output_tokens` is intentionally omitted: codex-style backends - // reject the parameter outright, and the Responses API already treats it - // as optional (the model default applies when unset). - let mut root = json!({ - "model": request.model, - "input": build_input(request), - "stream": stream, - }); - - if let Some(system) = &request.system { - // The Responses wire carries the system prompt in `instructions`; - // codex-style backends reject system-role input items outright. - root["instructions"] = json!(system); - } - - if let Some(tools) = &request.tools { - root["tools"] = responses_tool_definitions(tools); - } - - if profile.supports_temperature - && let Some(temperature) = request.sampling.temperature - { - root["temperature"] = json!(temperature); - } - - if profile.supports_top_p - && let Some(top_p) = request.sampling.top_p - { - root["top_p"] = json!(top_p); - } - - if profile.supports_top_k - && let Some(top_k) = request.sampling.top_k - { - root["top_k"] = json!(top_k); - } - - if let Some(reasoning) = request.reasoning_effort { - // "auto" keeps codex-style backends streaming reasoning summaries; - // it is the documented default elsewhere, so it is safe to always set. - root["reasoning"] = json!({ "effort": reasoning, "summary": "auto" }); - } - - // Responses backends derive usage from the terminal event; the codex - // backend additionally rejects chat-completions-style `stream_options` - // and requires stateless requests (no server-side response storage). - root["store"] = json!(false); - - append_openai_responses_hosted_tools(&mut root, &request.hosted_tools); - - merge_extra_body(&mut root, request.extra_body.as_ref()); - - root -} - /// Folds a [`StreamEvent`] stream into a single [`ModelResponse`] for the /// non-streaming [`ModelProviderSDK::completion`] surface. async fn fold_stream_response( @@ -169,398 +109,6 @@ async fn fold_stream_response( }) } -fn build_input(request: &ModelRequest) -> Vec { - let mut input = Vec::with_capacity(request.messages.len()); - - for message in &request.messages { - let role = request_role(&message.role); - append_input_items(&mut input, role, &message.content); - } - - input -} - -fn append_input_items(input: &mut Vec, role: OpenAIRole, content: &[RequestContent]) { - let mut message_blocks = Vec::new(); - let flush_message = |input: &mut Vec, blocks: &mut Vec| { - if blocks.is_empty() { - return; - } - input.push(json!({ - "type": "message", - "role": role, - "content": std::mem::take(blocks), - })); - }; - - for block in content { - match block { - RequestContent::Text { text } => { - // Responses message content: user/system use input_text; - // assistant replay uses output_text (DeepSeek/OpenAI). - let block_type = match role { - OpenAIRole::Assistant => "output_text", - _ => "input_text", - }; - message_blocks.push(json!({ - "type": block_type, - "text": text, - })); - } - RequestContent::Reasoning { .. } => { - // Message content only accepts input_text / output_text / - // input_image / input_file. Nested `type: "reasoning"` causes - // 400s on DeepSeek (and is not valid OpenAI message content). - // Prior reasoning is omitted unless carried as a provider - // reasoning item with a stable id (ProviderReasoning). - } - RequestContent::ProviderReasoning { .. } => {} - RequestContent::Image { - mime_type, - data_base64, - } => { - message_blocks.push(json!({ - "type": "input_image", - "image_url": format!("data:{mime_type};base64,{data_base64}"), - })); - } - RequestContent::ToolUse { - id, - name, - input: args, - } => { - flush_message(input, &mut message_blocks); - input.push(json!({ - "type": "function_call", - "call_id": id, - "name": name, - "arguments": serde_json::to_string(args).unwrap_or_else(|_| "{}".to_string()), - })); - } - RequestContent::ToolResult { - tool_use_id, - content, - is_error: _, - } => { - flush_message(input, &mut message_blocks); - input.push(json!({ - "type": "function_call_output", - "call_id": tool_use_id, - "output": content, - })); - } - RequestContent::HostedToolUse { - id, - name, - input: hosted_input, - output, - status, - } => { - flush_message(input, &mut message_blocks); - // DeepSeek / OpenAI Responses expect hosted calls as top-level - // items that are passed back as-is (not nested message content). - let item_type = match name.as_str() { - "web_search" => "web_search_call", - "web_fetch" => "web_fetch_call", - other => other, - }; - let mut item = json!({ - "type": item_type, - "id": id, - "status": status.clone().unwrap_or_else(|| "completed".to_string()), - }); - if name == "web_search" { - if let Some(query) = hosted_input.get("query") { - item["action"] = json!({ - "type": "search", - "query": query, - }); - } else if let Some(action) = hosted_input.get("action") { - item["action"] = action.clone(); - } - } else if !hosted_input.is_null() { - item["action"] = hosted_input.clone(); - } - if let Some(output) = output { - item["output"] = output.clone(); - } - input.push(item); - } - } - } - - flush_message(input, &mut message_blocks); -} - -fn parse_response(value: Value) -> Result { - let id = value - .get("id") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); - let mut content = Vec::new(); - let mut metadata = ResponseMetadata::default(); - - if let Some(output) = value.get("output").and_then(Value::as_array) { - for item in output { - if let Some(reasoning_content) = item.get("reasoning_content").and_then(Value::as_str) { - metadata.extras.push(ResponseExtra::ReasoningText { - text: reasoning_content.to_string(), - }); - } - if matches!(item.get("type").and_then(Value::as_str), Some("message")) { - if let Some(items) = item.get("content").and_then(Value::as_array) { - for message_item in items { - if let Some(text) = message_item.get("text").and_then(Value::as_str) { - let (assistant_text, reasoning) = split_tagged_text(text); - for text in reasoning { - if !text.is_empty() { - metadata.extras.push(ResponseExtra::ReasoningText { text }); - } - } - if !assistant_text.is_empty() { - content.push(ResponseContent::Text(assistant_text)); - } - continue; - } - if let Some(parsed) = parse_message_content(message_item) { - content.push(parsed); - } - } - } - continue; - } - content.extend(parse_output_item(item)); - } - } - - let stop_reason = value - .get("status") - .and_then(Value::as_str) - .map(parse_status_reason) - .or_else(|| { - value - .get("incomplete") - .and_then(|item| item.get("reason")) - .and_then(Value::as_str) - .map(parse_status_reason) - }); - - let usage = value.get("usage").and_then(parse_usage).unwrap_or_default(); - - Ok(ModelResponse { - id, - content, - stop_reason, - usage, - metadata, - }) -} - -fn parse_output_item(item: &Value) -> Vec { - match item.get("type").and_then(Value::as_str) { - Some("message") => item - .get("content") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(parse_message_content) - .collect(), - Some("function_call") | Some("tool_call") => { - let id = item - .get("call_id") - .or_else(|| item.get("id")) - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); - let name = item - .get("name") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); - let input = parse_function_call_arguments(item); - vec![ResponseContent::ToolUse { id, name, input }] - } - Some("web_search_call") => vec![parse_hosted_web_search_call(item)], - Some("web_fetch_call") => vec![parse_hosted_web_fetch_call(item)], - Some("reasoning") => Vec::new(), - _ => Vec::new(), - } -} - -fn parse_message_content(item: &Value) -> Option { - match item.get("type").and_then(Value::as_str) { - Some("output_text") | Some("text") | Some("input_text") => { - let assistant_text = - split_tagged_text(item.get("text").and_then(Value::as_str).unwrap_or_default()).0; - if assistant_text.is_empty() { - None - } else { - Some(ResponseContent::Text(assistant_text)) - } - } - Some("tool_call") | Some("function_call") => Some(ResponseContent::ToolUse { - id: item - .get("call_id") - .or_else(|| item.get("id")) - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(), - name: item - .get("name") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(), - input: parse_function_call_arguments(item), - }), - Some("web_search_call") => Some(parse_hosted_web_search_call(item)), - Some("web_fetch_call") => Some(parse_hosted_web_fetch_call(item)), - _ => None, - } -} - -fn parse_function_call_arguments(item: &Value) -> Value { - match item.get("arguments").or_else(|| item.get("input")) { - Some(Value::String(arguments)) => parse_function_call_arguments_json(arguments), - Some(Value::Null) | None => Value::Object(serde_json::Map::new()), - Some(value) => value.clone(), - } -} - -fn function_call_arguments_json(item: &Value) -> String { - match item.get("arguments").or_else(|| item.get("input")) { - Some(Value::String(arguments)) => arguments.clone(), - Some(Value::Null) | None => String::new(), - Some(value) => value.to_string(), - } -} - -fn parse_function_call_arguments_json(arguments_json: &str) -> Value { - if arguments_json.trim().is_empty() { - return Value::Object(serde_json::Map::new()); - } - serde_json::from_str(arguments_json).unwrap_or_else(|_| Value::Object(serde_json::Map::new())) -} - -fn hosted_tool_call_id(item: &Value) -> String { - item.get("call_id") - .or_else(|| item.get("id")) - .and_then(Value::as_str) - .unwrap_or_default() - .to_string() -} - -fn hosted_tool_status(item: &Value) -> Option { - item.get("status") - .and_then(Value::as_str) - .map(ToString::to_string) -} - -fn hosted_tool_output(item: &Value) -> Option { - item.get("output") - .or_else(|| item.get("results")) - .or_else(|| item.get("result")) - .or_else(|| item.get("content")) - .cloned() -} - -fn parse_hosted_tool_call(item: &Value, name: &str, input: Value) -> ResponseContent { - ResponseContent::HostedToolUse { - id: hosted_tool_call_id(item), - name: name.to_string(), - input, - output: hosted_tool_output(item), - status: hosted_tool_status(item), - } -} - -fn parse_hosted_web_search_call(item: &Value) -> ResponseContent { - parse_hosted_tool_call(item, "web_search", hosted_web_search_input(item)) -} - -fn hosted_web_search_input(item: &Value) -> Value { - if let Some(query) = item - .get("action") - .and_then(|action| action.get("query")) - .and_then(Value::as_str) - { - return json!({ "query": query }); - } - if let Some(query) = item.get("query").and_then(Value::as_str) { - return json!({ "query": query }); - } - item.get("action") - .cloned() - .unwrap_or_else(|| Value::Object(serde_json::Map::new())) -} - -fn parse_hosted_web_fetch_call(item: &Value) -> ResponseContent { - parse_hosted_tool_call(item, "web_fetch", hosted_web_fetch_input(item)) -} - -fn hosted_web_fetch_input(item: &Value) -> Value { - if let Some(url) = item - .get("action") - .and_then(|action| action.get("url")) - .and_then(Value::as_str) - { - return json!({ "url": url }); - } - if let Some(url) = item.get("url").and_then(Value::as_str) { - return json!({ "url": url }); - } - if let Some(url) = item - .get("input") - .and_then(|input| input.get("url")) - .and_then(Value::as_str) - { - return json!({ "url": url }); - } - item.get("action") - .cloned() - .unwrap_or_else(|| Value::Object(serde_json::Map::new())) -} - -fn parse_usage(value: &Value) -> Option { - Some(Usage { - input_tokens: value - .get("input_tokens") - .or_else(|| value.get("prompt_tokens")) - .and_then(Value::as_u64)? as usize, - output_tokens: value - .get("output_tokens") - .or_else(|| value.get("completion_tokens")) - .and_then(Value::as_u64)? as usize, - cache_creation_input_tokens: None, - cache_read_input_tokens: value - .pointer("/input_tokens_details/cached_tokens") - .or_else(|| value.pointer("/prompt_tokens_details/cached_tokens")) - .or_else(|| value.get("cache_read_input_tokens")) - .and_then(Value::as_u64) - .map(|tokens| tokens as usize), - reasoning_output_tokens: value - .get("output_tokens_details") - .or_else(|| value.get("completion_tokens_details")) - .and_then(|details| details.get("reasoning_tokens")) - .and_then(Value::as_u64) - .map(|tokens| tokens as usize), - total_tokens: value - .get("total_tokens") - .and_then(Value::as_u64) - .map(|tokens| tokens as usize), - }) -} - -fn parse_status_reason(value: &str) -> StopReason { - match value { - "completed" | "stop" | "end_turn" => StopReason::EndTurn, - "incomplete" | "max_output_tokens" | "length" => StopReason::MaxTokens, - "tool_use" | "tool_calls" => StopReason::ToolUse, - "stop_sequence" | "content_filter" => StopReason::StopSequence, - _ => StopReason::EndTurn, - } -} - #[async_trait] impl ModelProviderSDK for OpenAIResponsesProvider { async fn completion(&self, request: ModelRequest) -> Result { @@ -575,7 +123,7 @@ impl ModelProviderSDK for OpenAIResponsesProvider { &self, request: ModelRequest, ) -> Result> + Send>>> { - let body = build_request(&request, true); + let body = build_request(&request, /*stream*/ true); debug!( provider = "openai-responses", api_base = %self.base_url, @@ -610,955 +158,10 @@ impl ModelProviderSDK for OpenAIResponsesProvider { ) .await); } - let messages = crate::sse::decode_sse(response.bytes_stream()); - let stream = async_stream::try_stream! { - let mut text_buf = String::new(); - let mut reasoning_buf = String::new(); - let mut text_parser = TaggedTextParser::default(); - let mut response_id = String::new(); - let mut tool_calls: HashMap = HashMap::new(); - let mut tool_call_keys_by_item_id: HashMap = HashMap::new(); - let mut hosted_tool_calls: HashMap = HashMap::new(); - let mut usage: Option = None; - let mut reasoning_started = false; - // Item id of the reasoning block currently being streamed. The - // boundary between summary parts must only fire *within* one - // reasoning item: backends that run hosted tools mid-stream emit - // further reasoning items afterwards, and a cross-item boundary - // would surface as an orphan leading "\n\n" on the next durable - // reasoning item (the previous one was already closed when the - // tool call started). - let mut reasoning_item_id: Option = None; - // True while the most recently emitted stream event is reasoning - // text. A summary-part boundary must glue two parts of the same - // paragraph together, but must never lead a fresh reasoning block - // that follows a tool call or assistant text — the consumer closes - // the reasoning item there, so a leading "\n\n" would orphan-start - // the next block. - let mut reasoning_active = false; - let mut text_started = false; - - futures::pin_mut!(messages); - loop { - let message = match messages.next().await { - Some(Ok(message)) => message, - Some(Err(error)) => Err(stream_error(format!( - "openai responses stream error for model {}: {error}", - request.model - )))?, - None => break, - }; - - { - if message.data == "[DONE]" { - break; - } - - let chunk: Value = serde_json::from_str(&message.data) - .map_err(|error| anyhow::anyhow!("failed to parse openai responses stream chunk: {error}"))?; - - if response_id.is_empty() { - response_id = chunk - .get("id") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); - } - - if let Some(parsed_usage) = chunk.get("usage").and_then(parse_usage) { - usage = Some(parsed_usage.clone()); - yield StreamEvent::UsageDelta(parsed_usage); - } - - match message.event.as_str() { - "response.output_text.delta" => { - let delta = chunk - .get("delta") - .and_then(Value::as_str) - .or_else(|| chunk.get("text").and_then(Value::as_str)) - .unwrap_or_default(); - if !delta.is_empty() { - for fragment in text_parser.consume(delta) { - match fragment { - TaggedTextFragment::Text(text) => { - if text.is_empty() { - continue; - } - if !text_started { - text_started = true; - yield StreamEvent::TextStart { index: 0 }; - } - text_buf.push_str(&text); - reasoning_active = false; - yield StreamEvent::TextDelta { index: 0, text }; - } - TaggedTextFragment::Reasoning(text) => { - if text.is_empty() { - continue; - } - if !reasoning_started { - reasoning_started = true; - yield StreamEvent::ReasoningStart { index: 1 }; - } - reasoning_buf.push_str(&text); - reasoning_active = true; - yield StreamEvent::ReasoningDelta { index: 1, text }; - } - } - } - } - } - "response.reasoning_summary_part.added" => { - // OpenAI Responses streams each thinking summary - // as its own part. Without an explicit boundary - // consecutive parts fuse into one run-on - // paragraph ("…words****Inserting…" in the - // journal), so re-emit the boundary as a delta: - // every consumer — live TUI stream and the - // durable reasoning item — then renders the - // parts as separate paragraphs. The boundary - // only applies within the same reasoning item: - // a part with a new item id starts a fresh - // block, not a continuation of the previous one. - let part_item_id = - chunk.get("item_id").and_then(Value::as_str).map(str::to_string); - let same_item = match (&part_item_id, &reasoning_item_id) { - (Some(incoming), Some(tracked)) => incoming == tracked, - (None, None) => true, - _ => false, - }; - if reasoning_active && same_item { - reasoning_buf.push_str("\n\n"); - yield StreamEvent::ReasoningDelta { - index: 1, - text: "\n\n".to_string(), - }; - } - reasoning_item_id = part_item_id; - } - "response.reasoning_summary_text.delta" | "response.reasoning_text.delta" => { - // Codex-style backends stream thinking as - // reasoning summary deltas; without this arm the - // adapter silently drops them and the UI shows - // no thinking content. - let delta = chunk - .get("delta") - .and_then(Value::as_str) - .or_else(|| chunk.get("text").and_then(Value::as_str)) - .unwrap_or_default(); - if !delta.is_empty() { - if !reasoning_started { - reasoning_started = true; - yield StreamEvent::ReasoningStart { index: 1 }; - } - reasoning_buf.push_str(delta); - reasoning_active = true; - yield StreamEvent::ReasoningDelta { - index: 1, - text: delta.to_string(), - }; - } - } - "response.output_item.added" => { - if let Some(item) = chunk.get("item") { - if let Some(reasoning_content) = - item.get("reasoning_content").and_then(Value::as_str) - && !reasoning_content.is_empty() { - if !reasoning_started { - reasoning_started = true; - yield StreamEvent::ReasoningStart { index: 1 }; - } - reasoning_buf.push_str(reasoning_content); - reasoning_active = true; - yield StreamEvent::ReasoningDelta { - index: 1, - text: reasoning_content.to_string(), - }; - } - if let Some(content) = parse_output_item(item).into_iter().next() { - match content { - ResponseContent::ToolUse { id, name, input } => { - let key = id.clone(); - let item_id = item - .get("id") - .and_then(Value::as_str) - .unwrap_or_default(); - let index = tool_calls.len() + hosted_tool_calls.len() + 1; - let arguments_json = function_call_arguments_json(item); - tool_calls.insert( - key.clone(), - ResponsesStreamToolCall { - index, - id: id.clone(), - name: name.clone(), - arguments_json, - }, - ); - if !item_id.is_empty() { - tool_call_keys_by_item_id - .insert(item_id.to_string(), key); - } - yield StreamEvent::ToolCallStart { - index, - id, - name, - input, - }; - reasoning_active = false; - } - ResponseContent::HostedToolUse { id, name, input, output, status } => { - let index = tool_calls.len() + hosted_tool_calls.len() + 1; - let key = id.clone(); - hosted_tool_calls.insert( - key, - (index, id.clone(), name.clone(), input.clone(), output.clone(), status.clone()), - ); - yield StreamEvent::HostedToolCallStart { - index, - id: id.clone(), - name: name.clone(), - input: input.clone(), - }; - reasoning_active = false; - if output.is_some() { - yield StreamEvent::HostedToolCallDone { - index, - id, - name, - input, - output, - status, - }; - } - } - ResponseContent::Text(_) - | ResponseContent::ProviderReasoning { .. } => {} - } - } - } - } - "response.output_item.done" => { - if let Some(item) = chunk.get("item") - && let Some(ResponseContent::HostedToolUse { id, name, input, output, status }) = - parse_output_item(item).into_iter().next() - { - let key = id.clone(); - let index = if let Some(( - index, - stored_id, - stored_name, - stored_input, - stored_output, - stored_status, - )) = hosted_tool_calls.get_mut(&key) { - *stored_id = id.clone(); - *stored_name = name.clone(); - *stored_input = input.clone(); - if output.is_some() { - *stored_output = output.clone(); - } - if status.is_some() { - *stored_status = status.clone(); - } - *index - } else { - let index = tool_calls.len() + hosted_tool_calls.len() + 1; - hosted_tool_calls.insert( - key, - (index, id.clone(), name.clone(), input.clone(), output.clone(), status.clone()), - ); - index - }; - yield StreamEvent::HostedToolCallDone { - index, - id, - name, - input, - output, - status, - }; - } - } - "response.function_call_arguments.delta" | "response.output_item.delta" => { - let partial_json = chunk - .get("delta") - .or_else(|| chunk.get("arguments_delta")) - .and_then(Value::as_str) - .unwrap_or_default(); - let call_id = chunk - .get("call_id") - .and_then(Value::as_str) - .map(ToOwned::to_owned) - .or_else(|| { - chunk - .get("item_id") - .and_then(Value::as_str) - .and_then(|item_id| { - tool_call_keys_by_item_id.get(item_id).cloned() - }) - }); - if !partial_json.is_empty() - && let Some(call_id) = call_id - && let Some(entry) = tool_calls.get_mut(&call_id) - { - entry.arguments_json.push_str(partial_json); - yield StreamEvent::ToolCallInputDelta { - index: entry.index, - partial_json: partial_json.to_string(), - }; - } - } - "response.completed" | "response.done" => { - for fragment in text_parser.finish() { - match fragment { - TaggedTextFragment::Text(text) => { - if text.is_empty() { - continue; - } - if !text_started { - text_started = true; - yield StreamEvent::TextStart { index: 0 }; - } - text_buf.push_str(&text); - yield StreamEvent::TextDelta { index: 0, text }; - } - TaggedTextFragment::Reasoning(text) => { - if text.is_empty() { - continue; - } - if !reasoning_started { - reasoning_started = true; - yield StreamEvent::ReasoningStart { index: 1 }; - } - reasoning_buf.push_str(&text); - yield StreamEvent::ReasoningDelta { index: 1, text }; - } - } - } - if reasoning_started { - yield StreamEvent::ReasoningDone { index: 1 }; - } - let response = if let Some(parsed) = chunk.get("response") { - let mut response = parse_response(parsed.clone())?; - if response.content.is_empty() { - // Codex-style backends do not replay the - // output items in the terminal - // `response.completed` event, so the - // streamed deltas are the only record of - // the assistant content; a non-stream - // `completion()` folded from this stream - // would otherwise report no content. - let mut content = Vec::new(); - if !text_buf.is_empty() { - content.push(ResponseContent::Text( - text_buf.clone(), - )); - } - content.extend(responses_stream_tool_content( - &tool_calls, - &hosted_tool_calls, - )); - response.content = content; - } - if !reasoning_buf.is_empty() - && !response.metadata.extras.iter().any(|extra| { - matches!( - extra, - ResponseExtra::ReasoningText { text } - if text == &reasoning_buf - ) - }) - { - response.metadata.extras.push(ResponseExtra::ReasoningText { - text: reasoning_buf.clone(), - }); - } - response - } else { - ModelResponse { - id: response_id.clone(), - content: { - let mut content = Vec::new(); - if !text_buf.is_empty() { - content.push(ResponseContent::Text(text_buf.clone())); - } - content.extend(responses_stream_tool_content( - &tool_calls, - &hosted_tool_calls, - )); - content - }, - stop_reason: Some(StopReason::EndTurn), - usage: usage.unwrap_or_default(), - metadata: if reasoning_buf.is_empty() { - ResponseMetadata::default() - } else { - ResponseMetadata { - extras: vec![ResponseExtra::ReasoningText { - text: reasoning_buf.clone(), - }], - } - }, - } - }; - yield StreamEvent::MessageDone { response }; - return; - } - _ => {} - } - } - } - - for fragment in text_parser.finish() { - match fragment { - TaggedTextFragment::Text(text) => { - if text.is_empty() { - continue; - } - if !text_started { - text_started = true; - yield StreamEvent::TextStart { index: 0 }; - } - text_buf.push_str(&text); - yield StreamEvent::TextDelta { index: 0, text }; - } - TaggedTextFragment::Reasoning(text) => { - if text.is_empty() { - continue; - } - if !reasoning_started { - reasoning_started = true; - yield StreamEvent::ReasoningStart { index: 1 }; - } - reasoning_buf.push_str(&text); - yield StreamEvent::ReasoningDelta { index: 1, text }; - } - } - } - - if reasoning_started { - yield StreamEvent::ReasoningDone { index: 1 }; - } - - let response = ModelResponse { - id: response_id, - content: { - let mut content = Vec::new(); - if !text_buf.is_empty() { - content.push(ResponseContent::Text(text_buf)); - } - content.extend(responses_stream_tool_content( - &tool_calls, - &hosted_tool_calls, - )); - content - }, - stop_reason: Some(StopReason::EndTurn), - usage: usage.unwrap_or_default(), - metadata: if reasoning_buf.is_empty() { - ResponseMetadata::default() - } else { - ResponseMetadata { - extras: vec![ResponseExtra::ReasoningText { - text: reasoning_buf, - }], - } - }, - }; - yield StreamEvent::MessageDone { response }; - }; - - Ok(Box::pin(stream)) + Ok(stream::decode_response_stream(response, request)) } fn name(&self) -> &str { "openai-responses" } } - -#[derive(Debug)] -struct ResponsesStreamToolCall { - index: usize, - id: String, - name: String, - arguments_json: String, -} - -type ResponsesStreamHostedToolCall = (usize, String, String, Value, Option, Option); - -fn responses_stream_tool_content( - tool_calls: &HashMap, - hosted_tool_calls: &HashMap, -) -> Vec { - let mut indexed_content = Vec::new(); - for call in tool_calls.values() { - indexed_content.push(( - call.index, - ResponseContent::ToolUse { - id: call.id.clone(), - name: call.name.clone(), - input: parse_function_call_arguments_json(&call.arguments_json), - }, - )); - } - for (index, id, name, input, output, status) in hosted_tool_calls.values() { - indexed_content.push(( - *index, - ResponseContent::HostedToolUse { - id: id.clone(), - name: name.clone(), - input: input.clone(), - output: output.clone(), - status: status.clone(), - }, - )); - } - indexed_content.sort_by_key(|(index, _)| *index); - indexed_content - .into_iter() - .map(|(_, content)| content) - .collect() -} - -#[cfg(test)] -mod tests { - use devo_protocol::{ - ModelRequest, RequestContent, RequestMessage, SamplingControls, ToolDefinition, - }; - use pretty_assertions::assert_eq; - use serde_json::json; - - use super::parse_response; - use devo_protocol::{ResponseContent, ResponseExtra}; - - use crate::openai::responses::build_request; - - #[test] - fn debug_request_body_includes_reasoning_and_tools() { - let request = ModelRequest { - model_slug: devo_protocol::ModelProfileKey::CatalogSlug("custom-model".to_string()), - model: "custom-model".to_string(), - system: Some("You are helpful.".to_string()), - messages: vec![RequestMessage { - role: "user".to_string(), - content: vec![RequestContent::Text { - text: "hi".to_string(), - }], - }], - max_tokens: 256, - tools: Some(vec![ToolDefinition { - name: "get_weather".to_string(), - description: "Get weather by city".to_string(), - input_schema: json!({"type": "object"}), - output_schema: None, - }]), - hosted_tools: Vec::new(), - sampling: SamplingControls { - temperature: Some(0.4), - top_p: Some(0.7), - top_k: Some(12), - }, - request_thinking: Some("medium".to_string()), - reasoning_effort: Some(devo_protocol::ReasoningEffort::Medium), - extra_body: None, - }; - - let body = build_request(&request, true); - - assert_eq!(body["model"], json!("custom-model")); - assert_eq!(body["stream"], json!(true)); - assert!(body.get("max_output_tokens").is_none()); - assert_eq!(body["temperature"], json!(0.4)); - assert_eq!(body["top_p"], json!(0.7)); - assert!(body.get("top_k").is_none()); - assert_eq!(body["tools"][0]["type"], json!("function")); - assert_eq!(body["tools"][0]["name"], json!("get_weather")); - assert!(body["tools"][0].get("function").is_none()); - // The system prompt rides the Responses `instructions` field; input - // items never carry a system role (codex backends reject it). - assert_eq!(body["instructions"], json!("You are helpful.")); - assert_eq!(body["input"][0]["role"], json!("user")); - for item in body["input"].as_array().expect("input items") { - assert_ne!(item["role"], json!("system")); - } - } - - #[test] - fn gpt5_responses_profile_strips_sampling_parameters() { - let request = ModelRequest { - model_slug: devo_protocol::ModelProfileKey::CatalogSlug("gpt-5.6-luna".to_string()), - model: "gpt-5.6-luna".to_string(), - system: Some("You are helpful.".to_string()), - messages: vec![RequestMessage { - role: "user".to_string(), - content: vec![RequestContent::Text { - text: "hi".to_string(), - }], - }], - max_tokens: 256, - tools: None, - hosted_tools: Vec::new(), - sampling: SamplingControls { - temperature: Some(0.4), - top_p: Some(0.7), - top_k: Some(12), - }, - request_thinking: Some("medium".to_string()), - reasoning_effort: Some(devo_protocol::ReasoningEffort::Medium), - extra_body: None, - }; - - let body = build_request(&request, true); - - assert!(body.get("temperature").is_none()); - assert!(body.get("top_p").is_none()); - assert!(body.get("top_k").is_none()); - assert!(body.get("max_output_tokens").is_none()); - assert_eq!( - body["reasoning"], - json!({ "effort": "medium", "summary": "auto" }) - ); - assert_eq!(body["store"], json!(false)); - assert_eq!(body["instructions"], json!("You are helpful.")); - } - - #[test] - fn build_request_omits_nested_reasoning_and_uses_output_text_for_assistant() { - let request = ModelRequest { - model_slug: devo_protocol::ModelProfileKey::CatalogSlug( - "deepseek-v4-flash".to_string(), - ), - model: "deepseek-v4-flash".to_string(), - system: None, - messages: vec![ - RequestMessage { - role: "user".to_string(), - content: vec![RequestContent::Text { - text: "hi".to_string(), - }], - }, - RequestMessage { - role: "assistant".to_string(), - content: vec![ - RequestContent::Reasoning { - text: "secret thoughts".to_string(), - }, - RequestContent::Text { - text: "hello".to_string(), - }, - ], - }, - ], - max_tokens: 64, - tools: None, - hosted_tools: Vec::new(), - sampling: SamplingControls { - temperature: None, - top_p: None, - top_k: None, - }, - request_thinking: None, - reasoning_effort: None, - extra_body: None, - }; - - let body = build_request(&request, false); - let input = body["input"].as_array().expect("input array"); - assert_eq!(input.len(), 2); - assert_eq!(input[0]["content"][0]["type"], json!("input_text")); - assert_eq!(input[1]["role"], json!("assistant")); - assert_eq!(input[1]["content"].as_array().map(|c| c.len()), Some(1)); - assert_eq!(input[1]["content"][0]["type"], json!("output_text")); - assert_eq!(input[1]["content"][0]["text"], json!("hello")); - let serialized = body.to_string(); - assert!( - !serialized.contains("\"type\":\"reasoning\""), - "nested reasoning content must not appear in Responses input: {serialized}" - ); - } - - #[test] - fn build_request_omits_unsupported_hosted_tool_history() { - let request = ModelRequest { - model_slug: devo_protocol::ModelProfileKey::CatalogSlug("gpt-5.4".to_string()), - model: "gpt-5.4".to_string(), - system: None, - messages: vec![ - RequestMessage { - role: "user".to_string(), - content: vec![RequestContent::Text { - text: "before".to_string(), - }], - }, - RequestMessage { - role: "assistant".to_string(), - content: vec![ - RequestContent::HostedToolUse { - id: "hosted_ws_1".to_string(), - name: "web_search".to_string(), - input: json!({"query": "Rust docs"}), - output: None, - status: None, - }, - RequestContent::HostedToolUse { - id: "hosted_ws_1".to_string(), - name: "web_search".to_string(), - input: json!({"query": "Rust docs"}), - output: Some(json!([{ - "title": "Rust documentation", - "url": "https://example.test/rust" - }])), - status: Some("completed".to_string()), - }, - ], - }, - RequestMessage { - role: "user".to_string(), - content: vec![RequestContent::Text { - text: "after".to_string(), - }], - }, - ], - max_tokens: 256, - tools: None, - hosted_tools: Vec::new(), - sampling: SamplingControls::default(), - request_thinking: None, - reasoning_effort: None, - extra_body: None, - }; - - let body = build_request(&request, false); - - assert_eq!(body["input"].as_array().map(Vec::len), Some(4)); - assert_eq!(body["input"][0]["role"], json!("user")); - assert_eq!(body["input"][1]["type"], json!("web_search_call")); - assert_eq!(body["input"][2]["type"], json!("web_search_call")); - assert_eq!(body["input"][3]["role"], json!("user")); - let serialized = serde_json::to_string(&body).expect("serialize request body"); - assert!(!serialized.contains("hosted_tool_use")); - assert!(!serialized.contains("\"type\":\"tool_call\"")); - } - - #[test] - fn parse_response_extracts_text_and_tool_use() { - let response = parse_response(json!({ - "id": "resp_123", - "status": "completed", - "output": [ - { - "type": "message", - "content": [ - {"type": "output_text", "text": "Hello"}, - { - "type": "function_call", - "id": "call_1", - "name": "get_weather", - "arguments": {"city": "Boston"} - } - ] - } - ], - "usage": { - "input_tokens": 10, - "output_tokens": 5, - "total_tokens": 21, - "output_tokens_details": { - "reasoning_tokens": 6 - } - } - })) - .expect("parse response"); - - assert_eq!(response.id, "resp_123"); - assert_eq!(response.content.len(), 2); - assert_eq!(response.usage.reasoning_output_tokens, Some(6)); - assert_eq!(response.usage.total_tokens, Some(21)); - assert!(matches!(response.content[0], ResponseContent::Text(_))); - assert!(matches!( - response.content[1], - ResponseContent::ToolUse { .. } - )); - } - - #[test] - fn parse_response_preserves_cached_input_tokens() { - let response = parse_response(json!({ - "id": "resp_cache", - "status": "completed", - "output": [], - "usage": { - "input_tokens": 100, - "input_tokens_details": { "cached_tokens": 73 }, - "output_tokens": 4, - "total_tokens": 104 - } - })) - .expect("parse response"); - - assert_eq!(response.usage.input_tokens, 100); - assert_eq!(response.usage.cache_read_input_tokens, Some(73)); - assert_eq!(response.usage.total_tokens, Some(104)); - } - - #[test] - fn parse_response_falls_back_to_prompt_tokens_cache_details() { - let response = parse_response(json!({ - "id": "resp_prompt_cache", - "status": "completed", - "output": [], - "usage": { - "prompt_tokens": 20, - "input_tokens_details": {}, - "prompt_tokens_details": { "cached_tokens": 12 }, - "completion_tokens": 2, - "total_tokens": 22 - } - })) - .expect("parse response"); - - assert_eq!(response.usage.cache_read_input_tokens, Some(12)); - } - - #[test] - fn parse_response_parses_string_tool_arguments_as_json() { - let response = parse_response(json!({ - "id": "resp_args", - "status": "completed", - "output": [ - { - "type": "function_call", - "call_id": "call_1", - "name": "weather", - "arguments": "{\"city\":\"Boston\"}" - } - ] - })) - .expect("parse response"); - - assert_eq!( - response.content, - vec![ResponseContent::ToolUse { - id: "call_1".to_string(), - name: "weather".to_string(), - input: json!({"city": "Boston"}), - }] - ); - } - - #[test] - fn parse_response_extracts_web_search_call_as_hosted_tool_use() { - let response = parse_response(json!({ - "id": "resp_789", - "status": "completed", - "output": [ - { - "type": "web_search_call", - "id": "ws_1", - "status": "completed", - "action": { - "type": "search", - "query": "Rust async docs" - }, - "results": [ - { - "title": "Async Programming in Rust", - "url": "https://example.test/rust-async" - } - ] - } - ], - "usage": { - "input_tokens": 10, - "output_tokens": 5 - } - })) - .expect("parse response"); - - assert_eq!( - response.content, - vec![ResponseContent::HostedToolUse { - id: "ws_1".to_string(), - name: "web_search".to_string(), - input: json!({"query": "Rust async docs"}), - output: Some(json!([ - { - "title": "Async Programming in Rust", - "url": "https://example.test/rust-async" - } - ])), - status: Some("completed".to_string()), - }] - ); - } - - #[test] - fn parse_response_extracts_web_fetch_call_as_hosted_tool_use() { - let response = parse_response(json!({ - "id": "resp_fetch", - "status": "completed", - "output": [ - { - "type": "web_fetch_call", - "id": "wf_1", - "status": "completed", - "action": { - "type": "fetch", - "url": "https://example.test/docs" - }, - "output": { - "title": "Docs", - "url": "https://example.test/docs" - } - } - ], - "usage": { - "input_tokens": 10, - "output_tokens": 5 - } - })) - .expect("parse response"); - - assert_eq!( - response.content, - vec![ResponseContent::HostedToolUse { - id: "wf_1".to_string(), - name: "web_fetch".to_string(), - input: json!({"url": "https://example.test/docs"}), - output: Some(json!({ - "title": "Docs", - "url": "https://example.test/docs" - })), - status: Some("completed".to_string()), - }] - ); - } - - #[test] - fn parse_response_preserves_reasoning_text_as_metadata() { - let response = parse_response(json!({ - "id": "resp_456", - "status": "completed", - "output": [ - { - "type": "message", - "content": [ - {"type": "output_text", "text": "final"} - ], - "reasoning_content": "internal reasoning" - } - ], - "usage": { - "input_tokens": 3, - "output_tokens": 1 - } - })) - .expect("parse response"); - - assert_eq!(response.metadata.extras.len(), 1); - assert!(matches!( - response.metadata.extras[0], - ResponseExtra::ReasoningText { .. } - )); - } -} diff --git a/crates/provider/src/openai/responses/request.rs b/crates/provider/src/openai/responses/request.rs new file mode 100644 index 00000000..65fa8629 --- /dev/null +++ b/crates/provider/src/openai/responses/request.rs @@ -0,0 +1,193 @@ +//! OpenAI Responses request encoding. + +use devo_protocol::{ModelRequest, RequestContent}; +use serde_json::{Value, json}; + +use super::super::capabilities::{OpenAITransport, resolve_request_profile}; +use super::super::{ + OpenAIRole, + shared::{request_role, responses_tool_definitions}, +}; +use crate::hosted_tools::append_openai_responses_hosted_tools; +use crate::merge_extra_body; + +/// Builds the exact OpenAI Responses request body used by this provider. +pub(super) fn build_request(request: &ModelRequest, stream: bool) -> Value { + let profile = resolve_request_profile(&request.model_slug, OpenAITransport::Responses); + // `max_output_tokens` is intentionally omitted: codex-style backends + // reject the parameter outright, and the Responses API already treats it + // as optional (the model default applies when unset). + let mut root = json!({ + "model": request.model, + "input": build_input(request), + "stream": stream, + }); + + if let Some(system) = &request.system { + // The Responses wire carries the system prompt in `instructions`; + // codex-style backends reject system-role input items outright. + root["instructions"] = json!(system); + } + + if let Some(tools) = &request.tools { + root["tools"] = responses_tool_definitions(tools); + } + + if profile.supports_temperature + && let Some(temperature) = request.sampling.temperature + { + root["temperature"] = json!(temperature); + } + + if profile.supports_top_p + && let Some(top_p) = request.sampling.top_p + { + root["top_p"] = json!(top_p); + } + + if profile.supports_top_k + && let Some(top_k) = request.sampling.top_k + { + root["top_k"] = json!(top_k); + } + + if let Some(reasoning) = request.reasoning_effort { + // "auto" keeps codex-style backends streaming reasoning summaries; + // it is the documented default elsewhere, so it is safe to always set. + root["reasoning"] = json!({ "effort": reasoning, "summary": "auto" }); + } + + // Responses backends derive usage from the terminal event; the codex + // backend additionally rejects chat-completions-style `stream_options` + // and requires stateless requests (no server-side response storage). + root["store"] = json!(false); + + append_openai_responses_hosted_tools(&mut root, &request.hosted_tools); + + merge_extra_body(&mut root, request.extra_body.as_ref()); + + root +} + +fn build_input(request: &ModelRequest) -> Vec { + let mut input = Vec::with_capacity(request.messages.len()); + + for message in &request.messages { + let role = request_role(&message.role); + append_input_items(&mut input, role, &message.content); + } + + input +} + +fn append_input_items(input: &mut Vec, role: OpenAIRole, content: &[RequestContent]) { + let mut message_blocks = Vec::new(); + let flush_message = |input: &mut Vec, blocks: &mut Vec| { + if blocks.is_empty() { + return; + } + input.push(json!({ + "type": "message", + "role": role, + "content": std::mem::take(blocks), + })); + }; + + for block in content { + match block { + RequestContent::Text { text } => { + // Responses message content: user/system use input_text; + // assistant replay uses output_text (DeepSeek/OpenAI). + let block_type = match role { + OpenAIRole::Assistant => "output_text", + _ => "input_text", + }; + message_blocks.push(json!({ + "type": block_type, + "text": text, + })); + } + RequestContent::Reasoning { .. } => { + // Message content only accepts input_text / output_text / + // input_image / input_file. Nested `type: "reasoning"` causes + // 400s on DeepSeek (and is not valid OpenAI message content). + // Prior reasoning is omitted unless carried as a provider + // reasoning item with a stable id (ProviderReasoning). + } + RequestContent::ProviderReasoning { .. } => {} + RequestContent::Image { + mime_type, + data_base64, + } => { + message_blocks.push(json!({ + "type": "input_image", + "image_url": format!("data:{mime_type};base64,{data_base64}"), + })); + } + RequestContent::ToolUse { + id, + name, + input: args, + } => { + flush_message(input, &mut message_blocks); + input.push(json!({ + "type": "function_call", + "call_id": id, + "name": name, + "arguments": serde_json::to_string(args).unwrap_or_else(|_| "{}".to_string()), + })); + } + RequestContent::ToolResult { + tool_use_id, + content, + is_error: _, + } => { + flush_message(input, &mut message_blocks); + input.push(json!({ + "type": "function_call_output", + "call_id": tool_use_id, + "output": content, + })); + } + RequestContent::HostedToolUse { + id, + name, + input: hosted_input, + output, + status, + } => { + flush_message(input, &mut message_blocks); + // DeepSeek / OpenAI Responses expect hosted calls as top-level + // items that are passed back as-is (not nested message content). + let item_type = match name.as_str() { + "web_search" => "web_search_call", + "web_fetch" => "web_fetch_call", + other => other, + }; + let mut item = json!({ + "type": item_type, + "id": id, + "status": status.clone().unwrap_or_else(|| "completed".to_string()), + }); + if name == "web_search" { + if let Some(query) = hosted_input.get("query") { + item["action"] = json!({ + "type": "search", + "query": query, + }); + } else if let Some(action) = hosted_input.get("action") { + item["action"] = action.clone(); + } + } else if !hosted_input.is_null() { + item["action"] = hosted_input.clone(); + } + if let Some(output) = output { + item["output"] = output.clone(); + } + input.push(item); + } + } + } + + flush_message(input, &mut message_blocks); +} diff --git a/crates/provider/src/openai/responses/response.rs b/crates/provider/src/openai/responses/response.rs new file mode 100644 index 00000000..d2167403 --- /dev/null +++ b/crates/provider/src/openai/responses/response.rs @@ -0,0 +1,278 @@ +//! OpenAI Responses output and usage decoding. + +use anyhow::Result; +use devo_protocol::{ + ModelResponse, ResponseContent, ResponseExtra, ResponseMetadata, StopReason, Usage, +}; +use serde_json::{Value, json}; + +use crate::text_normalization::split_tagged_text; + +pub(super) fn parse_response(value: Value) -> Result { + let id = value + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let mut content = Vec::new(); + let mut metadata = ResponseMetadata::default(); + + if let Some(output) = value.get("output").and_then(Value::as_array) { + for item in output { + if let Some(reasoning_content) = item.get("reasoning_content").and_then(Value::as_str) { + metadata.extras.push(ResponseExtra::ReasoningText { + text: reasoning_content.to_string(), + }); + } + if matches!(item.get("type").and_then(Value::as_str), Some("message")) { + if let Some(items) = item.get("content").and_then(Value::as_array) { + for message_item in items { + if let Some(text) = message_item.get("text").and_then(Value::as_str) { + let (assistant_text, reasoning) = split_tagged_text(text); + for text in reasoning { + if !text.is_empty() { + metadata.extras.push(ResponseExtra::ReasoningText { text }); + } + } + if !assistant_text.is_empty() { + content.push(ResponseContent::Text(assistant_text)); + } + continue; + } + if let Some(parsed) = parse_message_content(message_item) { + content.push(parsed); + } + } + } + continue; + } + content.extend(parse_output_item(item)); + } + } + + let stop_reason = value + .get("status") + .and_then(Value::as_str) + .map(parse_status_reason) + .or_else(|| { + value + .get("incomplete") + .and_then(|item| item.get("reason")) + .and_then(Value::as_str) + .map(parse_status_reason) + }); + + let usage = value.get("usage").and_then(parse_usage).unwrap_or_default(); + + Ok(ModelResponse { + id, + content, + stop_reason, + usage, + metadata, + }) +} + +pub(super) fn parse_output_item(item: &Value) -> Vec { + match item.get("type").and_then(Value::as_str) { + Some("message") => item + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(parse_message_content) + .collect(), + Some("function_call") | Some("tool_call") => { + let id = item + .get("call_id") + .or_else(|| item.get("id")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let name = item + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let input = parse_function_call_arguments(item); + vec![ResponseContent::ToolUse { id, name, input }] + } + Some("web_search_call") => vec![parse_hosted_web_search_call(item)], + Some("web_fetch_call") => vec![parse_hosted_web_fetch_call(item)], + Some("reasoning") => Vec::new(), + _ => Vec::new(), + } +} + +fn parse_message_content(item: &Value) -> Option { + match item.get("type").and_then(Value::as_str) { + Some("output_text") | Some("text") | Some("input_text") => { + let assistant_text = + split_tagged_text(item.get("text").and_then(Value::as_str).unwrap_or_default()).0; + if assistant_text.is_empty() { + None + } else { + Some(ResponseContent::Text(assistant_text)) + } + } + Some("tool_call") | Some("function_call") => Some(ResponseContent::ToolUse { + id: item + .get("call_id") + .or_else(|| item.get("id")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + name: item + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + input: parse_function_call_arguments(item), + }), + Some("web_search_call") => Some(parse_hosted_web_search_call(item)), + Some("web_fetch_call") => Some(parse_hosted_web_fetch_call(item)), + _ => None, + } +} + +fn parse_function_call_arguments(item: &Value) -> Value { + match item.get("arguments").or_else(|| item.get("input")) { + Some(Value::String(arguments)) => parse_function_call_arguments_json(arguments), + Some(Value::Null) | None => Value::Object(serde_json::Map::new()), + Some(value) => value.clone(), + } +} + +pub(super) fn function_call_arguments_json(item: &Value) -> String { + match item.get("arguments").or_else(|| item.get("input")) { + Some(Value::String(arguments)) => arguments.clone(), + Some(Value::Null) | None => String::new(), + Some(value) => value.to_string(), + } +} + +pub(super) fn parse_function_call_arguments_json(arguments_json: &str) -> Value { + if arguments_json.trim().is_empty() { + return Value::Object(serde_json::Map::new()); + } + serde_json::from_str(arguments_json).unwrap_or_else(|_| Value::Object(serde_json::Map::new())) +} + +fn hosted_tool_call_id(item: &Value) -> String { + item.get("call_id") + .or_else(|| item.get("id")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() +} + +fn hosted_tool_status(item: &Value) -> Option { + item.get("status") + .and_then(Value::as_str) + .map(ToString::to_string) +} + +fn hosted_tool_output(item: &Value) -> Option { + item.get("output") + .or_else(|| item.get("results")) + .or_else(|| item.get("result")) + .or_else(|| item.get("content")) + .cloned() +} + +fn parse_hosted_tool_call(item: &Value, name: &str, input: Value) -> ResponseContent { + ResponseContent::HostedToolUse { + id: hosted_tool_call_id(item), + name: name.to_string(), + input, + output: hosted_tool_output(item), + status: hosted_tool_status(item), + } +} + +fn parse_hosted_web_search_call(item: &Value) -> ResponseContent { + parse_hosted_tool_call(item, "web_search", hosted_web_search_input(item)) +} + +fn hosted_web_search_input(item: &Value) -> Value { + if let Some(query) = item + .get("action") + .and_then(|action| action.get("query")) + .and_then(Value::as_str) + { + return json!({ "query": query }); + } + if let Some(query) = item.get("query").and_then(Value::as_str) { + return json!({ "query": query }); + } + item.get("action") + .cloned() + .unwrap_or_else(|| Value::Object(serde_json::Map::new())) +} + +fn parse_hosted_web_fetch_call(item: &Value) -> ResponseContent { + parse_hosted_tool_call(item, "web_fetch", hosted_web_fetch_input(item)) +} + +fn hosted_web_fetch_input(item: &Value) -> Value { + if let Some(url) = item + .get("action") + .and_then(|action| action.get("url")) + .and_then(Value::as_str) + { + return json!({ "url": url }); + } + if let Some(url) = item.get("url").and_then(Value::as_str) { + return json!({ "url": url }); + } + if let Some(url) = item + .get("input") + .and_then(|input| input.get("url")) + .and_then(Value::as_str) + { + return json!({ "url": url }); + } + item.get("action") + .cloned() + .unwrap_or_else(|| Value::Object(serde_json::Map::new())) +} + +pub(super) fn parse_usage(value: &Value) -> Option { + Some(Usage { + input_tokens: value + .get("input_tokens") + .or_else(|| value.get("prompt_tokens")) + .and_then(Value::as_u64)? as usize, + output_tokens: value + .get("output_tokens") + .or_else(|| value.get("completion_tokens")) + .and_then(Value::as_u64)? as usize, + cache_creation_input_tokens: None, + cache_read_input_tokens: value + .pointer("/input_tokens_details/cached_tokens") + .or_else(|| value.pointer("/prompt_tokens_details/cached_tokens")) + .or_else(|| value.get("cache_read_input_tokens")) + .and_then(Value::as_u64) + .map(|tokens| tokens as usize), + reasoning_output_tokens: value + .get("output_tokens_details") + .or_else(|| value.get("completion_tokens_details")) + .and_then(|details| details.get("reasoning_tokens")) + .and_then(Value::as_u64) + .map(|tokens| tokens as usize), + total_tokens: value + .get("total_tokens") + .and_then(Value::as_u64) + .map(|tokens| tokens as usize), + }) +} + +fn parse_status_reason(value: &str) -> StopReason { + match value { + "completed" | "stop" | "end_turn" => StopReason::EndTurn, + "incomplete" | "max_output_tokens" | "length" => StopReason::MaxTokens, + "tool_use" | "tool_calls" => StopReason::ToolUse, + "stop_sequence" | "content_filter" => StopReason::StopSequence, + _ => StopReason::EndTurn, + } +} diff --git a/crates/provider/src/openai/responses/stream.rs b/crates/provider/src/openai/responses/stream.rs new file mode 100644 index 00000000..cb32938c --- /dev/null +++ b/crates/provider/src/openai/responses/stream.rs @@ -0,0 +1,494 @@ +//! Streaming Responses decoding. Terminal failures must never fall through to success. + +use anyhow::Result; +use devo_protocol::{ + ModelRequest, ModelResponse, ResponseContent, ResponseExtra, ResponseMetadata, StopReason, + StreamEvent, Usage, +}; +use futures::{Stream, StreamExt}; +use serde_json::Value; +use std::{collections::HashMap, pin::Pin}; + +use super::response::{ + function_call_arguments_json, parse_output_item, parse_response, parse_usage, +}; +use super::stream_tools::{ + ResponsesStreamHostedToolCall, ResponsesStreamToolCall, responses_stream_tool_content, +}; +use crate::error::stream_error; +use crate::text_normalization::{TaggedTextFragment, TaggedTextParser}; + +pub(super) fn decode_response_stream( + response: reqwest::Response, + request: ModelRequest, +) -> Pin> + Send>> { + let messages = crate::sse::decode_sse(response.bytes_stream()); + let stream = async_stream::try_stream! { + let mut text_buf = String::new(); + let mut reasoning_buf = String::new(); + let mut text_parser = TaggedTextParser::default(); + let mut response_id = String::new(); + let mut tool_calls: HashMap = HashMap::new(); + let mut tool_call_keys_by_item_id: HashMap = HashMap::new(); + let mut hosted_tool_calls: HashMap = HashMap::new(); + let mut usage: Option = None; + let mut reasoning_started = false; + // Item id of the reasoning block currently being streamed. The + // boundary between summary parts must only fire *within* one + // reasoning item: backends that run hosted tools mid-stream emit + // further reasoning items afterwards, and a cross-item boundary + // would surface as an orphan leading "\n\n" on the next durable + // reasoning item (the previous one was already closed when the + // tool call started). + let mut reasoning_item_id: Option = None; + // True while the most recently emitted stream event is reasoning + // text. A summary-part boundary must glue two parts of the same + // paragraph together, but must never lead a fresh reasoning block + // that follows a tool call or assistant text — the consumer closes + // the reasoning item there, so a leading "\n\n" would orphan-start + // the next block. + let mut reasoning_active = false; + let mut text_started = false; + + futures::pin_mut!(messages); + loop { + let message = match messages.next().await { + Some(Ok(message)) => message, + Some(Err(error)) => Err(stream_error(format!( + "openai responses stream error for model {}: {error}", + request.model + )))?, + None => break, + }; + + { + if message.data == "[DONE]" { + break; + } + + let chunk: Value = serde_json::from_str(&message.data) + .map_err(|error| anyhow::anyhow!("failed to parse openai responses stream chunk: {error}"))?; + + // HTTP 200 only establishes the SSE transport. A terminal failure + // in that stream must bypass the successful EOF fallback below. + let event = match message.event.as_str() { + "" | "message" => chunk.get("type").and_then(Value::as_str).unwrap_or("message"), + event => event, + }; + if let Some(error) = super::stream_error::provider_error_from_event(event, &chunk, &request) { + Err(error)?; + } + + if response_id.is_empty() { + response_id = chunk + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + } + + if let Some(parsed_usage) = chunk.get("usage").and_then(parse_usage) { + usage = Some(parsed_usage.clone()); + yield StreamEvent::UsageDelta(parsed_usage); + } + + match event { + "response.output_text.delta" => { + let delta = chunk + .get("delta") + .and_then(Value::as_str) + .or_else(|| chunk.get("text").and_then(Value::as_str)) + .unwrap_or_default(); + if !delta.is_empty() { + for fragment in text_parser.consume(delta) { + match fragment { + TaggedTextFragment::Text(text) => { + if text.is_empty() { + continue; + } + if !text_started { + text_started = true; + yield StreamEvent::TextStart { index: 0 }; + } + text_buf.push_str(&text); + reasoning_active = false; + yield StreamEvent::TextDelta { index: 0, text }; + } + TaggedTextFragment::Reasoning(text) => { + if text.is_empty() { + continue; + } + if !reasoning_started { + reasoning_started = true; + yield StreamEvent::ReasoningStart { index: 1 }; + } + reasoning_buf.push_str(&text); + reasoning_active = true; + yield StreamEvent::ReasoningDelta { index: 1, text }; + } + } + } + } + } + "response.reasoning_summary_part.added" => { + // OpenAI Responses streams each thinking summary + // as its own part. Without an explicit boundary + // consecutive parts fuse into one run-on + // paragraph ("…words****Inserting…" in the + // journal), so re-emit the boundary as a delta: + // every consumer — live TUI stream and the + // durable reasoning item — then renders the + // parts as separate paragraphs. The boundary + // only applies within the same reasoning item: + // a part with a new item id starts a fresh + // block, not a continuation of the previous one. + let part_item_id = + chunk.get("item_id").and_then(Value::as_str).map(str::to_string); + let same_item = match (&part_item_id, &reasoning_item_id) { + (Some(incoming), Some(tracked)) => incoming == tracked, + (None, None) => true, + _ => false, + }; + if reasoning_active && same_item { + reasoning_buf.push_str("\n\n"); + yield StreamEvent::ReasoningDelta { + index: 1, + text: "\n\n".to_string(), + }; + } + reasoning_item_id = part_item_id; + } + "response.reasoning_summary_text.delta" | "response.reasoning_text.delta" => { + // Codex-style backends stream thinking as + // reasoning summary deltas; without this arm the + // adapter silently drops them and the UI shows + // no thinking content. + let delta = chunk + .get("delta") + .and_then(Value::as_str) + .or_else(|| chunk.get("text").and_then(Value::as_str)) + .unwrap_or_default(); + if !delta.is_empty() { + if !reasoning_started { + reasoning_started = true; + yield StreamEvent::ReasoningStart { index: 1 }; + } + reasoning_buf.push_str(delta); + reasoning_active = true; + yield StreamEvent::ReasoningDelta { + index: 1, + text: delta.to_string(), + }; + } + } + "response.output_item.added" => { + if let Some(item) = chunk.get("item") { + if let Some(reasoning_content) = + item.get("reasoning_content").and_then(Value::as_str) + && !reasoning_content.is_empty() { + if !reasoning_started { + reasoning_started = true; + yield StreamEvent::ReasoningStart { index: 1 }; + } + reasoning_buf.push_str(reasoning_content); + reasoning_active = true; + yield StreamEvent::ReasoningDelta { + index: 1, + text: reasoning_content.to_string(), + }; + } + if let Some(content) = parse_output_item(item).into_iter().next() { + match content { + ResponseContent::ToolUse { id, name, input } => { + let key = id.clone(); + let item_id = item + .get("id") + .and_then(Value::as_str) + .unwrap_or_default(); + let index = tool_calls.len() + hosted_tool_calls.len() + 1; + let arguments_json = function_call_arguments_json(item); + tool_calls.insert( + key.clone(), + ResponsesStreamToolCall { + index, + id: id.clone(), + name: name.clone(), + arguments_json, + }, + ); + if !item_id.is_empty() { + tool_call_keys_by_item_id + .insert(item_id.to_string(), key); + } + yield StreamEvent::ToolCallStart { + index, + id, + name, + input, + }; + reasoning_active = false; + } + ResponseContent::HostedToolUse { id, name, input, output, status } => { + let index = tool_calls.len() + hosted_tool_calls.len() + 1; + let key = id.clone(); + hosted_tool_calls.insert( + key, + (index, id.clone(), name.clone(), input.clone(), output.clone(), status.clone()), + ); + yield StreamEvent::HostedToolCallStart { + index, + id: id.clone(), + name: name.clone(), + input: input.clone(), + }; + reasoning_active = false; + if output.is_some() { + yield StreamEvent::HostedToolCallDone { + index, + id, + name, + input, + output, + status, + }; + } + } + ResponseContent::Text(_) + | ResponseContent::ProviderReasoning { .. } => {} + } + } + } + } + "response.output_item.done" => { + if let Some(item) = chunk.get("item") + && let Some(ResponseContent::HostedToolUse { id, name, input, output, status }) = + parse_output_item(item).into_iter().next() + { + let key = id.clone(); + let index = if let Some(( + index, + stored_id, + stored_name, + stored_input, + stored_output, + stored_status, + )) = hosted_tool_calls.get_mut(&key) { + *stored_id = id.clone(); + *stored_name = name.clone(); + *stored_input = input.clone(); + if output.is_some() { + *stored_output = output.clone(); + } + if status.is_some() { + *stored_status = status.clone(); + } + *index + } else { + let index = tool_calls.len() + hosted_tool_calls.len() + 1; + hosted_tool_calls.insert( + key, + (index, id.clone(), name.clone(), input.clone(), output.clone(), status.clone()), + ); + index + }; + yield StreamEvent::HostedToolCallDone { + index, + id, + name, + input, + output, + status, + }; + } + } + "response.function_call_arguments.delta" | "response.output_item.delta" => { + let partial_json = chunk + .get("delta") + .or_else(|| chunk.get("arguments_delta")) + .and_then(Value::as_str) + .unwrap_or_default(); + let call_id = chunk + .get("call_id") + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .or_else(|| { + chunk + .get("item_id") + .and_then(Value::as_str) + .and_then(|item_id| { + tool_call_keys_by_item_id.get(item_id).cloned() + }) + }); + if !partial_json.is_empty() + && let Some(call_id) = call_id + && let Some(entry) = tool_calls.get_mut(&call_id) + { + entry.arguments_json.push_str(partial_json); + yield StreamEvent::ToolCallInputDelta { + index: entry.index, + partial_json: partial_json.to_string(), + }; + } + } + "response.completed" | "response.done" => { + for fragment in text_parser.finish() { + match fragment { + TaggedTextFragment::Text(text) => { + if text.is_empty() { + continue; + } + if !text_started { + text_started = true; + yield StreamEvent::TextStart { index: 0 }; + } + text_buf.push_str(&text); + yield StreamEvent::TextDelta { index: 0, text }; + } + TaggedTextFragment::Reasoning(text) => { + if text.is_empty() { + continue; + } + if !reasoning_started { + reasoning_started = true; + yield StreamEvent::ReasoningStart { index: 1 }; + } + reasoning_buf.push_str(&text); + yield StreamEvent::ReasoningDelta { index: 1, text }; + } + } + } + if reasoning_started { + yield StreamEvent::ReasoningDone { index: 1 }; + } + let response = if let Some(parsed) = chunk.get("response") { + let mut response = parse_response(parsed.clone())?; + if response.content.is_empty() { + // Codex-style backends do not replay the + // output items in the terminal + // `response.completed` event, so the + // streamed deltas are the only record of + // the assistant content; a non-stream + // `completion()` folded from this stream + // would otherwise report no content. + let mut content = Vec::new(); + if !text_buf.is_empty() { + content.push(ResponseContent::Text( + text_buf.clone(), + )); + } + content.extend(responses_stream_tool_content( + &tool_calls, + &hosted_tool_calls, + )); + response.content = content; + } + if !reasoning_buf.is_empty() + && !response.metadata.extras.iter().any(|extra| { + matches!( + extra, + ResponseExtra::ReasoningText { text } + if text == &reasoning_buf + ) + }) + { + response.metadata.extras.push(ResponseExtra::ReasoningText { + text: reasoning_buf.clone(), + }); + } + response + } else { + ModelResponse { + id: response_id.clone(), + content: { + let mut content = Vec::new(); + if !text_buf.is_empty() { + content.push(ResponseContent::Text(text_buf.clone())); + } + content.extend(responses_stream_tool_content( + &tool_calls, + &hosted_tool_calls, + )); + content + }, + stop_reason: Some(StopReason::EndTurn), + usage: usage.unwrap_or_default(), + metadata: if reasoning_buf.is_empty() { + ResponseMetadata::default() + } else { + ResponseMetadata { + extras: vec![ResponseExtra::ReasoningText { + text: reasoning_buf.clone(), + }], + } + }, + } + }; + yield StreamEvent::MessageDone { response }; + return; + } + _ => {} + } + } + } + + for fragment in text_parser.finish() { + match fragment { + TaggedTextFragment::Text(text) => { + if text.is_empty() { + continue; + } + if !text_started { + text_started = true; + yield StreamEvent::TextStart { index: 0 }; + } + text_buf.push_str(&text); + yield StreamEvent::TextDelta { index: 0, text }; + } + TaggedTextFragment::Reasoning(text) => { + if text.is_empty() { + continue; + } + if !reasoning_started { + reasoning_started = true; + yield StreamEvent::ReasoningStart { index: 1 }; + } + reasoning_buf.push_str(&text); + yield StreamEvent::ReasoningDelta { index: 1, text }; + } + } + } + + if reasoning_started { + yield StreamEvent::ReasoningDone { index: 1 }; + } + + let response = ModelResponse { + id: response_id, + content: { + let mut content = Vec::new(); + if !text_buf.is_empty() { + content.push(ResponseContent::Text(text_buf)); + } + content.extend(responses_stream_tool_content( + &tool_calls, + &hosted_tool_calls, + )); + content + }, + stop_reason: Some(StopReason::EndTurn), + usage: usage.unwrap_or_default(), + metadata: if reasoning_buf.is_empty() { + ResponseMetadata::default() + } else { + ResponseMetadata { + extras: vec![ResponseExtra::ReasoningText { + text: reasoning_buf, + }], + } + }, + }; + yield StreamEvent::MessageDone { response }; + }; + + Box::pin(stream) +} diff --git a/crates/provider/src/openai/responses/stream_error.rs b/crates/provider/src/openai/responses/stream_error.rs new file mode 100644 index 00000000..f57ddc9d --- /dev/null +++ b/crates/provider/src/openai/responses/stream_error.rs @@ -0,0 +1,41 @@ +//! Normalize terminal Responses SSE errors into the shared provider taxonomy. + +use devo_protocol::ModelRequest; +use serde_json::{Value, json}; + +use super::super::error_payload::provider_error_from_payload; +use crate::error::ProviderError; + +pub(super) fn provider_error_from_event( + event: &str, + chunk: &Value, + request: &ModelRequest, +) -> Option { + let (error, fallback) = match event { + "error" => ( + chunk.get("error").unwrap_or(chunk), + "OpenAI Responses stream returned an error without details", + ), + "response.failed" => ( + chunk.pointer("/response/error").unwrap_or(&Value::Null), + "OpenAI Responses request failed without error details", + ), + // Non-terminal events still belong to the ordinary stream decoder. + _ => return None, + }; + let message = error + .get("message") + .and_then(Value::as_str) + .filter(|message| !message.trim().is_empty()) + .unwrap_or(fallback); + // Normalize nullable/missing/malformed fields so an explicit failure can + // never disappear merely because the upstream error has an unfamiliar shape. + let envelope = json!({ + "error": { + "message": message, + "type": error.get("type").and_then(Value::as_str), + "code": error.get("code"), + } + }); + provider_error_from_payload(&envelope, request) +} diff --git a/crates/provider/src/openai/responses/stream_tools.rs b/crates/provider/src/openai/responses/stream_tools.rs new file mode 100644 index 00000000..3b9bd08b --- /dev/null +++ b/crates/provider/src/openai/responses/stream_tools.rs @@ -0,0 +1,51 @@ +//! Ordered tool state accumulated across Responses stream events. + +use super::response::parse_function_call_arguments_json; +use devo_protocol::ResponseContent; +use serde_json::Value; +use std::collections::HashMap; + +#[derive(Debug)] +pub(super) struct ResponsesStreamToolCall { + pub(super) index: usize, + pub(super) id: String, + pub(super) name: String, + pub(super) arguments_json: String, +} + +pub(super) type ResponsesStreamHostedToolCall = + (usize, String, String, Value, Option, Option); + +pub(super) fn responses_stream_tool_content( + tool_calls: &HashMap, + hosted_tool_calls: &HashMap, +) -> Vec { + let mut indexed_content = Vec::new(); + for call in tool_calls.values() { + indexed_content.push(( + call.index, + ResponseContent::ToolUse { + id: call.id.clone(), + name: call.name.clone(), + input: parse_function_call_arguments_json(&call.arguments_json), + }, + )); + } + for (index, id, name, input, output, status) in hosted_tool_calls.values() { + indexed_content.push(( + *index, + ResponseContent::HostedToolUse { + id: id.clone(), + name: name.clone(), + input: input.clone(), + output: output.clone(), + status: status.clone(), + }, + )); + } + indexed_content.sort_by_key(|(index, _)| *index); + indexed_content + .into_iter() + .map(|(_, content)| content) + .collect() +} diff --git a/crates/provider/src/openai/responses/tests.rs b/crates/provider/src/openai/responses/tests.rs new file mode 100644 index 00000000..ae63e35f --- /dev/null +++ b/crates/provider/src/openai/responses/tests.rs @@ -0,0 +1,437 @@ +use devo_protocol::{ + ModelRequest, RequestContent, RequestMessage, SamplingControls, ToolDefinition, +}; +use pretty_assertions::assert_eq; +use serde_json::json; + +use super::response::parse_response; +use devo_protocol::{ResponseContent, ResponseExtra}; + +use crate::openai::responses::build_request; + +#[test] +fn debug_request_body_includes_reasoning_and_tools() { + let request = ModelRequest { + model_slug: devo_protocol::ModelProfileKey::CatalogSlug("custom-model".to_string()), + model: "custom-model".to_string(), + system: Some("You are helpful.".to_string()), + messages: vec![RequestMessage { + role: "user".to_string(), + content: vec![RequestContent::Text { + text: "hi".to_string(), + }], + }], + max_tokens: 256, + tools: Some(vec![ToolDefinition { + name: "get_weather".to_string(), + description: "Get weather by city".to_string(), + input_schema: json!({"type": "object"}), + output_schema: None, + }]), + hosted_tools: Vec::new(), + sampling: SamplingControls { + temperature: Some(0.4), + top_p: Some(0.7), + top_k: Some(12), + }, + request_thinking: Some("medium".to_string()), + reasoning_effort: Some(devo_protocol::ReasoningEffort::Medium), + extra_body: None, + }; + + let body = build_request(&request, true); + + assert_eq!(body["model"], json!("custom-model")); + assert_eq!(body["stream"], json!(true)); + assert!(body.get("max_output_tokens").is_none()); + assert_eq!(body["temperature"], json!(0.4)); + assert_eq!(body["top_p"], json!(0.7)); + assert!(body.get("top_k").is_none()); + assert_eq!(body["tools"][0]["type"], json!("function")); + assert_eq!(body["tools"][0]["name"], json!("get_weather")); + assert!(body["tools"][0].get("function").is_none()); + // The system prompt rides the Responses `instructions` field; input + // items never carry a system role (codex backends reject it). + assert_eq!(body["instructions"], json!("You are helpful.")); + assert_eq!(body["input"][0]["role"], json!("user")); + for item in body["input"].as_array().expect("input items") { + assert_ne!(item["role"], json!("system")); + } +} + +#[test] +fn gpt5_responses_profile_strips_sampling_parameters() { + let request = ModelRequest { + model_slug: devo_protocol::ModelProfileKey::CatalogSlug("gpt-5.6-luna".to_string()), + model: "gpt-5.6-luna".to_string(), + system: Some("You are helpful.".to_string()), + messages: vec![RequestMessage { + role: "user".to_string(), + content: vec![RequestContent::Text { + text: "hi".to_string(), + }], + }], + max_tokens: 256, + tools: None, + hosted_tools: Vec::new(), + sampling: SamplingControls { + temperature: Some(0.4), + top_p: Some(0.7), + top_k: Some(12), + }, + request_thinking: Some("medium".to_string()), + reasoning_effort: Some(devo_protocol::ReasoningEffort::Medium), + extra_body: None, + }; + + let body = build_request(&request, true); + + assert!(body.get("temperature").is_none()); + assert!(body.get("top_p").is_none()); + assert!(body.get("top_k").is_none()); + assert!(body.get("max_output_tokens").is_none()); + assert_eq!( + body["reasoning"], + json!({ "effort": "medium", "summary": "auto" }) + ); + assert_eq!(body["store"], json!(false)); + assert_eq!(body["instructions"], json!("You are helpful.")); +} + +#[test] +fn build_request_omits_nested_reasoning_and_uses_output_text_for_assistant() { + let request = ModelRequest { + model_slug: devo_protocol::ModelProfileKey::CatalogSlug("deepseek-v4-flash".to_string()), + model: "deepseek-v4-flash".to_string(), + system: None, + messages: vec![ + RequestMessage { + role: "user".to_string(), + content: vec![RequestContent::Text { + text: "hi".to_string(), + }], + }, + RequestMessage { + role: "assistant".to_string(), + content: vec![ + RequestContent::Reasoning { + text: "secret thoughts".to_string(), + }, + RequestContent::Text { + text: "hello".to_string(), + }, + ], + }, + ], + max_tokens: 64, + tools: None, + hosted_tools: Vec::new(), + sampling: SamplingControls { + temperature: None, + top_p: None, + top_k: None, + }, + request_thinking: None, + reasoning_effort: None, + extra_body: None, + }; + + let body = build_request(&request, false); + let input = body["input"].as_array().expect("input array"); + assert_eq!(input.len(), 2); + assert_eq!(input[0]["content"][0]["type"], json!("input_text")); + assert_eq!(input[1]["role"], json!("assistant")); + assert_eq!(input[1]["content"].as_array().map(|c| c.len()), Some(1)); + assert_eq!(input[1]["content"][0]["type"], json!("output_text")); + assert_eq!(input[1]["content"][0]["text"], json!("hello")); + let serialized = body.to_string(); + assert!( + !serialized.contains("\"type\":\"reasoning\""), + "nested reasoning content must not appear in Responses input: {serialized}" + ); +} + +#[test] +fn build_request_omits_unsupported_hosted_tool_history() { + let request = ModelRequest { + model_slug: devo_protocol::ModelProfileKey::CatalogSlug("gpt-5.4".to_string()), + model: "gpt-5.4".to_string(), + system: None, + messages: vec![ + RequestMessage { + role: "user".to_string(), + content: vec![RequestContent::Text { + text: "before".to_string(), + }], + }, + RequestMessage { + role: "assistant".to_string(), + content: vec![ + RequestContent::HostedToolUse { + id: "hosted_ws_1".to_string(), + name: "web_search".to_string(), + input: json!({"query": "Rust docs"}), + output: None, + status: None, + }, + RequestContent::HostedToolUse { + id: "hosted_ws_1".to_string(), + name: "web_search".to_string(), + input: json!({"query": "Rust docs"}), + output: Some(json!([{ + "title": "Rust documentation", + "url": "https://example.test/rust" + }])), + status: Some("completed".to_string()), + }, + ], + }, + RequestMessage { + role: "user".to_string(), + content: vec![RequestContent::Text { + text: "after".to_string(), + }], + }, + ], + max_tokens: 256, + tools: None, + hosted_tools: Vec::new(), + sampling: SamplingControls::default(), + request_thinking: None, + reasoning_effort: None, + extra_body: None, + }; + + let body = build_request(&request, false); + + assert_eq!(body["input"].as_array().map(Vec::len), Some(4)); + assert_eq!(body["input"][0]["role"], json!("user")); + assert_eq!(body["input"][1]["type"], json!("web_search_call")); + assert_eq!(body["input"][2]["type"], json!("web_search_call")); + assert_eq!(body["input"][3]["role"], json!("user")); + let serialized = serde_json::to_string(&body).expect("serialize request body"); + assert!(!serialized.contains("hosted_tool_use")); + assert!(!serialized.contains("\"type\":\"tool_call\"")); +} + +#[test] +fn parse_response_extracts_text_and_tool_use() { + let response = parse_response(json!({ + "id": "resp_123", + "status": "completed", + "output": [ + { + "type": "message", + "content": [ + {"type": "output_text", "text": "Hello"}, + { + "type": "function_call", + "id": "call_1", + "name": "get_weather", + "arguments": {"city": "Boston"} + } + ] + } + ], + "usage": { + "input_tokens": 10, + "output_tokens": 5, + "total_tokens": 21, + "output_tokens_details": { + "reasoning_tokens": 6 + } + } + })) + .expect("parse response"); + + assert_eq!(response.id, "resp_123"); + assert_eq!(response.content.len(), 2); + assert_eq!(response.usage.reasoning_output_tokens, Some(6)); + assert_eq!(response.usage.total_tokens, Some(21)); + assert!(matches!(response.content[0], ResponseContent::Text(_))); + assert!(matches!( + response.content[1], + ResponseContent::ToolUse { .. } + )); +} + +#[test] +fn parse_response_preserves_cached_input_tokens() { + let response = parse_response(json!({ + "id": "resp_cache", + "status": "completed", + "output": [], + "usage": { + "input_tokens": 100, + "input_tokens_details": { "cached_tokens": 73 }, + "output_tokens": 4, + "total_tokens": 104 + } + })) + .expect("parse response"); + + assert_eq!(response.usage.input_tokens, 100); + assert_eq!(response.usage.cache_read_input_tokens, Some(73)); + assert_eq!(response.usage.total_tokens, Some(104)); +} + +#[test] +fn parse_response_falls_back_to_prompt_tokens_cache_details() { + let response = parse_response(json!({ + "id": "resp_prompt_cache", + "status": "completed", + "output": [], + "usage": { + "prompt_tokens": 20, + "input_tokens_details": {}, + "prompt_tokens_details": { "cached_tokens": 12 }, + "completion_tokens": 2, + "total_tokens": 22 + } + })) + .expect("parse response"); + + assert_eq!(response.usage.cache_read_input_tokens, Some(12)); +} + +#[test] +fn parse_response_parses_string_tool_arguments_as_json() { + let response = parse_response(json!({ + "id": "resp_args", + "status": "completed", + "output": [ + { + "type": "function_call", + "call_id": "call_1", + "name": "weather", + "arguments": "{\"city\":\"Boston\"}" + } + ] + })) + .expect("parse response"); + + assert_eq!( + response.content, + vec![ResponseContent::ToolUse { + id: "call_1".to_string(), + name: "weather".to_string(), + input: json!({"city": "Boston"}), + }] + ); +} + +#[test] +fn parse_response_extracts_web_search_call_as_hosted_tool_use() { + let response = parse_response(json!({ + "id": "resp_789", + "status": "completed", + "output": [ + { + "type": "web_search_call", + "id": "ws_1", + "status": "completed", + "action": { + "type": "search", + "query": "Rust async docs" + }, + "results": [ + { + "title": "Async Programming in Rust", + "url": "https://example.test/rust-async" + } + ] + } + ], + "usage": { + "input_tokens": 10, + "output_tokens": 5 + } + })) + .expect("parse response"); + + assert_eq!( + response.content, + vec![ResponseContent::HostedToolUse { + id: "ws_1".to_string(), + name: "web_search".to_string(), + input: json!({"query": "Rust async docs"}), + output: Some(json!([ + { + "title": "Async Programming in Rust", + "url": "https://example.test/rust-async" + } + ])), + status: Some("completed".to_string()), + }] + ); +} + +#[test] +fn parse_response_extracts_web_fetch_call_as_hosted_tool_use() { + let response = parse_response(json!({ + "id": "resp_fetch", + "status": "completed", + "output": [ + { + "type": "web_fetch_call", + "id": "wf_1", + "status": "completed", + "action": { + "type": "fetch", + "url": "https://example.test/docs" + }, + "output": { + "title": "Docs", + "url": "https://example.test/docs" + } + } + ], + "usage": { + "input_tokens": 10, + "output_tokens": 5 + } + })) + .expect("parse response"); + + assert_eq!( + response.content, + vec![ResponseContent::HostedToolUse { + id: "wf_1".to_string(), + name: "web_fetch".to_string(), + input: json!({"url": "https://example.test/docs"}), + output: Some(json!({ + "title": "Docs", + "url": "https://example.test/docs" + })), + status: Some("completed".to_string()), + }] + ); +} + +#[test] +fn parse_response_preserves_reasoning_text_as_metadata() { + let response = parse_response(json!({ + "id": "resp_456", + "status": "completed", + "output": [ + { + "type": "message", + "content": [ + {"type": "output_text", "text": "final"} + ], + "reasoning_content": "internal reasoning" + } + ], + "usage": { + "input_tokens": 3, + "output_tokens": 1 + } + })) + .expect("parse response"); + + assert_eq!(response.metadata.extras.len(), 1); + assert!(matches!( + response.metadata.extras[0], + ResponseExtra::ReasoningText { .. } + )); +} diff --git a/crates/provider/tests/openai_responses_errors.rs b/crates/provider/tests/openai_responses_errors.rs new file mode 100644 index 00000000..95d0bdb0 --- /dev/null +++ b/crates/provider/tests/openai_responses_errors.rs @@ -0,0 +1,626 @@ +use std::time::Duration; + +use devo_protocol::{ + ModelProfileKey, ModelRequest, ModelResponse, RequestContent, RequestMessage, ResponseContent, + ResponseMetadata, StopReason, StreamEvent, Usage, +}; +use devo_provider::error::ProviderError; +use devo_provider::openai::OpenAIResponsesProvider; +use devo_provider::{ModelProviderSDK, ProviderHttpOptions}; +use futures::StreamExt; +use pretty_assertions::assert_eq; +use serde_json::{Value, json}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio::time::timeout; + +#[tokio::test] +async fn nested_quota_error_then_failed_emits_only_the_first_error() { + let created = json!({ + "type": "response.created", + "response": {"id": "resp_no_credits", "status": "in_progress"} + }); + let in_progress = json!({ + "type": "response.in_progress", + "response": {"id": "resp_no_credits", "status": "in_progress"} + }); + let first = json!({ + "type": "error", + "error": { + "type": "insufficient_quota", + "code": "credit_balance_exhausted", + "message": "You have no credits remaining. Please add credits to continue." + } + }); + let failed = json!({ + "type": "response.failed", + "response": {"id": "resp_no_credits", "status": "failed", "error": { + "type": "insufficient_quota", + "code": "credit_balance_exhausted", + "message": "Repeated terminal error" + }} + }); + let events = collect_events(format!( + "event: response.created\ndata: {created}\n\n\ + event: response.in_progress\ndata: {in_progress}\n\n\ + event: error\ndata: {first}\n\n\ + event: response.failed\ndata: {failed}\n\n" + )) + .await; + + assert_eq!( + events, + vec![Err(json!({ + "error_kind": "quota_exceeded_error", + "message": "You have no credits remaining. Please add credits to continue.", + "provider_name": "openai" + }))] + ); +} + +#[tokio::test] +async fn failed_without_preceding_error_emits_the_response_error() { + for code in ["insufficient_quota", "credit_balance_exhausted"] { + let payload = json!({ + "type": "response.failed", + "response": {"id": "resp_failed", "status": "failed", "error": { + "code": code, "message": "Account unavailable" + }} + }); + assert_eq!( + collect_events(format!("event: response.failed\ndata: {payload}\n\n")).await, + vec![Err(json!({ + "error_kind": "quota_exceeded_error", + "message": "Account unavailable", + "provider_name": "openai" + }))], + "standalone response.failed: {code}" + ); + } +} + +#[tokio::test] +async fn error_accepts_top_level_code_and_message() { + for code in ["insufficient_quota", "credit_balance_exhausted"] { + let payload = json!({"type": "error", "code": code, "message": "Account unavailable"}); + assert_eq!( + collect_events(format!("event: error\ndata: {payload}\n\n")).await, + vec![Err(json!({ + "error_kind": "quota_exceeded_error", + "message": "Account unavailable", + "provider_name": "openai" + }))], + "top-level error: {code}" + ); + } +} + +#[tokio::test] +async fn absent_message_and_empty_event_fields_fall_back_to_json_type() { + for event_field in ["", "event: message\n", "event:\n"] { + for payload in [ + json!({"type": "error", "code": "insufficient_quota", "message": "Account unavailable"}), + json!({"type": "response.failed", "response": {"error": { + "code": "credit_balance_exhausted", "message": "Account unavailable" + }}}), + ] { + assert_eq!( + collect_events(format!("{event_field}data: {payload}\n\n")).await, + vec![Err(json!({ + "error_kind": "quota_exceeded_error", + "message": "Account unavailable", + "provider_name": "openai" + }))], + "event field {event_field:?}, JSON payload {payload}" + ); + } + } +} + +#[tokio::test] +async fn explicit_event_takes_precedence_over_json_type() { + let payload = json!({ + "type": "error", "id": "resp_precedence", "delta": "Hello", + "error": {"code": "insufficient_quota", "message": "Must not be treated as an error"} + }); + assert_eq!( + collect_events(format!( + "event: response.output_text.delta\ndata: {payload}\n\n" + )) + .await, + vec![ + Ok(StreamEvent::TextStart { index: 0 }), + Ok(StreamEvent::TextDelta { + index: 0, + text: "Hello".to_string() + }), + Ok(StreamEvent::MessageDone { + response: ModelResponse { + id: "resp_precedence".to_string(), + content: vec![ResponseContent::Text("Hello".to_string())], + stop_reason: Some(StopReason::EndTurn), + usage: Usage::default(), + metadata: ResponseMetadata::default(), + } + }), + ] + ); + + let payload = json!({ + "type": "response.completed", "response": {"error": { + "code": "credit_balance_exhausted", "message": "Explicit failure" + }} + }); + assert_eq!( + collect_events(format!("event: response.failed\ndata: {payload}\n\n")).await, + vec![Err(json!({ + "error_kind": "quota_exceeded_error", + "message": "Explicit failure", + "provider_name": "openai" + }))] + ); +} + +#[tokio::test] +async fn failed_without_error_details_uses_failure_fallback() { + for payload in [ + json!({"type": "response.failed"}), + json!({"type": "response.failed", "response": {"status": "failed"}}), + json!({"type": "response.failed", "response": {"error": null}}), + json!({"type": "response.failed", "response": {"error": {}}}), + json!({"type": "response.failed", "response": {"error": {"message": " "}}}), + ] { + assert_eq!( + collect_events(format!("event: response.failed\ndata: {payload}\n\n")).await, + vec![Err(json!({ + "error_kind": "unknown_error", + "message": "OpenAI Responses request failed without error details", + "status_code": null + }))], + "missing failure details: {payload}" + ); + } +} + +#[tokio::test] +async fn error_without_message_uses_stream_error_fallback() { + for payload in [ + json!({"type": "error"}), + json!({"type": "error", "error": {}}), + json!({"type": "error", "message": " "}), + ] { + assert_eq!( + collect_events(format!("event: error\ndata: {payload}\n\n")).await, + vec![Err(json!({ + "error_kind": "unknown_error", + "message": "OpenAI Responses stream returned an error without details", + "status_code": null + }))], + "missing error message: {payload}" + ); + } +} + +#[tokio::test] +async fn null_and_malformed_terminal_payloads_never_become_success() { + for (event, fallback) in [ + ( + "error", + "OpenAI Responses stream returned an error without details", + ), + ( + "response.failed", + "OpenAI Responses request failed without error details", + ), + ] { + for malformed in [ + json!(null), + json!(false), + json!(17), + json!("broken"), + json!([]), + json!([{}]), + ] { + for payload in [ + malformed.clone(), + json!({"error": malformed, "response": {"error": malformed}}), + json!({"error": malformed, "response": malformed}), + ] { + assert_eq!( + collect_events(format!( + "event: {event}\ndata: {payload}\n\n\ + event: response.completed\ndata: {{\"id\":\"must_not_succeed\"}}\n\n" + )) + .await, + vec![Err(json!({ + "error_kind": "unknown_error", + "message": fallback, + "status_code": null + }))], + "terminal event {event} with malformed payload {payload}" + ); + } + } + } +} + +#[tokio::test] +async fn malformed_error_fields_preserve_failure_classification_and_valid_message() { + let original_message = " Original malformed-field error: δ\n"; + for (event, fallback) in [ + ( + "error", + "OpenAI Responses stream returned an error without details", + ), + ( + "response.failed", + "OpenAI Responses request failed without error details", + ), + ] { + let cases = [ + ( + json!({"message": null, "type": null, "code": null}), + json!({ + "error_kind": "unknown_error", "message": fallback, "status_code": null + }), + ), + ( + json!({"message": 17, "type": [], "code": {}}), + json!({ + "error_kind": "unknown_error", "message": fallback, "status_code": null + }), + ), + ( + json!({"message": [], "type": false, "code": []}), + json!({ + "error_kind": "unknown_error", "message": fallback, "status_code": null + }), + ), + ( + json!({"message": {}, "type": 17, "code": false}), + json!({ + "error_kind": "unknown_error", "message": fallback, "status_code": null + }), + ), + ( + json!({"message": original_message, "type": {}, "code": []}), + json!({ + "error_kind": "unknown_error", "message": original_message, "status_code": null + }), + ), + ( + json!({"message": null, "type": [], "code": "insufficient_quota"}), + json!({ + "error_kind": "quota_exceeded_error", "message": fallback, "provider_name": "openai" + }), + ), + ( + json!({"message": false, "type": {}, "code": "credit_balance_exhausted"}), + json!({ + "error_kind": "quota_exceeded_error", "message": fallback, "provider_name": "openai" + }), + ), + ( + json!({"message": original_message, "type": [], "code": 429}), + json!({ + "error_kind": "rate_limit_error", "message": original_message, + "retry_after_seconds": null, "provider_name": "openai" + }), + ), + ( + json!({"message": original_message, "type": "server_error", "code": {}}), + json!({ + "error_kind": "provider_server_error", "message": original_message, + "status_code": null, "provider_name": "openai" + }), + ), + ]; + for (error, expected) in cases { + let payloads = if event == "error" { + vec![error.clone(), json!({"error": error})] + } else { + vec![json!({"response": {"error": error}})] + }; + for payload in payloads { + assert_eq!( + collect_events(format!("event: {event}\ndata: {payload}\n\n")).await, + vec![Err(expected.clone())], + "terminal event {event} with malformed error fields {payload}" + ); + } + } + } +} + +#[tokio::test] +async fn failure_after_text_does_not_emit_a_successful_message_done() { + let failed = json!({ + "type": "response.failed", "response": {"error": { + "code": "insufficient_quota", "message": "Account unavailable" + }} + }); + assert_eq!( + collect_events(format!( + "event: response.output_text.delta\n\ + data: {{\"id\":\"resp_partial\",\"delta\":\"Partial answer\"}}\n\n\ + event: response.failed\ndata: {failed}\n\n" + )) + .await, + vec![ + Ok(StreamEvent::TextStart { index: 0 }), + Ok(StreamEvent::TextDelta { + index: 0, + text: "Partial answer".to_string() + }), + Err(json!({ + "error_kind": "quota_exceeded_error", + "message": "Account unavailable", + "provider_name": "openai" + })), + ] + ); +} + +#[tokio::test] +async fn non_quota_errors_keep_existing_classifications_and_unknown_message() { + let message = " Original upstream failure: δ / opaque-detail\n"; + let cases = [ + ( + json!({"code": 401}), + json!({ + "error_kind": "authentication_error", "message": message, + "provider_name": "openai", "status_code": 401 + }), + ), + ( + json!({"code": 404}), + json!({ + "error_kind": "model_not_found_error", "message": message, + "model_name": "test-model" + }), + ), + ( + json!({"code": 408}), + json!({ + "error_kind": "provider_timeout_error", "message": message, + "provider_name": "openai" + }), + ), + ( + json!({"code": 429}), + json!({ + "error_kind": "rate_limit_error", "message": message, + "provider_name": "openai", "retry_after_seconds": null + }), + ), + ( + json!({"code": "503"}), + json!({ + "error_kind": "provider_server_error", "message": message, + "provider_name": "openai", "status_code": 503 + }), + ), + ( + json!({"type": "rate_limit_error"}), + json!({ + "error_kind": "rate_limit_error", "message": message, + "provider_name": "openai", "retry_after_seconds": null + }), + ), + ( + json!({"type": "server_error"}), + json!({ + "error_kind": "provider_server_error", "message": message, + "provider_name": "openai", "status_code": null + }), + ), + ( + json!({"code": "context_length_exceeded"}), + json!({ + "error_kind": "context_limit_error", "message": message, + "current_tokens": null, "limit": null + }), + ), + ( + json!({"type": "opaque_failure", "code": "unknown_vendor_code"}), + json!({ + "error_kind": "unknown_error", "message": message, "status_code": null + }), + ), + ]; + for (mut error, expected) in cases { + error["message"] = json!(message); + for (event_field, payload) in [ + ("event: error\n", json!({"type": "error", "error": error})), + ( + "event: response.failed\n", + json!({"type": "response.failed", "response": {"error": error}}), + ), + ] { + assert_eq!( + collect_events(format!("{event_field}data: {payload}\n\n")).await, + vec![Err(expected.clone())], + "existing classification for {payload}" + ); + } + } +} + +#[tokio::test] +async fn completion_fold_propagates_failure_instead_of_returning_partial_text() { + for event_field in ["event: error\n", "event: response.failed\n"] { + let error = json!({"code": "insufficient_quota", "message": "Account unavailable"}); + let payload = json!({"error": error, "response": {"error": error}}); + let (provider, server) = spawn_provider(format!( + "event: response.output_text.delta\n\ + data: {{\"id\":\"resp_partial\",\"delta\":\"Partial answer\"}}\n\n\ + {event_field}data: {payload}\n\n" + )) + .await; + let response = timeout( + Duration::from_secs(10), + provider.completion(minimal_request()), + ) + .await + .expect("completion must terminate") + .map_err(|error| { + serde_json::to_value( + error + .downcast_ref::() + .expect("typed provider error"), + ) + .expect("serialize provider error") + }); + server.await.expect("local SSE server completed"); + assert_eq!( + response, + Err(json!({ + "error_kind": "quota_exceeded_error", + "message": "Account unavailable", + "provider_name": "openai" + })) + ); + } +} + +#[tokio::test] +async fn ordinary_eof_still_completes_streamed_text() { + let events = collect_events( + concat!( + "event: response.output_text.delta\n", + "data: {\"id\":\"resp_eof\",\"delta\":\"Hello\"}\n\n", + ) + .to_string(), + ) + .await; + assert_eq!( + events, + vec![ + Ok(StreamEvent::TextStart { index: 0 }), + Ok(StreamEvent::TextDelta { + index: 0, + text: "Hello".to_string() + }), + Ok(StreamEvent::MessageDone { + response: ModelResponse { + id: "resp_eof".to_string(), + content: vec![ResponseContent::Text("Hello".to_string())], + stop_reason: Some(StopReason::EndTurn), + usage: Usage::default(), + metadata: ResponseMetadata::default(), + } + }), + ] + ); +} + +async fn collect_events(body: String) -> Vec> { + let (provider, server) = spawn_provider(body).await; + let events = timeout(Duration::from_secs(10), async { + provider + .completion_stream(minimal_request()) + .await + .expect("HTTP 200 SSE stream") + .map(|event| { + event.map_err(|error| { + serde_json::to_value( + error + .downcast_ref::() + .expect("typed provider error"), + ) + .expect("serialize provider error") + }) + }) + .collect::>() + .await + }) + .await + .expect("local SSE stream must terminate"); + server.await.expect("local SSE server completed"); + events +} + +async fn spawn_provider(body: String) -> (OpenAIResponsesProvider, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind local SSE server"); + let address = listener.local_addr().expect("local SSE address"); + let server = tokio::spawn(async move { + timeout(Duration::from_secs(10), async move { + let (mut socket, _) = listener.accept().await.expect("accept provider request"); + let mut request = Vec::new(); + let mut buffer = [0; 4096]; + loop { + let count = socket + .read(&mut buffer) + .await + .expect("read provider request"); + assert_ne!(count, 0, "request closed before its body was received"); + request.extend_from_slice(&buffer[..count]); + if let Some(header_end) = + request.windows(4).position(|window| window == b"\r\n\r\n") + { + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .filter_map(|line| line.split_once(':')) + .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .map(|(_, value)| { + value + .trim() + .parse::() + .expect("request content length") + }) + .expect("JSON request has a content length"); + if request.len() >= header_end + 4 + content_length { + break; + } + } + } + let content_length = body.len(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\ + Content-Length: {content_length}\r\nConnection: close\r\n\r\n{body}" + ); + socket + .write_all(response.as_bytes()) + .await + .expect("write HTTP 200 SSE response"); + socket.shutdown().await.expect("close SSE response"); + }) + .await + .expect("local SSE server must terminate"); + }); + let http_options = ProviderHttpOptions::from_raw_with_no_proxy( + /*proxy_url*/ None, + Some("*".to_string()), + /*headers*/ None, + ) + .expect("explicit proxy bypass"); + let provider = OpenAIResponsesProvider::new(format!("http://{address}")) + .with_http_options(http_options) + .expect("local provider with no proxy"); + (provider, server) +} + +fn minimal_request() -> ModelRequest { + ModelRequest { + model_slug: ModelProfileKey::Generic, + model: "test-model".to_string(), + system: None, + messages: vec![RequestMessage { + role: "user".to_string(), + content: vec![RequestContent::Text { + text: "Hello".to_string(), + }], + }], + max_tokens: 16, + tools: None, + hosted_tools: Vec::new(), + sampling: Default::default(), + request_thinking: None, + reasoning_effort: None, + extra_body: None, + } +} diff --git a/crates/server/tests/provider_quota_reporting.rs b/crates/server/tests/provider_quota_reporting.rs new file mode 100644 index 00000000..99b613af --- /dev/null +++ b/crates/server/tests/provider_quota_reporting.rs @@ -0,0 +1,374 @@ +//! Quota exhaustion is a terminal provider failure, including empty streams. +//! Exercise Native notifications and durable replay without any network calls. + +use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; + +#[path = "support/rollout.rs"] +mod support; + +use anyhow::Context; +use anyhow::Result; +use async_trait::async_trait; +use devo_protocol::ModelRequest; +use devo_protocol::ModelResponse; +use devo_protocol::ResponseContent; +use devo_protocol::ResponseMetadata; +use devo_protocol::StopReason; +use devo_protocol::StreamEvent; +use devo_protocol::Usage; +use devo_protocol::native::error::AgentError; +use devo_protocol::native::page::Page; +use devo_protocol::native::rpc_session::SessionMetadataUpdateResult; +use devo_protocol::native::rpc_session::SessionNewResult; +use devo_protocol::native::rpc_session::SessionResumeResult; +use devo_protocol::native::rpc_turn::TurnStartResult; +use devo_protocol::native::turn::Turn; +use devo_protocol::native::turn::TurnStatus; +use devo_provider::ProviderRoute; +use devo_provider::ProviderRouter; +use devo_provider::error::ProviderError; +use devo_server::ClientTransportKind; +use devo_server::ServerRuntime; +use devo_server::SuccessResponse; +use devo_server::test_support::TestRuntime; +use futures::Stream; +use futures::stream; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use tempfile::TempDir; +use tokio::sync::mpsc; +use tokio::time::Duration; +use tokio::time::timeout; + +const QUOTA_MESSAGE: &str = + "Account quota exhausted: remaining credits = 0. 请充值后重试。 [request_id=quota-test]"; + +struct QuotaRouter { + text_before_error: &'static str, + attempts: AtomicUsize, +} + +#[async_trait] +impl ProviderRouter for QuotaRouter { + async fn stream( + &self, + _route: ProviderRoute, + _request: ModelRequest, + ) -> Result> + Send>>, ProviderError> + { + self.attempts.fetch_add(1, Ordering::SeqCst); + let mut events = Vec::new(); + if !self.text_before_error.is_empty() { + events.push(Ok(StreamEvent::TextDelta { + index: 0, + text: self.text_before_error.to_string(), + })); + } + // Fail inside the stream, not while opening it. No MessageDone follows. + events.push(Err(ProviderError::QuotaExceededError { + message: QUOTA_MESSAGE.to_string(), + provider_name: Some("openai".to_string()), + } + .into())); + Ok(Box::pin(stream::iter(events))) + } + + async fn complete( + &self, + _route: ProviderRoute, + _request: ModelRequest, + ) -> Result { + // Background title generation must stay local and must not count as a retry. + Ok(ModelResponse { + id: "quota-test-title".to_string(), + content: vec![ResponseContent::Text("Quota test".to_string())], + stop_reason: Some(StopReason::EndTurn), + usage: Usage::default(), + metadata: ResponseMetadata::default(), + }) + } + + fn name(&self) -> &str { + "quota-router" + } +} + +#[tokio::test] +async fn quota_failure_without_text_is_reported_once_and_survives_replay() -> Result<()> { + assert_quota_failure("").await +} + +#[tokio::test] +async fn quota_failure_after_partial_text_is_reported_once_and_survives_replay() -> Result<()> { + assert_quota_failure("Partial answer before quota exhaustion.").await +} + +async fn assert_quota_failure(text_before_error: &'static str) -> Result<()> { + let data_root = TempDir::new()?; + std::fs::write( + data_root.path().join("auth.json"), + serde_json::to_vec_pretty(&json!({ + "version": 1, + "credentials": { + "test_api_key": { "kind": "api_key", "value": "test-secret" } + } + }))?, + )?; + // Use a connection route so the provider/authStale path is actually exercised. + std::fs::write( + data_root.path().join("config.toml"), + r#" +[defaults] +model_binding = "main" + +[providers.openai] +enabled = true +name = "OpenAI" +credential = "test_api_key" +wire_apis = ["openai_chat_completions"] + +[model_bindings.main] +enabled = true +model_slug = "default-model" +provider = "openai" +request_model = "provider-model" +invocation_method = "openai_chat_completions" +"#, + )?; + let router = Arc::new(QuotaRouter { + text_before_error, + attempts: AtomicUsize::new(0), + }); + let runtime = build_runtime(data_root.path(), router.clone()); + let (connection_id, mut notifications) = initialize_connection(&runtime).await?; + let response = runtime + .handle_incoming( + connection_id, + json!({ + "id": 2, + "method": "session/new", + "params": { + "cwd": data_root.path(), + "idempotencyKey": "quota-session" + } + }), + ) + .await + .context("session/new response")?; + let session = serde_json::from_value::>(response)? + .result + .session; + let session_id = session.id; + let response = runtime + .handle_incoming( + connection_id, + json!({ + "id": 3, + "method": "session/metadata/update", + "params": { + "sessionId": session_id, + "expectedVersion": 0, + "modelBindingId": "main" + } + }), + ) + .await + .context("session/metadata/update response")?; + let _: SuccessResponse = serde_json::from_value(response)?; + let response = runtime + .handle_incoming( + connection_id, + json!({ + "id": 4, + "method": "turn/start", + "params": { + "sessionId": session_id, + "input": [{ "type": "text", "text": "try the provider" }], + "idempotencyKey": "quota-turn" + } + }), + ) + .await + .context("turn/start response")?; + let started = serde_json::from_value::>(response)?.result; + let turn_id = started.turn.id; + let mut completed_turns = Vec::new(); + let mut retry_or_auth_notifications = Vec::new(); + let mut assistant_items = Vec::new(); + let mut assistant_text = String::new(); + // Real time: the runtime boots a real RLM kernel even with a mock provider. + timeout(Duration::from_secs(30), async { + while let Some(value) = notifications.recv().await { + match value["method"].as_str() { + Some("model/queryRetrying" | "provider/authStale") => { + retry_or_auth_notifications.push(value); + } + Some("item/assistantMessage/delta") => { + assistant_text.push_str( + value["params"]["delta"] + .as_str() + .context("assistant delta text")?, + ); + } + Some("item/started" | "item/completed") + if value["params"]["item"]["item"]["type"] == "assistantMessage" => + { + assistant_items.push(value["params"]["item"].clone()); + } + Some("turn/completed") if value["params"]["turn"]["id"] == json!(turn_id) => { + completed_turns.push(value["params"]["turn"].clone()); + } + Some("session/statusChanged") + if value["params"]["sessionId"] == json!(session_id) + && value["params"]["status"] == "idle" + && !completed_turns.is_empty() => + { + return Ok(()); + } + Some(_) | None => {} + } + } + anyhow::bail!("notification channel closed before failed turn became idle") + }) + .await + .context("timed out waiting for quota failure")??; + + assert_eq!(router.attempts.load(Ordering::SeqCst), 1); + assert_eq!(retry_or_auth_notifications, Vec::::new()); + assert_eq!(assistant_text, text_before_error); + if text_before_error.is_empty() { + assert_eq!(assistant_items, Vec::::new()); + } + assert_eq!(completed_turns.len(), 1); + let completed = completed_turns.pop().context("one turn/completed")?; + // Preserve the provider's exact text, including Unicode and its request id, + // inside the existing core/provider Display prefixes (no generic fallback). + let message = format!("model provider error: quota exceeded: {QUOTA_MESSAGE}"); + assert_eq!( + json!({ "status": completed["status"], "error": completed["error"] }), + json!({ + "status": "failed", + "error": { + "errorCode": "QUOTA_EXCEEDED_ERROR", + "message": message, + "retryable": false, + "requiresSnapshot": false + } + }) + ); + let failed_turn: Turn = serde_json::from_value(completed)?; + assert_eq!( + (failed_turn.status, failed_turn.error.clone()), + ( + TurnStatus::Failed, + Some(AgentError::new("QUOTA_EXCEEDED_ERROR", message.clone())) + ) + ); + let rollout_path = data_root + .path() + .join("sessions") + .join(format!("{session_id}.jsonl")); + let persisted_failures = support::read_rollout_records(&rollout_path)? + .into_iter() + .filter_map(|line| { + if let devo_core::LegacyRolloutLine::Turn(line) = line + && line.turn.id.to_string() == turn_id.as_str() + && line.turn.status == devo_protocol::TurnStatus::Failed + { + Some((line.turn.status, line.turn.error)) + } else { + None + } + }) + .collect::>(); + assert_eq!( + persisted_failures, + vec![( + devo_protocol::TurnStatus::Failed, + Some(devo_core::TurnError { + code: "QUOTA_EXCEEDED_ERROR".to_string(), + message, + recovery_hint: None, + }) + )] + ); + + // Rebuild the runtime: same-runtime resume could pass using cached actor state. + runtime.shutdown().await; + drop(runtime); + let restored = build_runtime(data_root.path(), router.clone()); + restored.load_persisted_sessions().await?; + let (restored_connection_id, _notifications) = initialize_connection(&restored).await?; + let response = restored + .handle_incoming( + restored_connection_id, + json!({ + "id": 5, + "method": "session/resume", + "params": { "sessionId": session_id } + }), + ) + .await + .context("session/resume from rollout")?; + let resumed = serde_json::from_value::>(response)?.result; + assert_eq!(resumed.session.id, session_id); + let response = restored + .handle_incoming( + restored_connection_id, + json!({ + "id": 6, + "method": "session/turns/list", + "params": { "sessionId": session_id } + }), + ) + .await + .context("session/turns/list after replay")?; + let turns = serde_json::from_value::>>(response)?.result; + assert_eq!(turns.data, vec![failed_turn]); + assert_eq!(router.attempts.load(Ordering::SeqCst), 1); + restored.shutdown().await; + Ok(()) +} + +fn build_runtime(data_root: &Path, router: Arc) -> Arc { + TestRuntime::noop() + .router(router) + .with_named_model("default-model", "Default Model") + .default_model("default-model") + .db_file("provider_quota_reporting.db") + .runtime(data_root) +} + +async fn initialize_connection( + runtime: &Arc, +) -> Result<(u64, mpsc::Receiver)> { + let (tx, rx) = devo_server::test_outbound_channel(128); + let connection_id = runtime + .register_connection(ClientTransportKind::Stdio, tx) + .await; + let response = runtime + .handle_incoming( + connection_id, + json!({ + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": 1, + "clientCapabilities": {}, + "_meta": { "devo": { "protocol": "native" } }, + "clientInfo": { "name": "quota-test", "version": "1.0.0" } + } + }), + ) + .await + .context("initialize response")?; + assert!( + response.get("result").is_some(), + "initialize failed: {response}" + ); + Ok((connection_id, rx)) +} diff --git a/docs/usage.md b/docs/usage.md index 4ccf5bfb..c0669550 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -120,6 +120,20 @@ Local catalog files can still be refreshed, and the bundled catalog is always av Model requests go to the endpoint you configure. For local model traffic, choose a compatible local endpoint. +### Errors during a conversation + +A saved API key does not guarantee available credits. If an OpenAI-compatible +Responses stream reports `error` or `response.failed`, Devo ends that turn as a +failure and displays the provider's error in the conversation, even when no +assistant text arrived. Text already received remains visible. + +Within these stream events, `insufficient_quota` and `credit_balance_exhausted` +are quota failures, not login failures. Devo does not automatically retry them +or invalidate the saved credential. Add credits or choose another configured +provider, then send a new message. The failed turn remains visible when the +session is resumed. Non-success HTTP responses retain their existing status-code +classification; this handling applies to failures inside an established SSE stream. + ## Installation ### Desktop app From a322ec15909f0087a7b9fe28811cc55bf284ac84 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Fri, 6 Nov 2026 03:42:55 +0800 Subject: [PATCH 02/16] test(runtime): capture Windows tar extraction diagnostics --- scripts/runtime/packs.test.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/scripts/runtime/packs.test.ts b/scripts/runtime/packs.test.ts index 18a045e4..baefafdd 100644 --- a/scripts/runtime/packs.test.ts +++ b/scripts/runtime/packs.test.ts @@ -58,8 +58,26 @@ test("runtime tar archives are reproducible and preserve long Unicode paths, exe const extracted = join(root, "extracted"); mkdirSync(extracted); const tar = process.platform === "win32" ? join(process.env.SystemRoot!, "System32/tar.exe") : "tar"; + // DIAGNOSTIC: gather Windows native tar evidence without hiding exit/stderr. + const version = Bun.spawnSync([tar, "--version"], { stdout: "pipe", stderr: "pipe" }); + console.log("DIAG tar --version", { exit: version.exitCode, stdout: version.stdout.toString(), stderr: version.stderr.toString() }); + const listed = Bun.spawnSync([tar, "-tzf", first], { stdout: "pipe", stderr: "pipe" }); + console.log("DIAG tar -tzf", { exit: listed.exitCode, stdout: listed.stdout.toString(), stderr: listed.stderr.toString() }); const result = Bun.spawnSync([tar, "-xzf", first, "-C", extracted], { stdout: "pipe", stderr: "pipe" }); - expect({ exit: result.exitCode, stderr: result.stderr.toString(), longPath: readFileSync(join(extracted, long), "utf8") }).toEqual({ exit: 0, stderr: "", longPath: "unicode payload" }); + let extractedEntries: unknown; + try { + extractedEntries = readdirSync(extracted, { recursive: true }); + } catch (error) { + extractedEntries = String(error); + } + let longPath: string | null = null; + try { + longPath = readFileSync(join(extracted, long), "utf8"); + } catch (error) { + console.log("DIAG longPath error", String(error)); + } + console.log("DIAG tar extract", { exit: result.exitCode, stderr: result.stderr.toString(), extractedEntries }); + expect({ exit: result.exitCode, stderr: result.stderr.toString(), longPath }).toEqual({ exit: 0, stderr: "", longPath: "unicode payload" }); if (process.platform !== "win32") expect(readFileSync(join(extracted, "runtime/python/python-link"), "utf8")).toBe("original runtime/python/bin/python3"); } finally { rmSync(root, { recursive: true, force: true }); } }); From 483299b781897054f594231de98092fef1c00776 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Fri, 6 Nov 2026 03:57:26 +0800 Subject: [PATCH 03/16] fix(runtime): decode UTF-8 tar names on Windows --- install.ps1 | 6 ++++-- scripts/runtime/bundle.ts | 9 +++++++-- scripts/runtime/packs.test.ts | 25 +++++-------------------- 3 files changed, 16 insertions(+), 24 deletions(-) diff --git a/install.ps1 b/install.ps1 index 4db105dd..49426841 100644 --- a/install.ps1 +++ b/install.ps1 @@ -431,7 +431,9 @@ function Get-CachedDevoRuntime { Write-Host "Downloading $Kind runtime ..." Receive-DevoAsset -Url "$BaseUrl/$Asset" -Destination $archive if ((Get-Sha256Hex $archive) -ine $Digest) { throw "Runtime SHA-256 verification failed: $Asset" } - & "$env:SystemRoot\System32\tar.exe" -xzf $archive -C $stage + # Native bsdtar/libarchive 3.8.9 needs an explicit UTF-8 reader charset + # for non-ASCII entries (libarchive#3063). + & "$env:SystemRoot\System32\tar.exe" --options hdrcharset=UTF-8 -xzf $archive -C $stage if ($LASTEXITCODE -ne 0) { throw "Failed to extract $Asset" } Remove-Item -LiteralPath $archive $extractedBinary = Join-Path $stage "runtime\$Kind\$Kind.exe" @@ -498,7 +500,7 @@ function Install-DevoOnline { $appArchive = Join-Path $TempRoot "app.tar.gz" Receive-DevoAsset -Url "$baseUrl/$($packs.app.Asset)" -Destination $appArchive if ((Get-Sha256Hex $appArchive) -ine $packs.app.Digest) { throw "App SHA-256 verification failed" } - & "$env:SystemRoot\System32\tar.exe" -xzf $appArchive -C $extracted + & "$env:SystemRoot\System32\tar.exe" --options hdrcharset=UTF-8 -xzf $appArchive -C $extracted if ($LASTEXITCODE -ne 0) { throw "Failed to extract app archive" } } catch { $failure = $_ } finally { foreach ($worker in $workers) { diff --git a/scripts/runtime/bundle.ts b/scripts/runtime/bundle.ts index 0bfe30ce..18035a04 100644 --- a/scripts/runtime/bundle.ts +++ b/scripts/runtime/bundle.ts @@ -36,6 +36,11 @@ export async function bundleRuntime(target: string, output: string, rg: string, const backendNames = [`devo${ext}`, ...(platform === "win32" ? WINDOWS_SANDBOX_HELPERS : [])]; // Git Bash's GNU tar treats C: paths as remote hosts. Use native bsdtar. const tar = process.platform === "win32" ? join(process.env.SystemRoot ?? "C:/Windows", "System32/tar.exe") : "tar"; + // Native Windows bsdtar/libarchive 3.8.9 needs an explicit UTF-8 reader + // charset for non-ASCII entries (libarchive#3063). Apply it only to tar + // inputs: the Windows Node source is a ZIP and format options are not shared. + const tarOptionsFor = (archive: string) => + process.platform === "win32" && archive.endsWith(".tar.gz") ? ["--options", "hdrcharset=UTF-8"] : []; for (const file of [...backendNames.map(name => join(binaryDir, name)), rg]) { if (!existsSync(file)) throw new Error(`Missing release binary ${file}`); } @@ -46,14 +51,14 @@ export async function bundleRuntime(target: string, output: string, rg: string, const nodeArchive = await download(source.node, cache); const nodeExtract = join(work, "node"); mkdirSync(nodeExtract); - run(tar, ["-xf", nodeArchive, "-C", nodeExtract]); + run(tar, [...tarOptionsFor(nodeArchive), "-xf", nodeArchive, "-C", nodeExtract]); const nodeRoot = join(nodeExtract, readdirSync(nodeExtract)[0]); const nodePath = join(output, "runtime/node", platform === "win32" ? "node.exe" : "bin/node"); copyFileSync(join(nodeRoot, platform === "win32" ? "node.exe" : "bin/node"), nodePath); copyFileSync(join(nodeRoot, "LICENSE"), join(output, "runtime/node/LICENSE")); if (platform !== "win32") chmodSync(nodePath, 0o755); const pythonArchive = await download(source.python, cache); - run(tar, ["-xf", pythonArchive, "-C", join(output, "runtime")]); + run(tar, [...tarOptionsFor(pythonArchive), "-xf", pythonArchive, "-C", join(output, "runtime")]); const site = join(output, "runtime/python-site"); run("uv", ["pip", "install", "--python-version", "3.13", "--python-platform", runtimeTarget, "--target", site, "--only-binary", ":all:", "--require-hashes", "--no-compile-bytecode", diff --git a/scripts/runtime/packs.test.ts b/scripts/runtime/packs.test.ts index baefafdd..9040b416 100644 --- a/scripts/runtime/packs.test.ts +++ b/scripts/runtime/packs.test.ts @@ -58,26 +58,11 @@ test("runtime tar archives are reproducible and preserve long Unicode paths, exe const extracted = join(root, "extracted"); mkdirSync(extracted); const tar = process.platform === "win32" ? join(process.env.SystemRoot!, "System32/tar.exe") : "tar"; - // DIAGNOSTIC: gather Windows native tar evidence without hiding exit/stderr. - const version = Bun.spawnSync([tar, "--version"], { stdout: "pipe", stderr: "pipe" }); - console.log("DIAG tar --version", { exit: version.exitCode, stdout: version.stdout.toString(), stderr: version.stderr.toString() }); - const listed = Bun.spawnSync([tar, "-tzf", first], { stdout: "pipe", stderr: "pipe" }); - console.log("DIAG tar -tzf", { exit: listed.exitCode, stdout: listed.stdout.toString(), stderr: listed.stderr.toString() }); - const result = Bun.spawnSync([tar, "-xzf", first, "-C", extracted], { stdout: "pipe", stderr: "pipe" }); - let extractedEntries: unknown; - try { - extractedEntries = readdirSync(extracted, { recursive: true }); - } catch (error) { - extractedEntries = String(error); - } - let longPath: string | null = null; - try { - longPath = readFileSync(join(extracted, long), "utf8"); - } catch (error) { - console.log("DIAG longPath error", String(error)); - } - console.log("DIAG tar extract", { exit: result.exitCode, stderr: result.stderr.toString(), extractedEntries }); - expect({ exit: result.exitCode, stderr: result.stderr.toString(), longPath }).toEqual({ exit: 0, stderr: "", longPath: "unicode payload" }); + // Windows 11 / Server 2025 ships libarchive 3.8.9, which mis-decodes UTF-8 + // names unless the tar reader is told the intended charset (libarchive#3063). + const tarArgs = process.platform === "win32" ? ["--options", "hdrcharset=UTF-8"] : []; + const result = Bun.spawnSync([tar, ...tarArgs, "-xzf", first, "-C", extracted], { stdout: "pipe", stderr: "pipe" }); + expect({ exit: result.exitCode, stderr: result.stderr.toString(), longPath: readFileSync(join(extracted, long), "utf8") }).toEqual({ exit: 0, stderr: "", longPath: "unicode payload" }); if (process.platform !== "win32") expect(readFileSync(join(extracted, "runtime/python/python-link"), "utf8")).toBe("original runtime/python/bin/python3"); } finally { rmSync(root, { recursive: true, force: true }); } }); From a4e0ded297dc5e8b30095ccb6012754fe834dded Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Fri, 6 Nov 2026 04:02:46 +0800 Subject: [PATCH 04/16] revert(runtime): remove ineffective Windows tar charset option --- install.ps1 | 6 ++---- scripts/runtime/bundle.ts | 9 ++------- scripts/runtime/packs.test.ts | 25 ++++++++++++++++++++----- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/install.ps1 b/install.ps1 index 49426841..4db105dd 100644 --- a/install.ps1 +++ b/install.ps1 @@ -431,9 +431,7 @@ function Get-CachedDevoRuntime { Write-Host "Downloading $Kind runtime ..." Receive-DevoAsset -Url "$BaseUrl/$Asset" -Destination $archive if ((Get-Sha256Hex $archive) -ine $Digest) { throw "Runtime SHA-256 verification failed: $Asset" } - # Native bsdtar/libarchive 3.8.9 needs an explicit UTF-8 reader charset - # for non-ASCII entries (libarchive#3063). - & "$env:SystemRoot\System32\tar.exe" --options hdrcharset=UTF-8 -xzf $archive -C $stage + & "$env:SystemRoot\System32\tar.exe" -xzf $archive -C $stage if ($LASTEXITCODE -ne 0) { throw "Failed to extract $Asset" } Remove-Item -LiteralPath $archive $extractedBinary = Join-Path $stage "runtime\$Kind\$Kind.exe" @@ -500,7 +498,7 @@ function Install-DevoOnline { $appArchive = Join-Path $TempRoot "app.tar.gz" Receive-DevoAsset -Url "$baseUrl/$($packs.app.Asset)" -Destination $appArchive if ((Get-Sha256Hex $appArchive) -ine $packs.app.Digest) { throw "App SHA-256 verification failed" } - & "$env:SystemRoot\System32\tar.exe" --options hdrcharset=UTF-8 -xzf $appArchive -C $extracted + & "$env:SystemRoot\System32\tar.exe" -xzf $appArchive -C $extracted if ($LASTEXITCODE -ne 0) { throw "Failed to extract app archive" } } catch { $failure = $_ } finally { foreach ($worker in $workers) { diff --git a/scripts/runtime/bundle.ts b/scripts/runtime/bundle.ts index 18035a04..0bfe30ce 100644 --- a/scripts/runtime/bundle.ts +++ b/scripts/runtime/bundle.ts @@ -36,11 +36,6 @@ export async function bundleRuntime(target: string, output: string, rg: string, const backendNames = [`devo${ext}`, ...(platform === "win32" ? WINDOWS_SANDBOX_HELPERS : [])]; // Git Bash's GNU tar treats C: paths as remote hosts. Use native bsdtar. const tar = process.platform === "win32" ? join(process.env.SystemRoot ?? "C:/Windows", "System32/tar.exe") : "tar"; - // Native Windows bsdtar/libarchive 3.8.9 needs an explicit UTF-8 reader - // charset for non-ASCII entries (libarchive#3063). Apply it only to tar - // inputs: the Windows Node source is a ZIP and format options are not shared. - const tarOptionsFor = (archive: string) => - process.platform === "win32" && archive.endsWith(".tar.gz") ? ["--options", "hdrcharset=UTF-8"] : []; for (const file of [...backendNames.map(name => join(binaryDir, name)), rg]) { if (!existsSync(file)) throw new Error(`Missing release binary ${file}`); } @@ -51,14 +46,14 @@ export async function bundleRuntime(target: string, output: string, rg: string, const nodeArchive = await download(source.node, cache); const nodeExtract = join(work, "node"); mkdirSync(nodeExtract); - run(tar, [...tarOptionsFor(nodeArchive), "-xf", nodeArchive, "-C", nodeExtract]); + run(tar, ["-xf", nodeArchive, "-C", nodeExtract]); const nodeRoot = join(nodeExtract, readdirSync(nodeExtract)[0]); const nodePath = join(output, "runtime/node", platform === "win32" ? "node.exe" : "bin/node"); copyFileSync(join(nodeRoot, platform === "win32" ? "node.exe" : "bin/node"), nodePath); copyFileSync(join(nodeRoot, "LICENSE"), join(output, "runtime/node/LICENSE")); if (platform !== "win32") chmodSync(nodePath, 0o755); const pythonArchive = await download(source.python, cache); - run(tar, [...tarOptionsFor(pythonArchive), "-xf", pythonArchive, "-C", join(output, "runtime")]); + run(tar, ["-xf", pythonArchive, "-C", join(output, "runtime")]); const site = join(output, "runtime/python-site"); run("uv", ["pip", "install", "--python-version", "3.13", "--python-platform", runtimeTarget, "--target", site, "--only-binary", ":all:", "--require-hashes", "--no-compile-bytecode", diff --git a/scripts/runtime/packs.test.ts b/scripts/runtime/packs.test.ts index 9040b416..baefafdd 100644 --- a/scripts/runtime/packs.test.ts +++ b/scripts/runtime/packs.test.ts @@ -58,11 +58,26 @@ test("runtime tar archives are reproducible and preserve long Unicode paths, exe const extracted = join(root, "extracted"); mkdirSync(extracted); const tar = process.platform === "win32" ? join(process.env.SystemRoot!, "System32/tar.exe") : "tar"; - // Windows 11 / Server 2025 ships libarchive 3.8.9, which mis-decodes UTF-8 - // names unless the tar reader is told the intended charset (libarchive#3063). - const tarArgs = process.platform === "win32" ? ["--options", "hdrcharset=UTF-8"] : []; - const result = Bun.spawnSync([tar, ...tarArgs, "-xzf", first, "-C", extracted], { stdout: "pipe", stderr: "pipe" }); - expect({ exit: result.exitCode, stderr: result.stderr.toString(), longPath: readFileSync(join(extracted, long), "utf8") }).toEqual({ exit: 0, stderr: "", longPath: "unicode payload" }); + // DIAGNOSTIC: gather Windows native tar evidence without hiding exit/stderr. + const version = Bun.spawnSync([tar, "--version"], { stdout: "pipe", stderr: "pipe" }); + console.log("DIAG tar --version", { exit: version.exitCode, stdout: version.stdout.toString(), stderr: version.stderr.toString() }); + const listed = Bun.spawnSync([tar, "-tzf", first], { stdout: "pipe", stderr: "pipe" }); + console.log("DIAG tar -tzf", { exit: listed.exitCode, stdout: listed.stdout.toString(), stderr: listed.stderr.toString() }); + const result = Bun.spawnSync([tar, "-xzf", first, "-C", extracted], { stdout: "pipe", stderr: "pipe" }); + let extractedEntries: unknown; + try { + extractedEntries = readdirSync(extracted, { recursive: true }); + } catch (error) { + extractedEntries = String(error); + } + let longPath: string | null = null; + try { + longPath = readFileSync(join(extracted, long), "utf8"); + } catch (error) { + console.log("DIAG longPath error", String(error)); + } + console.log("DIAG tar extract", { exit: result.exitCode, stderr: result.stderr.toString(), extractedEntries }); + expect({ exit: result.exitCode, stderr: result.stderr.toString(), longPath }).toEqual({ exit: 0, stderr: "", longPath: "unicode payload" }); if (process.platform !== "win32") expect(readFileSync(join(extracted, "runtime/python/python-link"), "utf8")).toBe("original runtime/python/bin/python3"); } finally { rmSync(root, { recursive: true, force: true }); } }); From a04783353fc9656f1d4309472326ae979ecb5282 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Fri, 6 Nov 2026 04:07:13 +0800 Subject: [PATCH 05/16] fix(install): fail closed on stale runtime cache locks --- install.sh | 18 ++++++++++-------- scripts/runtime/installer-fixture.ts | 4 ++-- scripts/runtime/packs.test.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/install.sh b/install.sh index 5c6fd29e..93e8660e 100644 --- a/install.sh +++ b/install.sh @@ -551,17 +551,19 @@ cached_runtime() ( attempts=0 while ! mkdir "$lock" 2>/dev/null; do attempts=$((attempts + 1)) - [ "$attempts" -lt 300 ] || die "Timed out waiting for runtime cache lock: $lock" - if [ -f "$lock/pid" ]; then - owner="$(cat "$lock/pid")" - case "$owner" in ''|*[!0-9]*) ;; *) - if ! kill -0 "$owner" 2>/dev/null; then rm -f "$lock/pid"; rmdir "$lock" 2>/dev/null || true; fi ;; - esac - fi + [ "$attempts" -lt 300 ] || die "Timed out waiting for runtime cache lock. If no installer is running, remove the stale lock directory manually: $lock" sleep 1 done stage="" - trap 'if [ -n "$stage" ]; then rm -rf "$stage"; fi; rm -f "$lock/pid"; rmdir "$lock"' EXIT + cleanup_runtime_lock() { + # A crash leaves the lock for explicit user cleanup. Only the process + # that wrote this pid token may release its own lock. + if [ -f "$lock/pid" ] && [ "$(cat "$lock/pid" 2>/dev/null || true)" = "$$" ]; then + rm -f "$lock/pid" + rmdir "$lock" + fi + } + trap 'if [ -n "$stage" ]; then rm -rf "$stage"; fi; cleanup_runtime_lock' EXIT trap 'exit 1' INT TERM printf '%s\n' "$$" > "$lock/pid" case "$kind" in node) executable="bin/node" ;; python) executable="bin/python3" ;; esac diff --git a/scripts/runtime/installer-fixture.ts b/scripts/runtime/installer-fixture.ts index d171d47a..f7e5007d 100644 --- a/scripts/runtime/installer-fixture.ts +++ b/scripts/runtime/installer-fixture.ts @@ -10,7 +10,7 @@ export function writeChecksums(assets: string): void { } /** Run the actual network/install functions without modifying the user's PATH. */ -export async function installOnline(root: string, destination: string, cache: string, origin: string, target: string, version = "v0.2.0", mode: "functions" | "entrypoint" = "functions") { +export async function installOnline(root: string, destination: string, cache: string, origin: string, target: string, version = "v0.2.0", mode: "functions" | "entrypoint" = "functions", extraEnv: Record = {}) { const work = join(root, `work-${crypto.randomUUID()}`); mkdirSync(work); const windows = process.platform === "win32"; @@ -32,7 +32,7 @@ export async function installOnline(root: string, destination: string, cache: st ? [join(process.env.SystemRoot!, "System32/WindowsPowerShell/v1.0/powershell.exe"), "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", path] : ["sh", path, "--no-modify-path"], { stdout: "pipe", stderr: "pipe", - env: { ...process.env, VERSION: version, DEVO_RUNTIME_CACHE: cache, DEVO_RELEASE_BASE_URL: origin, DEVO_RUNTIME_BASE_URL: `${origin}/runtime-packs`, DEVO_INSTALL_DIR: destination, DEVO_NO_MODIFY_PATH: "1", + env: { ...process.env, ...extraEnv, VERSION: version, DEVO_RUNTIME_CACHE: cache, DEVO_RELEASE_BASE_URL: origin, DEVO_RUNTIME_BASE_URL: `${origin}/runtime-packs`, DEVO_INSTALL_DIR: destination, DEVO_NO_MODIFY_PATH: "1", ...(windows ? { PSModulePath: join(process.env.SystemRoot!, "System32/WindowsPowerShell/v1.0/Modules") } : {}) }, }); const [exit, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); diff --git a/scripts/runtime/packs.test.ts b/scripts/runtime/packs.test.ts index baefafdd..9c59753c 100644 --- a/scripts/runtime/packs.test.ts +++ b/scripts/runtime/packs.test.ts @@ -147,6 +147,34 @@ test("real installers reuse runtimes across upgrades, repair damage, reject corr expect({ failed: corruptIndex.exit !== 0, version: JSON.parse(readFileSync(join(destination, "runtime/manifest.json"), "utf8")).version }).toEqual({ failed: true, version: "0.2.1" }); corrupt = ""; requests.length = 0; + if (process.platform !== "win32") { + // A stale lock must fail closed: no automatic deletion. A controlled + // `sleep` on PATH makes the 300-attempt wait deterministic without a + // production timeout knob. + const staleCache = join(root, "stale lock cache"); + mkdirSync(staleCache); + const staleLock = join(staleCache, `${first[1][1]}.lock`); + mkdirSync(staleLock); + writeFileSync(join(staleLock, "pid"), "999999\n"); + const fakeSleepDir = join(root, "fake sleep bin"); + mkdirSync(fakeSleepDir); + writeFileSync(join(fakeSleepDir, "sleep"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + const blocked = await installOnline(root, join(root, "stale lock install"), staleCache, origin, target, "v0.2.0", "functions", { + PATH: `${fakeSleepDir}:${process.env.PATH ?? ""}`, + }); + expect({ + failed: blocked.exit !== 0, + lockPreserved: existsSync(staleLock), + pidPreserved: readFileSync(join(staleLock, "pid"), "utf8"), + manualCleanupMessage: blocked.stderr.includes("remove the stale lock directory manually"), + }).toEqual({ failed: true, lockPreserved: true, pidPreserved: "999999\n", manualCleanupMessage: true }); + rmSync(staleLock, { recursive: true, force: true }); + const retry = await installOnline(root, join(root, "stale lock retry install"), staleCache, origin, target); + if (retry.exit !== 0) throw new Error(`${retry.stdout}\n${retry.stderr}`); + expect(existsSync(staleLock)).toBe(false); + requests.length = 0; + } + const concurrentCache = join(root, "concurrent cache"); const results = await Promise.all(["one", "two"].map(name => installOnline(root, join(root, name), concurrentCache, origin, target))); for (const item of results) if (item.exit !== 0) throw new Error(`${item.stdout}\n${item.stderr}`); From db95581709c3154df1f8acc688bd7fad42ca6093 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Fri, 6 Nov 2026 04:11:54 +0800 Subject: [PATCH 06/16] test(runtime): log concurrent installer request timing --- scripts/runtime/packs.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/runtime/packs.test.ts b/scripts/runtime/packs.test.ts index 9c59753c..43939b3f 100644 --- a/scripts/runtime/packs.test.ts +++ b/scripts/runtime/packs.test.ts @@ -86,12 +86,14 @@ test("real installers reuse runtimes across upgrades, repair damage, reject corr const root = mkdtempSync(join(tmpdir(), "devo shared installer QA ")); const assets = join(root, "assets"); const requests: string[] = []; + const requestLog: Array<{ at: number; name: string }> = []; const runtimeUrls: string[] = []; let corrupt = ""; const missing = new Set(); const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) { const name = basename(new URL(request.url).pathname); requests.push(name); + requestLog.push({ at: Date.now(), name }); if (name.startsWith("devo-runtime-")) runtimeUrls.push(new URL(request.url).pathname); if (missing.has(name)) return new Response("Not found", { status: 404 }); if (name === corrupt) return new Response("corrupt bytes"); @@ -114,7 +116,12 @@ test("real installers reuse runtimes across upgrades, repair damage, reject corr const origin = `http://127.0.0.1:${server.port}`; let result = await installOnline(root, destination, cache, origin, target); if (result.exit !== 0) throw new Error(`${result.stdout}\n${result.stderr}`); - expect(requests.filter(name => name.startsWith("devo-runtime-")).sort()).toEqual(first.slice(1).map(parts => parts[2]).sort()); + const runtimeRequests = requests.filter(name => name.startsWith("devo-runtime-")).sort(); + if (JSON.stringify(runtimeRequests) !== JSON.stringify(first.slice(1).map(parts => parts[2]).sort())) { + console.log("CONCURRENT_REQUEST_LOG", JSON.stringify(requestLog)); + console.log("CONCURRENT_CACHE", JSON.stringify(readdirSync(concurrentCache, { recursive: true }))); + } + expect(runtimeRequests).toEqual(first.slice(1).map(parts => parts[2]).sort()); expect(runtimeUrls.every(path => path.startsWith("/runtime-packs/"))).toBe(true); expect({ localNode: existsSync(join(destination, "runtime/node")), localPython: existsSync(join(destination, "runtime/python")), nodeReference: existsSync(join(destination, "runtime/node.path")), appModule: existsSync(join(destination, "runtime/python-site/rlm/repl.py")) }).toEqual({ localNode: false, localPython: false, nodeReference: true, appModule: true }); requests.length = 0; @@ -175,6 +182,7 @@ test("real installers reuse runtimes across upgrades, repair damage, reject corr requests.length = 0; } + requestLog.length = 0; const concurrentCache = join(root, "concurrent cache"); const results = await Promise.all(["one", "two"].map(name => installOnline(root, join(root, name), concurrentCache, origin, target))); for (const item of results) if (item.exit !== 0) throw new Error(`${item.stdout}\n${item.stderr}`); From a300eac9584a070626e7ffd27aab4e3d2887f5d7 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Fri, 6 Nov 2026 04:13:03 +0800 Subject: [PATCH 07/16] test(runtime): fix concurrent request diagnostic placement --- scripts/runtime/packs.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/scripts/runtime/packs.test.ts b/scripts/runtime/packs.test.ts index 43939b3f..4746151b 100644 --- a/scripts/runtime/packs.test.ts +++ b/scripts/runtime/packs.test.ts @@ -116,12 +116,7 @@ test("real installers reuse runtimes across upgrades, repair damage, reject corr const origin = `http://127.0.0.1:${server.port}`; let result = await installOnline(root, destination, cache, origin, target); if (result.exit !== 0) throw new Error(`${result.stdout}\n${result.stderr}`); - const runtimeRequests = requests.filter(name => name.startsWith("devo-runtime-")).sort(); - if (JSON.stringify(runtimeRequests) !== JSON.stringify(first.slice(1).map(parts => parts[2]).sort())) { - console.log("CONCURRENT_REQUEST_LOG", JSON.stringify(requestLog)); - console.log("CONCURRENT_CACHE", JSON.stringify(readdirSync(concurrentCache, { recursive: true }))); - } - expect(runtimeRequests).toEqual(first.slice(1).map(parts => parts[2]).sort()); + expect(requests.filter(name => name.startsWith("devo-runtime-")).sort()).toEqual(first.slice(1).map(parts => parts[2]).sort()); expect(runtimeUrls.every(path => path.startsWith("/runtime-packs/"))).toBe(true); expect({ localNode: existsSync(join(destination, "runtime/node")), localPython: existsSync(join(destination, "runtime/python")), nodeReference: existsSync(join(destination, "runtime/node.path")), appModule: existsSync(join(destination, "runtime/python-site/rlm/repl.py")) }).toEqual({ localNode: false, localPython: false, nodeReference: true, appModule: true }); requests.length = 0; @@ -186,7 +181,12 @@ test("real installers reuse runtimes across upgrades, repair damage, reject corr const concurrentCache = join(root, "concurrent cache"); const results = await Promise.all(["one", "two"].map(name => installOnline(root, join(root, name), concurrentCache, origin, target))); for (const item of results) if (item.exit !== 0) throw new Error(`${item.stdout}\n${item.stderr}`); - expect(requests.filter(name => name.startsWith("devo-runtime-")).sort()).toEqual(first.slice(1).map(parts => parts[2]).sort()); + const concurrentRuntimeRequests = requests.filter(name => name.startsWith("devo-runtime-")).sort(); + if (JSON.stringify(concurrentRuntimeRequests) !== JSON.stringify(first.slice(1).map(parts => parts[2]).sort())) { + console.log("CONCURRENT_REQUEST_LOG", JSON.stringify(requestLog)); + console.log("CONCURRENT_CACHE", JSON.stringify(readdirSync(concurrentCache, { recursive: true }))); + } + expect(concurrentRuntimeRequests).toEqual(first.slice(1).map(parts => parts[2]).sort()); expect(readdirSync(concurrentCache).some(name => name.startsWith(".stage"))).toBe(false); const entrypoint = await installOnline(root, join(root, "entrypoint install"), cache, origin, target, "v0.2.0", "entrypoint"); if (entrypoint.exit !== 0) throw new Error(`${entrypoint.stdout}\n${entrypoint.stderr}`); From 62bfc5a7fe3daf296db2f667c1513db38b7228d1 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Fri, 6 Nov 2026 10:28:27 +0800 Subject: [PATCH 08/16] fix(runtime): package Windows online packs as ZIP --- docs/usage.md | 3 ++ install.ps1 | 29 ++++++++---- scripts/runtime/packs.test.ts | 87 +++++++++++++++++++++++++++++++++-- scripts/runtime/packs.ts | 44 ++++++++++++++++-- scripts/runtime/zip-list.ps1 | 14 ++++++ scripts/runtime/zip-pack.ps1 | 82 +++++++++++++++++++++++++++++++++ 6 files changed, 244 insertions(+), 15 deletions(-) create mode 100644 scripts/runtime/zip-list.ps1 create mode 100644 scripts/runtime/zip-pack.ps1 diff --git a/docs/usage.md b/docs/usage.md index c0669550..c71cd441 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -176,6 +176,9 @@ same version number while reusing unchanged cached runtimes. Online installers download the smaller app package and cache checksum-pinned Node/Python runtimes separately. Upgrades reuse unchanged runtimes without downloading or extracting them again. No package manager runs during installation. +On Windows, online app and runtime packs use `.zip` archives and are extracted +with the .NET ZIP reader; Unix packs remain `.tar.gz`. Older Windows indexes that +still reference `.tar.gz` packs remain installable. The cache defaults to `%LOCALAPPDATA%\devo\runtimes` on Windows and `${XDG_CACHE_HOME:-~/.cache}/devo/runtimes` on Unix. Set `DEVO_RUNTIME_CACHE` before installing to choose another persistent directory. Keep this cache while the diff --git a/install.ps1 b/install.ps1 index 4db105dd..60182dfd 100644 --- a/install.ps1 +++ b/install.ps1 @@ -400,6 +400,19 @@ function Receive-DevoAsset { } } +function Expand-DevoArchive { + param( + [Parameter(Mandatory = $true)][string]$Archive, + [Parameter(Mandatory = $true)][string]$Destination + ) + if ($Archive -like "*.zip") { + [IO.Compression.ZipFile]::ExtractToDirectory($Archive, $Destination) + } else { + & "$env:SystemRoot\System32\tar.exe" -xzf $Archive -C $Destination + if ($LASTEXITCODE -ne 0) { throw "Failed to extract $Archive" } + } +} + function Get-CachedDevoRuntime { param([string]$Kind, [string]$Digest, [string]$Asset, [string]$BaseUrl) $cache = if ($env:DEVO_RUNTIME_CACHE) { $env:DEVO_RUNTIME_CACHE } else { Join-Path $env:LOCALAPPDATA "devo\runtimes" } @@ -427,12 +440,12 @@ function Get-CachedDevoRuntime { } } New-Item -ItemType Directory -Path $stage | Out-Null - $archive = Join-Path $stage "pack.tar.gz" + $archiveName = if ($Asset -like "*.zip") { "pack.zip" } else { "pack.tar.gz" } + $archive = Join-Path $stage $archiveName Write-Host "Downloading $Kind runtime ..." Receive-DevoAsset -Url "$BaseUrl/$Asset" -Destination $archive if ((Get-Sha256Hex $archive) -ine $Digest) { throw "Runtime SHA-256 verification failed: $Asset" } - & "$env:SystemRoot\System32\tar.exe" -xzf $archive -C $stage - if ($LASTEXITCODE -ne 0) { throw "Failed to extract $Asset" } + Expand-DevoArchive -Archive $archive -Destination $stage Remove-Item -LiteralPath $archive $extractedBinary = Join-Path $stage "runtime\$Kind\$Kind.exe" if (-not (Test-Path -LiteralPath $extractedBinary -PathType Leaf)) { throw "Incomplete $Kind runtime pack" } @@ -473,7 +486,7 @@ function Install-DevoOnline { for ($i = 1; $i -le 3; $i++) { $kind = @("app", "node", "python")[$i - 1] $parts = $lines[$i] -split ' ' - if ($parts.Count -ne 3 -or $parts[0] -ne $kind -or $parts[1] -cnotmatch '^[a-f0-9]{64}$' -or $parts[2] -cnotmatch '^[a-zA-Z0-9_.-]+\.tar\.gz$') { throw "Invalid $kind runtime index entry" } + if ($parts.Count -ne 3 -or $parts[0] -ne $kind -or $parts[1] -cnotmatch '^[a-f0-9]{64}$' -or $parts[2] -cnotmatch '^[a-zA-Z0-9_.-]+\.(tar\.gz|zip)$') { throw "Invalid $kind runtime index entry" } $packs[$kind] = @{ Digest = $parts[1]; Asset = $parts[2] } } # A version tag can be rebuilt; compare its verified app digest before @@ -491,15 +504,15 @@ function Install-DevoOnline { # Function definitions live only in this runspace; the worker # runspaces get them re-declared, including the PS 3.0-safe # SHA-256 helper used by Get-CachedDevoRuntime. - $workerScript = "param(`$Kind, `$Digest, `$Asset, `$BaseUrl)`n`$ErrorActionPreference = 'Stop'`nfunction Get-Sha256Hex { $((Get-Command Get-Sha256Hex).Definition) }`nfunction Receive-DevoAsset { $((Get-Command Receive-DevoAsset).Definition) }`nfunction Get-CachedDevoRuntime { $((Get-Command Get-CachedDevoRuntime).Definition) }`nGet-CachedDevoRuntime -Kind `$Kind -Digest `$Digest -Asset `$Asset -BaseUrl `$BaseUrl" + $workerScript = "param(`$Kind, `$Digest, `$Asset, `$BaseUrl)`n`$ErrorActionPreference = 'Stop'`nAdd-Type -AssemblyName System.IO.Compression.FileSystem`nfunction Get-Sha256Hex { $((Get-Command Get-Sha256Hex).Definition) }`nfunction Receive-DevoAsset { $((Get-Command Receive-DevoAsset).Definition) }`nfunction Expand-DevoArchive { $((Get-Command Expand-DevoArchive).Definition) }`nfunction Get-CachedDevoRuntime { $((Get-Command Get-CachedDevoRuntime).Definition) }`nGet-CachedDevoRuntime -Kind `$Kind -Digest `$Digest -Asset `$Asset -BaseUrl `$BaseUrl" [void]$shell.AddScript($workerScript).AddArgument($kind).AddArgument($packs[$kind].Digest).AddArgument($packs[$kind].Asset).AddArgument($runtimeBase) $workers += @{ Kind = $kind; Shell = $shell; Handle = $shell.BeginInvoke() } } - $appArchive = Join-Path $TempRoot "app.tar.gz" + $appArchiveName = if ($packs.app.Asset -like "*.zip") { "app.zip" } else { "app.tar.gz" } + $appArchive = Join-Path $TempRoot $appArchiveName Receive-DevoAsset -Url "$baseUrl/$($packs.app.Asset)" -Destination $appArchive if ((Get-Sha256Hex $appArchive) -ine $packs.app.Digest) { throw "App SHA-256 verification failed" } - & "$env:SystemRoot\System32\tar.exe" -xzf $appArchive -C $extracted - if ($LASTEXITCODE -ne 0) { throw "Failed to extract app archive" } + Expand-DevoArchive -Archive $appArchive -Destination $extracted } catch { $failure = $_ } finally { foreach ($worker in $workers) { try { diff --git a/scripts/runtime/packs.test.ts b/scripts/runtime/packs.test.ts index 4746151b..3005f0e3 100644 --- a/scripts/runtime/packs.test.ts +++ b/scripts/runtime/packs.test.ts @@ -1,14 +1,18 @@ import { expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, join } from "node:path"; -import { archiveTree, buildPacks } from "./packs"; +import { archiveTree, archiveTreeZip, buildPacks } from "./packs"; import { installOnline, writeChecksums } from "./installer-fixture"; import { WINDOWS_SANDBOX_HELPERS } from "./backend"; const target = `${process.arch === "arm64" ? "aarch64" : "x86_64"}-${process.platform === "win32" ? "pc-windows-msvc" : process.platform === "darwin" ? "apple-darwin" : "unknown-linux-musl"}`; const assetTarget = target.replace("-unknown-linux-", "-linux-"); +const WINDOWS_LONG_APP_FILE = `${"long path ".repeat(10)}unicode-路径.txt`; +const WINDOWS_HIDDEN_APP_FILE = ".hidden-unicode-payload"; +const WINDOWS_EMPTY_APP_DIR = "empty-app-dir"; function fixture(root: string) { const source = join(root, "source"); @@ -19,10 +23,42 @@ function fixture(root: string) { mkdirSync(dirname(join(source, file)), { recursive: true }); writeFileSync(join(source, file), "original " + file, { mode: 0o755 }); } + if (windows) { + writeFileSync(join(source, WINDOWS_LONG_APP_FILE), "unicode payload"); + writeFileSync(join(source, WINDOWS_HIDDEN_APP_FILE), "hidden payload"); + mkdirSync(join(source, WINDOWS_EMPTY_APP_DIR)); + } writeFileSync(join(source, "runtime/manifest.json"), JSON.stringify({ schema: 1, version: "0.2.0", target })); return source; } +test.skipIf(process.platform === "win32")("Windows ZIP packs require a Windows build host", async () => { + const root = mkdtempSync(join(tmpdir(), "devo Windows pack host ")); + try { + const source = fixture(root); + writeFileSync(join(source, "runtime/manifest.json"), JSON.stringify({ schema: 1, version: "0.2.0", target: "x86_64-pc-windows-msvc" })); + await expect(buildPacks(source, join(root, "assets"))).rejects.toThrow("Windows build host"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test.skipIf(process.platform !== "win32")("Windows ZIP rejects reparse points", async () => { + const root = mkdtempSync(join(tmpdir(), "devo Windows reparse ")); + try { + const source = fixture(root); + try { + symlinkSync(join(source, "runtime", "python"), join(source, "runtime", "python", "linked"), "junction"); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "EPERM" || code === "EACCES" || code === "ENOTSUP") { + console.warn(`Skipping Windows reparse test: junction creation unavailable (${code})`); + return; + } + throw error; + } + await expect(archiveTreeZip(source, ["runtime/python"], join(root, "out.zip"))).rejects.toThrow("reparse point"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + test("Linux public pack names omit the vendor while retaining canonical manifest targets", async () => { const root = mkdtempSync(join(tmpdir(), "devo Linux asset names ")); try { @@ -42,7 +78,7 @@ test("Linux public pack names omit the vendor while retaining canonical manifest } finally { rmSync(root, { recursive: true, force: true }); } }); -test("runtime tar archives are reproducible and preserve long Unicode paths, executable modes and symlinks", async () => { +test.skipIf(process.platform === "win32")("runtime tar archives are reproducible and preserve long Unicode paths, executable modes and symlinks", async () => { const root = mkdtempSync(join(tmpdir(), "devo tar QA ")); try { const source = fixture(root); @@ -105,6 +141,18 @@ test("real installers reuse runtimes across upgrades, repair damage, reject corr const index = await buildPacks(source, assets); const first = readFileSync(index, "utf8").trim().split("\n").slice(1).map(line => line.split(" ")); expect(first.map(parts => parts[0])).toEqual(["app", "node", "python"]); + if (process.platform === "win32") { + const repeat = join(root, "repeat assets"); + const repeatIndex = await buildPacks(source, repeat); + expect(readFileSync(repeatIndex, "utf8")).toEqual(readFileSync(index, "utf8")); + const powershell = join(process.env.SystemRoot!, "System32/WindowsPowerShell/v1.0/powershell.exe"); + const listed = Bun.spawnSync([ + powershell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-File", join(import.meta.dir, "zip-list.ps1"), "-Archive", join(repeat, first[0][2]), + ], { stdout: "pipe", stderr: "pipe" }); + expect(listed.exitCode).toBe(0); + expect(listed.stdout.toString().split(/\r?\n/)).toContain(WINDOWS_LONG_APP_FILE); + } writeFileSync(join(source, "runtime/manifest.json"), JSON.stringify({ schema: 1, version: "0.2.1", target })); await buildPacks(source, assets); const second = readFileSync(join(assets, `devo-tui-v0.2.1-${assetTarget}.install.txt`), "utf8").trim().split("\n").slice(1).map(line => line.split(" ")); @@ -118,7 +166,7 @@ test("real installers reuse runtimes across upgrades, repair damage, reject corr if (result.exit !== 0) throw new Error(`${result.stdout}\n${result.stderr}`); expect(requests.filter(name => name.startsWith("devo-runtime-")).sort()).toEqual(first.slice(1).map(parts => parts[2]).sort()); expect(runtimeUrls.every(path => path.startsWith("/runtime-packs/"))).toBe(true); - expect({ localNode: existsSync(join(destination, "runtime/node")), localPython: existsSync(join(destination, "runtime/python")), nodeReference: existsSync(join(destination, "runtime/node.path")), appModule: existsSync(join(destination, "runtime/python-site/rlm/repl.py")) }).toEqual({ localNode: false, localPython: false, nodeReference: true, appModule: true }); + expect({ localNode: existsSync(join(destination, "runtime/node")), localPython: existsSync(join(destination, "runtime/python")), nodeReference: existsSync(join(destination, "runtime/node.path")), appModule: existsSync(join(destination, "runtime/python-site/rlm/repl.py")), longUnicode: process.platform === "win32" ? readFileSync(join(destination, WINDOWS_LONG_APP_FILE), "utf8") : "unicode payload", hidden: process.platform === "win32" ? readFileSync(join(destination, WINDOWS_HIDDEN_APP_FILE), "utf8") : "hidden payload", emptyDir: process.platform === "win32" ? statSync(join(destination, WINDOWS_EMPTY_APP_DIR)).isDirectory() && readdirSync(join(destination, WINDOWS_EMPTY_APP_DIR)).length === 0 : true }).toEqual({ localNode: false, localPython: false, nodeReference: true, appModule: true, longUnicode: "unicode payload", hidden: "hidden payload", emptyDir: true }); requests.length = 0; result = await installOnline(root, destination, cache, origin, target, "v0.2.1"); if (result.exit !== 0) throw new Error(`${result.stdout}\n${result.stderr}`); @@ -227,3 +275,34 @@ test("real installers reuse runtimes across upgrades, repair damage, reject corr } } finally { server.stop(true); rmSync(root, { recursive: true, force: true }); } }, 120_000); + +test.skipIf(process.platform !== "win32")("legacy ASCII tar runtime index still installs on Windows", async () => { + const root = mkdtempSync(join(tmpdir(), "devo legacy tar index ")); + let server: ReturnType | undefined; + try { + const source = fixture(root); + rmSync(join(source, WINDOWS_LONG_APP_FILE), { force: true }); + const assets = join(root, "legacy assets"); + mkdirSync(assets); + const appPaths = readdirSync(source).filter(name => name !== "runtime") + .concat(readdirSync(join(source, "runtime")).filter(name => !["node", "python", "node.path", "python.path"].includes(name)).map(name => `runtime/${name}`)); + const appName = `devo-tui-app-v0.2.0-${assetTarget}.tar.gz`; + const nodeName = `devo-runtime-node-${assetTarget}.tar.gz`; + const pythonName = `devo-runtime-python-${assetTarget}.tar.gz`; + await archiveTree(source, appPaths, join(assets, appName)); + await archiveTree(source, ["runtime/node"], join(assets, nodeName)); + await archiveTree(source, ["runtime/python"], join(assets, pythonName)); + const digest = (path: string) => createHash("sha256").update(readFileSync(path)).digest("hex"); + const index = join(assets, `devo-tui-v0.2.0-${assetTarget}.install.txt`); + writeFileSync(index, ["devo-install-v1", `app ${digest(join(assets, appName))} ${appName}`, `node ${digest(join(assets, nodeName))} ${nodeName}`, `python ${digest(join(assets, pythonName))} ${pythonName}`].join("\n") + "\n"); + writeChecksums(assets); + server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) { + const name = basename(new URL(request.url).pathname); + const file = Bun.file(join(assets, name)); + return await file.exists() ? new Response(file) : new Response("Not found", { status: 404 }); + } }); + const result = await installOnline(root, join(root, "legacy installed"), join(root, "legacy cache"), `http://127.0.0.1:${server.port}`, target); + if (result.exit !== 0) throw new Error(`${result.stdout}\n${result.stderr}`); + expect(existsSync(join(root, "legacy installed", "runtime/manifest.json"))).toBe(true); + } finally { server?.stop(true); rmSync(root, { recursive: true, force: true }); } +}, 30_000); diff --git a/scripts/runtime/packs.ts b/scripts/runtime/packs.ts index 12dc06ce..b51c158a 100644 --- a/scripts/runtime/packs.ts +++ b/scripts/runtime/packs.ts @@ -63,10 +63,44 @@ export async function archiveTree(root: string, paths: string[], output: string) await pipeline(Readable.from(archive()), createGzip({ level: 9 }), createWriteStream(output)); } +/** + * Create a deterministic Windows runtime pack through the maintained .NET + * ZipArchive writer. Windows packs are built only on Windows hosts; a + * cross-target Unix build fails explicitly instead of silently depending on + * PowerShell. + */ +export async function archiveTreeZip(root: string, paths: string[], output: string): Promise { + if (process.platform !== "win32") { + throw new Error("Windows ZIP runtime packs require a Windows build host with PowerShell/.NET"); + } + const powershell = join(process.env.SystemRoot ?? "C:/Windows", "System32/WindowsPowerShell/v1.0/powershell.exe"); + const helper = join(import.meta.dir, "zip-pack.ps1"); + const result = Bun.spawnSync([ + powershell, + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + helper, + "-Root", + root, + "-Output", + output, + "-PathsJson", + JSON.stringify(paths), + ], { stdout: "pipe", stderr: "pipe" }); + if (result.exitCode !== 0) { + throw new Error(`Windows ZIP pack failed (${result.exitCode}): ${result.stderr.toString()}`); + } +} + /** Emit the small online app and independently cacheable interpreter packs. */ export async function buildPacks(bundle: string, output: string): Promise { const manifest = JSON.parse(readFileSync(join(bundle, "runtime/manifest.json"), "utf8")); const assetTarget = manifest.target.replace("-unknown-linux-", "-linux-"); + const windows = manifest.target.includes("-pc-windows-msvc"); + const extension = windows ? ".zip" : ".tar.gz"; mkdirSync(output, { recursive: true }); const lines = ["devo-install-v1"]; for (const kind of ["app", "node", "python"] as const) { @@ -76,12 +110,16 @@ export async function buildPacks(bundle: string, output: string): Promise name !== "runtime") .concat(readdirSync(join(bundle, "runtime")).filter(name => !["node", "python", "node.path", "python.path"].includes(name)).map(name => `runtime/${name}`)) : [`runtime/${kind}`]; - await archiveTree(bundle, paths, partial); + if (windows) { + await archiveTreeZip(bundle, paths, partial); + } else { + await archiveTree(bundle, paths, partial); + } const hash = createHash("sha256"); for await (const chunk of createReadStream(partial)) hash.update(chunk); const digest = hash.digest("hex"); - const name = kind === "app" ? `devo-tui-app-v${manifest.version}-${assetTarget}.tar.gz` - : `devo-runtime-${kind}-${assetTarget}-${digest}.tar.gz`; + const name = kind === "app" ? `devo-tui-app-v${manifest.version}-${assetTarget}${extension}` + : `devo-runtime-${kind}-${assetTarget}-${digest}${extension}`; const destination = join(output, name); // Overwriting the same runtime digest is harmless; app archives are versioned. rmSync(destination, { force: true }); diff --git a/scripts/runtime/zip-list.ps1 b/scripts/runtime/zip-list.ps1 new file mode 100644 index 00000000..b7f2b893 --- /dev/null +++ b/scripts/runtime/zip-list.ps1 @@ -0,0 +1,14 @@ +param( + [Parameter(Mandatory = $true)][string]$Archive +) + +$ErrorActionPreference = "Stop" +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) +$OutputEncoding = [Console]::OutputEncoding +Add-Type -AssemblyName System.IO.Compression.FileSystem +$zip = [IO.Compression.ZipFile]::OpenRead($Archive) +try { + foreach ($entry in $zip.Entries) { $entry.FullName } +} finally { + $zip.Dispose() +} diff --git a/scripts/runtime/zip-pack.ps1 b/scripts/runtime/zip-pack.ps1 new file mode 100644 index 00000000..e26b21b4 --- /dev/null +++ b/scripts/runtime/zip-pack.ps1 @@ -0,0 +1,82 @@ +param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Output, + [Parameter(Mandatory = $true)][string]$PathsJson +) + +$ErrorActionPreference = "Stop" +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem + +$rootFull = [IO.Path]::GetFullPath($Root) +$paths = @(ConvertFrom-Json -InputObject $PathsJson) +if ($paths.Count -eq 0) { throw "No pack paths were provided" } + +$entries = [System.Collections.Generic.List[object]]::new() +$seen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + +function Add-ZipEntry { + param([string]$FullPath, [string]$Relative, [bool]$Directory) + $name = $Relative.Replace('\', '/') + while ($name.StartsWith("./")) { $name = $name.Substring(2) } + if ($name -eq "" -or $name.StartsWith("/") -or ($name.Split('/') -contains "..")) { + throw "Invalid runtime pack path: $Relative" + } + if ($Directory) { $name = $name.TrimEnd('/') + "/" } + if (-not $seen.Add($name)) { throw "Duplicate runtime pack entry: $name" } + $entries.Add([pscustomobject]@{ FullPath = $FullPath; Name = $name; Directory = $Directory }) +} + +function Add-Tree { + param([string]$FullPath, [string]$Relative) + $item = Get-Item -LiteralPath $FullPath -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Unsupported reparse point in Windows runtime pack: $Relative" + } + if ($item.PSIsContainer) { + Add-ZipEntry -FullPath $item.FullName -Relative $Relative -Directory $true + foreach ($child in @(Get-ChildItem -LiteralPath $item.FullName -Force | Sort-Object Name)) { + Add-Tree -FullPath $child.FullName -Relative ($Relative.TrimEnd('/') + "/" + $child.Name) + } + } else { + Add-ZipEntry -FullPath $item.FullName -Relative $Relative -Directory $false + } +} + +foreach ($path in $paths) { + Add-Tree -FullPath (Join-Path $rootFull $path) -Relative $path +} + +$byName = [System.Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) +foreach ($entry in $entries) { $byName[$entry.Name] = $entry } +$names = [string[]]@($byName.Keys) +[Array]::Sort($names, [StringComparer]::Ordinal) +$sorted = foreach ($name in $names) { $byName[$name] } + +$stream = [IO.File]::Open($Output, [IO.FileMode]::Create, [IO.FileAccess]::Write, [IO.FileShare]::None) +try { + $utf8 = [System.Text.UTF8Encoding]::new($false) + $archive = New-Object -TypeName System.IO.Compression.ZipArchive -ArgumentList @($stream, [IO.Compression.ZipArchiveMode]::Create, $true, $utf8) + try { + # Fixed DOS timestamp keeps repeated Windows pack builds byte-identical + # within the supported .NET toolchain. + $fixed = [DateTimeOffset]::new(1980, 1, 1, 0, 0, 0, [TimeSpan]::Zero) + foreach ($entry in $sorted) { + # ZipArchive writes UTF-8 entry names (and the UTF-8 flag) when a + # name contains non-ASCII characters. + $zipEntry = $archive.CreateEntry($entry.Name, [IO.Compression.CompressionLevel]::Optimal) + $zipEntry.LastWriteTime = $fixed + if (-not $entry.Directory) { + $input = [IO.File]::OpenRead($entry.FullPath) + try { + $outputStream = $zipEntry.Open() + try { $input.CopyTo($outputStream) } finally { $outputStream.Dispose() } + } finally { $input.Dispose() } + } + } + } finally { + $archive.Dispose() + } +} finally { + $stream.Dispose() +} From ccd756ec7c774237005a574e93f8a42138dc7aaf Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Fri, 6 Nov 2026 10:30:38 +0800 Subject: [PATCH 09/16] fix(runtime): pass Windows ZIP path manifest as a file --- scripts/runtime/packs.ts | 40 +++++++++++++++++++++--------------- scripts/runtime/zip-pack.ps1 | 5 +++-- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/scripts/runtime/packs.ts b/scripts/runtime/packs.ts index b51c158a..72d1df88 100644 --- a/scripts/runtime/packs.ts +++ b/scripts/runtime/packs.ts @@ -75,23 +75,29 @@ export async function archiveTreeZip(root: string, paths: string[], output: stri } const powershell = join(process.env.SystemRoot ?? "C:/Windows", "System32/WindowsPowerShell/v1.0/powershell.exe"); const helper = join(import.meta.dir, "zip-pack.ps1"); - const result = Bun.spawnSync([ - powershell, - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-File", - helper, - "-Root", - root, - "-Output", - output, - "-PathsJson", - JSON.stringify(paths), - ], { stdout: "pipe", stderr: "pipe" }); - if (result.exitCode !== 0) { - throw new Error(`Windows ZIP pack failed (${result.exitCode}): ${result.stderr.toString()}`); + const manifest = `${output}.paths.json`; + writeFileSync(manifest, JSON.stringify(paths)); + try { + const result = Bun.spawnSync([ + powershell, + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + helper, + "-Root", + root, + "-Output", + output, + "-Manifest", + manifest, + ], { stdout: "pipe", stderr: "pipe" }); + if (result.exitCode !== 0) { + throw new Error(`Windows ZIP pack failed (${result.exitCode}): ${result.stderr.toString()}`); + } + } finally { + rmSync(manifest, { force: true }); } } diff --git a/scripts/runtime/zip-pack.ps1 b/scripts/runtime/zip-pack.ps1 index e26b21b4..776c71ea 100644 --- a/scripts/runtime/zip-pack.ps1 +++ b/scripts/runtime/zip-pack.ps1 @@ -1,7 +1,7 @@ param( [Parameter(Mandatory = $true)][string]$Root, [Parameter(Mandatory = $true)][string]$Output, - [Parameter(Mandatory = $true)][string]$PathsJson + [Parameter(Mandatory = $true)][string]$Manifest ) $ErrorActionPreference = "Stop" @@ -9,7 +9,8 @@ Add-Type -AssemblyName System.IO.Compression Add-Type -AssemblyName System.IO.Compression.FileSystem $rootFull = [IO.Path]::GetFullPath($Root) -$paths = @(ConvertFrom-Json -InputObject $PathsJson) +$manifestText = [IO.File]::ReadAllText($Manifest, [Text.Encoding]::UTF8) +$paths = @(ConvertFrom-Json -InputObject $manifestText) if ($paths.Count -eq 0) { throw "No pack paths were provided" } $entries = [System.Collections.Generic.List[object]]::new() From 7ac706a9ac7e2a71a2fbe26f40ab0dcd7d5df917 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Fri, 6 Nov 2026 10:33:18 +0800 Subject: [PATCH 10/16] fix(runtime): validate flat Windows ZIP path manifest --- scripts/runtime/packs.test.ts | 12 ++++++++++++ scripts/runtime/zip-pack.ps1 | 16 +++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/scripts/runtime/packs.test.ts b/scripts/runtime/packs.test.ts index 3005f0e3..87e7051f 100644 --- a/scripts/runtime/packs.test.ts +++ b/scripts/runtime/packs.test.ts @@ -59,6 +59,18 @@ test.skipIf(process.platform !== "win32")("Windows ZIP rejects reparse points", } finally { rmSync(root, { recursive: true, force: true }); } }); +test.skipIf(process.platform !== "win32")("Windows ZIP pack handles zero, one and multiple paths", async () => { + const root = mkdtempSync(join(tmpdir(), "devo Windows zip path counts ")); + try { + const source = fixture(root); + await expect(archiveTreeZip(source, [], join(root, "zero.zip"))).rejects.toThrow("No pack paths"); + await archiveTreeZip(source, ["runtime/python"], join(root, "one.zip")); + expect(existsSync(join(root, "one.zip"))).toBe(true); + await archiveTreeZip(source, ["runtime/python", "runtime/node"], join(root, "many.zip")); + expect(existsSync(join(root, "many.zip"))).toBe(true); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + test("Linux public pack names omit the vendor while retaining canonical manifest targets", async () => { const root = mkdtempSync(join(tmpdir(), "devo Linux asset names ")); try { diff --git a/scripts/runtime/zip-pack.ps1 b/scripts/runtime/zip-pack.ps1 index 776c71ea..4ab92be7 100644 --- a/scripts/runtime/zip-pack.ps1 +++ b/scripts/runtime/zip-pack.ps1 @@ -10,7 +10,21 @@ Add-Type -AssemblyName System.IO.Compression.FileSystem $rootFull = [IO.Path]::GetFullPath($Root) $manifestText = [IO.File]::ReadAllText($Manifest, [Text.Encoding]::UTF8) -$paths = @(ConvertFrom-Json -InputObject $manifestText) +$parsed = ConvertFrom-Json -InputObject $manifestText +$paths = @() +if ($null -eq $parsed) { + # Windows PowerShell 5.1 may unwrap an empty JSON array to $null. +} elseif ($parsed -is [System.Array]) { + foreach ($item in $parsed) { + if ($item -isnot [string]) { throw "Pack paths must be a flat JSON array of strings" } + $paths += $item + } +} elseif ($parsed -is [string]) { + # Windows PowerShell 5.1 unwraps a one-element JSON array to a scalar. + $paths = @($parsed) +} else { + throw "Pack paths must be a flat JSON array of strings" +} if ($paths.Count -eq 0) { throw "No pack paths were provided" } $entries = [System.Collections.Generic.List[object]]::new() From 5349309c27043f3c91adf553012fe6c78c1a0471 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Fri, 6 Nov 2026 10:35:15 +0800 Subject: [PATCH 11/16] test(runtime): read Windows ZIP entries from UTF-8 file --- scripts/runtime/packs.test.ts | 4 +++- scripts/runtime/zip-list.ps1 | 9 +++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/scripts/runtime/packs.test.ts b/scripts/runtime/packs.test.ts index 87e7051f..35d6140a 100644 --- a/scripts/runtime/packs.test.ts +++ b/scripts/runtime/packs.test.ts @@ -158,12 +158,14 @@ test("real installers reuse runtimes across upgrades, repair damage, reject corr const repeatIndex = await buildPacks(source, repeat); expect(readFileSync(repeatIndex, "utf8")).toEqual(readFileSync(index, "utf8")); const powershell = join(process.env.SystemRoot!, "System32/WindowsPowerShell/v1.0/powershell.exe"); + const listedFile = join(root, "zip-list.txt"); const listed = Bun.spawnSync([ powershell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", join(import.meta.dir, "zip-list.ps1"), "-Archive", join(repeat, first[0][2]), + "-OutputFile", listedFile, ], { stdout: "pipe", stderr: "pipe" }); expect(listed.exitCode).toBe(0); - expect(listed.stdout.toString().split(/\r?\n/)).toContain(WINDOWS_LONG_APP_FILE); + expect(readFileSync(listedFile, "utf8").split(/\r?\n/)).toContain(WINDOWS_LONG_APP_FILE); } writeFileSync(join(source, "runtime/manifest.json"), JSON.stringify({ schema: 1, version: "0.2.1", target })); await buildPacks(source, assets); diff --git a/scripts/runtime/zip-list.ps1 b/scripts/runtime/zip-list.ps1 index b7f2b893..38ccad18 100644 --- a/scripts/runtime/zip-list.ps1 +++ b/scripts/runtime/zip-list.ps1 @@ -1,14 +1,15 @@ param( - [Parameter(Mandatory = $true)][string]$Archive + [Parameter(Mandatory = $true)][string]$Archive, + [Parameter(Mandatory = $true)][string]$OutputFile ) $ErrorActionPreference = "Stop" -[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) -$OutputEncoding = [Console]::OutputEncoding Add-Type -AssemblyName System.IO.Compression.FileSystem $zip = [IO.Compression.ZipFile]::OpenRead($Archive) try { - foreach ($entry in $zip.Entries) { $entry.FullName } + $names = [System.Collections.Generic.List[string]]::new() + foreach ($entry in $zip.Entries) { $names.Add($entry.FullName) } + [IO.File]::WriteAllLines($OutputFile, $names, [System.Text.UTF8Encoding]::new($false)) } finally { $zip.Dispose() } From c541a03293c3786bafcb18d9ce2d4479bffa5b58 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Fri, 6 Nov 2026 10:45:41 +0800 Subject: [PATCH 12/16] test(runtime): add Windows ZIP codepoint diagnostics --- scripts/runtime/packs.test.ts | 42 +++++++++++++++++++++++ scripts/runtime/zip-diagnose.ps1 | 58 ++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 scripts/runtime/zip-diagnose.ps1 diff --git a/scripts/runtime/packs.test.ts b/scripts/runtime/packs.test.ts index 35d6140a..3a746846 100644 --- a/scripts/runtime/packs.test.ts +++ b/scripts/runtime/packs.test.ts @@ -71,6 +71,48 @@ test.skipIf(process.platform !== "win32")("Windows ZIP pack handles zero, one an } finally { rmSync(root, { recursive: true, force: true }); } }); +test.skipIf(process.platform !== "win32")("Windows ZIP codepoint diagnostics", async () => { + const root = mkdtempSync(join(tmpdir(), "devo Windows zip codepoints ")); + try { + const source = join(root, "source"); + mkdirSync(source, { recursive: true }); + writeFileSync(join(source, WINDOWS_LONG_APP_FILE), "unicode payload"); + const manifest = join(root, "paths.json"); + writeFileSync(manifest, JSON.stringify([WINDOWS_LONG_APP_FILE])); + const zip = join(root, "diagnostic.zip"); + const powershell = join(process.env.SystemRoot!, "System32/WindowsPowerShell/v1.0/powershell.exe"); + const packed = Bun.spawnSync([ + powershell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-File", join(import.meta.dir, "zip-pack.ps1"), "-Root", source, "-Output", zip, "-Manifest", manifest, + ], { stdout: "pipe", stderr: "pipe" }); + expect(packed.exitCode).toBe(0); + const diagnostics = join(root, "diagnostics.txt"); + const diagnosed = Bun.spawnSync([ + powershell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-File", join(import.meta.dir, "zip-diagnose.ps1"), "-Archive", zip, "-Manifest", manifest, "-OutputFile", diagnostics, + ], { stdout: "pipe", stderr: "pipe" }); + expect(diagnosed.exitCode).toBe(0); + const text = readFileSync(diagnostics, "utf8"); + console.log("ZIP_CODEPOINT_DIAGNOSTICS\n" + text); + const expected = WINDOWS_LONG_APP_FILE; + const expectedHex = Buffer.from(expected, "utf8").toString("hex").toUpperCase(); + const python = Bun.which("python") ?? Bun.which("py"); + let pythonNames: string[] | null = null; + if (python) { + const listed = Bun.spawnSync([python, "-c", "import json, sys, zipfile; z=zipfile.ZipFile(sys.argv[1]); print(json.dumps([i.filename for i in z.infolist()], ensure_ascii=True))", zip], { stdout: "pipe", stderr: "pipe" }); + console.log("PYTHON_ZIP_NAMES\n" + listed.stdout.toString()); + console.log("PYTHON_ZIP_STDERR\n" + listed.stderr.toString()); + if (listed.exitCode === 0) pythonNames = JSON.parse(listed.stdout.toString()); + } else { + console.log("PYTHON_ZIP_NAMES unavailable"); + } + expect(text).toContain(`MANIFEST_UTF8_HEX=${expectedHex}`); + expect(text).toContain(`UTF8_NAME=${expected}`); + expect(text).toContain(`RAW_NAME_HEX=${expectedHex}`); + if (pythonNames) expect(pythonNames).toContain(expected); + } finally { rmSync(root, { recursive: true, force: true }); } +}, 30_000); + test("Linux public pack names omit the vendor while retaining canonical manifest targets", async () => { const root = mkdtempSync(join(tmpdir(), "devo Linux asset names ")); try { diff --git a/scripts/runtime/zip-diagnose.ps1 b/scripts/runtime/zip-diagnose.ps1 new file mode 100644 index 00000000..fee9c3c7 --- /dev/null +++ b/scripts/runtime/zip-diagnose.ps1 @@ -0,0 +1,58 @@ +param( + [Parameter(Mandatory = $true)][string]$Archive, + [Parameter(Mandatory = $true)][string]$Manifest, + [Parameter(Mandatory = $true)][string]$OutputFile +) + +$ErrorActionPreference = "Stop" +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem + +function Hex-Bytes([byte[]]$Bytes) { + ($Bytes | ForEach-Object { $_.ToString("X2") }) -join "" +} + +$lines = [System.Collections.Generic.List[string]]::new() +$manifestText = [IO.File]::ReadAllText($Manifest, [Text.Encoding]::UTF8) +$parsed = ConvertFrom-Json -InputObject $manifestText +$manifestPath = if ($parsed -is [System.Array]) { [string]$parsed[0] } else { [string]$parsed } +$manifestPath = $manifestPath.Replace('\', '/') +$lines.Add("MANIFEST_UTF8_HEX=" + (Hex-Bytes ([Text.Encoding]::UTF8.GetBytes($manifestPath)))) + +$stream = [IO.File]::OpenRead($Archive) +try { + $defaultZip = New-Object -TypeName System.IO.Compression.ZipArchive -ArgumentList @($stream, [IO.Compression.ZipArchiveMode]::Read, $true) + try { + foreach ($entry in $defaultZip.Entries) { + $lines.Add("DEFAULT_NAME=" + $entry.FullName) + $lines.Add("DEFAULT_UTF8_HEX=" + (Hex-Bytes ([Text.Encoding]::UTF8.GetBytes($entry.FullName)))) + } + } finally { $defaultZip.Dispose() } +} finally { $stream.Dispose() } + +$stream = [IO.File]::OpenRead($Archive) +try { + $utf8Zip = New-Object -TypeName System.IO.Compression.ZipArchive -ArgumentList @($stream, [IO.Compression.ZipArchiveMode]::Read, $true, [Text.Encoding]::UTF8) + try { + foreach ($entry in $utf8Zip.Entries) { + $lines.Add("UTF8_NAME=" + $entry.FullName) + $lines.Add("UTF8_HEX=" + (Hex-Bytes ([Text.Encoding]::UTF8.GetBytes($entry.FullName)))) + } + } finally { $utf8Zip.Dispose() } +} finally { $stream.Dispose() } + +$bytes = [IO.File]::ReadAllBytes($Archive) +for ($i = 0; $i -le $bytes.Length - 30; $i++) { + if ($bytes[$i] -eq 0x50 -and $bytes[$i + 1] -eq 0x4b -and $bytes[$i + 2] -eq 0x03 -and $bytes[$i + 3] -eq 0x04) { + $flags = [BitConverter]::ToUInt16($bytes, $i + 6) + $nameLength = [BitConverter]::ToUInt16($bytes, $i + 26) + if ($nameLength -gt 0 -and ($i + 30 + $nameLength) -le $bytes.Length) { + $nameBytes = New-Object byte[] $nameLength + [Array]::Copy($bytes, $i + 30, $nameBytes, 0, $nameLength) + $lines.Add("RAW_FLAGS=0x" + $flags.ToString("X4")) + $lines.Add("RAW_NAME_UTF8=" + [Text.Encoding]::UTF8.GetString($nameBytes)) + $lines.Add("RAW_NAME_HEX=" + (Hex-Bytes $nameBytes)) + } + } +} +[IO.File]::WriteAllLines($OutputFile, $lines, [System.Text.UTF8Encoding]::new($false)) From cb0e34a5991e0e3aea46daa9292310cc5f5819b5 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Fri, 6 Nov 2026 10:55:08 +0800 Subject: [PATCH 13/16] fix(runtime): use UTC8 singleton for Windows ZIP names --- scripts/runtime/packs.test.ts | 2 ++ scripts/runtime/zip-pack.ps1 | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/runtime/packs.test.ts b/scripts/runtime/packs.test.ts index 3a746846..15de3d49 100644 --- a/scripts/runtime/packs.test.ts +++ b/scripts/runtime/packs.test.ts @@ -107,7 +107,9 @@ test.skipIf(process.platform !== "win32")("Windows ZIP codepoint diagnostics", a console.log("PYTHON_ZIP_NAMES unavailable"); } expect(text).toContain(`MANIFEST_UTF8_HEX=${expectedHex}`); + expect(text).toContain(`DEFAULT_NAME=${expected}`); expect(text).toContain(`UTF8_NAME=${expected}`); + expect(text).toContain("RAW_FLAGS=0x0800"); expect(text).toContain(`RAW_NAME_HEX=${expectedHex}`); if (pythonNames) expect(pythonNames).toContain(expected); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/scripts/runtime/zip-pack.ps1 b/scripts/runtime/zip-pack.ps1 index 4ab92be7..b4461067 100644 --- a/scripts/runtime/zip-pack.ps1 +++ b/scripts/runtime/zip-pack.ps1 @@ -70,7 +70,7 @@ $sorted = foreach ($name in $names) { $byName[$name] } $stream = [IO.File]::Open($Output, [IO.FileMode]::Create, [IO.FileAccess]::Write, [IO.FileShare]::None) try { - $utf8 = [System.Text.UTF8Encoding]::new($false) + $utf8 = [System.Text.Encoding]::UTF8 $archive = New-Object -TypeName System.IO.Compression.ZipArchive -ArgumentList @($stream, [IO.Compression.ZipArchiveMode]::Create, $true, $utf8) try { # Fixed DOS timestamp keeps repeated Windows pack builds byte-identical From adef73daafcef75f60464beab5205b8de8b6462b Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Fri, 6 Nov 2026 11:02:01 +0800 Subject: [PATCH 14/16] test(runtime): place Windows ZIP payloads in installed runtime tree --- scripts/runtime/packs.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/scripts/runtime/packs.test.ts b/scripts/runtime/packs.test.ts index 15de3d49..c754f4dc 100644 --- a/scripts/runtime/packs.test.ts +++ b/scripts/runtime/packs.test.ts @@ -10,9 +10,10 @@ import { WINDOWS_SANDBOX_HELPERS } from "./backend"; const target = `${process.arch === "arm64" ? "aarch64" : "x86_64"}-${process.platform === "win32" ? "pc-windows-msvc" : process.platform === "darwin" ? "apple-darwin" : "unknown-linux-musl"}`; const assetTarget = target.replace("-unknown-linux-", "-linux-"); -const WINDOWS_LONG_APP_FILE = `${"long path ".repeat(10)}unicode-路径.txt`; -const WINDOWS_HIDDEN_APP_FILE = ".hidden-unicode-payload"; -const WINDOWS_EMPTY_APP_DIR = "empty-app-dir"; +const WINDOWS_LONG_FILE_NAME = `${"long path ".repeat(10)}unicode-路径.txt`; +const WINDOWS_LONG_APP_FILE = `runtime/python-site/${WINDOWS_LONG_FILE_NAME}`; +const WINDOWS_HIDDEN_APP_FILE = "runtime/python-site/.hidden-unicode-payload"; +const WINDOWS_EMPTY_APP_DIR = "runtime/python-site/empty-app-dir"; function fixture(root: string) { const source = join(root, "source"); @@ -76,9 +77,9 @@ test.skipIf(process.platform !== "win32")("Windows ZIP codepoint diagnostics", a try { const source = join(root, "source"); mkdirSync(source, { recursive: true }); - writeFileSync(join(source, WINDOWS_LONG_APP_FILE), "unicode payload"); + writeFileSync(join(source, WINDOWS_LONG_FILE_NAME), "unicode payload"); const manifest = join(root, "paths.json"); - writeFileSync(manifest, JSON.stringify([WINDOWS_LONG_APP_FILE])); + writeFileSync(manifest, JSON.stringify([WINDOWS_LONG_FILE_NAME])); const zip = join(root, "diagnostic.zip"); const powershell = join(process.env.SystemRoot!, "System32/WindowsPowerShell/v1.0/powershell.exe"); const packed = Bun.spawnSync([ @@ -94,7 +95,7 @@ test.skipIf(process.platform !== "win32")("Windows ZIP codepoint diagnostics", a expect(diagnosed.exitCode).toBe(0); const text = readFileSync(diagnostics, "utf8"); console.log("ZIP_CODEPOINT_DIAGNOSTICS\n" + text); - const expected = WINDOWS_LONG_APP_FILE; + const expected = WINDOWS_LONG_FILE_NAME; const expectedHex = Buffer.from(expected, "utf8").toString("hex").toUpperCase(); const python = Bun.which("python") ?? Bun.which("py"); let pythonNames: string[] | null = null; From 60c7a69723275e7b0bf527d308e234bc8757ba9d Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Fri, 6 Nov 2026 11:11:01 +0800 Subject: [PATCH 15/16] test(runtime): retain UTF8 ZIP flag regression --- scripts/runtime/packs.test.ts | 40 +++++++--------------- scripts/runtime/zip-diagnose.ps1 | 58 -------------------------------- 2 files changed, 13 insertions(+), 85 deletions(-) delete mode 100644 scripts/runtime/zip-diagnose.ps1 diff --git a/scripts/runtime/packs.test.ts b/scripts/runtime/packs.test.ts index c754f4dc..73732ad7 100644 --- a/scripts/runtime/packs.test.ts +++ b/scripts/runtime/packs.test.ts @@ -72,47 +72,33 @@ test.skipIf(process.platform !== "win32")("Windows ZIP pack handles zero, one an } finally { rmSync(root, { recursive: true, force: true }); } }); -test.skipIf(process.platform !== "win32")("Windows ZIP codepoint diagnostics", async () => { - const root = mkdtempSync(join(tmpdir(), "devo Windows zip codepoints ")); +test.skipIf(process.platform !== "win32")("Windows ZIP default reader preserves UTF8 names", async () => { + const root = mkdtempSync(join(tmpdir(), "devo Windows zip utf8 ")); try { const source = join(root, "source"); mkdirSync(source, { recursive: true }); writeFileSync(join(source, WINDOWS_LONG_FILE_NAME), "unicode payload"); const manifest = join(root, "paths.json"); writeFileSync(manifest, JSON.stringify([WINDOWS_LONG_FILE_NAME])); - const zip = join(root, "diagnostic.zip"); + const zip = join(root, "utf8.zip"); const powershell = join(process.env.SystemRoot!, "System32/WindowsPowerShell/v1.0/powershell.exe"); const packed = Bun.spawnSync([ powershell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", join(import.meta.dir, "zip-pack.ps1"), "-Root", source, "-Output", zip, "-Manifest", manifest, ], { stdout: "pipe", stderr: "pipe" }); expect(packed.exitCode).toBe(0); - const diagnostics = join(root, "diagnostics.txt"); - const diagnosed = Bun.spawnSync([ + const listedFile = join(root, "listed.txt"); + const listed = Bun.spawnSync([ powershell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", - "-File", join(import.meta.dir, "zip-diagnose.ps1"), "-Archive", zip, "-Manifest", manifest, "-OutputFile", diagnostics, + "-File", join(import.meta.dir, "zip-list.ps1"), "-Archive", zip, "-OutputFile", listedFile, ], { stdout: "pipe", stderr: "pipe" }); - expect(diagnosed.exitCode).toBe(0); - const text = readFileSync(diagnostics, "utf8"); - console.log("ZIP_CODEPOINT_DIAGNOSTICS\n" + text); - const expected = WINDOWS_LONG_FILE_NAME; - const expectedHex = Buffer.from(expected, "utf8").toString("hex").toUpperCase(); - const python = Bun.which("python") ?? Bun.which("py"); - let pythonNames: string[] | null = null; - if (python) { - const listed = Bun.spawnSync([python, "-c", "import json, sys, zipfile; z=zipfile.ZipFile(sys.argv[1]); print(json.dumps([i.filename for i in z.infolist()], ensure_ascii=True))", zip], { stdout: "pipe", stderr: "pipe" }); - console.log("PYTHON_ZIP_NAMES\n" + listed.stdout.toString()); - console.log("PYTHON_ZIP_STDERR\n" + listed.stderr.toString()); - if (listed.exitCode === 0) pythonNames = JSON.parse(listed.stdout.toString()); - } else { - console.log("PYTHON_ZIP_NAMES unavailable"); - } - expect(text).toContain(`MANIFEST_UTF8_HEX=${expectedHex}`); - expect(text).toContain(`DEFAULT_NAME=${expected}`); - expect(text).toContain(`UTF8_NAME=${expected}`); - expect(text).toContain("RAW_FLAGS=0x0800"); - expect(text).toContain(`RAW_NAME_HEX=${expectedHex}`); - if (pythonNames) expect(pythonNames).toContain(expected); + expect(listed.exitCode).toBe(0); + const bytes = readFileSync(zip); + expect(bytes.readUInt32LE(0)).toBe(0x04034b50); + expect(bytes.readUInt16LE(6) & 0x0800).toBe(0x0800); + // ZipFile.OpenRead uses the default reader; without bit 11 this name would + // decode as ANSI mojibake instead of the expected Unicode path. + expect(readFileSync(listedFile, "utf8").split(/\r?\n/)).toContain(WINDOWS_LONG_FILE_NAME); } finally { rmSync(root, { recursive: true, force: true }); } }, 30_000); diff --git a/scripts/runtime/zip-diagnose.ps1 b/scripts/runtime/zip-diagnose.ps1 deleted file mode 100644 index fee9c3c7..00000000 --- a/scripts/runtime/zip-diagnose.ps1 +++ /dev/null @@ -1,58 +0,0 @@ -param( - [Parameter(Mandatory = $true)][string]$Archive, - [Parameter(Mandatory = $true)][string]$Manifest, - [Parameter(Mandatory = $true)][string]$OutputFile -) - -$ErrorActionPreference = "Stop" -Add-Type -AssemblyName System.IO.Compression -Add-Type -AssemblyName System.IO.Compression.FileSystem - -function Hex-Bytes([byte[]]$Bytes) { - ($Bytes | ForEach-Object { $_.ToString("X2") }) -join "" -} - -$lines = [System.Collections.Generic.List[string]]::new() -$manifestText = [IO.File]::ReadAllText($Manifest, [Text.Encoding]::UTF8) -$parsed = ConvertFrom-Json -InputObject $manifestText -$manifestPath = if ($parsed -is [System.Array]) { [string]$parsed[0] } else { [string]$parsed } -$manifestPath = $manifestPath.Replace('\', '/') -$lines.Add("MANIFEST_UTF8_HEX=" + (Hex-Bytes ([Text.Encoding]::UTF8.GetBytes($manifestPath)))) - -$stream = [IO.File]::OpenRead($Archive) -try { - $defaultZip = New-Object -TypeName System.IO.Compression.ZipArchive -ArgumentList @($stream, [IO.Compression.ZipArchiveMode]::Read, $true) - try { - foreach ($entry in $defaultZip.Entries) { - $lines.Add("DEFAULT_NAME=" + $entry.FullName) - $lines.Add("DEFAULT_UTF8_HEX=" + (Hex-Bytes ([Text.Encoding]::UTF8.GetBytes($entry.FullName)))) - } - } finally { $defaultZip.Dispose() } -} finally { $stream.Dispose() } - -$stream = [IO.File]::OpenRead($Archive) -try { - $utf8Zip = New-Object -TypeName System.IO.Compression.ZipArchive -ArgumentList @($stream, [IO.Compression.ZipArchiveMode]::Read, $true, [Text.Encoding]::UTF8) - try { - foreach ($entry in $utf8Zip.Entries) { - $lines.Add("UTF8_NAME=" + $entry.FullName) - $lines.Add("UTF8_HEX=" + (Hex-Bytes ([Text.Encoding]::UTF8.GetBytes($entry.FullName)))) - } - } finally { $utf8Zip.Dispose() } -} finally { $stream.Dispose() } - -$bytes = [IO.File]::ReadAllBytes($Archive) -for ($i = 0; $i -le $bytes.Length - 30; $i++) { - if ($bytes[$i] -eq 0x50 -and $bytes[$i + 1] -eq 0x4b -and $bytes[$i + 2] -eq 0x03 -and $bytes[$i + 3] -eq 0x04) { - $flags = [BitConverter]::ToUInt16($bytes, $i + 6) - $nameLength = [BitConverter]::ToUInt16($bytes, $i + 26) - if ($nameLength -gt 0 -and ($i + 30 + $nameLength) -le $bytes.Length) { - $nameBytes = New-Object byte[] $nameLength - [Array]::Copy($bytes, $i + 30, $nameBytes, 0, $nameLength) - $lines.Add("RAW_FLAGS=0x" + $flags.ToString("X4")) - $lines.Add("RAW_NAME_UTF8=" + [Text.Encoding]::UTF8.GetString($nameBytes)) - $lines.Add("RAW_NAME_HEX=" + (Hex-Bytes $nameBytes)) - } - } -} -[IO.File]::WriteAllLines($OutputFile, $lines, [System.Text.UTF8Encoding]::new($false)) From b76c1627c8fe6592d520206d8bead749269f3fcd Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Fri, 6 Nov 2026 11:34:33 +0800 Subject: [PATCH 16/16] fix(install): use atomic noclobber runtime lock --- install.sh | 15 ++++++---- scripts/runtime/packs.test.ts | 55 ++++++++++++++++++++++------------- 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/install.sh b/install.sh index 93e8660e..5a4f9e58 100644 --- a/install.sh +++ b/install.sh @@ -549,23 +549,26 @@ cached_runtime() ( destination="$cache/$digest" lock="$cache/$digest.lock" attempts=0 - while ! mkdir "$lock" 2>/dev/null; do + # `mkdir` is not a portable lock here: uutils coreutils (Ubuntu 26.04) + # reports a concurrent EEXIST as success, which lets two installers share + # the lock. Noclobber creation is atomic and fails closed on existing + # regular lock files and legacy mkdir lock directories; special files + # differ. + while ! ( set -C; printf '%s\n' "$$" > "$lock" ) 2>/dev/null; do attempts=$((attempts + 1)) - [ "$attempts" -lt 300 ] || die "Timed out waiting for runtime cache lock. If no installer is running, remove the stale lock directory manually: $lock" + [ "$attempts" -lt 300 ] || die "Timed out waiting for runtime cache lock. If no installer is running, remove the stale lock manually: $lock" sleep 1 done stage="" cleanup_runtime_lock() { # A crash leaves the lock for explicit user cleanup. Only the process # that wrote this pid token may release its own lock. - if [ -f "$lock/pid" ] && [ "$(cat "$lock/pid" 2>/dev/null || true)" = "$$" ]; then - rm -f "$lock/pid" - rmdir "$lock" + if [ -f "$lock" ] && [ "$(cat "$lock" 2>/dev/null || true)" = "$$" ]; then + rm -f "$lock" fi } trap 'if [ -n "$stage" ]; then rm -rf "$stage"; fi; cleanup_runtime_lock' EXIT trap 'exit 1' INT TERM - printf '%s\n' "$$" > "$lock/pid" case "$kind" in node) executable="bin/node" ;; python) executable="bin/python3" ;; esac binary="$destination/runtime/$kind/$executable" if [ -f "$destination/.complete" ] && [ "$(cat "$destination/.complete")" = "$digest" ] && [ -x "$binary" ] && [ -f "$destination/.binary.sha256" ]; then diff --git a/scripts/runtime/packs.test.ts b/scripts/runtime/packs.test.ts index 73732ad7..5707cc41 100644 --- a/scripts/runtime/packs.test.ts +++ b/scripts/runtime/packs.test.ts @@ -245,29 +245,44 @@ test("real installers reuse runtimes across upgrades, repair damage, reject corr if (process.platform !== "win32") { // A stale lock must fail closed: no automatic deletion. A controlled // `sleep` on PATH makes the 300-attempt wait deterministic without a - // production timeout knob. - const staleCache = join(root, "stale lock cache"); - mkdirSync(staleCache); - const staleLock = join(staleCache, `${first[1][1]}.lock`); - mkdirSync(staleLock); - writeFileSync(join(staleLock, "pid"), "999999\n"); + // production timeout knob. Exercise the legacy mkdir directory and the + // current lock file; neither may be reclaimed automatically. const fakeSleepDir = join(root, "fake sleep bin"); mkdirSync(fakeSleepDir); writeFileSync(join(fakeSleepDir, "sleep"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); - const blocked = await installOnline(root, join(root, "stale lock install"), staleCache, origin, target, "v0.2.0", "functions", { - PATH: `${fakeSleepDir}:${process.env.PATH ?? ""}`, - }); - expect({ - failed: blocked.exit !== 0, - lockPreserved: existsSync(staleLock), - pidPreserved: readFileSync(join(staleLock, "pid"), "utf8"), - manualCleanupMessage: blocked.stderr.includes("remove the stale lock directory manually"), - }).toEqual({ failed: true, lockPreserved: true, pidPreserved: "999999\n", manualCleanupMessage: true }); - rmSync(staleLock, { recursive: true, force: true }); - const retry = await installOnline(root, join(root, "stale lock retry install"), staleCache, origin, target); - if (retry.exit !== 0) throw new Error(`${retry.stdout}\n${retry.stderr}`); - expect(existsSync(staleLock)).toBe(false); - requests.length = 0; + for (const legacyDirectory of [true, false]) { + const label = legacyDirectory ? "legacy directory" : "file"; + const staleCache = join(root, `stale lock ${label} cache`); + mkdirSync(staleCache); + const staleLock = join(staleCache, `${first[1][1]}.lock`); + if (legacyDirectory) { + mkdirSync(staleLock); + writeFileSync(join(staleLock, "pid"), "999999\n"); + } else { + writeFileSync(staleLock, "999999\n"); + } + const blocked = await installOnline(root, join(root, `stale lock ${label} install`), staleCache, origin, target, "v0.2.0", "functions", { + PATH: `${fakeSleepDir}:${process.env.PATH ?? ""}`, + }); + expect({ + failed: blocked.exit !== 0, + lockPreserved: existsSync(staleLock), + pidPreserved: readFileSync(legacyDirectory ? join(staleLock, "pid") : staleLock, "utf8"), + manualCleanupMessage: blocked.stderr.includes("remove the stale lock manually"), + }).toEqual({ failed: true, lockPreserved: true, pidPreserved: "999999\n", manualCleanupMessage: true }); + rmSync(staleLock, { recursive: true, force: true }); + const retry = await installOnline(root, join(root, `stale lock ${label} retry install`), staleCache, origin, target); + if (retry.exit !== 0) throw new Error(`${retry.stdout}\n${retry.stderr}`); + expect(existsSync(staleLock)).toBe(false); + requests.length = 0; + } + // Old-version installers use mkdir for the same lock pathname. A new + // file lock must make that legacy acquisition fail closed too. + const legacyProbe = join(root, "legacy mkdir probe.lock"); + writeFileSync(legacyProbe, "999999\n"); + const legacyMkdir = Bun.spawnSync(["/bin/mkdir", legacyProbe]); + expect(legacyMkdir.exitCode).not.toBe(0); + rmSync(legacyProbe, { force: true }); } requestLog.length = 0;