Skip to content

feat(azure): add azure_to_openai translation filter - #910

Open
noalimoy wants to merge 1 commit into
praxis-proxy:mainfrom
noalimoy:feat/886-azure-openai-translation
Open

feat(azure): add azure_to_openai translation filter#910
noalimoy wants to merge 1 commit into
praxis-proxy:mainfrom
noalimoy:feat/886-azure-openai-translation

Conversation

@noalimoy

@noalimoy noalimoy commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Add an azure_to_openai HttpFilter under apis/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:

  • Request: injects api-version query parameter, strips the model field (Azure uses the deployment name in the URL path), removes Accept-Encoding to prevent double-compressed bodies.
  • Response (JSON): strips prompt_filter_results and per-choice content_filter_results/content_filter_offsets.
  • Response (SSE): reuses the shared SseFrameParser rather than hand-rolling line splitting — the parser already handles cross-chunk buffering, bare \r and split \r\n line endings, data: without space after colon, multi-line data: joining, and buffer-overflow protection. The filter calls parse_chunk, strips Azure fields from each frame's data payload via the same strip_azure_fields used for non-streaming JSON, and re-emits clean SSE.
  • Errors: fills type: null from the HTTP status code and strips innererror, 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 same content_filter_results fields that strip_azure_fields already 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 in on_response_body.

Follows the same {provider}/to_openai module structure established by anthropic/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

  • Unit tests — 34 Azure-specific tests in praxis-ai-apis (config parsing, request/response stripping, SSE frame rebuild, partial-line buffering via SseFrameParser, multi-byte UTF-8 boundary, error normalization)
  • Integration or functional tests — 5 tests via azure_translation.rs (request forwarding with api-version, content-filter stripping, SSE event stripping, error normalization with type: null+innererror, pass-through of already-valid errors)
  • make lint — clippy (default + all-features), nightly fmt, rustdoc all pass

Checklist

  • I reviewed every changed line and can explain the change.
  • New capabilities include an example config and functional example test.
  • User-facing behavior and generated documentation are updated.
  • Performance-sensitive changes include appropriate benchmark or load-test evidence.
  • Commits are signed and include a Signed-off-by trailer.

Breaking changes

None. New filter only; no existing behavior or API is changed.

@noalimoy
noalimoy force-pushed the feat/886-azure-openai-translation branch from d96f898 to afccb81 Compare September 3, 2026 10:33
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>
@noalimoy
noalimoy force-pushed the feat/886-azure-openai-translation branch from afccb81 to 590bb7d Compare September 3, 2026 10:46

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Comment thread apis/src/azure/wire.rs
let mut modified = false;

let needs_type_fix = error
.get("type")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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;
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 praxis-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

PR Review

Azure-to-OpenAI translation filter — re-review focusing on new findings only (2 prior comments already posted).

1 new finding below.

}
}

// -----------------------------------------------------------------------------

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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;
},

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.

API translation: Azure OpenAI ↔ Chat Completions

2 participants