Skip to content

fix: harden OpenAI-compatible parsing with SSE fallback - #312

Closed
jamiepine wants to merge 1 commit into
mainfrom
fix/openai-sse-fallback
Closed

fix: harden OpenAI-compatible parsing with SSE fallback#312
jamiepine wants to merge 1 commit into
mainfrom
fix/openai-sse-fallback

Conversation

@jamiepine

Copy link
Copy Markdown
Member

Summary

  • add an OpenAI-compatible fallback path that retries with stream: true and parses SSE when the primary non-stream response cannot be decoded as JSON (the worker failure path seen with OpenRouter/Moonshot)
  • force accept-encoding: identity on these calls to reduce response-body decode failures from compressed/chunked provider responses
  • implement SpacebotModel::stream with a usable streamed wrapper and add coverage for SSE reconstruction (including tool-call deltas) and streaming usage reporting

Testing

  • ./scripts/preflight.sh
  • ./scripts/gate-pr.sh

@coderabbitai

coderabbitai Bot commented Mar 4, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

Implemented streaming support for OpenAI-compatible providers in SpacebotModel::stream by constructing live RawStreamingChoice events. Extended RawStreamingResponse with optional usage field. Added SSE parsing utilities and streaming fallback mechanism with enhanced error handling and usage propagation throughout.

Changes

Cohort / File(s) Summary
Streaming Core Implementation
src/llm/model.rs
Implemented SpacebotModel::stream to produce StreamingCompletionResponse from CompletionRequest, constructing live RawStreamingChoice events with MessageId, Message contents, ToolCall details, Reasoning, and FinalResponse carrying usage.
SSE Parsing & Reconstruction
src/llm/model.rs
Added OpenAiStreamingToolCall struct and parse_openai_chat_sse_response function to aggregate text, reasoning, tool calls, and usage from SSE data lines into unified OpenAI format; includes helper utilities like with_streaming_enabled, looks_like_sse_response, and format_api_error_from_response_text.
Fallback & Integration Pattern
src/llm/model.rs
Introduced call_openai_chat_with_stream_fallback orchestration method and call_openai_compatible_with_optional_auth helper to support streaming fallback on primary request failure; replaced direct JSON-call patterns with streaming-aware builder injecting headers (authorization, account-id, user-agent, extra headers) and optional bearer auth across multiple provider paths.
Response Enhancement & Tests
src/llm/model.rs
Extended RawStreamingResponse with optional usage field and updated GetTokenUsage trait; added four test cases covering tool_call reconstruction, SSE data requirements, raw streaming usage reporting, and OpenRouter metadata error formatting.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.56% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: hardening OpenAI-compatible parsing with an SSE fallback mechanism, which is the primary focus of the changeset.
Description check ✅ Passed The description is directly related to the changeset, detailing the SSE fallback path, header handling, SpacebotModel::stream implementation, and test coverage.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/openai-sse-fallback

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/llm/model.rs`:
- Around line 1006-1010: The code builds a primary_failure_reason by embedding
the raw response body (using truncate_body(&primary_text)) and later logs it at
warn level; change this so sensitive payloads are not included in logs: update
the format! that sets primary_failure_reason in src/llm/model.rs to avoid
inserting the response text (instead include non-sensitive metadata like
response length, a fixed redaction marker, or a short hash/checksum), and update
the warn call that logs primary_failure_reason to only emit that sanitized
metadata. Locate the string construction and the warn! that references
primary_failure_reason and replace the embedded body with a
redacted/metadata-only value.
- Around line 1447-1449: The SSE chunk parsing currently swallows JSON parse
errors where the code does serde_json::from_str::<serde_json::Value>(data) and
continues; change this to fail-fast by returning or propagating an Err (or
logging the error with context and breaking the stream) instead of silently
continuing. Similarly, find the reconstructed tool-argument parsing site that
currently falls back to {} on failure (the block that parses the reconstructed
tool args and uses {} on parse error) and replace the silent fallback with
proper error handling: propagate the parse error or return a descriptive Err/log
entry including the raw payload and tool name so a single corrupt chunk cannot
silently mutate arguments.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 636b3743-8654-4a69-aee2-64fb0c0cbadb

📥 Commits

Reviewing files that changed from the base of the PR and between 23929f9 and cf7cfdf.

📒 Files selected for processing (1)
  • src/llm/model.rs

Comment thread src/llm/model.rs
Comment on lines +1006 to +1010
Some(format!(
"response body was not valid JSON and did not look like SSE. Body: {}",
truncate_body(&primary_text)
))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid logging response-body snippets at warn level.

Line 1007 embeds response text into primary_failure_reason, and Line 1015 logs that field at warn. This can leak model/user payload data into centralized logs.

🔧 Proposed fix
-                Some(format!(
-                    "response body was not valid JSON and did not look like SSE. Body: {}",
-                    truncate_body(&primary_text)
-                ))
+                Some("response body was not valid JSON and did not look like SSE".to_string())

Also applies to: 1015-1018

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/llm/model.rs` around lines 1006 - 1010, The code builds a
primary_failure_reason by embedding the raw response body (using
truncate_body(&primary_text)) and later logs it at warn level; change this so
sensitive payloads are not included in logs: update the format! that sets
primary_failure_reason in src/llm/model.rs to avoid inserting the response text
(instead include non-sensitive metadata like response length, a fixed redaction
marker, or a short hash/checksum), and update the warn call that logs
primary_failure_reason to only emit that sanitized metadata. Locate the string
construction and the warn! that references primary_failure_reason and replace
the embedded body with a redacted/metadata-only value.

Comment thread src/llm/model.rs
Comment on lines +1447 to +1449
let Ok(event_body) = serde_json::from_str::<serde_json::Value>(data) else {
continue;
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fail fast on malformed SSE JSON/tool-argument chunks instead of silently continuing.

Line 1447 currently drops invalid SSE data: payloads, and Line 1606 tolerates malformed reconstructed tool arguments by degrading to {}. A single corrupt chunk can silently mutate a tool call into incorrect arguments.

🔧 Proposed fix
-        let Ok(event_body) = serde_json::from_str::<serde_json::Value>(data) else {
-            continue;
-        };
+        let event_body = serde_json::from_str::<serde_json::Value>(data).map_err(|error| {
+            CompletionError::ProviderError(format!(
+                "{provider_label} streaming response contained invalid JSON event: {error}. Event: {}",
+                truncate_body(data)
+            ))
+        })?;
...
-        let arguments =
-            parse_openai_tool_arguments(&serde_json::Value::String(tool_call.arguments));
+        let arguments = if tool_call.arguments.trim().is_empty() {
+            serde_json::json!({})
+        } else {
+            serde_json::from_str::<serde_json::Value>(&tool_call.arguments).map_err(|error| {
+                CompletionError::ProviderError(format!(
+                    "{provider_label} streaming tool_call arguments were invalid JSON: {error}"
+                ))
+            })?
+        };

As per coding guidelines: "Never silently discard errors. No let _ = on Results. Handle them, log them, or propagate them."

Also applies to: 1605-1606

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/llm/model.rs` around lines 1447 - 1449, The SSE chunk parsing currently
swallows JSON parse errors where the code does
serde_json::from_str::<serde_json::Value>(data) and continues; change this to
fail-fast by returning or propagating an Err (or logging the error with context
and breaking the stream) instead of silently continuing. Similarly, find the
reconstructed tool-argument parsing site that currently falls back to {} on
failure (the block that parses the reconstructed tool args and uses {} on parse
error) and replace the silent fallback with proper error handling: propagate the
parse error or return a descriptive Err/log entry including the raw payload and
tool name so a single corrupt chunk cannot silently mutate arguments.

@jamiepine jamiepine closed this Mar 4, 2026
ibhagwan added a commit to ibhagwan/spacebot that referenced this pull request Mar 4, 2026
Implements Signal messaging support using the signal-cli daemon HTTP API,
following the existing adapter architecture (Telegram, Discord, Slack, Twitch).

## Implementation Details

### Signal Adapter (`src/messaging/signal.rs`)
- **Inbound:** SSE stream from signal-cli daemon with automatic reconnection
  and exponential backoff (2s → 60s). Handles UTF-8 chunk boundaries
  and buffer overflow protection (1MB max buffer, 256KB max event).
- **Outbound:** JSON-RPC `send` calls. Critical: DM recipients must be
  passed as a JSON **array** (`["+1234567890"]`) per signal-cli requirements.
- **Typing indicators:** JSON-RPC `sendTyping` with repeating task (expire ~5s).
- **Attachments outbound:** Temp files in `{instance_dir}/tmp/`, file paths
  passed in `attachments` JSON array, auto-cleaned after send.
- **Attachments inbound:** signal-cli provides file paths on disk (treated as
  opaque - message falls back to `[Attachment]` placeholder).
- **Streaming:** Not supported (Signal can't edit messages). StreamStart/
  Chunk/End are no-ops.
- **Permissions:** DM allowlist + group filter (None = block all groups).

### Config Changes
- `src/config/types.rs`: Added and `SignalInstance `SignalConfig`Config`
  with `SystemSecrets` impl for http_url credential.
- `src/config/toml_schema.rs`: Added TOML deserialization types.
- `src/config/load.rs`: Config loading with env var fallbacks
  (`SIGNAL_HTTP_URL`, `SIGNAL_ACCOUNT`).
- `src/config/permissions.rs`: Added `SignalPermissions` with
  `from_config()`, `from_instance_config()`, `from_bindings_for_adapter()`.
- `src/config/watcher.rs`: Added hot-reload support for Signal permissions
  and adapter hot-start on config changes.

### Wiring
- `src/messaging.rs`: Added `pub mod signal;`.
- `src/main.rs`: Wired adapter registration in `initialize_agents()`,
  file watcher, and hot-reload paths. Supports default + named instances.
- `src/secrets/store.rs`: Added `SignalConfig` to system secret registry
  for auto-categorization of `SIGNAL_HTTP_URL`.

### Testing
- 23 unit tests covering SSE parsing, permission filtering, metadata
  construction, and JSON-RPC parameter building.

## Usage

Add to config.toml:
```toml
[messaging.signal]
enabled = true
http_url = "http://127.0.0.1:8686"
account = "+1234567890"
ignore_stories = true
dm_allowed_users = ["+0987654321"]

[[messaging.signal.instances]]
name = "work"
enabled = true
http_url = "http://127.0.0.1:8687"
account = "+1122334455"
dm_allowed_users = ["+5566778899"]
```

Requires signal-cli daemon running: `signal-cli daemon --http`

Closes spacedriveapp#312
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant