Skip to content

feat(binding-llm): LlmDialectFactorySpi / LlmDialect exported SPI - #2556

Open
jfallows wants to merge 7 commits into
developfrom
claude/issue-2480-us0ae1
Open

feat(binding-llm): LlmDialectFactorySpi / LlmDialect exported SPI#2556
jfallows wants to merge 7 commits into
developfrom
claude/issue-2480-us0ae1

Conversation

@jfallows

@jfallows jfallows commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Description

Defines and exports the pluggable-dialect SPI for binding-llm, modelled on binding-mcp's within-binding SPI precedent (module-info.java exports the SPI package, uses the SPI, with built-in provides implementations added later).

  • LlmDialect (io.aklivity.zilla.runtime.binding.llm.dialect) — name(), detect(path, headers), contentType(Kind, HttpHeaders, HttpRequestBody), plus supplyDecoder(Kind)/supplyEncoder(Kind) returning common-json's JsonTransform (native ↔ canonical). Kind is nested on LlmDialect, distinguishing the request and response directions since each has its own schema and mapping.
  • LlmDialectFactorySpi — the ServiceLoader-registered entry point (name() + create()), registered in META-INF/services/io.aklivity.zilla.runtime.binding.llm.dialect.LlmDialectFactorySpi.
  • HttpHeaders — a minimal read-only header accessor for detect(path, headers) and contentType(...). No such abstraction previously existed in this codebase (no jakarta.ws.rs dependency anywhere), so this is a small Zilla-owned contract rather than pulling in JAX-RS for one method.
  • HttpRequestBody — a minimal read-only scalar-member accessor for a request body, mirroring HttpHeaders. Added so contentType() can resolve per request (e.g. a streaming-capable dialect choosing text/event-stream vs. application/json from a stream field in the request body) instead of being fixed once when the dialect instance is created — a capability gap surfaced by a downstream dialect implementation, folded back into this SPI issue while its own PR is still open rather than needing a second signature change later. contentType() also takes Kind, since a dialect's request and response content-types can differ and must resolve independently; headers/body are nullable for callers without that context.
  • LlmDialectFactorySpi/LlmDialect are exported from the start (unlike LlmContentDecoderSpi, which stays internal) — module-info.java now exports the dialect package and declares uses LlmDialectFactorySpi, with requires transitive on common-json since JsonTransform appears in the exported public API.
  • LlmDialectFactorySpiTest / LlmTestDialect / LlmTestDialectFactorySpi — unit tests exercising the contract via a stub dialect registered under test-scope META-INF/services, mirroring this module's existing LlmContentDecoderSpi/LlmTestContentDecoderFactorySpi pattern. Covers name/detect, per-Kind/per-request contentType() resolution (streaming vs. non-streaming, including a null body), identity-transform forwarding for both Kind values, and Kind.values()/valueOf().

No concrete dialect implementations yet (OpenAI/Anthropic land later, per the issue's acceptance criteria) — uses is declared without a corresponding provides.

Verified ./mvnw clean verify -pl incubator/binding-llm passes end-to-end: checkstyle (0 violations), license headers, and all 45 unit tests pass with full (1.00 ratio, 0 missed) jacoco coverage.

This branch is built on top of claude/issue-2479-2qhkfm (#2554, the SSE/JSON content decoders), so this diff includes #2554's commits until that merges to develop — at which point this PR's diff will shrink to just this dialect SPI's commits.

Fixes #2480

🤖 Generated with Claude Code

https://claude.ai/code/session_016NcAVwRPN1Pwjzpobr75w6

Scaffold incubator/binding-llm.spec per AGENTS.md conventions and define
LlmBeginEx, LlmDataEx, and the LlmFlushEx union, modelled on
binding-mcp.spec's idl.

LlmBeginEx carries dialect only; model routing is deferred. LlmDataEx has
no fields: content flows through the DATA frame's own payload octets and
INIT/FIN through its existing flags, so nothing survives in the extension
once block identity moves to the FLUSH plane. LlmFlushEx is a 7-case union
covering message start, block start/end, finish, usage, keepalive, and an
opaque native/raw case for re-encoding events a same-dialect route doesn't
recognize.

Fixes #2476

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AuZoMsETwEczJx3cbkb8EJ
Scaffolds incubator/binding-llm and incubator/binding-llm.conf, modelled
on binding-mcp's SERVER/CLIENT BindingContext structure. LlmBindingInfo
is annotated @Incubating so type: llm config loading is gated behind
ZILLA_INCUBATOR_ENABLED via FeatureFilter, matching the AmqpBindingInfo/
PgsqlBindingInfo/RisingwaveBindingInfo precedent.

Fixes #2477

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142buJWS7C89AKr9uDJtSy4
…t-type

Registers by content-type and hands back a per-stream LlmContentDecoder;
stays in an internal, unexported package for now with no concrete
implementation registered yet.

Fixes #2478

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mw32oxEw24fLt5Ypakj6pH
@jfallows
jfallows force-pushed the claude/issue-2480-us0ae1 branch from a98acbf to 92d9326 Compare September 11, 2026 20:20
Closes the non-streaming half of #2478's own scope: "Non-streaming
application/json goes through the same abstraction as one event, rather
than a special-cased branch." Only text/event-stream had an
LlmContentDecoderSpi implementation; application/json requests
(non-streaming dialect responses) had no decoder to dispatch to.

LlmJsonContentDecoder treats the entire buffered document as a single
event (one data + one flush call, no framing loop), mirroring
LlmSseContentDecoder's structure and unit-test conventions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mw32oxEw24fLt5Ypakj6pH
Implements LlmContentDecoderSpi for text/event-stream, decoding blank-line-
delimited SSE framing (data:/event:/id: fields, comment lines, CRLF/CR/LF
line endings) into event DATA + FLUSH frames per the WHATWG SSE parsing
algorithm. Framing decode only, independent of any dialect-specific (e.g.
OpenAI, Anthropic) payload interpretation.

The last-event-id persists across dispatches until overwritten by a
subsequent id: field, matching SSE reconnection semantics, and is reported
as the flush boundary's associated bytes. A trailing lone CR at the edge of
the currently available bytes is deliberately left unconsumed (returned as
no progress) since it may still turn out to be part of a CRLF pair once
more bytes arrive - avoiding a premature, ambiguous line-terminator decision
on a streamed input.

Representational-slack note for the round-trip decision this issue flags:
this decoder normalizes field ordering and does not preserve field-value
whitespace beyond the single optional space after the colon that the SSE
spec itself strips, so re-encoding from the decoded data/flush frames alone
cannot byte-exactly reproduce arbitrary source framing - only semantic
equality should be asserted against this decoder's output.

Fixes #2479

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013kK9z4ywuPJe33jm6W1fik
Defines the pluggable-dialect contract for binding-llm, exported from the
start (unlike LlmContentDecoderSpi, which stays internal): LlmDialect
exposes name()/detect()/contentType() plus supplyDecoder(Kind)/
supplyEncoder(Kind) returning common-json JsonTransform stages, and
LlmDialectFactorySpi is the ServiceLoader-registered entry point.
HttpHeaders is a minimal read-only accessor for detect(path, headers),
since no HTTP header abstraction previously existed in this codebase and
pulling in jakarta.ws.rs would add a dependency never otherwise used here.
Kind is nested on LlmDialect, distinguishing request/response schemas.

No concrete dialect implementations yet (OpenAI/Anthropic land later) --
module-info.java exports the dialect package and declares uses without a
corresponding provides. Unit-tested via a stub LlmTestDialect/
LlmTestDialectFactorySpi registered under test-scope META-INF/services,
mirroring this module's existing LlmContentDecoderSpi/
LlmTestContentDecoderFactorySpi pattern.

Fixes #2480

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NcAVwRPN1Pwjzpobr75w6
@jfallows
jfallows force-pushed the claude/issue-2480-us0ae1 branch from 92d9326 to c543776 Compare September 12, 2026 01:39
…er instance

contentType() previously took no parameters, so a dialect could only
report one fixed content-type for its lifetime -- insufficient for an
API whose response framing (event-stream vs. a single JSON document)
depends on a flag in the request body, since neither contentType() nor
detect(String, HttpHeaders) offered any way to inspect it.

Adds HttpRequestBody, a minimal read-only scalar-member accessor
mirroring HttpHeaders, and changes contentType() to
contentType(Kind, HttpHeaders, HttpRequestBody): Kind lets request and
response resolve independently (a dialect's request body content-type
can be fixed while its response varies), and the headers/body context
lets that resolution depend on the actual request rather than being
fixed at dialect-instance-creation time. Both parameters are nullable
for callers without that context available.

LlmTestDialect now resolves text/test-event-stream for a streaming
response and application/test+json otherwise, exercising the new
per-Kind, per-request resolution the stub previously couldn't express.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NcAVwRPN1Pwjzpobr75w6
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.

binding-llm: LlmDialectFactorySpi / LlmDialect exported SPI

2 participants