Skip to content

feat(binding-llm): llm server/client/proxy — OpenAI↔Anthropic translation via JsonPipeline - #2599

Open
jfallows wants to merge 232 commits into
developfrom
claude/charming-carson-izfewx
Open

jfallows wants to merge 232 commits into
developfrom
claude/charming-carson-izfewx

Conversation

@jfallows

@jfallows jfallows commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #2596

Description

Introduces the llm binding (kind: server/client/proxy) for bridging OpenAI Chat Completions and Anthropic Messages traffic, plus examples/llm.proxy demonstrating it.

Wire model. Every part of the app-facing protocol — content and structural signals alike (event name, finish reason, usage) — is a single DATA frame carrying a minimal LlmDataEx extension (a type discriminator plus logProbability/inputTokens/outputTokens), fragmented via ordinary init/fin flags like any oversized value. There is no separate LlmFlushEx union frame for structural signals.

Translation, directly against common-json. Both directions are expressed against common-json's JsonPipeline/JsonStream/JsonTransform/JsonSink/JsonSchema, not the generic engine ModelHandler/ModelPipeline/ModelTransform SPI or a catalog-resolved schema:

  • Request-side field renaming (max_tokens↔maxOutputTokens, top_p↔topP, etc.) is a JsonTransform that captures/defers a top-level key on KEY_NAME, decides the rename on the paired scalar value, and writes a resumable KEY-then-VALUE pair to its sink.
  • Response-side streaming translation between dialects (OpenAI SSE chunks ↔ Anthropic SSE events, including the flat-tool-call-delta ↔ separate-content-block fan-out) is a per-dialect decode JsonTransform + encode JsonSink pair, sharing one long-lived JsonPipeline per response stream: the decode stage emits genuine START_DOCUMENT/END_DOCUMENT cycles (one per canonical action) to its sink rather than an ad hoc convention, and JsonPipeline.nextDocument() (added to common-json in this PR) advances the pipeline between native chunks without cascading a state-wiping reset() to either stage — reset() is reserved for REJECTED abandonment and stream teardown.
  • Each dialect compiles its own request/response JsonSchema directly from a bundled classpath resource at construction; there is no sys: namespace catalog or ModelHandler-backed schema validation anywhere in the binding.

Dialects. openai and anthropic, each with detection (method/path/content-type or headers), credential-header conventions (Authorization: Bearer, x-api-key), and a native JSON schema for both directions.

Routing. llm(proxy) routes purely on LlmBeginEx.dialect/model — both already resolved by the llm(server) that produced the stream — never on request content, mirroring how mcp(proxy) routes on tool name. Supports both cross-dialect translation (the default per dialect) and intra-dialect, model-based routing to a second same-dialect deployment (no translation on that leg).

Credential pass-through. options.authorization templates the caller's own presented credential ("Bearer {credentials}" / "{credentials}") rather than configuring a secret of Zilla's own, so a caller's real API key forwards upstream unchanged in either direction.

examples/llm.proxy demonstrates both frontends (north_llm_server_openai/_anthropic), the shared north_llm_proxy, and four llm(client) legs (two dialects × primary/secondary). etc/zilla.yaml targets the real OpenAI/Anthropic APIs over TLS by default; .github/compose.mock.yaml (merged via .github/.env.test's COMPOSE_FILE) swaps in four mock backends and a mock-hostname zilla.yaml for CI/local testing without real API keys, mirroring the compose-overlay pattern used for analogous mock-vs-real cases elsewhere.

Testing

  • k3po ITs: LlmServerIT, LlmClientIT, LlmProxyIT (incubator/binding-llm), ApplicationIT/NetworkIT (incubator/binding-llm.spec), including openai/anthropic streaming, non-streaming, tool-call round trips, guarded-authorization, cross-dialect, and flow-control (10k/100k) scenarios.
  • Unit tests for dialect detection/resolution, request field-rename transforms, and the per-dialect canonical decode/encode transforms (driven through a real JsonPipeline, not mocked collaborators), including a test proving cross-chunk state survives nextDocument().
  • examples/llm.proxy's own ./.github/test.sh (8 assertions: both cross-dialect translation directions, both tool-call round trips, both credential pass-through directions, both model-based secondary-deployment routes) run against a locally built image.
  • grep -rn "engine\.model\." incubator/binding-llm/src returns nothing — confirms no engine Model SPI usage remains anywhere in the binding.

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
…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
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
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
…sponse transforms

Implements LlmDialect for OpenAI Chat Completions, registered via
LlmDialectFactorySpi: detect() matches POST /v1/chat/completions and
POST /v1/completions; contentType() is text/event-stream, the only
LlmContentDecoderSpi/LlmContentEncoderSpi framing codec this module
has today (a non-streaming application/json pair is a content-decoder-
layer gap, not a dialect one, and is left as follow-up).

supplyDecoder/supplyEncoder rename OpenAI-native request/response JSON
members to the canonical vocabulary this dialect defines a synonym
for -- max_tokens/maxOutputTokens, top_p/topP, n/choiceCount and
friends on requests; index/choiceIndex, finish_reason/finishReason
(remapping tool_calls/tool_call), logprobs/logProbability, and the
usage token counts on responses -- via a depth-tracked JsonTransform
that renames JSON events without DOM parsing. Everything without an
established canonical synonym (id, model, messages, tools, the whole
delta/tool_calls structure including streamed function.arguments
fragments) forwards unchanged, at any depth, so decode -> encode
round-trips with no loss.

Tests cover dialect detection/registration and both Kinds of
transform, including full round-trips through the canonical form.

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

model-json's ModelTransform integration was observation-only (ModelFieldBridge),
discarding REPLACED/DECLINED answers instead of writing them. JsonModelFieldTransform
drives a wired ModelTransform inline as JSON streams through, computing a JSON-pointer
path per scalar field (with array-index segments) at any nesting depth, and writes
FIELD/REPLACED/DECLINED answers straight to the destination -- including a REPLACED
substitute redirecting a field to a sibling key of the same enclosing object.

Adds the missing key-write for container-valued members entering a named object
member, fixes a resumed key/value write re-offering the whole text instead of the
remainder (TextSource now tracks its own consumed() progress), and reuses the
already-decoded scalar text/key for an unchanged FIELD answer instead of a fresh
per-field allocation.

Also fixes JsonModelHandlerImpl.supplyEncoder silently dropping its transform
parameter.

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

Rebases LlmOpenAiDialect and its request/response transforms onto the
redesigned LlmDialect SPI: detect(ModelEnvelope), no more contentType()
(content-type is now resolved from real upstream Content-Type headers),
supplyDecoder/supplyEncoder(Kind, ModelEnvelope) returning a ModelTransform.

The request/response transforms are rewritten as plain ModelTransform
implementations matching each field's own full path (e.g. $.choices[0].index,
$.usage.prompt_tokens) rather than tracking JSON structural depth, since the
model-json adapter now computes paths itself. This drops the old
JsonEvent-token depth-tracking machinery and the JsonSource/JsonController
wrappers (LlmOpenAiStructuredController is no longer needed); the renamed
LlmOpenAiSubstitutedSource is now a plain ModelSource.

The choices[]/usage direct-member path checks defer their substring() until
after confirming the path is actually a direct member, so the many deeply
nested per-chunk fields that share the prefix (delta.tool_calls[].index and
the like) cost no allocation.

The one dropped rename from the prior implementation is logprobs/logProbability:
it names a container-valued field, and this dialect only renames scalar leaves.

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

LlmOpenAiRequestTransform renamed a fixed set of top-level fields but never
observed model, so LlmServerFactory.doAppBegin's server.envelope.get("model", 0)
read right after running the request through this transform came back empty and
LlmBeginEx.model was never stamped for real dialect: openai traffic.

Mirrors LlmTestPermissiveDialect's inline ModelExtractTransform (and
KafkaExtractTransform's own pattern): on a FIELD event at $.model, copy the
value into the envelope alongside the existing rename-or-forward decision,
without touching the RENAMES table. LlmOpenAiResponseTransform needs no
equivalent change -- nothing reads model back off a response.

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

CI's LlmServerIT.shouldRejectRequestWithUnresolvedDialect failed: detect()
matched on :method/:path alone, so it ambiguously co-matched every existing
k3po server fixture -- all of which hit the same /v1/chat/completions path
with a test-specific content-type (application/vnd.zilla.test-permissive+json,
application/vnd.zilla.test-strict+json) to select a *different* dialect
unambiguously. Only one failure surfaced in CI because failsafe stops after
the first failure, but the same ambiguity affected every other fixture in
that class too -- confirmed by LlmServerIT going from 6 run/1 failed/1
skipped to 8/8 passing with this fix, and LlmClientIT unaffected at 4/4.

detect() now also requires content-type: application/json, the only
content-type a genuine OpenAI request ever carries, so a request to the same
path with a different dialect's own content-type no longer collides.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SMybNYCLJEWTwCjDscepZm
The llm.schema.patch.json allowed options.server on kind:server bindings,
but LlmServerFactory never reads binding.options.server — only
LlmClientFactory dials it as the upstream endpoint. Restrict the
kind:server options schema to dialect (fixed-dialect mode), matching
actual runtime usage and the milestone's example configs.

Add LlmSchemaValidationTest exercising the full EngineConfigReader
pipeline against real zilla.yaml text for both kind:server and
kind:client, covering acceptance (bare server, fixed-dialect server,
full client) and rejection (server option on kind:server, missing
required client fields, malformed server pattern, unknown kind).

Fixes #2488

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aawd4ZFaUE3FgQnzkABj9F
Baselines for the decodeMessage/encodeMessage consolidation onto the
streaming pipeline: tool-call-only responses, a multi-tool-call
response, and the Anthropic->OpenAI direction of the existing 100k
cross-dialect test, none of which had any regression coverage before.
All pass against the current DOM-based implementation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AKZMN6ckSpx1QpHvaE4DwM
…he streaming pipeline

LlmDialect.decodeMessage/encodeMessage duplicated the same OpenAI<->
canonical<->Anthropic mapping rules the streaming decode/encode transforms
already expressed, in a completely different (jakarta.json DOM) style, as a
second, independently-maintained place a mapping fix had to land twice.

Routes a non-streaming whole document through the same long-lived event
pipeline streaming already uses:

- LlmOpenaiDecodeTransform treats a "message" key as the non-streaming alias
  of "delta" (same relative shape), defaults a tool call's index from its
  array position when no explicit "index" key is present, queues text before
  tool calls regardless of native field order, and opens the canonical TEXT
  block lazily for a whole document (only once real text is seen, or not at
  all for a tool-call-only response) while keeping streaming's own eager
  open-at-message-start behavior unchanged.
- LlmAnthropicDecodeTransform adds a nativeEvent-null branch walking a
  non-streaming document's own content[] array directly, reconstructing a
  tool_use block's "input" object into the same JSON-string shape streaming's
  partial_json already carries.
- Both decode transforms now queue a canonical "end" action for a whole
  document, mirroring the real "[DONE]"/message_stop termination signal
  streaming relies on to tell the encode sink when to flush.
- LlmOpenaiEncodeSink/LlmAnthropicEncodeSink read a new per-stream "streaming"
  envelope entry (written by LlmClientFactory from the real response
  content-type) once, at the first action, and either keep emitting one
  native chunk per action (streaming) or only accumulate fields and build the
  whole native document once at the final action (non-streaming).
- LlmClientFactory.transformNativeEvent() now always drives the event
  pipeline; the old DOM-based branch, LlmDialect.decodeMessage/encodeMessage,
  and the now-unused LlmDialectJson helper are removed.

Existing cross-dialect non-streaming fixtures needed no byte changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AKZMN6ckSpx1QpHvaE4DwM
…ponse transform unit tests

LlmOpenaiToAnthropicResponseTransformTest and LlmAnthropicToOpenaiResponseTransformTest
only ever drove the shared pipeline with JsonEnvelope.NONE, which always reads back as
streaming. Add shouldEncodeWholeDocumentWhenNonStreaming to each, building an isolated
pipeline over an envelope that reads streaming=false, to exercise the encode sinks'
accumulate-then-emit-once path that the streaming ITs and other unit tests never touch.

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

Strip the multi-paragraph class javadoc and inline "why" comments added while
building the non-streaming decode/encode consolidation. The method/field names
(ensureMessageStarted, flushTextIfPending, onToolCallEnd, wholeDocumentSteps,
streaming/held*/doc*) already carry that meaning, so the prose was restating
what the code already says. Behavior is unchanged.

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

JsonSchemaImpl's schema-validator stage kept its per-document eval/failed
state across nextDocument() calls, so a permissive schema's already-VALID
verdict from a prior document leaked into the next one and reported
Status.COMPLETED before any of the new document's own content was read.
Reset that state on START_DOCUMENT instead.

Separately, JsonTokenizer.onScalarStarved() decided whether to fragment a
scalar spanning windows by comparing its own scanned bytes against the
whole window length. That comparison never trips for a value that doesn't
start at byte 0 of its window (preceded by other JSON in the same window),
so the tokenizer kept rewinding and waiting for a window large enough to
hold the value whole -- which never arrives for a value larger than any
single window. Compare against the room actually left for the value in its
window instead.

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

LlmSseContentDecoder buffered a whole SSE line before processing it, so a
single data: field whose value exceeded one buffer slot could never
complete a line and the client stalled indefinitely. Redesign it as a
persistent byte-level state machine that streams a data: value to
LlmContentDecoderOutput#data as bytes arrive, without waiting for the
value's own terminating line break, so a single field's value may span any
number of decode() calls of any total size. event:/id: fields stay
whole-line-buffered since they are always short in practice.

LlmContentDecoderOutput#data gains a last flag so the decoder can tell a
downstream consumer exactly when a value's own line terminator was found,
rather than always signalling completion at the end of a whole buffer.

LlmClientFactory's same-dialect forward path (forwardResponseContent) now
suspends and resumes cleanly against application backpressure instead of
tearing down the connection when a single fragment's write can't fully
drain in one pass, mirroring the existing eventPipeline suspend/resume
pattern.

LlmCanonicalEncodeSink-derived sinks (LlmOpenaiEncodeSink,
LlmAnthropicEncodeSink) chunk a streamed text/argument value into
fixed-size fragments across resumed calls instead of writing it as one
atomic step, so cross-dialect re-encoding of an unbounded value no longer
requires it to fit the sink's generator buffer in one shot.

anthropic.response.schema.json now also models the streaming SSE envelope
(index/delta/content_block/message) alongside the non-streaming message
shape it already covered, so the response validator has an applicable,
content-independent schema for a streamed text delta instead of falling
back to whole-value reassembly.

No change to buffer.slot.capacity/decodeMax anywhere in this change --
arbitrarily large field values now stream through a fixed-size buffer
pool by design rather than needing a bigger one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AKZMN6ckSpx1QpHvaE4DwM
…orting or corrupting SSE framing

LlmServerIT#shouldForwardOpenaiStreaming100k/shouldForwardAnthropicStreaming100k
hung, then aborted, then delivered corrupted content once flow control stopped
aborting: a large response value spanning multiple app-level DATA frames could
overrun the encode slot (raw byte credit was granted without accounting for the
SSE framing overhead each encoded chunk adds), and each chunk of one logical
data value was independently wrapped in its own "data: "/newline framing,
splicing spurious bytes into the middle of the value.

LlmServerFactory now reserves headroom in the reply window for framing
overhead, tracks exactly how many raw bytes are still unflushed in the encode
slot so app credit is only re-granted once their encoded form has actually
drained, and forwards each response chunk with the flags that reflect its real
position in the value instead of always claiming to be self-contained.
LlmContentEncoder#encodeData now takes first/last markers so a value's
"data: " prefix and terminating newline are written once across every
fragment of that value, not once per fragment; LlmJsonContentEncoder, whose
encoding has no per-value framing to duplicate, ignores them.

No buffer.slot.capacity/decodeMax/encodeMax change anywhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AKZMN6ckSpx1QpHvaE4DwM
…ed streamed values

LlmClientIT#shouldTransformOpenaiToAnthropicStreaming100k crashed with
"nextDocument() requires the prior transform() to have returned COMPLETED"
when a single native SSE content value large enough to span multiple decode
buffer slots was cross-dialect transformed while streaming: once the app-side
reply window backed up mid-transform, LlmClientFactory$LlmHttpClient's
resumeEventPipeline() advanced the event pipeline's document itself whenever
the drained transform completed, but the SSE decoder then resumed past the
value's trailing blank line in the very same decodeNet() call and invoked
onEventFlush(), which advanced the same document a second time.

eventPipelineCompleted now latches a COMPLETED transform() until whichever of
onEventFlush()/resumeEventPipeline() first observes it calls nextDocument()
and clears the flag, so the advance happens exactly once regardless of which
path resolves the suspension.

Added openai.streaming.transformed.100k (application) and
anthropic.streaming.transformed.100k (network) k3po fixtures -- paired
client/server scripts per the spec module's convention -- combining the
existing streaming-transformed scenario with a ${core:randomBase64(100000)}
content value, plus LlmClientIT#shouldTransformOpenaiToAnthropicStreaming100k
and the matching ApplicationIT/NetworkIT self-consistency entries.

No buffer.slot.capacity/decodeMax/encodeMax change anywhere.

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

Replace the fixed window-width margin subtracted from the reply window's
maximum with Zilla's own per-frame padding field on the WINDOW: the reply
window now grants the encode slot's full capacity, and each frame's own
reserved credit is inflated by the SSE framing overhead instead of a single
lump-sum deduction from the whole window. This scales correctly regardless of
how many frames a value is split across, where the old fixed margin only
happened to cover the frame counts exercised by the current tests.

No buffer.slot.capacity/decodeMax/encodeMax change anywhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AKZMN6ckSpx1QpHvaE4DwM
… keys already do

ProtobufJsonTest#shouldStreamJsonAcrossTinyWindows and
ProtobufJsonChunkingTest#shouldMatchFieldNameKeyThatFragmentsAcrossJsonInputWindows
started failing with REJECTED once common-json's JsonTokenizer began correctly
fragmenting a scalar value that doesn't start at byte 0 of its input window (a
separate common-json fix). ProtobufJsonParserImpl's own contract already
requires one JSON leaf value to arrive whole -- messageStep()/mapStep() already
decline an incomplete KEY_NAME fragment by leaving it unacted-on and re-pulling,
relying on the parser's own accumulation to reassemble it -- but valueStep(),
arrayStep(), and the map-entry value step never applied that same treatment to
a VALUE_STRING/VALUE_NUMBER token, so a value delivered as a genuine fragment
got dispatched as if it were the whole thing.

Give scalar values the same decline as keys: when parser.deferredBytes() is
still true, leave the value unacted-on and let the next pull() continue the
reassembly, exactly mirroring the existing KEY_NAME handling.

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

Copy link
Copy Markdown
Contributor Author

Build (25) failed on commit 4737831e (superseded) with common-protobuf test failures — unrelated to this PR's own module, but caused by it: this PR's JsonTokenizer fix (fixing binding-llm's streaming-100k hang) made the tokenizer correctly fragment a scalar value that doesn't start at byte 0 of its input window, which ProtobufJsonParserImpl wasn't prepared for — its messageStep()/mapStep() already decline an incomplete KEY_NAME fragment (leaving it unacted-on and re-pulling), but valueStep()/arrayStep()/the map-entry value step never applied that same treatment to a VALUE_STRING/VALUE_NUMBER token.

Fixed and pushed in 41f14db6: give scalar values the same decline as keys already get. ProtobufJsonTest/ProtobufJsonChunkingTest (the two suites that caught this) are green again, and the full common-protobuf and binding-llm module verifies pass together at the current head.


Generated by Claude Code

…ntax fallback

LlmOptionsConfigAdapter.defaultPort()'s http branch (PORT_HTTP) was never
exercised by any existing test - every server-option test either supplies
an explicit port or uses the https scheme, so only the https branch of the
ternary was covered. Add shouldReadServerOptionWithDefaultHttpPort using
"http://example.com" (no explicit port) to cover it.

Also add shouldReadInvalidServerUriAsAbsent using a genuinely malformed URI
(an unclosed IPv6-literal host) to exercise adaptServer()'s
catch (URISyntaxException ex) block, which the existing
shouldReadMalformedServerOptionAsAbsent test does not reach (its input
parses successfully as a relative URI, only failing the subsequent
host/scheme check).

Together these restore binding-llm.conf's required 100% instruction
coverage.

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

Copy link
Copy Markdown
Contributor Author

Third distinct CI failure (unrelated to the previous common-protobuf fix): binding-llm.conf's JaCoCo check failed — instructions covered ratio is 0.99, but expected minimum is 1.00.

Root cause: two branches in LlmOptionsConfigAdapter had no test coverage:

  • defaultPort(String)'s ternary only had its HTTPS branch exercised (shouldReadServerOptionWithDefaultHttpsPort); every other server-option test either supplies an explicit port or uses https, so the HTTP-default-port (80) branch was never taken.
  • adaptServer()'s catch (URISyntaxException ex) {} block was never reached — the existing shouldReadMalformedServerOptionAsAbsent test's input ("not-a-host-and-port") parses successfully as a relative URI and only fails the subsequent host/scheme check, so it never throws.

Fixed in bd99d81 by adding two unit tests: shouldReadServerOptionWithDefaultHttpPort ("http://example.com", asserts port 80) and shouldReadInvalidServerUriAsAbsent (a malformed IPv6-literal host that genuinely throws URISyntaxException). Verified locally: binding-llm.conf now passes clean verify with 100% instruction coverage, and the full binding-llm clean verify (including all k3po ITs) still passes against the updated dependency.


Generated by Claude Code

…erver option

llm(client)'s server option schema description/pattern changed from a
host:port form to a full base-URL form, but examples/inspect.schema's
golden schema.expected.json (which asserts the full merged JSON schema
byte-for-byte via `zilla inspect schema`) was never updated to match,
breaking the inspect.schema example's CI check.

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

Copy link
Copy Markdown
Contributor Author

Fourth CI failure, on commit bd99d81: testing (inspect.schema) — the examples/inspect.schema example's golden schema.expected.json fixture had drifted from the actual merged JSON schema.

Root cause: this PR's earlier work aligning llm(client)'s server option with the standard SDK base-URL convention changed its schema description/pattern (from a host:port form to a full base-URL form), but examples/inspect.schema/.github/schema.expected.json — which asserts the complete merged schema byte-for-byte via zilla inspect schema — was never updated to match.

Fixed in b9836c2 by updating the fixture's server property block to the actual current schema (description, format: uri, pattern: ^https?://). Confirmed the diff matches exactly what CI reported, and that no other host:port-pattern occurrences in the fixture are related (the other two are unrelated Kafka servers array options).


Generated by Claude Code

…ng to http

LlmServerFactory (kind: server) and LlmClientFactory (kind: client) both sit
directly upstream of a real `http` binding, which grants a non-zero `padding`
in its WINDOW frames whenever it will re-frame the forwarded bytes (chunked
Transfer-Encoding, used whenever no Content-Length is known upfront - true
for every response llm(server) writes, and every request llm(client) writes,
since neither declares a content-length). Per the established convention
(see HttpServerFactory's own `replyPad` usage, and runtime/AGENTS.md's
per-stream field table), the receiver's required padding must be added to
`reserved` on every outbound DATA frame, and subtracted from the available
window before sizing a frame's payload.

Both classes ignored `window.padding()` entirely and sent `reserved` equal
to the raw payload length, with no field even tracking it. Against http's
own synthetic k3po test doubles (which always grant padding=0, since no
existing binding-llm test simulates a real http peer's actual chunked-
encoding requirement) this went unnoticed, but a real `http` binding's
non-zero padding causes it to reject or truncate the frame, producing
exactly the symptom seen in examples/llm.proxy's CI run: curl error 18
("transfer closed with outstanding read data remaining") for a plain
non-streaming request, and a hang on the request-encode side once padding
is large enough to matter there too.

Fix: add `replyPad`/`initialPad` fields to the two classes, capture
`window.padding()` from the real WINDOW, and include it in both the
available-window check and the `reserved` value of each outbound DATA
frame. With padding=0 (every pre-existing test fixture) the arithmetic is
unchanged, so no existing behavior is affected.

Added regression coverage that simulates a real http peer's non-zero
padding via `option zilla:padding` on the accept/connect side:
- LlmServerIT#shouldForwardOpenaiNonstreamingWithReplyPadding (response leg)
- LlmClientIT#shouldForwardOpenaiRequestWithReplyPadding (request leg)
plus their NetworkIT/ApplicationIT peer self-consistency counterparts. Both
new IT tests fail (truncated response / request-encode hang) against the
pre-fix code and pass with it.

Also added LlmProxyIT#shouldRouteOpenaiToAppZero, a small (non-10k)
single-frame reply relay case through the proxy - the existing proxy
relay tests only ever exercised multi-frame (10k+) replies, leaving the
single-frame path unverified; it was ruled out as a source of this bug but
is worth keeping as coverage.

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

Copy link
Copy Markdown
Contributor Author

Fifth CI failure, and the significant one: testing (llm.proxy) — the example's own end-to-end assertions failed at the very first one (call_openai_text): curl got error 18, "transfer closed with outstanding read data remaining", for a plain non-streaming request.

Root cause

LlmServerFactory (kind: server) and LlmClientFactory (kind: client) both sit directly upstream of a real http binding. Neither declares a content-length for what it writes (the response body, the outbound request body), so http always uses chunked Transfer-Encoding for these — which means http grants a non-zero padding in its WINDOW frames, to cover its own chunk-framing overhead (chunk-size hex line + CRLFs). Per the established convention elsewhere in the codebase (HttpServerFactory's own replyPad usage — see reserved = length + replyPad throughout that class — and runtime/AGENTS.md's per-stream field table), a sender must add the receiver's declared padding to reserved on every outbound DATA frame, and reserve room for it before sizing the frame's payload.

Both LlmServerFactory and LlmClientFactory ignored window.padding() entirely — no field even tracked it — and sent reserved equal to the raw payload length. Every existing binding-llm k3po test simulates the http peer as a synthetic script that (like most k3po fixtures) grants padding: 0 by default, so this was invisible to the entire existing IT suite; a real http binding's non-zero padding causes it to reject/mis-account for the frame, producing exactly the observed truncated-response symptom on the reply leg, and an analogous hang on the request leg once padding is large enough to matter there too.

Fix (0302f15)

Added replyPad (LlmServerFactory) / initialPad (LlmClientFactory) fields, captured from window.padding() on the real WINDOW, and included them in both the available-window check and the reserved value of each outbound DATA frame. With padding=0 (every pre-existing test fixture), the arithmetic is unchanged, so no existing behavior is affected — confirmed by the full binding-llm/binding-llm.spec test suites passing unmodified.

Added regression coverage that simulates a real http peer's non-zero padding via k3po's option zilla:padding on the accept/connect side (confirmed each new test fails with the pre-fix code and passes with it):

  • LlmServerIT#shouldForwardOpenaiNonstreamingWithReplyPadding (response leg)
  • LlmClientIT#shouldForwardOpenaiRequestWithReplyPadding (request leg)
  • plus their NetworkIT/ApplicationIT peer self-consistency counterparts

Also added LlmProxyIT#shouldRouteOpenaiToAppZero, a small (non-10k) single-frame reply-relay case through the proxy — the existing proxy relay tests only ever exercised multi-frame (10k+) replies. This was ruled out as the source of the bug (it passes both before and after the fix) but closes a real coverage gap, so it's kept.

Note: this sandbox has no working Docker daemon, so I could not re-run examples/llm.proxy's own docker-compose end-to-end test directly. Verification here relies on the k3po regression tests above, which reproduce the exact WINDOW-padding mechanics a real http binding uses. I'd appreciate the next CI run (which does have Docker) confirming testing (llm.proxy) now passes end-to-end.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Build (25) failed on commit 0302f15, but not from anything in this PR's diff: the actual failure is io.aklivity.zilla.runtime.binding.kafka.internal.stream.CacheProduceIT.shouldReconnectPartitionNotLeader in binding-kafka — a module this PR never touches. This looks like a flaky Kafka-reconnection timing test. Re-running the failed jobs once to confirm.


Generated by Claude Code

…client

LlmHttpClient.encodeNet only ever set FLAG_INIT on the DATA frames it
sends toward a real http:client binding, never FLAG_FIN. HttpClientFactory's
chunked-body encoder (doEncodeHttp1Body) gates writing the chunk-size-hex
prefix on FLAG_FIN and the trailing CRLF on FLAG_INIT, so every outbound
request chunk was missing its size-hex line entirely, producing invalid
HTTP/1.1 chunked encoding. This made any real downstream backend abort the
connection before reading a complete request body, since llm never declares
Content-Length on outbound requests (translated JSON body length is unknown
up front), forcing chunked transfer-encoding.

Since each physical write to encodeNet already becomes its own
self-contained wire-level chunk (chunk-size scoped to just that write,
independent of upstream logical message boundaries), and FLAG_INIT/FLAG_FIN
here are consumed solely by the chunk-framing decision (request headers are
written separately, and HTTP/2 ignores these flags entirely), always
setting both bits is correct regardless of how encodeNet's own
backpressure buffering may fragment a single JSON encode-output slice
across multiple physical network writes. initialStarted becomes dead and is
removed.

Full binding-llm unit + k3po IT suite (136 unit tests, 57 ITs) passes
unchanged.

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

Copy link
Copy Markdown
Contributor Author

testing (llm.proxy) — actual root cause found

The WINDOW-padding fix in 096eb25a's predecessor (0302f15a) was real and necessary but, as the last update noted, did not fix the call_openai_text e2e failure. Confirmed via direct local reproduction (not just CI-log archaeology): with a build containing that fix, curl against the real examples/llm.proxy stack got 200 OK + Transfer-Encoding: chunked headers but zero body bytes, and mock-anthropic's Express/raw-body layer aborted the connection before reading a complete request — meaning zilla itself never sent a complete, validly-framed request body to the backend.

Root cause: LlmClientFactory.java's LlmHttpClient.encodeNet (the code path that forwards a translated request body from llm:client to a real http:client binding) computed its outbound DATA frame flags as initialStarted ? 0 : FLAG_INIT — i.e. it never set FLAG_FIN on any request frame, ever. HttpClientFactory.doEncodeHttp1Body (runtime/binding-http) gates writing the HTTP/1.1 chunk-size-hex prefix on FLAG_FIN and the trailing CRLF on FLAG_INIT. Since FLAG_FIN was never set, every outbound request chunk was missing its size-hex line entirely — the backend received raw JSON bytes with no valid chunked-encoding framing at all, which explains the abort exactly.

Fix (096eb25a): always set both FLAG_INIT | FLAG_FIN on every physical outbound write in encodeNet. Each physical write already becomes its own self-contained wire-level chunk (chunk size scoped to just that write), request headers are written on a separate path unaffected by these flags, and HTTP/2 ignores them entirely — so this is safe regardless of how encodeNet's own backpressure buffering may split a single JSON encode-output slice across multiple physical writes. The now-unused initialStarted field was removed.

Notably, no existing k3po test in this module could have caught this: every LlmClientIT scenario stubs the network side as a synthetic k3po script that reads decoded HTTP begin/data content directly, never exercising the real HttpClientFactory chunk-encoding logic. I'm adding a dedicated regression test that chains llm:client -> http:client -> tcp:client for real and asserts the literal wire bytes, to close that coverage gap — will push separately. Full existing binding-llm unit + IT suite (136 unit tests, 57 ITs) passes unchanged with this fix.

Re-running local docker-compose e2e now to confirm directly before considering this closed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AKZMN6ckSpx1QpHvaE4DwM


Generated by Claude Code

…ect paths

Each mock backend registered its POST handler on "/" under the assumption
that south_llm_client_* always issues its outbound request there regardless
of dialect. That assumption was stale: LlmClientFactory actually sends the
dialect's own canonical path (LlmAnthropicDialect.MESSAGES_PATH = "/v1/messages",
LlmOpenaiDialect.CHAT_COMPLETIONS_PATH = "/v1/chat/completions"), matching the
real upstream APIs. Confirmed directly from a live frame-level trace (decoded
via the engine's own ring-buffer event log) of the request zilla sends to
south_tcp_client_anthropic: "POST /v1/messages HTTP/1.1 ...".

With the request-side FLAG_FIN fix (096eb25) the request body now arrives at
each mock intact, but Express 404s ("Cannot POST /v1/messages") since no
route matched "/" -- masked before that fix because the malformed chunked
body made the mock abort during body-parsing, before routing even ran.

Fixed by registering each mock's route on the dialect's real path instead.
Confirmed against the actual CI-built image (loaded locally, not rebuilt):
all 8 examples/llm.proxy/etc/test/verify.sh assertions pass end-to-end,
including cross-dialect translation, tool calls, credential pass-through,
and model-based secondary routing.

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

Copy link
Copy Markdown
Contributor Author

testing (llm.proxy) — confirmed fixed end-to-end (two bugs, both real)

Root-caused this properly by decoding the actual wire-level frame trace out of the engine's own ring-buffer event log (extracted from a CI artifact and read with a small standalone driver against runtime/command-logs' LogsReader/incubator/command-dump's RingBufferSpy — ground truth beats re-deriving behavior from source alone). That surfaced two distinct, sequential bugs blocking this test, not one:

Bug 1 (fixed in 096eb25a) — LlmHttpClient.encodeNet never set FLAG_FIN on outbound request DATA frames to the real http:client binding, so HttpClientFactory's chunk encoder never wrote the HTTP/1.1 chunk-size-hex prefix. CI re-ran against that commit and the symptom changed (confirmed via the frame trace): the request body now arrives at mock-anthropic correctly framed —

40\r\n{"model":"gpt-4","messages":[{"role":"user","content":"Hello"}]}\r\n
0\r\n\r\n

— but the backend replied 404 Not Found: Cannot POST /v1/messages, a different failure than before (previously the malformed chunking made Express abort mid-parse before routing even ran).

Bug 2 (fixed in 3bb08ded) — examples/llm.proxy's four mock backends (mock-openai(-secondary), mock-anthropic(-secondary)) all registered their route on POST / under a comment claiming south_llm_client_* "always issues its outbound request to '/', regardless of dialect." That was stale: LlmClientFactory actually sends each dialect's real canonical path (/v1/messages for Anthropic, /v1/chat/completions for OpenAI — matching the real upstream APIs), confirmed directly from the same frame trace. Bug 1's malformed body previously masked this, since Express's body-parsing middleware aborted before route matching ever got a chance to 404. Fixed by pointing each mock's route at the dialect's actual path.

Verified for real, not just in theory: downloaded the exact zilla-image artifact CI built from 096eb25a (no local rebuild needed) and ran the full local docker-compose stack with mock fix 3bb08ded applied. All 8 assertions in etc/test/verify.sh pass end-to-end: cross-dialect text + tool-call translation both directions, credential pass-through both directions (confirmed via each mock's own logs), and model-based secondary routing both directions. Also caught and worked around an unrelated local-only footgun: compose.yaml's image: defaults to :latest unless ZILLA_VERSION is set — CI always sets it, so this never bit CI, only my local repro.

Expect testing (llm.proxy) green on the next CI run against 3bb08ded.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AKZMN6ckSpx1QpHvaE4DwM


Generated by Claude Code

…d message_start

LlmAnthropicEventMapper.encodeMessageStart built the outbound Anthropic
message_start event with only id/type/role/model, omitting content,
stop_reason, and stop_sequence -- fields the real Anthropic API always
includes and that the official anthropic-sdk-python's streaming
accumulator depends on to initialize its per-message state. Without
content: [], the SDK crashed on the first content_block_start/delta with
'NoneType' object has no attribute 'append', surfaced while exercising
examples/llm.proxy's cross-dialect streaming translation through the real
SDK instead of hand-built assertions.

usage stays intentionally omitted here, per the existing comment on
LlmUsageFlushEx: input token counts are decoupled from message_start so a
dialect that only discloses them later (OpenAI) doesn't need a
placeholder zero corrected afterward.

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

Replace etc/test/verify.sh's hand-built curl+grep assertions with
etc/test/verify.py, driven through the official openai and anthropic
Python SDKs against the example's openai-facing and anthropic-facing
frontends respectively. Proves the dialects are wire-compatible enough
for an off-the-shelf client to succeed unmodified, not just compatible
with our own request/response shapes -- and, in doing so, caught the
message_start gap fixed in the preceding commit that curl+grep never
would have (an official SDK enforces the real wire contract; a substring
match on a curl response does not).

Covers both dialects' default (cross-dialect translation) and secondary
(intra-dialect, model-routed) legs, non-streaming and streaming, plus
tool calls and credential pass-through, mirroring the prior script's
scenario coverage.

All four mock backends (mock-openai, mock-openai-secondary,
mock-anthropic, mock-anthropic-secondary) gained SSE streaming support --
previously they only ever returned static non-streaming JSON regardless
of the request's `stream` field -- since the SDKs' streaming iterators
need a real event stream to consume through the proxy/translation layer.

The verify compose service switches from node:20-alpine (curl) to
python:3.13-alpine (openai + anthropic pip packages), since the checks no
longer need Node or curl at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5EdovtZRhxkqY5DF6MsoG
The prior commit added content/stop_reason/stop_sequence to
LlmAnthropicEventMapper's encoded message_start but left usage omitted
when input tokens aren't known yet (still true for an OpenAI-sourced
stream, which never discloses them before its terminal chunk). That
omission is a different problem from the deferred-disclosure one
LlmUsageFlushEx's own comment addresses: anthropic-sdk-python's streaming
accumulator initializes its per-message snapshot from message_start and
then patches usage.output_tokens on it in place when message_delta
arrives -- with no usage object to patch, that crashed with 'NoneType'
object has no attribute 'output_tokens' (confirmed streaming an
openai-to-anthropic cross-dialect response through the real SDK via
examples/llm.proxy).

message_start now always carries a usage object; input_tokens is a
provisional 0 for a source dialect that hasn't disclosed it yet,
corrected by nothing further since Anthropic's own message_delta usage
never carries input_tokens either -- only output_tokens, which
encodeFinish already corrects from whatever the source discloses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5EdovtZRhxkqY5DF6MsoG
Replace the curl commands with the openai/anthropic Python SDKs for all
four demo scenarios, folding the separate SDK section into Try it so
there's one walkthrough instead of two overlapping ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5EdovtZRhxkqY5DF6MsoG
@jfallows
jfallows force-pushed the claude/charming-carson-izfewx branch from 0d9348d to 3bb08de Compare September 23, 2026 00:40
…izfewx

Resolves overlapping changes with PR #2598 (llm(proxy) routing):

- Mock server route paths: kept this branch's fix (register on the
  dialect's real canonical path /v1/messages or /v1/chat/completions)
  layered on top of PR #2598's added SSE streaming support for each mock,
  since both changes touch the same route-registration line but are
  otherwise independent.
- README.md: took PR #2598's version entirely (SDK-based walkthrough
  rewrite); this branch never touched the file.
- LlmAnthropicEventMapper.java/Test.java and the openai.to.anthropic.streaming
  k3po fixture: kept deleted. These predate this branch's LlmFlushEx ->
  LlmDataEx wire-model rewrite and JsonPipeline consolidation, and the
  fixture still asserts the retired zilla:flush/matchFlushEx() shape.
  Ported the real bug PR #2598 fixed in the now-deleted mapper --
  Anthropic's message_start event needs content/stop_reason/stop_sequence
  and a structural usage object, or anthropic-sdk-python's streaming
  accumulator crashes -- into this branch's replacement,
  LlmAnthropicEncodeSink.messageStartSteps(), and updated this branch's
  own equivalent fixture (application/anthropic.streaming.transformed)
  to match. Full binding-llm.spec + binding-llm verify suite green after
  the port.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AKZMN6ckSpx1QpHvaE4DwM
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: replace LlmFlushEx union + event mapper with uniform DATA + LlmDataEx, driven directly by common-json

2 participants