feat(azure): add azure_to_openai translation filter - #910
Conversation
d96f898 to
afccb81
Compare
Normalize Azure OpenAI requests and responses into standard Chat Completions form: inject api-version, strip model from request body, remove content-filter fields from JSON and SSE responses, and fill type: null in error envelopes. SSE streaming reuses the shared SseFrameParser for correct cross-chunk buffering, CRLF handling, and buffer-overflow protection. Signed-off-by: noalimoy <nlimoy@redhat.com>
afccb81 to
590bb7d
Compare
praxis-bot
left a comment
There was a problem hiding this comment.
Review: feat(azure): add azure_to_openai translation filter
Well-structured filter following established {provider}/to_openai module patterns. Clean separation of request, response, SSE, and error normalization. Good reuse of the shared SseFrameParser for streaming. Unit test coverage (34 tests) and integration tests (5 tests) are thorough.
Findings
| # | Severity | File | Description |
|---|---|---|---|
| 1 | Medium | apis/src/azure/wire.rs |
normalize_error_response does not handle entirely-missing type field |
| 2 | Medium | apis/src/azure/to_openai/mod.rs |
rebuild_sse_frames silently drops event_type from SSE frames |
| let mut modified = false; | ||
|
|
||
| let needs_type_fix = error | ||
| .get("type") |
There was a problem hiding this comment.
Medium: The is_some_and check handles type: null and type: "" but returns false when type is entirely absent from the error object. In that case normalize_error_response returns None (no modification), leaving the response without a type field at all — which is also not a valid Chat Completions error envelope.
Azure's current schema always includes "type": null, but a defensive fix would also handle the missing case:
let needs_type_fix = error
.get("type")
.map_or(true, |v| v.is_null() || v.as_str().is_some_and(str::is_empty));This also requires moving the modified flag logic since innererror removal alone currently triggers serialization.
| } | ||
| return; | ||
| }; | ||
|
|
There was a problem hiding this comment.
Medium: rebuild_sse_frames emits only data: lines. If a source SseFrame has a non-None event_type, it is silently dropped — the reconstructed SSE stream loses the event: line.
Azure Chat Completions SSE does not currently use named event types, so this is not a live bug, but it creates a silent data-loss path if Azure adds event types in the future (or if a custom deployment returns them). Consider re-emitting the event type when present:
if let Some(ref event) = frame.event_type {
output.extend_from_slice(b"event: ");
output.extend_from_slice(event.as_bytes());
output.extend_from_slice(b"\n");
}
praxis-bot
left a comment
There was a problem hiding this comment.
PR Review
Azure-to-OpenAI translation filter — re-review focusing on new findings only (2 prior comments already posted).
1 new finding below.
| } | ||
| } | ||
|
|
||
| // ----------------------------------------------------------------------------- |
There was a problem hiding this comment.
[Medium] When parse_chunk returns Err, strip_sse_chunk returns early without modifying body, forwarding the raw unprocessed chunk to the client with Azure-specific content-filter fields intact. This leaks Azure internals (content_filter_results, prompt_filter_results) to downstream clients on parse errors.
Replace the early return with clearing the body so malformed chunks are dropped rather than forwarded unstripped:
Err(e) => {
debug!(error = %e, "SSE parse error in azure_to_openai");
ctx.insert_filter_state(parser);
*body = Some(Bytes::new());
return;
},
Summary
Add an
azure_to_openaiHttpFilter underapis/src/azure/that lets standard Chat Completions clients talk to Azure OpenAI deployments through Praxis. Azure's wire format is nearly identical to Chat Completions — the filter handles the small but operationally critical differences:api-versionquery parameter, strips themodelfield (Azure uses the deployment name in the URL path), removesAccept-Encodingto prevent double-compressed bodies.prompt_filter_resultsand per-choicecontent_filter_results/content_filter_offsets.SseFrameParserrather than hand-rolling line splitting — the parser already handles cross-chunk buffering, bare\rand split\r\nline endings,data:without space after colon, multi-linedata:joining, and buffer-overflow protection. The filter callsparse_chunk, strips Azure fields from each frame's data payload via the samestrip_azure_fieldsused for non-streaming JSON, and re-emits clean SSE.type: nullfrom the HTTP status code and stripsinnererror, normalizing Azure errors to the standard Chat Completions error envelope.SSE handled inline, not as a separate filter
Unlike
anthropic_stream_events(which is a standalone filter doing full format translation between OpenAI and Anthropic event schemas), Azure SSE is already Chat Completions format — the only work is stripping the samecontent_filter_resultsfields thatstrip_azure_fieldsalready removes from non-streaming JSON. A separate filter would add config surface and registration overhead for no new logic, so the SSE path lives alongside the JSON and error paths inon_response_body.Follows the same
{provider}/to_openaimodule structure established byanthropic/to_openai. This is the first provider filter for Epic #114 (Schema translation); the remaining providers (Bedrock, Vertex AI, Cohere) follow separately.Related issue
Part of #114
Closes #886
Validation
praxis-ai-apis(config parsing, request/response stripping, SSE frame rebuild, partial-line buffering via SseFrameParser, multi-byte UTF-8 boundary, error normalization)azure_translation.rs(request forwarding with api-version, content-filter stripping, SSE event stripping, error normalization withtype: null+innererror, pass-through of already-valid errors)make lint— clippy (default + all-features), nightly fmt, rustdoc all passChecklist
Signed-off-bytrailer.Breaking changes
None. New filter only; no existing behavior or API is changed.