Skip to content

feat(binding-llm): LlmContentDecoderSpi internal SPI, keyed by content-type - #2553

Open
jfallows wants to merge 5 commits into
developfrom
claude/issue-2478-76f5ry
Open

jfallows wants to merge 5 commits into
developfrom
claude/issue-2478-76f5ry

Conversation

@jfallows

@jfallows jfallows commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Description

Defines LlmContentCodecSpi, keyed by content-type, so binding-llm can dispatch a stream's raw bytes to the decoder/encoder pair registered for that content-type. Framing is a property of content-type, not of dialect (OpenAI and Anthropic both use SSE), so this stays a single content-type-keyed lookup rather than a per-dialect branch.

  • LlmContentCodecSpi (io.aklivity.zilla.runtime.binding.llm.internal.codec) — keyed by contentType(), supplies a fresh LlmContentDecoder/LlmContentEncoder pair per stream. A single interface for both directions, rather than two independently-registered SPIs, so "content-type X is supported" is one type-enforced fact — a decoder registered with no matching encoder (or vice versa) can't happen. Kept in an internal, unexported package for now, per the issue: no exports, no compatibility obligation while init/cont/fin semantics are still being learned.
  • LlmContentDecoder/LlmContentEncoder — decode/encode buffered bytes for one stream.
  • LlmContentDecoderOutput — receives the decoded event data/flush frames as the decoder makes progress.
  • LlmContentCodecFactory — ServiceLoader-based dispatch by content-type, modelled on binding-mcp's McpToolSearchIndexFactorySpi/McpToolSearchIndexFactory precedent for a within-module SPI.
  • LlmSseContentCodecSpi / LlmJsonContentCodecSpi — the two content-types named in the issue's own scope: text/event-stream (framing decode/encode per the WHATWG SSE parsing algorithm) and non-streaming application/json (the entire buffered document dispatched as a single event, since it has no event boundary of its own — "the same abstraction as one event, rather than a special-cased branch").
  • LlmContentCodecFactoryTest covers SPI lookup/dispatch by content-type (registered and unrecognized, both directions), backed by a test-scope LlmTestContentCodecSpi registered via META-INF/services.

The internal/encode/ base package (LlmContentEncoder) and its text/event-stream implementation (LlmSseContentEncoder) are pulled forward from #2571 so the decoder/encoder collapse could happen where decode already lives, rather than forking internal/encode/ ahead of its own introduction downstream. LlmSseContentDecoder/LlmSseContentEncoder/LlmJsonContentDecoder widen from package-private to public (unchanged otherwise) since their codec-SPI providers construct them from the sibling internal.codec package. LlmJsonContentEncoder is new: the application/json inverse of LlmJsonContentDecoder, copying content bytes through unchanged with no framing on flush. #2571 will need to rebase on top of this and drop its now-duplicate internal/encode/ base package and LlmSseContentEncoder.

This is scoped to the SPI contract plus both of the issue's named content-types — LlmDialect (event classification) and exporting this SPI once more than one content-type has exercised the shape (#2501) remain separate follow-ups.

Verified ./mvnw clean install -pl incubator/binding-llm.conf,incubator/binding-llm passes end-to-end: checkstyle, license headers, and all 47 unit tests pass with full jacoco coverage.

This branch is built on top of claude/issue-2477-h1b9ia (#2552, scaffolding binding-llm), so this diff will include #2552's commits until that merges to develop — at which point this PR's diff will shrink to just this PR's own commits.

Fixes #2478

🤖 Generated with Claude Code

https://claude.ai/code/session_01Mw32oxEw24fLt5Ypakj6pH

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
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
…odecSpi

LlmContentDecoderSpi and LlmContentEncoderSpi (the latter previously
living downstream in #2571) let a content-type register a decoder with
no matching encoder, or vice versa, since each was ServiceLoader-discovered
and dispatched independently. LlmContentCodecSpi makes "this content-type
is fully supported" one type-enforced fact: a single contentType() key
with both supplyDecoder() and supplyEncoder(), one META-INF/services
registration per content-type, one LlmContentCodecFactory dispatching
both directions.

Pulls the internal/encode/ base package and its text/event-stream
implementation (LlmContentEncoder, LlmSseContentEncoder) forward from
#2571 so the collapse can happen where decode already lives, rather than
forking that package ahead of its own introduction there; #2571 will
need to rebase on top of this and drop its now-duplicate copies.

LlmSseContentDecoder, LlmSseContentEncoder, LlmJsonContentDecoder widen
from package-private to public (unchanged otherwise) since their new
LlmSseContentCodecSpi/LlmJsonContentCodecSpi providers construct them
from the sibling internal.codec package. Adds LlmJsonContentEncoder
(new): the application/json inverse of LlmJsonContentDecoder, copying
content bytes through unchanged with no framing on flush.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mw32oxEw24fLt5Ypakj6pH
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: LlmContentDecoderSpi internal SPI, keyed by content-type

2 participants