Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -718,7 +718,7 @@ The legacy `vault_path` per-provider setting was removed in v0.3.1.
Tests run without the full LegionIO stack. `spec/spec_helper.rb` uses real `Legion::Logging` and `Legion::Settings` (no stubs — hard dependencies are always present). Each test resets settings to defaults via `before(:each)`.

```bash
bundle exec rspec # 3044 examples, 0 failures
bundle exec rspec # all examples green, 0 failures
bundle exec rubocop # 0 offenses
```

Expand All @@ -729,7 +729,7 @@ bundle exec rubocop # 0 offenses
**The in-process matrix is the commit gate. Live e2e is confirmation only.**
Every PR that touches `lib/legion/llm/api/`, the executor, or the canonical/translator boundary must pass the matrix locally before push. Live `legionio-e2e` runs against a daemon, costs cloud credits, and surfaces regressions hours after they ship — the matrix surfaces them in milliseconds with deterministic fixtures. If a regression breaks live e2e but not the matrix, the matrix is missing a scenario; add it.

The harness has been proven to catch this week's translator-boundary regressions: `9771ae8` (ThinkingConfig kwargs), `cb7a08b` (flat tool_call hashes in OpenAI Responses), and `172c756` (reasoning output item shape). See `docs/work/planning/reports/G23-matrix-harness.md` for the proof and the scenario catalog.
The harness has been proven to catch the P5 translator regressions: `9771ae8` (ThinkingConfig kwargs), `cb7a08b` (flat tool_call hashes in OpenAI Responses), and `172c756` (reasoning output item shape).

## LLM Routing Invariants

Expand All @@ -746,7 +746,7 @@ These are non-negotiable contracts that govern how requests flow through the dae

4. **Thinking never crosses providers.** Reasoning content, signatures, and `redacted_thinking` blocks survive same-provider history replay; on any cross-provider transition (escalation, mid-stream failover, tier swap) thinking is stripped from replayed history. Signatures are cryptographically provider-bound and wire formats have no lossless mapping; foreign chain-of-thought is out-of-distribution for the new model.

5. **Mid-stream provider failover is a first-class requirement.** A provider outage must never kill an in-flight conversation — that is vendor lock-in, not resiliency. The `StreamAssembler` decouples client protocol state from provider stream state: the client keeps one continuous SSE session while the canonical chunk source switches providers underneath. Failover phase points (`:before_first_byte`, `:mid_text`, `:mid_thinking`, `:mid_tool_call`) drive what the assembler replays vs. discards. Tool-call argument buffering policy (`llm.streaming.tool_call_buffering`) is configurable; the default is `:buffered` with periodic SSE keep-alive pings so large tool args don't make the upstream look dead.
5. **Mid-stream provider failover is a first-class requirement.** A provider outage must never kill an in-flight conversation — that is vendor lock-in, not resiliency. The `StreamAssembler` decouples client protocol state from provider stream state: the client keeps one continuous SSE session while the canonical chunk source switches providers underneath. Failover phase points (`:before_first_byte`, `:mid_text`, `:mid_thinking`, `:mid_tool_call`) drive what the assembler replays vs. discards. Tool-call argument buffering policy (`llm.streaming.tool_call_buffering`) is configurable; the default is `:buffered`; a single keep-alive ping fires when a buffered tool-call opens (interval-based pinging is reserved but not yet implemented).

6. **Every pipeline exit emits ledger events.** Every terminal path — success/sync, success/stream, error, escalation-exhausted, fleet-success, fleet-error, timeout, client-disconnect — routes through the same emission function and produces the metering, prompt-audit, and (for tool scenarios) tool-audit rows the ledger consumer expects. Pipeline bypasses are not allowed (the `_direct` shims are deprecated and route through the governed pipeline; see `Legion/Framework/NoDirectDispatch`). If you cannot record the event trail, fail closed when `llm.compliance.fail_closed` is set.

Expand Down
32 changes: 26 additions & 6 deletions lib/legion/llm/api/client_translators/anthropic_messages.rb
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,18 @@ def build_inference_request(canonical_request, request_id:, server_caller:, moda

messages = inference_messages(canonical_request.messages)

# C7 — header-supplied X-Legion-Model wins; otherwise fall back to
# the client-requested body[:model] so routing is never blank when
# the caller named a model.
routing = (canonical_request.routing || {}).dup
client_model = canonical_request.metadata[:client_model]
routing[:model] ||= client_model if client_model

Legion::LLM::Inference::Request.build(
id: request_id,
messages: messages,
system: canonical_request.system,
routing: canonical_request.routing,
routing: routing,
tools: tool_defs,
tool_choice: canonical_request.tool_choice,
caller: server_caller,
Expand Down Expand Up @@ -462,6 +469,7 @@ def normalize_assistant_message(content)

text_parts = []
tool_calls = []
thinking_blocks = []

content.each do |block|
bs = symbolize(block)
Expand All @@ -476,12 +484,13 @@ def normalize_assistant_message(content)
# multi-turn replay R5 says signatures must round-trip on
# same-provider; leave them in the message for now (the
# provider translator drops them for cross-provider).
next
thinking_blocks << bs
end
end

msg = { role: :assistant, content: text_parts.join("\n\n") }
msg[:tool_calls] = tool_calls if tool_calls.any?
msg[:thinking_blocks] = thinking_blocks if thinking_blocks.any?
[msg]
end

Expand All @@ -491,6 +500,7 @@ def normalize_user_message(content)

messages = []
text_parts = []
content_blocks = []

content.each do |block|
bs = symbolize(block)
Expand All @@ -499,9 +509,12 @@ def normalize_user_message(content)
when 'text'
text_parts << bs[:text].to_s
when 'tool_result'
if text_parts.any?
messages << { role: :user, content: text_parts.join("\n\n") }
if text_parts.any? || content_blocks.any?
msg = { role: :user, content: text_parts.join("\n\n") }
msg[:content_blocks] = content_blocks if content_blocks.any?
messages << msg
text_parts = []
content_blocks = []
end
result_content = bs[:content]
messages << if result_content.is_a?(Array)
Expand All @@ -510,11 +523,18 @@ def normalize_user_message(content)
{ role: :tool, tool_call_id: bs[:tool_use_id], content: result_content.to_s }
end
else
text_parts << bs.to_s
# Preserve structured non-text blocks (image, document, …)
# rather than stringifying them — provider translators decide
# how to surface these per-modality.
content_blocks << bs
end
end

messages << { role: :user, content: text_parts.join } if text_parts.any?
if text_parts.any? || content_blocks.any?
msg = { role: :user, content: text_parts.join("\n\n") }
msg[:content_blocks] = content_blocks if content_blocks.any?
messages << msg
end
messages.empty? ? [{ role: :user, content: '' }] : messages
end

Expand Down
25 changes: 19 additions & 6 deletions lib/legion/llm/api/client_translators/openai_chat.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,18 @@ def build_inference_request(canonical_request, request_id:, server_caller:, moda

messages = inference_messages(canonical_request.messages)

# C7 — header-supplied X-Legion-Model wins; otherwise fall back to
# the client-requested body[:model] so routing is never blank when
# the caller named a model.
routing = (canonical_request.routing || {}).dup
client_model = canonical_request.metadata[:client_model]
routing[:model] ||= client_model if client_model

Legion::LLM::Inference::Request.build(
id: request_id,
messages: messages,
system: canonical_request.system,
routing: canonical_request.routing,
routing: routing,
tools: tool_defs,
tool_choice: canonical_request.tool_choice,
caller: server_caller,
Expand Down Expand Up @@ -225,6 +232,8 @@ def initialize(out:, request_id:, model:, conv_id: nil, include_reasoning: true)
@conv_id = conv_id
@include_reasoning = include_reasoning
@done_emitted = false
@tool_ordinal = -1
@tool_ordinals = {}
end

def on_start(model:, request_id:, input_tokens:)
Expand Down Expand Up @@ -269,12 +278,15 @@ def on_thinking_close(block_index:, signature:)

def on_tool_call_open(block_index:, tool_call:, server_tool:)
_ = server_tool
# Send the initial tool_call with name. Index is 0-based across
# this streaming response; chat.completion.chunk references it
# by index in the delta.
# Send the initial tool_call with name. Index is the 0-based
# tool-call ordinal across this streaming response (NOT the
# assembler's global block index); chat.completion.chunk
# references it by index in the delta.
idx = (@tool_ordinal += 1)
@tool_ordinals[block_index] = idx
emit_chunk(delta_envelope({
Comment on lines 279 to 287
tool_calls: [{
index: block_index,
index: idx,
id: tool_call[:id],
type: 'function',
function: {
Expand All @@ -288,9 +300,10 @@ def on_tool_call_open(block_index:, tool_call:, server_tool:)
def on_tool_call_delta(block_index:, partial_arguments_json:)
return if partial_arguments_json.to_s.empty?

idx = @tool_ordinals[block_index] ||= (@tool_ordinal += 1)
emit_chunk(delta_envelope({
tool_calls: [{
index: block_index,
index: idx,
function: { arguments: partial_arguments_json }
}]
}))
Comment on lines 300 to 309
Expand Down
28 changes: 20 additions & 8 deletions lib/legion/llm/api/client_translators/openai_responses.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ module ClientTranslators
# - parse_request(body, env) -> Canonical::Request (handles
# input/instructions/reasoning/tools shapes specific to /v1/responses)
# - format_response(canonical_or_pipeline_response) -> Hash with output[]
# containing {thinking, function_call, message} items
# containing {reasoning (phase: 'reasoning' message), server_tool
# function_call/output pairs, actionable function_call, message} items
# - format_error(error, status_code:, type:)
# - events_emitter(out, ...) -> Events emitter conforming to the
# StreamAssembler contract.
Expand Down Expand Up @@ -71,7 +72,7 @@ def parse_request(body, env = {})
# /v1/responses lane omits reasoning summary content unless the
# request opts in — without this, codex→openai cells return only
# the message item (no reasoning), and the e2e validator
# reports "reasoning never produced" (P5-final-cells.md B3).
# reports "reasoning never produced".
def ensure_reasoning_summary(body)
reasoning = body[:reasoning]
return body unless reasoning.is_a?(Hash)
Expand All @@ -94,11 +95,18 @@ def build_inference_request(canonical_request, request_id:, server_caller:, moda

messages = inference_messages(canonical_request.messages)

# C7 — header-supplied X-Legion-Model wins; otherwise fall back to
# the client-requested body[:model] so routing is never blank when
# the caller named a model.
routing = (canonical_request.routing || {}).dup
client_model = canonical_request.metadata[:client_model]
routing[:model] ||= client_model if client_model

Legion::LLM::Inference::Request.build(
id: request_id,
messages: messages,
system: canonical_request.system,
routing: canonical_request.routing,
routing: routing,
tools: tool_defs,
tool_choice: canonical_request.tool_choice,
caller: server_caller,
Expand Down Expand Up @@ -341,7 +349,6 @@ def on_thinking_close(block_index:, signature:)
end

def on_tool_call_open(block_index:, tool_call:, server_tool:)
_ = server_tool
tc_id = tool_call[:id] || "call_#{SecureRandom.hex(12)}"
idx = @output_items.length
item = { id: tc_id, type: 'function_call', name: tool_call[:name].to_s,
Expand All @@ -352,7 +359,8 @@ def on_tool_call_open(block_index:, tool_call:, server_tool:)
name: tool_call[:name].to_s,
arguments_str: '',
output_index: idx,
args_emitted: +''
args_emitted: +'',
server_tool: server_tool
}
emit('response.output_item.added', {
type: 'response.output_item.added', sequence_number: next_seq,
Expand Down Expand Up @@ -446,8 +454,12 @@ def on_done(stop_reason:, usage:, model:)
@output_items[@msg_index] = completed_item
end

has_tool_calls = @pending_tool_calls.any?
status = stop_reason == :tool_use || has_tool_calls ? 'requires_action' : 'completed'
_ = stop_reason
# G24 — server-executed tool calls are non-actionable; only
# client-callable tool calls drive `requires_action`.
actionable = @pending_tool_calls.values.reject { |tc| tc[:server_tool] }
has_tool_calls = actionable.any?
status = has_tool_calls ? 'requires_action' : 'completed'
event = status == 'requires_action' ? 'response.done' : 'response.completed'
payload = {
type: event, sequence_number: next_seq,
Expand All @@ -460,7 +472,7 @@ def on_done(stop_reason:, usage:, model:)
if status == 'requires_action'
payload[:response][:action_required] = {
type: 'function_calls',
function_calls: @output_items.select { |i| i[:type] == 'function_call' }
function_calls: actionable.map { |tc| @output_items[tc[:output_index]] }
}
end
emit(event, payload)
Expand Down
4 changes: 3 additions & 1 deletion lib/legion/llm/api/debug_formats.rb
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ def self.attach_echo_request(client_format_body, canonical_request)
# on write failure.
def self.emit_echo_request_sse(out, canonical_request)
out << "event: legion.debug.echo_request\ndata: #{Legion::JSON.dump(canonical_request.to_h)}\n\n"
rescue IOError, Errno::EPIPE
rescue IOError, Errno::EPIPE => e
handle_exception(e, level: :debug, handled: true,
operation: 'llm.api.debug_formats.emit_echo_request_sse')
nil
end

Expand Down
2 changes: 1 addition & 1 deletion lib/legion/llm/api/namespaces/anthropic/messages.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ module Messages
)
rescue Legion::LLM::API::StreamAssembler::StreamClosed
# Client disconnected — assembler logged the disconnect; treat as cancellation (G10).
rescue IOError, Errno::EPIPE
rescue IOError, Errno::EPIPE, *(defined?(Puma::ConnectionError) ? [Puma::ConnectionError] : [])
# Client disconnected mid-write before assembler caught it.
rescue StandardError => e
handle_exception(e, level: :error, handled: false,
Expand Down
2 changes: 1 addition & 1 deletion lib/legion/llm/api/namespaces/openai/chat/completions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ def self.registered(app)
)
rescue Legion::LLM::API::StreamAssembler::StreamClosed
# Client disconnected — caller treats as cancellation per G10.
rescue IOError, Errno::EPIPE
rescue IOError, Errno::EPIPE, *(defined?(Puma::ConnectionError) ? [Puma::ConnectionError] : [])
# Client disconnected mid-write before assembler caught it.
rescue StandardError => e
handle_exception(e, level: :error, handled: false,
Expand Down
2 changes: 1 addition & 1 deletion lib/legion/llm/api/namespaces/openai/responses.rb
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ def self.registered(app) # rubocop:disable Metrics/AbcSize
)
rescue Legion::LLM::API::StreamAssembler::StreamClosed
# Client disconnected — caller treats as cancellation per G10.
rescue IOError, Errno::EPIPE
rescue IOError, Errno::EPIPE, *(defined?(Puma::ConnectionError) ? [Puma::ConnectionError] : [])
# Client disconnected mid-write before assembler caught it.
rescue StandardError => e
handle_exception(e, level: :error, handled: false,
Expand Down
Loading