Skip to content

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

Merged
jfallows merged 3 commits into
developfrom
claude/issue-2480-us0ae1
Sep 30, 2026
Merged

jfallows merged 3 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(ModelEnvelope), plus supplyDecoder(Kind, ModelEnvelope)/supplyEncoder(Kind, ModelEnvelope) returning the engine's own ModelTransform (native ↔ canonical, field by field). 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.
  • No bespoke LLM-specific types for request inspection: ModelEnvelope/ModelTransform (runtime/engine/.../model/) are reused unmodified. detect(ModelEnvelope) folds a request's :method/:path pseudo-headers into the same envelope as its ordinary headers — no separate path parameter. supplyDecoder/supplyEncoder receive the per-stream ModelEnvelope too, so a decoder can extract a field (e.g. a model name, or a streaming flag deciding text/event-stream vs. application/json) into a named envelope entry while the field still flows through unchanged, mirroring KafkaExtractTransform (runtime/binding-kafka/.../cache/) — a caller reads that signal back off the envelope as decoding proceeds, rather than buffering the whole body up front just to peek at one field.
  • LlmDialectFactorySpi/LlmDialect are exported from the start (unlike the internal content-codec SPI) — module-info.java exports the dialect package and declares uses LlmDialectFactorySpi, with requires transitive on engine since ModelEnvelope/ModelTransform appear in the exported public API.
  • LlmDialectFactorySpiTest / LlmTestConditionalDialect / LlmTestConditionalDialectFactorySpi — unit tests exercising the contract via a stub dialect registered under test-scope META-INF/services, mirroring this module's existing internal-SPI stub-test pattern. Covers detect() reading folded-in :method/:path (including ModelEnvelope.NONE), a supplyDecoder(Kind.REQUEST, envelope) that extracts a field into the envelope while forwarding it unchanged (and correctly ignores an unrelated field), identity behavior for the remaining Kind/direction combinations, 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 59 unit tests pass with full (1.00 ratio, 0 missed) jacoco coverage.

Rebased directly onto develop — the module scaffold, internal content-decoder SPI, and both content codecs this dialect SPI sits alongside are now on develop (via #2552/#2553), so this diff is scoped to just the dialect SPI's own commits.

Fixes #2480

🤖 Generated with Claude Code

https://claude.ai/code/session_016NcAVwRPN1Pwjzpobr75w6

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
…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
…velope, not whole-body buffering

Replaces LlmDialect's HttpHeaders/HttpRequestBody/contentType() with the
engine's existing ModelEnvelope/ModelTransform (runtime/engine/.../model/),
reusing machinery this codebase already has instead of inventing
LLM-specific buffering to resolve text/event-stream vs. application/json,
or a model name, by peeking one field of an otherwise-unbuffered body.

detect(ModelEnvelope) folds :path/:method into the same envelope as
ordinary headers -- no separate path parameter. supplyDecoder/supplyEncoder
now take (Kind, ModelEnvelope) and return ModelTransform: a per-field
stage that can extract a field (e.g. a model name) into the envelope while
the body still flows through unchanged, mirroring
KafkaExtractTransform (runtime/binding-kafka/.../cache/) -- so a caller
reads that signal back off the envelope as decoding proceeds rather than
buffering the whole body first to inspect it. contentType() is removed
entirely: nothing in this shape needs it once the streaming/non-streaming
signal is just another envelope entry a caller reads after extraction.

common-json/JsonTransform is no longer used anywhere in this module now
that the dialect SPI itself doesn't need it, so the dependency comes back
out of module-info.java and pom.xml along with it.

LlmTestDialect/LlmTestDialectFactorySpi become LlmTestConditionalDialect/
LlmTestConditionalDialectFactorySpi, since what they now demonstrate is
exactly this: request detection and model-name extraction conditional on
envelope contents, not a fixed per-instance answer.

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 22d3390 to 5b652f9 Compare September 30, 2026 04:24
@jfallows
jfallows merged commit 049424a into develop Sep 30, 2026
3 checks passed
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