Skip to content

feat(larql-cloud): outbound LLM clients (OpenAI-compatible + Bedrock) and a cloud proxy - #479

Open
gburd wants to merge 6 commits into
chrishayuk:mainfrom
gburd:feat/larql-cloud
Open

gburd wants to merge 6 commits into
chrishayuk:mainfrom
gburd:feat/larql-cloud

Conversation

@gburd

@gburd gburd commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Adds larql-cloud: outbound LLM clients, so larql-server can act as a caller of OpenAI-compatible services and AWS Bedrock, not only as an answerer of that API.

Purely additive — one new crate plus its CI workflow. No existing code is touched.

What it is

larql-server already speaks the OpenAI API as an answerer (crates/larql-server/src/routes/openai/). This is the mirror: one trait, CloudClient, exposing the three operations a database front-end needs to delegate to a cloud model.

trait CloudClient {
    async fn infer(&self, req: InferRequest) -> Result<InferResponse, ProviderError>;
    async fn embed(&self, req: EmbedRequest) -> Result<Vec<Vec<f32>>, ProviderError>;
    async fn chat(&self,  req: ChatRequest)  -> Result<ChatResponse, ProviderError>;
}

Providers:

Backend Auth Model name shape
OpenAI OPENAI_API_KEY bearer gpt-4o-mini
Exoscale SKS-LLM EXOSCALE_API_KEY bearer meta-llama-3.1-8b
Together AI TOGETHER_API_KEY bearer meta-llama/Llama-3-…
Fireworks FIREWORKS_API_KEY bearer accounts/fireworks/…
vLLM / llama.cpp none server-defined
ollama none qwen2:1.5b
AWS Bedrock bearer token or SigV4 anthropic.claude-…, incl. cross-region inference profiles

Also larql-cloud-proxy: a binary exposing /v1/infer, /v1/embeddings, /v1/chat/completions and a /v1/stats shaped so an existing client's registration probe succeeds, with /v1/walk and /v1/describe answering 501 (there is no vindex behind a cloud API). That lets a client point at either a real vindex or a hosted model without knowing which.

Why upstream rather than in my fork

The crate exists because I needed it for pg_infer, but nothing in it is pg_infer-specific — it is a general outbound client for this workspace, and the proxy is the piece that makes "vindex or hosted model, same wire protocol" work for any client. Carrying it in a fork indefinitely means the workspace has two divergent answers to "how does larql call out to a model".

Happy to drop the proxy binary and land only the library if you'd rather keep the binary surface smaller.

Feature gating

default = ["openai", "bedrock"]. openai is dependency-free; bedrock gates the SigV4 dependencies (hmac, sha2, hex). Both build and test independently — CI checks all four combinations, since a no-default build is what proves the gating is real.

CI

Every crate here has its own workflow, so this adds larql-cloud.yml, modelled on larql-core.yml (the closest match: portable, no weights, no platform backend). Linux + Windows on PRs, macOS additionally on main, following the rationale larql-core.yml documents about macOS queue waits.

Every command in the workflow was run locally on the pinned 1.98.0 before committing, and one failed:

clippy -p larql-cloud --no-default-features --all-targets -- -D warnings

BedrockClient::send takes service for the SigV4 signing scope, read only by the cfg(feature = "bedrock") arm of the auth match — so with the feature off it is an unused variable. A latent warning that would have made the workflow red on its first run. Fixed with a cfg'd allow(unused_variables) rather than cfg-ing the parameter out of the signature, so both builds keep the same call sites.

Verification

Pinned toolchain (1.98.0), full feature matrix: fmt, 5 check variants, 2 clippy -D warnings variants, 4 test variants — all pass. 13 tests including a proxy integration test that binds loopback only (no network, no credentials, hermetic).

New workspace member crates/larql-cloud/.  Pairs with larql-server's
existing OpenAI-compatible /answerer/ surface (routes/openai/) by
providing the inverse: a uniform Rust trait for /calling/ external
LLM services from larql.

The single trait CloudClient exposes infer / embed / chat over two
implementations:

OpenAiCompatible
  Drives every OpenAI-API-shaped backend behind a single impl:
  - openai()    — OpenAI proper, OPENAI_API_KEY
  - exoscale()  — Exoscale.ch AI gateway, EXOSCALE_API_KEY
                  (configurable base URL via EXOSCALE_AI_BASE)
  - together()  — Together AI, TOGETHER_API_KEY
  - local()     — vLLM / llama.cpp / ollama / pgesq's own
                  larql-server proxy.  Optional bearer.
  All four route to /v1/chat/completions and /v1/embeddings with
  the OpenAI request/response shape.  infer() synthesises top-K
  predictions from chat output (no logprobs assumed).

BedrockClient
  Two auth modes via BedrockAuth:
  - BearerToken — AWS_BEARER_TOKEN_BEDROCK (the 2024 short-lived
                  API-key flow).  Plain HTTPS Bearer header.
  - SigV4       — AWS_ACCESS_KEY_ID / SECRET_ACCESS_KEY (+ optional
                  SESSION_TOKEN), AWS_REGION.  Hand-rolled SigV4
                  using hmac + sha2 + hex; 60 LOC, validated
                  against the AWS canonical signing-key example.
  Speaks the Anthropic Messages API for chat/infer (the dominant
  Bedrock model family).  Embeddings go through amazon.titan-embed-*.
  Non-anthropic models reject chat with Unsupported; non-titan
  models reject embed.

Provider matrix (see lib.rs docstring) covers OpenAI, Exoscale,
Together, Fireworks (constructor parity to be added),
vLLM/llama.cpp/ollama, and Bedrock with both auth modes.

Tests: 11 passing.
- openai: chat round trip + embed round trip + infer-from-chat
  synthesis + transport error propagation, all against an in-process
  hyper mock that asserts the request URI per call.
- bedrock: auth env precedence (bearer > sigv4), missing-creds
  error, provider_id reflects auth mode, embed/chat reject
  wrong-family models, SigV4 derived signing key matches the
  AWS canonical test vector.

Workspace registration: Cargo.toml gains crates/larql-cloud as a
non-default member behind features openai (default) and bedrock
(default, gates the SigV4 hmac/sha2/hex deps).

Build: cargo check -p larql-cloud clean.  cargo test -p larql-cloud
11/11 green.
Single-binary HTTP server that serves a CloudClient as an
OpenAI-compatible JSON API.  Drops in front of any provider:

  larql-cloud-proxy --provider openai   --model gpt-4o-mini --port 8080
  larql-cloud-proxy --provider bedrock  --model anthropic.claude-3-haiku-20240307-v1:0 --region us-east-1 --port 8081
  larql-cloud-proxy --provider exoscale --model meta-llama-3.1-8b-instruct --port 8082
  larql-cloud-proxy --provider local    --base-url http://ollama:11434 --model qwen2:1.5b --port 8083

Endpoints (axum 0.8):
  GET  /v1/health             — liveness + version
  GET  /v1/stats              — model + mode='cloud-proxy' + provider id;
                                pg_infer's RemoteBackend reads this on
                                infer_create_model_remote registration
  POST /v1/infer              — {prompt, top, temperature, max_tokens}
                                → {predictions, latency_ms, usage}
  POST /v1/embeddings         — OpenAI-shaped {input, model} →
                                {object, model, data: [{embedding}], usage}
  POST /v1/chat/completions   — OpenAI-shaped passthrough; for Bedrock
                                this is the Anthropic Messages API
                                hidden behind the OpenAI shape
  GET  /v1/walk, /v1/describe, /v1/relations
                              — 501 with 'vindex-only endpoint not
                                available on cloud-proxy' so pg_infer
                                short-circuits cleanly

Auth picked up from env per provider:
  OpenAI       — OPENAI_API_KEY
  Exoscale     — EXOSCALE_API_KEY (+ EXOSCALE_AI_BASE override)
  Together     — TOGETHER_API_KEY
  Bedrock      — AWS_BEARER_TOKEN_BEDROCK (preferred) or
                 AWS_ACCESS_KEY_ID/SECRET_ACCESS_KEY (SigV4)
  Local        — optional LARQL_PROXY_API_KEY

Graceful shutdown on SIGINT.

Tests: 12 total (11 unit + 1 end-to-end integration).
- proxy_end_to_end_against_local_provider spawns a hyper mock OpenAI
  server, launches the proxy binary, and exercises every endpoint
  including the vindex-only-501 path.  Asserts the mock saw exactly
  the requests pg_infer would issue (1x infer→chat, 1x embed,
  1x chat).
- Env-touching bedrock tests wrapped in a process-global Mutex so
  they're safe under the default --test-threads parallel runner.

Build: cargo check -p larql-cloud --bin larql-cloud-proxy clean.
cargo test -p larql-cloud: 12/12 green at default concurrency.
is_anthropic() previously matched only foundation model ids
("anthropic.claude-...") and 501'd cross-region inference profile
ids like "us.anthropic.claude-haiku-4-5-20251001-v1:0" with
'provider does not support chat (only Anthropic Messages API is
wired)'.  Cross-region inference profiles are the AWS-recommended
way to invoke Claude on Bedrock now (region prefix selects the
inference profile; model is routed by Bedrock to whichever region
has capacity), and `us.`/`eu.`/`apac.`/`us-gov.` are all
valid prefixes.

Match by either leading `anthropic.` (foundation models) or the
`.anthropic.` substring (any inference profile shape).  No
non-Anthropic Bedrock model id contains that token, so the
substring match is sufficient.

Verified end-to-end against live Bedrock with
AWS_BEARER_TOKEN_BEDROCK against
`us.anthropic.claude-haiku-4-5-20251001-v1:0` in us-east-1:
  /v1/health        -> 200 ok
  /v1/stats         -> 200 ok (provider=bedrock-bearer)
  /v1/chat/completions -> 200 ok, OpenAI-shaped response
  /v1/embeddings (Titan) -> 200 ok, 1024-dim vectors
  /v1/walk          -> 501 (vindex-only, expected)
  /v1/embeddings (Anthropic) -> 501 'embed (model is not
                       amazon.titan-embed-*)' (expected)

Tests
  Added is_anthropic_matches_cross_region_inference_profiles to
  guard against regression on the four prefix shapes.
  cargo test -p larql-cloud --lib bedrock: 8 passed.

Known gaps left for future work (out of scope here):
  - TitanEmbedResponse drops inputTextTokenCount, so embedding
    usage comes back null.  Wire the field if downstream needs it.
  - Titan dimensions are hardcoded to 1024; should be configurable.
  - Streaming chat (invoke-model-with-response-stream + EventStream
    decoding) is not wired through cloud-proxy.
  - SigV4 path is untested against live Bedrock; only bearer-token
    auth verified.
The crate was written against a June 2026 upstream and carried its own
`[lints.clippy]` table (`unwrap_used`/`panic`/`panic_in_result_fn` =
deny). Every other crate in the workspace now uses `[lints] workspace =
true`, and the workspace table denies neither -- `larql-vindex` alone
has ~5100 `.unwrap()` calls. All 14 violations this produced were in
`#[cfg(test)]` blocks, i.e. exactly the sites the rest of the repo
permits. Adopting the workspace table is the convention, not a
loosening; a crate-local policy stricter than the workspace is drift.

Also, to pass CI's `clippy --all-targets -- -D warnings`:
  * `#[allow(clippy::too_many_arguments)]` on `sigv4::sign` (9 args --
    SigV4 needs them all; the repo's own idiom, ~30 sites)
  * de-indent one doc list continuation (`doc_overindented_list_items`,
    a lint that postdates the crate)
  * `cargo fmt` under the pinned 1.98.0

Verified with the pinned toolchain rather than the ambient nix one --
nix's clippy-driver 1.94 shadows rustup's and silently reports
`unknown lint: clippy::chunks_exact_to_as_chunks` (E0602) for the
workspace's own allow, which looks like a real finding and is not.

clippy -D warnings: clean.  cargo test -p larql-cloud: 13/13.
Every other crate in the workspace has its own per-crate workflow, so
without this one larql-cloud is the only crate CI never builds or tests.
Modelled on larql-core.yml, which is the closest match: portable code, no
model weights, no platform backend. Linux + Windows on pull requests and
macOS additionally on main, for the reason larql-core.yml documents --
this crate has no `cfg(target_os = ...)` surface, no unix/windows split
that Linux and Windows do not already cover, and no Accelerate linkage,
so a macOS PR leg would compile the same portable code a third time while
macOS queue waits set the repo's PR wall clock.

Every command in the workflow was run locally on the pinned 1.98.0 before
committing, across the whole feature matrix, and one of them failed:

  clippy -p larql-cloud --no-default-features --all-targets -- -D warnings

`BedrockClient::send` takes `service` for the SigV4 signing scope, and
that argument is only read by the `cfg(feature = "bedrock")` arm of the
auth match. With the feature off, it is an unused variable -- a latent
warning in the crate as merged, which would have made this workflow red
on its first run. Fixed with a cfg'd `allow(unused_variables)` rather
than by cfg-ing the parameter out of the signature: `service` is part of
the method's contract on every feature combination, and removing it under
`--no-default-features` would give the two builds different call sites.

The feature matrix is worth testing rather than assuming, since `bedrock`
is the combination that pulls extra dependencies (hmac/sha2/hex for
SigV4) and `openai` is dependency-free; a no-default build is what proves
the gating is real. Clippy runs on both default and no-default because
the proxy binary is what operators actually run, and a lint-clean library
with a broken binary is not a useful green.

Verified: fmt, 5 check variants, 2 clippy variants, 4 test variants all
pass; workflow YAML parses.
@gburd

gburd commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Heads-up on the two red checks (cargo-audit, cargo-deny · advisories) — they are not from this PR.

RUSTSEC-2026-0285 is a rustls advisory published 2026-09-14, a few hours after main's last quality.yml run (23a56db1, 2026-09-14T11:45Z), which is why main still shows green. main's own lockfile pins rustls 0.23.39; the advisory wants >= 0.23.45.

This PR's Cargo.lock is byte-identical to main's:

$ git diff --quiet upstream/main..<this branch> -- Cargo.lock && echo IDENTICAL
IDENTICAL

So any PR opened against main right now inherits the same two failures, and a rustls bump is needed on main independently of this change. Happy to add that bump here if you'd like it bundled, but it seemed wrong to fold an unrelated dependency change into a Nix fix.

@gburd

gburd commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up on the two red checks: I've opened #481, which bumps rustls 0.23.39 → 0.23.45 (lockfile only) and clears RUSTSEC-2026-0285.

On that branch cargo-audit and cargo-deny · advisories both pass. Merging it first (or rebasing this branch onto it) turns these two green here as well — they are inherited from main, not introduced by this PR.

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