Skip to content

feat(binding-llm): llm client dialect comparison, conditional decoder→encoder chaining - #2571

Open
jfallows wants to merge 24 commits into
developfrom
claude/confident-galileo-pfq91x
Open

jfallows wants to merge 24 commits into
developfrom
claude/confident-galileo-pfq91x

Conversation

@jfallows

Copy link
Copy Markdown
Contributor

Description

Implements the llm client binding's dialect-comparison mechanism: it compares the inbound app-declared dialect (LlmBeginEx.dialect, set by whatever upstream produced the app-typed stream) against the binding's own configured options.dialect. Same dialect → forward with framing re-encoded only, no payload transform. Different dialect → chain a JsonPipeline through LlmDialect.supplyDecoder/supplyEncoder (native → canonical → native) — the first real caller of that SPI anywhere in this codebase.

Nothing for kind: client existed before this PR — there was no LlmClientFactory, and the dialect JsonTransform SPI and framing-encode direction were entirely unwired. This PR adds:

  • LlmClientFactory (new internal/stream/) — dual inner-class pattern (LlmClient app-facing, LlmHttpClient net-facing, mirroring SseClientFactory's shape), wired into LlmBindingContext for kind: client.
  • internal/encode packageLlmContentEncoder/LlmContentEncoderSpi/LlmContentEncoderFactory + LlmSseContentEncoder, the SSE-framing encode counterpart to the existing decode-only internal/decode package (nothing built outbound SSE bytes anywhere in the repo before this).
  • LlmDialectResolver.dialectNamed(String) — small addition to resolve the inbound dialect by name when it differs from the configured one.
  • Schema patch — adds a kind: client branch (options.dialect + options.server) to llm.schema.patch.json. Also fixes an adjacent pre-existing gap in the same block: the kind: server branch's additionalProperties: false didn't list server, so options.server (added in feat(binding-llm): llm client endpoint config (options.server) #2570) would have failed schema validation even under kind: server.
  • Tests, written first — new k3po scenarios (same.dialect, cross.dialect, client.opaque.fallback, client.abort) under binding-llm.spec, plus a new LlmClientIT; confirmed these fail for the right reason (no LlmClientFactory) before implementing, per this repo's test-first discipline.

Scope boundary

Per the milestone's own sub-issue breakdown, the following are tracked separately and intentionally not part of this PR:

This PR's own tests instead use the same synthetic test-dialect fixtures (LlmTestDialect/LlmTestSseDialect, plus a new LlmTestSseAltDialect for the cross-dialect branch) that the server side already uses.

Known follow-up

The outbound request :path is a fixed placeholder ("/") — LlmOptionsConfig/the llm.idl carry no per-dialect path field yet. Natural follow-up once #2487 defines real per-dialect request shapes.

Stacking

This branch is stacked on #2570 (llm client endpoint config), which stacks on the earlier M1 protocol-scaffold PRs — so this diff includes those commits until they merge to develop, at which point this PR's diff will shrink to just the dialect-comparison commit.

Test plan

  • ./mvnw clean verify -pl incubator/binding-llm — unit tests, LlmServerIT, LlmClientIT, checkstyle, license headers, JaCoCo coverage all pass
  • ./mvnw clean verify -pl incubator/binding-llm.specNetworkIT/ApplicationIT self-consistency tests pass, including new scenarios
  • ./mvnw clean verify -pl incubator/binding-llm.conf — schema/config adapter tests pass

Fixes #2486


🤖 Generated with Claude Code

https://claude.ai/code/session_01Qssroe9MdjvpvQrthi15ex


Generated by Claude Code

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

Copy link
Copy Markdown
Contributor Author

Flagging a follow-up gap while closing out #2478 (LlmContentDecoderSpi): that issue's own scope calls for application/json to "go through the same abstraction as one event, rather than a special-cased branch." #2553 now adds LlmJsonContentDecoder/LlmJsonContentDecoderFactorySpi for the decode direction.

The inverse — an application/json LlmContentEncoderSpi implementation, alongside the existing LlmSseContentEncoder — doesn't exist yet. Since internal/encode/LlmContentEncoderSpi/LlmContentEncoderFactory/LlmContentEncoder are introduced in this PR rather than in #2478's branch, adding the JSON encoder there would have meant forking this package ahead of its own introduction here. Raising it here instead so it lands as a natural extension once this PR's encode/ package exists on develop — a LlmJsonContentEncoder/LlmJsonContentEncoderFactorySpi pair mirroring LlmSseContentEncoder's structure, encoding a single buffered event back to a raw JSON document with no framing.

This also unblocks LlmOpenAiDialect.contentType() (#2487/#2572) resolving application/json vs text/event-stream per request/response shape instead of the current fixed text/event-stream constant, per that PR's own documented follow-up.


Generated by Claude Code

@jfallows
jfallows force-pushed the claude/confident-galileo-pfq91x branch from 8b6b727 to 4864481 Compare September 12, 2026 03:09
jfallows pushed a commit that referenced this pull request Sep 12, 2026
Adds LlmJsonContentEncoder/LlmJsonContentEncoderFactorySpi, the inverse of
LlmJsonContentDecoder: a single buffered event is written back out as a raw
JSON document with no framing of its own, same as the decode side's "no
framing" contract. encodeFlush writes nothing, since the terminal event it
answers to is a synthetic end-of-stream signal, not bytes on the wire.

Addresses the encode/ package's application/json gap flagged on PR #2571.

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

Copy link
Copy Markdown
Contributor Author

Added LlmJsonContentEncoder/LlmJsonContentEncoderFactorySpi (application/json), mirroring LlmSseContentEncoder's structure per your note: encodeData writes the buffered event straight through with no framing, encodeFlush writes nothing since the terminal event it answers to is a synthetic end-of-stream signal rather than bytes on the wire — matching LlmJsonContentDecoder's own "no framing" contract on the decode side. Registered via module-info.java and META-INF/services, with unit tests mirroring LlmJsonContentDecoderTest's style.

Also rebased this branch onto #2570's latest commits (which picked up LlmJsonContentDecoder itself, plus a couple of other fixes) — ./mvnw clean verify still green across binding-llm, binding-llm.spec, and binding-llm.conf.


Generated by Claude Code

@jfallows
jfallows force-pushed the claude/confident-galileo-pfq91x branch from 3b61006 to 9f30dec Compare September 12, 2026 04:39
jfallows pushed a commit that referenced this pull request Sep 12, 2026
Adds LlmJsonContentEncoder/LlmJsonContentEncoderFactorySpi, the inverse of
LlmJsonContentDecoder: a single buffered event is written back out as a raw
JSON document with no framing of its own, same as the decode side's "no
framing" contract. encodeFlush writes nothing, since the terminal event it
answers to is a synthetic end-of-stream signal, not bytes on the wire.

Addresses the encode/ package's application/json gap flagged on PR #2571.

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

Copy link
Copy Markdown
Contributor Author

Update: #2553 now pulls internal/encode/ (the LlmContentEncoder base interface and LlmSseContentEncoder) forward from this PR, and collapses it with the decode side into a single LlmContentCodecSpi (one contentType() + supplyDecoder() + supplyEncoder(), one META-INF/services registration per content-type via LlmContentCodecFactory) — replacing the two independently-registered LlmContentDecoderSpi/LlmContentEncoderSpi.

This supersedes my earlier comment here about adding a standalone application/json encoder on top of this PR's LlmContentEncoderSpi: that SPI no longer exists once #2553 merges. Once this PR rebases on top of #2553:

  • Drop the now-duplicate internal/encode/LlmContentEncoderSpi.java, LlmContentEncoderFactory.java, and LlmSseContentEncoderFactorySpi.java (superseded by LlmSseContentCodecSpi in internal/codec/).
  • LlmSseContentEncoder itself is unchanged content-wise (just now public, constructed from internal/codec instead of its own FactorySpi) — keep it, drop only its old FactorySpi/test-factory-spi files.
  • application/json is already fully covered (LlmJsonContentCodecSpi, including the new LlmJsonContentEncoder), so no further action needed there.

Generated by Claude Code

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
…ken detail

Extend LlmFlushEx's block-lifecycle skeleton (from #2476) with the two
places OpenAI's parallel completions and per-token detail need to survive
in the vocabulary, per the issue's scope:

- choiceIndex (default 0) on messageStart, blockStart, blockEnd, finish,
  and native/raw: the (choice, block) compound index's outer half.
  Anthropic is always choice 0, so every existing dialect mapping is
  unaffected; OpenAI's n > 1 becomes one messageStart per parallel
  completion, distinguished by choiceIndex. usage stays choiceIndex-free
  since every dialect reports it aggregated across choices, never per
  choice.
- logProbability (nullable) on LlmDataEx: the one per-delta detail the
  vocabulary carries directly, for dialects exposing per-token detail
  (OpenAI logprobs) without reopening the DATA/FLUSH split from #2476 or
  growing the vocabulary for the full log-probability structure — richer
  detail than one value per token stays behind LlmNativeFlushEx.

Documents the three lossiness cases as doc comments alongside the fields
they concern: choiceIndex and logProbability both drop out on any
cross-dialect route to Anthropic (structurally exactly one choice, no
per-token detail); message-start input token counts are resolved by
decoupling inputTokens into its own deferred usage event rather than
emitting a placeholder on messageStart and correcting it later, so a
source that discloses tokens late (OpenAI) just emits usage late instead
of needing a correction event.

Fixes #2481

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

Translates between each dialect's native streaming event sequence and the
canonical vocabulary from #2557, in both directions:

- LlmAnthropicEventMapper: holds input_tokens from message_start until the
  paired usage event at message_delta; tracks the currently open block's
  type to know when content_block_stop needs a canonical blockEnd (tool
  calls only) and to route content_block_delta payloads (text_delta vs
  input_json_delta) on encode.
- LlmOpenAiEventMapper: translates OpenAI's tool-call-only index space into
  the canonical (Anthropic-shaped) block index via a per-stream map, and
  synthesizes blockEnd lazily -- deferred until the next tool call starts
  or the stream finishes, since OpenAI has no explicit block-close event.

Unit-tested against both worked-example tables from the issue (message
role/content/tool-call cardinality changes in each direction), plus the
held-usage/already-consumed and lazy-blockEnd edge cases.

Fixes #2482

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BHBkLjV2tcbNxMEgxkpSw
LlmDialectResolver dispatches path/header detection across every LlmDialect
registered via LlmDialectFactorySpi, without hardcoding any dialect's
signals. A configured fixed dialect name bypasses detection entirely,
including when it matches no registered dialect. When detection matches
more than one dialect, or none, resolution is ambiguous and returns null
so the caller rejects the request rather than guessing.

LlmOptionsConfig adds the optional server-kind `dialect` option (schema,
config, and adapter) used to pin a fixed dialect.

Fixes #2483

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtkteS7C66qiVLGeEDJ2FP
The sys: namespace's patch contract (Binding.system()/Exporter.system())
lets a component contribute a shared binding (e.g. http_client) but has
no pre-seeded slot for a shared catalog, so a patch adding one has
nothing to append into. Pre-seed catalogs: {} alongside the existing
bindings: {} in the base sys namespace skeleton, the same "pre-seed the
extension point" convention already used for the JSON-schema *-ext
scaffolds, so any component can share a catalog-backed resource the way
bindings are already shared.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mHT1vrPATQGSdRcB8NNKM
…ect schemas

Adds LlmDialectFactorySpi.schema(Kind), letting a dialect contribute a URL
to its own JSON schema for the request or response direction without
knowing anything about catalogs or patches. LlmBinding.system() enumerates
every registered dialect, reads each contributed schema, and generates a
sys: namespace patch adding one shared inline catalog (llm_dialects) with
a <dialect>.request/<dialect>.response subject per schema a dialect
contributes -- built once, at engine startup, since the dialect set is
ServiceLoader-discovered off the classpath and therefore fixed for the
JVM's life, the same way sys: already shares a binding (e.g. http_client)
across every binding that references it.

LlmDataUrlStreamHandler decodes a base64 data: URL (RFC 2397) in memory,
scoped to a single URL via the URL.of(URI, URLStreamHandler) factory --
no temporary file, no globally-registered protocol handler -- used to
hand the generated patch to Binding.system()'s URL-returning contract
without writing it to disk.

LlmBeginEx gains contentType and model fields alongside dialect, for a
server-kind stream factory to stamp once it has resolved the dialect and
read the request's content-type.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mHT1vrPATQGSdRcB8NNKM
… LlmBeginEx.dialect

Implements request/response framing decode-and-forward for the LLM server
binding (#2484): LlmServerFactory drives the request body
through the resolved dialect's ModelPipeline as bytes arrive off the wire,
forwards transformed content to app0 incrementally rather than buffering
the whole body, and threads the resolved dialect name onto LlmBeginEx so
app0 can see which wire dialect produced the request.

Flow control between the client, this binding, and app0 is enforced with
dedicated decodeSlot/encodeSlot buffers on each side of the exchange
(LlmServer for the network-facing leg, LlmStream for the app-facing leg),
each granting credit strictly from its own local slot occupancy rather than
copying a peer's sequence numbers across independent byte domains. LlmState
tracks per-direction open/closing/closed transitions and end-of-stream
deferral while a buffer is still draining.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mHT1vrPATQGSdRcB8NNKM
…fferPool handles

DefaultBufferPool.buffer(slot) rewraps and returns a single shared mutable
field per pool instance, so holding a buffer reference across a nested call
that fetches a different slot from the same pool silently repoints it.
LlmServer.decodeNetwork() directly, synchronously calls LlmStream's
request-relay staging method as a plain nested Java method call, so a
single pool instance backing both the network-decode slot and the app0
request-relay slot would alias between them.

Give LlmServerFactory two BufferPool handles instead of one: decodePool
(network decode) and encodePool (app0 relay, both directions), obtained via
context.bufferPool() and .duplicate() -- matching McpServerFactory's
decodePool/encodePool precedent. The two relay directions sharing
encodePool (the reply-direction slot on LlmServer and the request-direction
slot on LlmStream) never appear in the same call stack: cross-binding
accept() is ring-buffer-mediated and dispatched on a later engine tick, not
a nested call, so one encodePool instance can't alias between them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mHT1vrPATQGSdRcB8NNKM
…nline.conf

LlmSystemNamespaceGenerator emits a real "type": "inline" catalog config
into the generated sys: namespace patch, serviced at actual runtime by
catalog-inline's CatalogFactorySpi via ServiceLoader -- not just exercised
by this module's own tests. test scope kept it off the runtime classpath
entirely; provided scope wouldn't fit either, since nothing in main source
compiles against catalog-inline's Java API (the reference is a plain
string), so there's nothing to satisfy at compile time.

Match binding-asyncapi/binding-openapi's precedent for this same
generated-"type: inline"-config pattern: catalog-inline at runtime scope,
plus the companion catalog-inline.conf (config-schema side) at default
scope alongside the existing model-json.conf dependency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mHT1vrPATQGSdRcB8NNKM
LlmBindingConfig.newModelConfig() constructs a real JsonModelConfig in main
source, resolved to a working ModelHandler by context.supplyModel() via a
ModelFactorySpi lookup at actual runtime -- not just exercised by this
module's own tests. Matches binding-asyncapi/binding-mcp-openapi/
binding-openapi, each of which also builds JsonModelConfig directly in main
source and declares model-json at runtime scope.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mHT1vrPATQGSdRcB8NNKM
Stale relative to the catalog-inline/model-json scope fixes on binding-llm
(catalog-inline.conf and model-json.conf now roll up into this aggregate),
plus a pre-existing gap for binding-http.spec's license entry. Regenerated
via ./mvnw notice:generate -pl incubator -amd, never hand-edited, per
AGENTS.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mHT1vrPATQGSdRcB8NNKM
@jfallows
jfallows force-pushed the claude/confident-galileo-pfq91x branch 2 times, most recently from a521630 to 9b4dc3f Compare September 16, 2026 01:24

Copy link
Copy Markdown
Contributor Author

Build (25) failed on the previous head (inherited from rebasing onto #2570's current tip): incubator's maven-notice-plugin:check rejected incubator/NOTICE — the aggregator NOTICE wasn't regenerated after incubator/binding-llm's pom.xml picked up catalog-inline.conf/model-json.conf (from 6d1f276b and 71332ef1 on #2570), even though the module-level NOTICE was.

Not this PR's own diff, but inherited from the base it's stacked on. Ported the fix (regenerated incubator/NOTICE via ./mvnw notice:generate --projects incubator) in 8255d61 and pushed — no-ops once #2570 carries the same regeneration.


Generated by Claude Code

Add LlmServerConfig/LlmServerConfigBuilder (host/port, nested builder)
following the existing *OptionsConfigAdapter pattern used by
binding-kafka's options.servers, wired into LlmOptionsConfig via a new
`server` field so `llm client` bindings can configure their upstream
endpoint. Config adapter unit tests cover parsing and serializing
options.server.

Fixes #2485

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FQ4XvYNCp1NzQC4Ne4eWbK
Exercise the inject() path on LlmServerConfigBuilder so the nested
server builder reaches the module's required 100% instruction
coverage, mirroring the existing shouldInjectBuilder test for
LlmOptionsConfigBuilder.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FQ4XvYNCp1NzQC4Ne4eWbK
…→encoder chaining

Adds LlmClientFactory (kind: client), the first real integration point for
LlmDialect.supplyDecoder/supplyEncoder: it compares the inbound app-declared
dialect (LlmBeginEx.dialect) against the binding's own configured dialect and
only chains a JsonPipeline payload transform when they differ, forwarding
framing-only re-encoded content when they match.

- internal/encode: LlmContentEncoder/Spi/Factory + LlmSseContentEncoder, the
  SSE-framing encode counterpart to internal/decode's existing SSE decoder
- LlmDialectResolver.dialectNamed(String): by-name lookup for resolving the
  inbound dialect when it differs from the client's configured one
- Schema patch: kind: client options (dialect, server); also fixes kind:
  server to accept options.server (previously unreachable under its
  additionalProperties: false, despite LlmOptionsConfig already supporting it
  since PR 2570)
- Spec scripts (same.dialect, cross.dialect, client.opaque.fallback,
  client.abort) plus a new LlmClientIT, written first per this repo's
  test-first discipline, confirmed failing before LlmClientFactory existed

Fixes #2486

Real dialect implementations (openai, anthropic), the mock backend, and the
full round-trip identity test are separate, sibling issues (#2487, #2490,

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qssroe9MdjvpvQrthi15ex
…aders, body)

Rebased onto #2570's latest, which changed LlmDialect.contentType() to take
(Kind, headers, body) parameters, resolved per request rather than fixed
once per dialect instance. The client resolves content-type separately for
each direction now (REQUEST for its outbound encoder, RESPONSE for its
inbound decoder) rather than a single shared call, matching the interface's
own point: request and response can have different native content-types.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qssroe9MdjvpvQrthi15ex
LlmClientFactory.newStream resolved the RESPONSE content decoder eagerly at
BEGIN time with body=null, so LlmDialect.contentType(Kind.RESPONSE, ...)
could never actually see request-body content when deciding between a
streaming and non-streaming response, defeating the per-request capability
added for that method. REQUEST-side encoder resolution is unaffected since
it does not depend on body content.

Accumulate the request body (post cross-dialect transform, pre-framing) into
a buffer-pool-backed slot as DATA arrives, and resolve the decoder at
onAppEnd, once the full request is available, relying on this binding's
half-duplex transmission convention to guarantee no response bytes arrive
before then. Add LlmJsonRequestBody, a pure-Java HttpRequestBody view driven
by common-json's one-shot parser API, plus a unit test. Add a new
test-conditional dialect and two client IT/spec scenarios proving the
decoder now differs based on a stream field in the request body.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qssroe9MdjvpvQrthi15ex
…line SPI

LlmDialect no longer exposes contentType(Kind, HttpHeaders, HttpRequestBody);
dialects now detect via ModelEnvelope and supply ModelTransform-based
decoders/encoders driven through the engine's ModelHandler/ModelPipeline SPI.
Content-type is read directly off real wire headers instead of being computed
per-dialect, so the request-body buffering added to defer response-decoder
selection (LlmJsonRequestBody) is no longer needed and is removed.

LlmClientFactory is rebuilt against the new SPI: dialect resolution via
LlmBindingConfig.resolveDialect/dialectNamed, a per-stream ModelEnvelope, and
a ModelPipeline (ModelTransform.NONE for same-dialect) run unconditionally in
both directions so downstream code always sees validated, well-formed
payloads. Test dialects are renamed (test-client, test-client-sse,
test-client-sse-alt) to avoid colliding with the real upstream test fixtures,
and gain request/response JSON schemas so the pipeline can actually validate
their payloads instead of rejecting everything for lack of a schema.

Adds the missing llm:flushEx()/llm:matchFlushEx() k3po functions that the
client k3po scripts already relied on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qssroe9MdjvpvQrthi15ex
@jfallows
jfallows force-pushed the claude/confident-galileo-pfq91x branch from 8255d61 to 491be11 Compare September 16, 2026 02:05

Copy link
Copy Markdown
Contributor Author

CI is fully green on the current head (491be11) — all 44 checks pass, including Build (25) after the NOTICE fix — no merge conflicts, and no open review threads. Waiting on reviewer approval; will keep watching for further changes to #2570 to rebase onto, and for any new CI/review activity here.


Generated by Claude Code

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.

llm client: dialect comparison, conditional decoder→encoder chaining

2 participants