Skip to content

feat(switchyard-llm-client): add HTTP LLM client crate#79

Open
messiaen wants to merge 1 commit into
mainfrom
grclark/llm-client-crate
Open

feat(switchyard-llm-client): add HTTP LLM client crate#79
messiaen wants to merge 1 commit into
mainfrom
grclark/llm-client-crate

Conversation

@messiaen

@messiaen messiaen commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds switchyard-llm-client — an HTTP LLM client that speaks Switchyard's neutral IR
end-to-end, built on the protocol traits and translation helpers from the base branches.

Base: translation-helpers

Changes

  • New crate crates/libsy-llm-client/ (package switchyard-llm-client):
    • TranslatingLlmClient — maps a model + wire format to a per-model Backend (built from
      a list of ModelConfigs), encodes the request, makes the HTTP call (auth + caller-header
      forwarding), and decodes the response — buffered JSON or SSE — back to the IR.
    • Implements RoutedLlmClient, so it can back a libsy LlmTarget directly.
    • call_rewrite_model_raw(ctx, raw_http_request, http_headers, model, wire_format) — the
      whole decode → call → encode path in one call, returning a buffered JSON body or an
      unframed stream of wire events (RawResponse); SSE framing stays a transport concern, so
      the crate has no HTTP-server dependency.
  • Root Cargo.toml — registers the crate as a workspace member.

Notes

All dependencies were already in place on the base — switchyard-protocol provides the
RoutedLlmClient/Decision/Context vocabulary and switchyard-translation the neutral-IR
encode/decode + SSE helpers — so no other changes were needed.

Validation

Full workspace builds clean, clippy at zero, the crate's 27 tests pass (buffered/streaming
round-trips, header forwarding, the RoutedLlmClient impl, and call_rewrite_model_raw).

Checklist

  • One class per file; filename = snake_case of the primary class.
  • New public symbols exported from switchyard/__init__.py.__all__ if intended for downstream use.
  • Unit tests added for new components / bug fixes.
  • README / --help updated if customer-facing surface changed.
  • Commits signed off (Signed-off-by: Your Name <email>) per the DCO.

Notes for reviewers

Anything reviewers should pay extra attention to — risky paths, follow-up tickets, intentional trade-offs.

Summary by CodeRabbit

  • New Features
    • Added an HTTP client for connecting to LLM backends using OpenAI Chat, OpenAI Responses, and Anthropic formats.
    • Supports buffered and streaming responses, request translation, model routing, authentication, header forwarding, and model-name rewriting.
    • Added structured error handling, including context-window overflow detection.
    • Added configuration and documentation for integrating supported backends.

@messiaen
messiaen requested a review from a team as a code owner July 16, 2026 17:35
@messiaen messiaen self-assigned this Jul 16, 2026
@messiaen
messiaen force-pushed the grclark/translation-helpers branch from fcf75d3 to de9ddbe Compare July 16, 2026 23:39
@messiaen
messiaen force-pushed the grclark/llm-client-crate branch from f524f3e to 30d8979 Compare July 16, 2026 23:48
@messiaen
messiaen force-pushed the grclark/translation-helpers branch from de9ddbe to bb40d0f Compare July 17, 2026 16:52
@nv-msvoboda

Copy link
Copy Markdown
Contributor

@sir-merge-a-lot groom

@nv-sir-merge-a-lot

nv-sir-merge-a-lot Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor
Previous updates (collapsed)

🤖 sir-merge-a-lot

Resolving conflicts

🔄 sir-merge-a-lot is resolving conflicts on this MR.

Detected 3 conflicting file(s) (REGENERATE). I'll update this comment with the result. This can take a couple of minutes.


🤖 sir-merge-a-lot

Coding agent working

🤖 sir-merge-a-lot handed a large conflict to its sandboxed coding agent.

The LLM tier could not resolve crates/switchyard-translation/src/helpers.rs (too large or timed out), so I escalated this single file to the coding agent. I'll update this comment with the result. This can take several minutes.

🤖 sir-merge-a-lot

Coding agent could not resolve the conflict

⚠️ The coding agent could not resolve crates/switchyard-translation/src/helpers.rs (reason: multi_file_conflict). This file needs manual conflict resolution. Re-invoke @sir-merge-a-lot-bot groom to try again, or resolve it by hand.

Resolution ladder trace

Rung Result Details
Server-side rebase skipped Skipped because conflicts were detected during local analysis; GitLab rebase would not produce a clean branch. Files: Cargo.lock, crates/switchyard-translation/src/helpers.rs, crates/switchyard-translation/src/sse.rs
Client-side rebase skipped Skipped because conflicts were detected during local analysis; using the conflict-resolution ladder instead of force-pushing a conflicted rebase. Files: Cargo.lock, crates/switchyard-translation/src/helpers.rs, crates/switchyard-translation/src/sse.rs
Rerere miss No cached rerere resolution matched the remaining conflicts. Files: Cargo.lock, crates/switchyard-translation/src/helpers.rs, crates/switchyard-translation/src/sse.rs
Mergiraf declined Mergiraf ran but left conflicts in the remaining files. Files: Cargo.lock, crates/switchyard-translation/src/helpers.rs, crates/switchyard-translation/src/sse.rs
Auto-regenerable fallback resolved Took whole-file source/MR side for known-regenerable paths (lock files / vendored deps / generated schemas) so the LLM does not burn tokens on files that will be regenerated by the next install or schema-gen step. Files: Cargo.lock
LLM invoked Escalating remaining files to LLM for bounded LLM resolution. Files: crates/switchyard-translation/src/helpers.rs, crates/switchyard-translation/src/sse.rs
LLM resolved LLM produced accepted bounded-edit resolutions. Files: crates/switchyard-translation/src/sse.rs
LLM failed LLM could not produce an accepted resolution for the remaining files. Failure reason: llm_timeout. Files: crates/switchyard-translation/src/helpers.rs
Coding agent failed multi_file_conflict Files: crates/switchyard-translation/src/helpers.rs

Base automatically changed from grclark/translation-helpers to main July 17, 2026 20:51
…eutral IR

Signed-off-by: Greg Clark <grclark@nvidia.com>
@messiaen
messiaen force-pushed the grclark/llm-client-crate branch from 30d8979 to a3c4498 Compare July 17, 2026 21:02
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds the switchyard-llm-client workspace crate, defining provider backends, model and wire-format routing, HTTP translation, buffered and streaming responses, error handling, routed-client integration, tests, and usage documentation.

Changes

LLM Client Crate

Layer / File(s) Summary
Crate API and configuration
Cargo.toml, crates/libsy-llm-client/Cargo.toml, crates/libsy-llm-client/src/lib.rs, crates/libsy-llm-client/src/raw.rs, crates/libsy-llm-client/src/client.rs
Registers the crate, defines dependencies and public exports, adds RawResponse, and introduces model configuration.
Backend and error handling
crates/libsy-llm-client/src/backend.rs, crates/libsy-llm-client/src/error.rs
Adds OpenAI and Anthropic backend configuration, URL and authentication handling, overflow detection, shared errors, and unit tests.
Translation client and usage
crates/libsy-llm-client/src/client.rs, crates/libsy-llm-client/README.md
Implements model and format resolution, HTTP translation, streaming and raw response paths, routed-client integration, comprehensive tests, and usage documentation.

Estimated code review effort: 4 (Complex) | ~60 minutes

Poem

A rabbit hops through JSON snow,
With backend maps arranged in rows.
Streams sparkle, headers fly,
Errors bloom beneath the sky.
“Translate on!” the bunny cries—
New client magic starts to rise.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding a new HTTP LLM client crate.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/libsy-llm-client/README.md`:
- Around line 8-15: Rewrite all README examples to use the crate’s actual API:
import ModelConfig and TranslatingLlmClient from switchyard_llm_client,
construct the client with TranslatingLlmClient::new, and invoke
call_rewrite_model instead of the unsupported LlmModelClient, libsy_llm_client,
nested backend maps, call, formats_for, or aggregate interfaces. Remove the
unused LlmModelClient link definition while preserving the examples’ intended
behavior.

In `@crates/libsy-llm-client/src/backend.rs`:
- Around line 33-41: Replace the derived Debug implementation on
HttpBackendConfig with a manual implementation that preserves base_url and
extra_headers while rendering any present api_key as “[REDACTED]” rather than
its value. Keep Debug available for containing types such as Backend and
ModelConfig without exposing credentials.

In `@crates/libsy-llm-client/src/client.rs`:
- Around line 491-495: Replace the `.expect("buffered response")` calls in the
aggregate-response handling at crates/libsy-llm-client/src/client.rs lines
491-495 and 683-687 with explicit matching, asserting or handling the success
and failure cases directly in each test while preserving the existing successful
aggregate behavior.
- Around line 25-38: Update the header-filtering logic using RESERVED_HEADERS so
credential-bearing headers such as cookie and proxy-authorization are never
forwarded from caller metadata. Prefer an explicit allowlist for headers
eligible for forwarding; otherwise expand the denylist to cover all browser,
proxy, and authentication credential headers, and add tests covering the
forwarding policy.

In `@crates/libsy-llm-client/src/lib.rs`:
- Around line 14-19: Update the module-level documentation near the
dependency-boundary explanation to remove the speculative
future-refactor/project-management statement, while retaining the explanation of
the vendored context-overflow detection and crate dependencies.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 5051dedb-431a-4206-b555-88a8b1b832ea

📥 Commits

Reviewing files that changed from the base of the PR and between 8b8dad8 and a3c4498.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock, !Cargo.lock
📒 Files selected for processing (8)
  • Cargo.toml
  • crates/libsy-llm-client/Cargo.toml
  • crates/libsy-llm-client/README.md
  • crates/libsy-llm-client/src/backend.rs
  • crates/libsy-llm-client/src/client.rs
  • crates/libsy-llm-client/src/error.rs
  • crates/libsy-llm-client/src/lib.rs
  • crates/libsy-llm-client/src/raw.rs

Comment on lines +8 to +15
An HTTP client that speaks Switchyard's neutral IR directly. You hand it a
[`libsy_protocol::Request`] and a model name; it looks up the configured backend,
encodes the request to that backend's wire format, adds auth and forwards your
headers, makes the call with a shared `reqwest::Client`, and decodes the reply
back into a [`libsy_protocol::Response`] — buffered or streamed.

It depends only on `libsy-protocol` and `switchyard-translation`; no server, no
provider SDK. For an HTTP front door built on top of it, see [`libsy-proxy`].

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Update the README to use the API shipped by this crate.

The examples target a different interface: LlmModelClient, libsy_llm_client, nested backend maps, call(request, model, format), formats_for, and aggregate() are not provided here. Rewrite them using switchyard_llm_client::{ModelConfig, TranslatingLlmClient}, TranslatingLlmClient::new, and call_rewrite_model; also remove the now-unused LlmModelClient link definition.

Also applies to: 47-65, 71-90, 97-121, 124-141, 153-174

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/libsy-llm-client/README.md` around lines 8 - 15, Rewrite all README
examples to use the crate’s actual API: import ModelConfig and
TranslatingLlmClient from switchyard_llm_client, construct the client with
TranslatingLlmClient::new, and invoke call_rewrite_model instead of the
unsupported LlmModelClient, libsy_llm_client, nested backend maps, call,
formats_for, or aggregate interfaces. Remove the unused LlmModelClient link
definition while preserving the examples’ intended behavior.

Source: Linters/SAST tools

Comment on lines +33 to +41
/// Shared HTTP configuration for one upstream backend.
#[derive(Clone, Debug)]
pub struct HttpBackendConfig {
/// Base URL of the provider API (e.g. `https://api.openai.com/v1`).
pub base_url: String,
/// API key for the provider, loaded by the caller. `None` sends no auth.
pub api_key: Option<String>,
/// Static headers added to every outbound call to this backend.
pub extra_headers: BTreeMap<String, String>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Redact API keys from Debug output.

The derived implementation prints api_key verbatim, so logging HttpBackendConfig, Backend, or ModelConfig can disclose provider credentials. Implement a redacted Debug representation or remove Debug from the containing public types.

🛡️ Redact the field
-#[derive(Clone, Debug)]
+#[derive(Clone)]
 pub struct HttpBackendConfig {
impl std::fmt::Debug for HttpBackendConfig {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("HttpBackendConfig")
            .field("base_url", &self.base_url)
            .field("api_key", &self.api_key.as_ref().map(|_| "[REDACTED]"))
            .field("extra_headers", &self.extra_headers)
            .finish()
    }
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Shared HTTP configuration for one upstream backend.
#[derive(Clone, Debug)]
pub struct HttpBackendConfig {
/// Base URL of the provider API (e.g. `https://api.openai.com/v1`).
pub base_url: String,
/// API key for the provider, loaded by the caller. `None` sends no auth.
pub api_key: Option<String>,
/// Static headers added to every outbound call to this backend.
pub extra_headers: BTreeMap<String, String>,
/// Shared HTTP configuration for one upstream backend.
#[derive(Clone)]
pub struct HttpBackendConfig {
/// Base URL of the provider API (e.g. `https://api.openai.com/v1`).
pub base_url: String,
/// API key for the provider, loaded by the caller. `None` sends no auth.
pub api_key: Option<String>,
/// Static headers added to every outbound call to this backend.
pub extra_headers: BTreeMap<String, String>,
}
impl std::fmt::Debug for HttpBackendConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HttpBackendConfig")
.field("base_url", &self.base_url)
.field("api_key", &self.api_key.as_ref().map(|_| "[REDACTED]"))
.field("extra_headers", &self.extra_headers)
.finish()
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/libsy-llm-client/src/backend.rs` around lines 33 - 41, Replace the
derived Debug implementation on HttpBackendConfig with a manual implementation
that preserves base_url and extra_headers while rendering any present api_key as
“[REDACTED]” rather than its value. Keep Debug available for containing types
such as Backend and ModelConfig without exposing credentials.

Comment on lines +25 to +38
// Headers this client owns or that are hop-by-hop; never forwarded from the
// caller's metadata. Auth/version/content-type are set by the backend or the
// JSON body, so a forwarded copy would either be ignored or conflict. Compared
// case-insensitively. Aligns with `_SENSITIVE_HEADERS` in the Python
// `switchyard/lib/request_metadata.py` forwarding logic.
const RESERVED_HEADERS: &[&str] = &[
"host",
"content-length",
"connection",
"authorization",
"x-api-key",
"anthropic-version",
"content-type",
];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Do not forward credential-bearing caller headers by default.

The denylist still permits headers such as cookie and proxy-authorization, potentially leaking session or proxy credentials to an external backend. Prefer an explicit forwarding allowlist, or at minimum reject all credential-bearing browser/proxy headers and test the policy.

🛡️ Minimal hardening
 const RESERVED_HEADERS: &[&str] = &[
     "host",
     "content-length",
     "connection",
     "authorization",
+    "proxy-authorization",
+    "proxy-authenticate",
+    "cookie",
+    "set-cookie",
     "x-api-key",
     "anthropic-version",
     "content-type",
 ];

Also applies to: 294-307

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/libsy-llm-client/src/client.rs` around lines 25 - 38, Update the
header-filtering logic using RESERVED_HEADERS so credential-bearing headers such
as cookie and proxy-authorization are never forwarded from caller metadata.
Prefer an explicit allowlist for headers eligible for forwarding; otherwise
expand the denylist to cover all browser, proxy, and authentication credential
headers, and add tests covering the forwarding policy.

Comment on lines +491 to +495
let agg = response
.llm_response
.into_agg()
.await
.expect("buffered response");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Replace .expect() in these tests with explicit matching.

  • crates/libsy-llm-client/src/client.rs#L491-L495: explicitly match the buffered aggregate result.
  • crates/libsy-llm-client/src/client.rs#L683-L687: apply the same explicit matching pattern.

As per coding guidelines, “Do not use .expect() in Rust source or tests; propagate errors with ?, return typed errors, or match explicitly in tests.”

📍 Affects 1 file
  • crates/libsy-llm-client/src/client.rs#L491-L495 (this comment)
  • crates/libsy-llm-client/src/client.rs#L683-L687
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/libsy-llm-client/src/client.rs` around lines 491 - 495, Replace the
`.expect("buffered response")` calls in the aggregate-response handling at
crates/libsy-llm-client/src/client.rs lines 491-495 and 683-687 with explicit
matching, asserting or handling the success and failure cases directly in each
test while preserving the existing successful aggregate behavior.

Source: Coding guidelines

Comment on lines +14 to +19
//! The crate depends only on `libsy-protocol` and `switchyard-translation`. Neutral
//! IR encode/decode — including SSE stream decoding — lives in
//! `switchyard-translation`; this crate is the HTTP transport around it. The
//! context-overflow detection in [`mod@error`] is vendored from
//! `switchyard-components` (whose copy is crate-private and unavailable here); a
//! future refactor could promote it to a shared location.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the project-management note from the module documentation.

Keep the dependency-boundary explanation, but omit the speculative “future refactor” statement.

As per coding guidelines, “Source comments must explain code behavior…; do not include project-management references such as tracker steps, issue IDs, or plan links.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/libsy-llm-client/src/lib.rs` around lines 14 - 19, Update the
module-level documentation near the dependency-boundary explanation to remove
the speculative future-refactor/project-management statement, while retaining
the explanation of the vendored context-overflow detection and crate
dependencies.

Source: Coding guidelines

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.

2 participants