diff --git a/.github/workflows/local.yml b/.github/workflows/local.yml index b0b802d..3f91e69 100644 --- a/.github/workflows/local.yml +++ b/.github/workflows/local.yml @@ -1,14 +1,14 @@ name: Local engines -# Agreement with Jev and throughput of typesafe-java-local's engines, as a table in the job summary. +# Agreement with Jev and throughput of typesafe-java-client-local's engines, as a table in the job summary. # Model directories are cached: Laya fp32 (+ fp16 on macOS, for the GPU) and Qwen 4-bit, all onnx-community exports. on: workflow_dispatch: push: branches: [main] - paths: ['local/**', '.github/workflows/local.yml', '.github/scripts/local_summary.py'] + paths: ['client-local/**', '.github/workflows/local.yml', '.github/scripts/local_summary.py'] pull_request: - paths: ['local/**', '.github/workflows/local.yml', '.github/scripts/local_summary.py'] + paths: ['client-local/**', '.github/workflows/local.yml', '.github/scripts/local_summary.py'] jobs: numbers: @@ -48,23 +48,23 @@ jobs: uvx --from huggingface_hub hf download Cloudflare/clef-flash --revision 17f0b0ad64efb65d273590632833508766b2aae6 tokenizer.json --local-dir $M/clef-flash - name: Build - run: ./mvnw -B -q -pl local -am test-compile dependency:build-classpath -Dmdep.outputFile=$PWD/cp.txt -Dmdep.includeScope=test + run: ./mvnw -B -q -pl client-local -am test-compile dependency:build-classpath -Dmdep.outputFile=$PWD/cp.txt -Dmdep.includeScope=test - name: Real-model tests - run: ./mvnw -B -pl local -am test -DexcludedGroups=acceptance -Dengine=laya -Dsurefire.failIfNoSpecifiedTests=false + run: ./mvnw -B -pl client-local -am test -DexcludedGroups=acceptance -Dengine=laya -Dsurefire.failIfNoSpecifiedTests=false - name: Agreement with Jev - working-directory: local + working-directory: client-local run: | CP="$(cat ../cp.txt):target/classes:target/test-classes" ENGINES="laya qwen" if [ "$RUNNER_OS" = macOS ]; then ENGINES="$ENGINES laya-fp16-gpu"; fi for e in $ENGINES; do - java -cp "$CP" io.github.dfa1.typesafe.local.JevComparison $e | tee -a ../jev.txt + java -cp "$CP" io.github.dfa1.typesafe.client.local.JevComparison $e | tee -a ../jev.txt done - name: Throughput - working-directory: local + working-directory: client-local run: | CP="$(cat ../cp.txt):target/classes:target/test-classes" ENGINES=LAYA,QWEN diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b002c0..67aad09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,25 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- **Breaking: `JsonCodec` is now `Codec`, and it and `HttpTransport` speak `byte[]`** — `writeValueAsBytes`/`readValue(byte[], ...)` (no pretty-printing variant: the CLI's `--verbose` prints compact JSON), `Builder.codec(...)` (was `jsonCodec(...)`), and a `byte[]` request/response body, so JSON goes to and from the wire as UTF-8 with no `String` copy and a binary codec can implement the same SPI; `Jackson2Codec` now writes emoji and other non-BMP characters unescaped, like `Jackson3Codec`. (#35) +- **Breaking: modules split into model (`core`), serialization (`codec`), contract (`client`) and implementations (`client-*`), names aligned** — `core` keeps the model; `Codec` is the new `codec` module; `TypeSafeClient`, `TypeSafeException` and the decorators move to the new `client`, and `DefaultTypeSafeClient`/`ApiKey`/`HttpTransport` to the new `client-http`; `TypeSafeClient.builder()` becomes `DefaultTypeSafeClient.builder()`, and every module's artifact and package now match its directory (`client-http-jdk`, `client-http-okhttp`, `codec-jackson2`, `codec-jackson3`, `client-local`, `client-mapping`, `client-testkit`); see ADR 0003. (#35) - **docs: local-model setup as three steps** — dependency, Laya download, code, then a model comparison; the optional Clef 4-bit script runs from its GitHub URL with `uv run`, and load errors link to the how-to. -- **`local`: hardening** — special tokens in caller text stay plain text (no prompt injection via `<|im_end|>`), the tokenizer cache is bounded, and Clef checks its weights' sha256 on first load. -- **`local`: shared request validation, `evaluateAsync` off virtual threads** — every engine rejects no questions or empty criteria with `BadRequest` (Laya used to NPE or answer a null choice), and `evaluateAsync` runs on one platform thread so native inference can't pin the JVM's virtual-thread carriers. -- **`local`: `LocalClefTypeSafeClient.loadOnGpu(...)`** — Clef-flash on ONNX Runtime's WebGPU backend (macOS on Apple Silicon), same answers as the CPU; Laya gets no GPU option, since its WebGPU answers drift between launches. (#19) +- **`client-local`: hardening** — special tokens in caller text stay plain text (no prompt injection via `<|im_end|>`), the tokenizer cache is bounded, and Clef checks its weights' sha256 on first load. +- **`client-local`: shared request validation, `evaluateAsync` off virtual threads** — every engine rejects no questions or empty criteria with `BadRequest` (Laya used to NPE or answer a null choice), and `evaluateAsync` runs on one platform thread so native inference can't pin the JVM's virtual-thread carriers. +- **`client-local`: `LocalClefTypeSafeClient.loadOnGpu(...)`** — Clef-flash on ONNX Runtime's WebGPU backend (macOS on Apple Silicon), same answers as the CPU; Laya gets no GPU option, since its WebGPU answers drift between launches. (#19) - **docs: run Clef-flash on a Mac with MLX** — a how-to pointing the regular client at mlx-community's local Clef-flash server: about 0.55 s per request on an M5, much closer to Jev than Laya. (#14) -- **`local`: `LocalClefTypeSafeClient`** — Cloudflare's Clef-flash (Qwen3.5-9B + joint schema head) on Ollaya's ONNX graph over the upstream bf16 weights; one forward pass per request; `scripts/clef/quantize_q4.py` converts it to 4-bit weights (7.7 GB, 2–4 s per request on an M5 GPU). (#14) -- **`local`: `LocalLayaTypeSafeClient`, `LocalQwenTypeSafeClient`** — new module, one client class per model, evaluating in-process on ONNX Runtime from a local model directory (Laya fp32 from onnx-community, or Qwen2.5 as a prompted-LLM baseline), with a `Local engines` workflow publishing agreement-with-Jev and throughput tables. (#14) +- **`client-local`: `LocalClefTypeSafeClient`** — Cloudflare's Clef-flash (Qwen3.5-9B + joint schema head) on Ollaya's ONNX graph over the upstream bf16 weights; one forward pass per request; `scripts/clef/quantize_q4.py` converts it to 4-bit weights (7.7 GB, 2–4 s per request on an M5 GPU). (#14) +- **`client-local`: `LocalLayaTypeSafeClient`, `LocalQwenTypeSafeClient`** — new module, one client class per model, evaluating in-process on ONNX Runtime from a local model directory (Laya fp32 from onnx-community, or Qwen2.5 as a prompted-LLM baseline), with a `Local engines` workflow publishing agreement-with-Jev and throughput tables. (#14) - **build: checkstyle engine 14.3.0** — the plugin's default 9.3 can't parse Java 21 pattern matching for switch; same rules. (#14) - **Breaking: optional API key, `ApiKey` without a public constructor** — `TypeSafeClient.builder()` takes no argument and `.apiKey(key)` is optional (no key, no `Authorization` header, for local servers); `ApiKey` is a final class built with `of(String)`/`fromFile`/`fromDefaultFile`/`fromEnv`, and no longer exposes its value. (#18) -- **`jackson2`: ignore unknown fields, like `jackson3`** — a field the API adds to a response no longer fails decoding with `ResponseDecoding`; Jackson 2's default rejected it. (#18) +- **`codec-jackson2`: ignore unknown fields, like `codec-jackson3`** — a field the API adds to a response no longer fails decoding with `ResponseDecoding`; Jackson 2's default rejected it. (#18) - **`core`: `Content.fields(...)` and `EvaluateRequest.Builder` keep their order** — both copied with `Map.copyOf`, which reshuffles keys per JVM run, so the same request serialized with fields/questions in a different order each run; they now keep the caller's iteration order. -- **`core`: `TokenCounter`** — running totals of input/output tokens from `EvaluateResponse#usage()`, added via `.decorateWith(tokens::decorate)`; thread-safe, shareable across clients. -- **`core`: `Builder.decorateWith(...)` naming and docs** — named to read as additive and to pair with the `decorate(...)` factories; `Builder` javadoc and the how-to explain decorator ordering. -- **`core`: `build()` rejects a second `RetryingTypeSafeClient`** — throws `IllegalStateException`, since stacked retries multiply attempts; `decorate(...)` now takes the delegate first on every decorator. +- **`client`: `TokenCounter`** — running totals of input/output tokens from `EvaluateResponse#usage()`, added via `.decorateWith(tokens::decorate)`; thread-safe, shareable across clients. +- **`client-http`: `Builder.decorateWith(...)` naming and docs** — named to read as additive and to pair with the `decorate(...)` factories; `Builder` javadoc and the how-to explain decorator ordering. +- **`client-http`: `build()` rejects a second `RetryingTypeSafeClient`** — throws `IllegalStateException`, since stacked retries multiply attempts; `decorate(...)` now takes the delegate first on every decorator. - **Breaking: retries are opt-in** — `build()` makes one attempt per call; `Builder.maxRetries`/`initialBackoff` are gone (use `.decorateWith(RetryingTypeSafeClient::decorate)`, 5 retries from 500ms), and `Builder.decorator(...)` is renamed `decorateWith(...)`. -- **`core`: `Builder.decorator(...)`** (renamed `decorateWith(...)` below) — stacks decorators from the builder, last added outermost; `build(Function)` stays for a type-preserving outermost decorator. -- **`core`: `RetryingTypeSafeClient.decorate(...)`/`DeadlineTypeSafeClient.decorate(...)`** — static factories taking the client to wrap first, matching `MappingTypeSafeClient::decorate`; constructors are package-private. -- **`core`: `DeadlineTypeSafeClient` caps a call's total time, retries included** — fails with `TypeSafeException.Timeout` past the deadline; `RetryingTypeSafeClient` stops retrying once its future is done. -- **`core`: retry/backoff extracted into a `RetryingTypeSafeClient` decorator** — `DefaultTypeSafeClient` no longer retries itself, and `InternalServer` gains `retryAfter()`. +- **`client-http`: `Builder.decorator(...)`** (renamed `decorateWith(...)` below) — stacks decorators from the builder, last added outermost; `build(Function)` stays for a type-preserving outermost decorator. +- **`client`: `RetryingTypeSafeClient.decorate(...)`/`DeadlineTypeSafeClient.decorate(...)`** — static factories taking the client to wrap first, matching `MappingTypeSafeClient::decorate`; constructors are package-private. +- **`client`: `DeadlineTypeSafeClient` caps a call's total time, retries included** — fails with `TypeSafeException.Timeout` past the deadline; `RetryingTypeSafeClient` stops retrying once its future is done. +- **`client`: retry/backoff extracted into a `RetryingTypeSafeClient` decorator** — `DefaultTypeSafeClient` no longer retries itself, and `InternalServer` gains `retryAfter()`. ## [0.6.0] - 2026-09-30 diff --git a/CLAUDE.md b/CLAUDE.md index 2dadb94..298fa4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,37 +10,50 @@ plus a set of `Noul`/`Choice`/`Score` questions, get back typed answers. ## Module structure ``` -core — TypeSafeClient (interface; the only implementation, DefaultTypeSafeClient, is - public and owns Builder — TypeSafeClient.builder(...) is a one-line delegate to - DefaultTypeSafeClient.builder(...), so call sites don't change. A consumer can - implement TypeSafeClient itself to decorate one, e.g. with caching). - DefaultTypeSafeClient does one request, one response, no retries by default. - Retry/backoff is the opt-in RetryingTypeSafeClient decorator (classifies by - TypeSafeException, not raw responses); DeadlineTypeSafeClient caps a call's total - time. Both are added via Builder.decorateWith(RetryingTypeSafeClient::decorate) etc. (each - one goes outside the previous; build(Function) is outermost and keeps its type). - Every decorator's static decorate(...) takes the delegate first, like - MappingTypeSafeClient.decorate. build() throws if RetryingTypeSafeClient is added - more than once. TokenCounter (LongAdder totals of EvaluateResponse#usage()) is an - object the caller keeps, handing out its decorator via tokens::decorate, so the - totals stay readable once the decorator is inside a stack. - RetryingTypeSafeClient stops retrying once its returned future is done. ApiKey, - TypeSafeException, and the wire DTOs (Answer, Question, Content, - EvaluateRequest/EvaluateResponse, Usage, RequestId, Model, ModelDetails), all in - io.github.dfa1.typesafe.core; plus the JsonCodec (io.github.dfa1.typesafe.json) + - HttpTransport (io.github.dfa1.typesafe.transport) SPIs. Zero dependency on any - JSON or HTTP library — DefaultTypeSafeClient talks to HttpTransport/JsonCodec, - never to a concrete library directly, so the DTOs + JsonCodec alone are reusable - (e.g. by a Kafka producer/consumer) without pulling in TypeSafeClient's HTTP - concerns. -client-jdk — HttpTransport backed by java.net.http (artifact - typesafe-java-client-jdk, class JdkHttpTransport, package - io.github.dfa1.typesafe.jdk). Depends only on core. Discovered via - ServiceLoader at Builder.build() time (or an explicit - Builder.httpTransport(...) override). -client-okhttp — HttpTransport backed by OkHttp (artifact typesafe-java-client-okhttp, class - OkHttpTransport, package io.github.dfa1.typesafe.okhttp). Depends only on core - (plus OkHttp). An alternative to client-jdk for environments java.net.http doesn't +Naming rule: a module's directory, artifact (`typesafe-java-`) and single package +(`io.github.dfa1.typesafe.`, dashes as dots) share one name, no package spans two modules, +and a module named `X-Y` builds on module `X` (ADR 0003). + +core — the model as plain records, no dependencies: Answer, Question, Content, + EvaluateRequest/EvaluateResponse, Usage, RequestId, Model, ModelDetails, in + io.github.dfa1.typesafe.core. The model + a codec-* module serializes payloads (e.g. + for Kafka) without any client code. +codec — the Codec SPI (byte[] in/out; writeValueAsBytes/readValue), no dependencies. + Implemented by codec-*, discovered via ServiceLoader; client-http and client-local + need a JSON one (the API, model configs and prompts are JSON). +client — the contract, on core: TypeSafeClient (interface; no builder() — client can't see + its implementations: DefaultTypeSafeClient in client-http, the local clients in + client-local. A consumer can implement TypeSafeClient itself to decorate one, e.g. + with caching) and TypeSafeException. Retry/backoff is the opt-in + RetryingTypeSafeClient decorator (classifies by TypeSafeException, not raw + responses); DeadlineTypeSafeClient caps a call's total time. Both wrap any + TypeSafeClient and are added via + DefaultTypeSafeClient.Builder.decorateWith(RetryingTypeSafeClient::decorate) etc. + (each one goes outside the previous; build(Function) is outermost and keeps its + type). Every decorator's static decorate(...) takes the delegate first, like + MappingTypeSafeClient.decorate. TokenCounter (LongAdder totals of + EvaluateResponse#usage()) is an object the caller keeps, handing out its decorator + via tokens::decorate, so the totals stay readable once the decorator is inside a + stack. RetryingTypeSafeClient stops retrying once its returned future is done. + javadoc can't {@link} upwards (core → client, client → client-*: javadoc-check + fails); name those types in {@code ...}. +codec-jackson2 — Codec backed by Jackson 2.x. Depends only on codec and core. Owns the `type` + discriminator for Answer/Question via private Jackson mixins (addMixIn); Content + (no discriminator — string/object/array on the wire; backs both + EvaluateRequest.state and Question.instructions) via a custom serializer, + registered through META-INF/services. +codec-jackson3 — same, backed by Jackson 3.x (tools.jackson.databind). +client-http — DefaultTypeSafeClient (public, owns Builder: DefaultTypeSafeClient.builder()), + ApiKey, and the HttpTransport SPI, in io.github.dfa1.typesafe.client.http. One + request, one response, no retries by default. build() throws if + RetryingTypeSafeClient is added more than once. Talks to HttpTransport/Codec, + never to a concrete library; both discovered via ServiceLoader at build() time (or + Builder.httpTransport(...)/codec(...)). +client-http-jdk — HttpTransport backed by java.net.http (class JdkHttpTransport). Depends + only on client-http. Discovered via ServiceLoader at Builder.build() time (or an + explicit Builder.httpTransport(...) override). +client-http-okhttp — HttpTransport backed by OkHttp (class OkHttpTransport). Depends only on + client-http (plus OkHttp). An alternative to client-http-jdk for environments java.net.http doesn't cover, e.g. Android. Depends on `com.squareup.okhttp3:okhttp-jvm`, not `okhttp` — OkHttp 5.x publishes as Kotlin Multiplatform, and the bare `okhttp` coordinate resolves under plain Maven (no Gradle Module Metadata variant awareness) to an @@ -49,14 +62,8 @@ client-okhttp — HttpTransport backed by OkHttp (artifact typesafe-java-client- preserves wire casing) — harmless since HttpTransportResponse#header(String) is a case-insensitive lookup, but `OkHttpTransportTest` asserts through `header(...)` rather than the raw `headers()` map for exactly this reason. -jackson2 — JsonCodec backed by Jackson 2.x. Depends only on core. Owns the `type` - discriminator for Answer/Question via private Jackson mixins (addMixIn); Content - (no discriminator — string/object/array on the wire; backs both - EvaluateRequest.state and Question.instructions) via a custom serializer, - registered through META-INF/services. -jackson3 — same, backed by Jackson 3.x (tools.jackson.databind). -testkit — two TypeSafeClient test doubles, in io.github.dfa1.typesafe.testkit, depending - only on core. RecordingTypeSafeClient implements TypeSafeClient directly, at the +client-testkit — two TypeSafeClient test doubles, in io.github.dfa1.typesafe.client.testkit, depending + only on client. RecordingTypeSafeClient implements TypeSafeClient directly, at the EvaluateRequest/EvaluateResponse level: `enqueueEvaluate`/`enqueueModels` queue a response (FIFO, no request matcher — a test already controls call order itself) to whichever evaluate()/evaluateAsync()/listModels() call comes next; @@ -66,7 +73,7 @@ testkit — two TypeSafeClient test doubles, in io.github.dfa1.typesafe.testki RecordingTypeSafeClient, composing recording with periodic failure): every `failEvery`-th call (evaluate/evaluateAsync/listModels share one counter) throws a supplied exception instead of reaching the delegate. -mapping — MappingTypeSafeClient (io.github.dfa1.typesafe.mapping), a TypeSafeClient decorator +client-mapping — MappingTypeSafeClient (io.github.dfa1.typesafe.client.mapping), a TypeSafeClient decorator adding evaluateTyped(Content, Class)/evaluateTypedAsync(...) for a caller-defined record T whose components carry @Noul/@Choice/@Score (each mirroring the matching Question factory's shape: @Noul/@Score take a double component, @Choice a String @@ -75,16 +82,16 @@ mapping — MappingTypeSafeClient (io.github.dfa1.typesafe.mapping), a TypeSaf build the EvaluateRequest's questions, keyed by component name, then constructs a new T from EvaluateResponse#answers() via T's canonical constructor — so a caller gets a typed record back instead of Map and a manual - (Answer.Noul)-style cast. Depends only on core in production; its own tests depend - on testkit's RecordingTypeSafeClient (test scope only), the same test-double a + (Answer.Noul)-style cast. Depends only on client in production; its own tests depend + on client-testkit's RecordingTypeSafeClient (test scope only), the same test-double a consumer of this module would reach for. -local — one public TypeSafeClient per model (io.github.dfa1.typesafe.local: LocalLayaTypeSafeClient, +client-local — one public TypeSafeClient per model (io.github.dfa1.typesafe.client.local: LocalLayaTypeSafeClient, LocalQwenTypeSafeClient, LocalClefTypeSafeClient, each with a static load(Path)) over a package-private LocalTypeSafeClient base, evaluating in-process on ONNX Runtime, from a model directory the caller fills with `hf download` (docs/how-to.md): Laya = onnx-community's export — fp32 default, fp16 same answers/half size/slow on CPU — and Qwen q4; ~/.cache/typesafe-local by convention. Onnx.model(dir) finds the one .onnx file, so HF file - names stay as-is. local/scripts is Python for test fixtures (laya/export_onnx.py: PyTorch fixture; + names stay as-is. client-local/scripts is Python for test fixtures (laya/export_onnx.py: PyTorch fixture; tokenizer/reference.py: HF tokenizer ids; clef/reference.py: Clef sequences) plus clef/quantize_q4.py, the one script users run (Clef in 4 bits); the CI summary is .github/scripts/local_summary.py. Caller text goes @@ -94,8 +101,8 @@ local — one public TypeSafeClient per model (io.github.dfa1.typesafe.local request's questions in one batch; a port of Laya's Python sequence builder), QwenEngine (one prefill per question, softmax over Yes/No/letter/digit logits) and ClefEngine (Qwen3.5-9B + joint schema head, a request in one forward pass). Own pure-Java BpeTokenizer - (tokenizer.json); JSON through typesafe-java's JsonCodec (ServiceLoader, like the API client), - so a jackson2/jackson3 module is needed at run time; BpeTokenizerTest checks ids + (tokenizer.json); JSON through typesafe-java's Codec (ServiceLoader, like the API client), + so a codec-jackson2/codec-jackson3 module is needed at run time; BpeTokenizerTest checks ids against HF tokenizers, LayaEngineTest checks logits against PyTorch (fixtures under src/test/resources). Tests needing model files are @Tag("model"), excluded by the module's own excludedGroups (acceptance,model); opt in with -DexcludedGroups=acceptance -Dengine=laya. @@ -106,22 +113,22 @@ local — one public TypeSafeClient per model (io.github.dfa1.typesafe.local (onnx/model.onnx, config.json "laya", bool marker_mask). The `Local engines` workflow runs tests, comparison and JMH on Linux/macOS and writes tables to the job summary. -bom — dependency-management POM listing core/client-jdk/client-okhttp/jackson2/jackson3/testkit/mapping/local. +bom — dependency-management POM listing every published module (core, codec-*, client-*). acceptance — live-API tests only; not published. `AbstractTypeSafeClientAcceptanceTest` - holds every test method; one concrete subclass per HttpTransport/JsonCodec + holds every test method; one concrete subclass per HttpTransport/Codec combination (`JdkHttpClientWithJackson2AcceptanceTest`, `JdkHttpClientWithJackson3AcceptanceTest`) supplies the pair via two abstract hooks, explicitly constructing the codec/transport (`new Jackson2Codec()`, ...) rather than relying on ServiceLoader, since this module deliberately has more - than one of each on its test classpath at once. jackson2 and jackson3 both pull + than one of each on its test classpath at once. codec-jackson2 and codec-jackson3 both pull in `com.fasterxml.jackson.core:jackson-annotations` transitively, each at its own version; acceptance/pom.xml pins the newest one explicitly (bump it with either codec), or Maven's mediation can pick an older one and the other codec fails at runtime (`NoSuchFieldError`/`NoClassDefFoundError`). CI doesn't run acceptance, so a Jackson bump needs a local acceptance run. Also depends on - `mapping` (test scope) — one test wraps `sut` in a `MappingTypeSafeClient` to + `client-mapping` (test scope) — one test wraps `sut` in a `MappingTypeSafeClient` to exercise a `@Noul`/`@Choice`/`@Score`-annotated record against the live API. -cli — command-line entry point (`Main`), over client-jdk + jackson3. Its main artifact +cli — command-line entry point (`Main`), over client-http-jdk + codec-jackson3. Its main artifact is a plain (non-executable) jar of just this module's own classes; the runnable uber-jar (maven-shade-plugin) is published separately under the `all` classifier (`typesafe-java-cli-VERSION-all.jar` — `java -jar` this one), so a normal @@ -134,25 +141,26 @@ cli — command-line entry point (`Main`), over client-jdk + jackson3. Its set by the shade plugin, and exits without calling the API), `--help`/`-h` (prints usage and exits without calling the API). Stdout is silent unless `--print ` (that answer's value) or `--verbose` (the full - `EvaluateResponse` as pretty-printed JSON, via `JsonCodec.writeValueAsPrettyString`) + `EvaluateResponse` as compact JSON — pipe it into `jq` to read it) is given. ``` -Dependency rule: `client-jdk → core`, `client-okhttp → core`, `jackson2 → core`, `jackson3 → -core`, `testkit → core`, `mapping → core` (`mapping`'s own tests additionally depend on -`testkit`, test scope only), `acceptance → core, client-jdk, client-okhttp, jackson2, jackson3, -mapping` (test scope only), `cli → core, client-jdk, jackson3`, `local → core` (plus ONNX Runtime; -its own tests additionally depend on `client-jdk` and `jackson2`, test scope only) — nothing production depends on -`acceptance`, `cli`, or `testkit`. See -[ADR 0001](adr/0001-multi-module-layout-with-pluggable-json-codec.md) for why the SPIs -exist at all. +Dependency rule: `codec-jackson2 → codec, core`, `codec-jackson3 → codec, core`, `client → core`, `client-http → client, codec`, +`client-http-jdk → client-http`, `client-http-okhttp → client-http`, `client-local → client, codec` (plus ONNX +Runtime; its own tests additionally depend on `client-http-jdk` and `codec-jackson2`, test scope +only), `client-mapping → client` (its own tests additionally depend on `client-testkit`, test scope +only), `client-testkit → client`, `cli → client-http, client-http-jdk, codec-jackson3`, `acceptance → +everything` (test scope only) — no `client-*` module depends on another in production, and nothing +production depends on `acceptance`, `cli`, or `client-testkit`. See +[ADR 0003](adr/0003-model-in-core-contract-in-client.md) for the layout and +[ADR 0001](adr/0001-multi-module-layout-with-pluggable-json-codec.md) for why the SPIs exist at all. ## Commands ```bash ./mvnw clean verify # build + unit tests, all modules -./mvnw test -pl jackson2 -am # one module (+ its dependencies) -./mvnw test -pl jackson2 -am -Dtest=Jackson2CodecTest -Dsurefire.failIfNoSpecifiedTests=false +./mvnw test -pl codec-jackson2 -am # one module (+ its dependencies) +./mvnw test -pl codec-jackson2 -am -Dtest=Jackson2CodecTest -Dsurefire.failIfNoSpecifiedTests=false ``` No step here uses `install` — a routine build has no reason to write into `~/.m2/repository`. @@ -162,7 +170,7 @@ resolving them from the local repo, so a single-module command works right after without it, surefire errors on the upstream modules `-am` rebuilds that don't contain the named test class. -Acceptance tests (in `acceptance`, one concrete class per HttpTransport/JsonCodec +Acceptance tests (in `acceptance`, one concrete class per HttpTransport/Codec combination) are `@Tag("acceptance")`, hit the real TypeSafe API, and need a token at `~/.typesafe.apikey`. Excluded from a routine `./mvnw test` via the `excludedGroups=acceptance` property (surefire). Opt in with: @@ -176,9 +184,9 @@ property (surefire). Opt in with: - **`core` has zero Jackson dependency and the DTOs carry zero Jackson annotations.** Polymorphism (`Answer`/`Question`'s `type` discriminator) is wired up entirely inside each codec module via mixins, not on the DTOs. Adding a third JSON library means - adding one more codec module; `core`/`client-jdk` don't change. -- **`JsonCodec` is discovered via `ServiceLoader`, not a hard compile dependency.** A - consumer that depends on `client-jdk` but forgets a codec module gets a clear + adding one more codec module; `core`/`client-http` don't change. +- **`Codec` is discovered via `ServiceLoader`, not a hard compile dependency.** A + consumer that depends on `client-http-jdk` but forgets a codec module gets a clear `IllegalStateException` from `Builder.build()`, not a `NoClassDefFoundError`. - **Small public API.** Don't expose internals — when in doubt, leave it out or make it package-private. @@ -188,14 +196,14 @@ property (surefire). Opt in with: JUnit 6 + AssertJ (`assertThat(...)`, not JUnit's `Assertions.assertEquals`/`assertTrue`) + Mockito (BDDMockito: static-import only `given`/`then`, e.g. `given(mock.m()).willReturn(v)` / `then(mock).should().m()` — never `willReturn`/`willThrow`/`verify` unqualified). JUnit Pioneer's -`@SetEnvironmentVariable` (core only, for `ApiKeyTest`) sets an env var for one test method; +`@SetEnvironmentVariable` (client-http only, for `ApiKeyTest`) sets an env var for one test method; needs the `--add-opens java.base/java.util`/`java.lang=ALL-UNNAMED` flags on surefire's `argLine` in the root pom (Java 17+ blocks the reflection it uses otherwise). Prefer testing behavior through the real classes involved (e.g. `Jackson2CodecTest`/`Jackson3CodecTest` exercise the codec, not a bare `ObjectMapper`) — this is what caught that Jackson 3's builder API differs from Jackson 2's mutable -`ObjectMapper` during the initial split. `TypeSafeClientTest` mocks `HttpTransport`/`JsonCodec` -to verify `TypeSafeClient` calls the SPIs correctly, without a real HTTP round trip. Every test +`ObjectMapper` during the initial split. `DefaultTypeSafeClientTest` mocks `HttpTransport`/`Codec` +to verify `DefaultTypeSafeClient` calls the SPIs correctly, without a real HTTP round trip. Every test has `// Given` / `// When` / `// Then` comments marking its three phases (omit `// Given` when there's nothing to arrange). The pre-built instance a test invokes behavior on is named `sut` (e.g. a `Jackson2Codec` field, or an object constructed in `// Given` that `// When` calls a diff --git a/README.md b/README.md index 6b1fe52..9c3e2da 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Save a token to `~/.typesafe.apikey` first — either way below picks it up auto ```java ApiKey token = ApiKey.fromDefaultFile(); // reads ~/.typesafe.apikey // or: ApiKey.fromEnv(); // reads the TYPESAFE_API_KEY environment variable -TypeSafeClient client = TypeSafeClient.builder().apiKey(token).build(); +TypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(token).build(); EvaluateRequest request = EvaluateRequest.of( Content.text("Help! My payouts have been failing for 3 days."), @@ -27,13 +27,13 @@ Answer.Noul answer = client.evaluate(request).nouls().get("is_urgent"); answer.noul(); // e.g. 0.92 ``` -Or skip the `Map`/cast with a typed record (`typesafe-java-mapping`): +Or skip the `Map`/cast with a typed record (`typesafe-java-client-mapping`): ```java record UrgencyCheck(@Noul("Does this convey urgency?") double isUrgent) { } -MappingTypeSafeClient client = TypeSafeClient.builder().apiKey(token).build(MappingTypeSafeClient::decorate); +MappingTypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(token).build(MappingTypeSafeClient::decorate); UrgencyCheck result = client.evaluateTyped( Content.text("Help! My payouts have been failing for 3 days."), UrgencyCheck.class); result.isUrgent(); // e.g. 0.92 @@ -91,35 +91,39 @@ Maven, via the BOM (see the Maven Central badge above for the latest version): io.github.dfa1.typesafe-java - typesafe-java-client-jdk + typesafe-java-client-http-jdk io.github.dfa1.typesafe-java - typesafe-java-jackson3 + typesafe-java-codec-jackson3 ``` -On Android, or anywhere else `java.net.http` isn't available, swap `typesafe-java-client-jdk` -for `typesafe-java-client-okhttp`. +On Android, or anywhere else `java.net.http` isn't available, swap `typesafe-java-client-http-jdk` +for `typesafe-java-client-http-okhttp`. ## Modules | Module | Contains | |---|---| -| `core` | `TypeSafeClient`, the DTOs, and the `JsonCodec`/`HttpTransport` SPIs | -| `client-jdk` | `HttpTransport` backed by `java.net.http` | -| `client-okhttp` | `HttpTransport` backed by OkHttp — an alternative for environments `java.net.http` doesn't cover, e.g. Android | -| `jackson2` / `jackson3` | `JsonCodec` backed by Jackson 2.x / 3.x | -| `testkit` | `RecordingTypeSafeClient`/`FailingTypeSafeClient`, `TypeSafeClient` test doubles for unit tests | -| `mapping` | `MappingTypeSafeClient` — maps a `@Noul`/`@Choice`/`@Score`-annotated record to/from `EvaluateRequest`/`EvaluateResponse` | -| `local` | `LocalLayaTypeSafeClient`, `LocalQwenTypeSafeClient`, `LocalClefTypeSafeClient` — evaluate in-process on ONNX Runtime instead of calling the API (Laya, Qwen2.5 or Clef-flash, from a local model directory); API parity, not model parity with Jev. See [how-to](docs/how-to.md#run-without-the-api-on-a-local-model); on a Mac, Clef-flash via MLX is closer to Jev ([how-to](docs/how-to.md#run-clef-flash-on-a-mac-with-mlx)) | +| `core` | the model (`EvaluateRequest`, `EvaluateResponse`, `Question`, `Answer`, ...), as plain records; no dependencies | +| `codec` | the `Codec` SPI: serialization to and from bytes; no dependencies | +| `client` | the `TypeSafeClient` interface, `TypeSafeException`, and the decorators that wrap any client (retries, deadline, token counting) | +| `codec-jackson2` / `codec-jackson3` | `Codec` backed by Jackson 2.x / 3.x | +| `client-http` | `DefaultTypeSafeClient`, the `TypeSafeClient` that calls the API, and the `HttpTransport` SPI | +| `client-http-jdk` | `HttpTransport` backed by `java.net.http` | +| `client-http-okhttp` | `HttpTransport` backed by OkHttp — an alternative for environments `java.net.http` doesn't cover, e.g. Android | +| `client-local` | `LocalLayaTypeSafeClient`, `LocalQwenTypeSafeClient`, `LocalClefTypeSafeClient` — evaluate in-process on ONNX Runtime instead of calling the API (Laya, Qwen2.5 or Clef-flash, from a local model directory); API parity, not model parity with Jev. See [how-to](docs/how-to.md#run-without-the-api-on-a-local-model); on a Mac, Clef-flash via MLX is closer to Jev ([how-to](docs/how-to.md#run-clef-flash-on-a-mac-with-mlx)) | +| `client-mapping` | `MappingTypeSafeClient` — maps a `@Noul`/`@Choice`/`@Score`-annotated record to/from `EvaluateRequest`/`EvaluateResponse` | +| `client-testkit` | `RecordingTypeSafeClient`/`FailingTypeSafeClient`, `TypeSafeClient` test doubles for unit tests | | `bom` | dependency management for the modules above | | `cli` | ad hoc checks from a terminal; runnable uber-jar under the `all` classifier, `java -jar` | | `acceptance` | live-API tests only — not published | -See [ADR 0001](adr/0001-multi-module-layout-with-pluggable-json-codec.md) for why it's split -this way. +Each module's directory, artifact (`typesafe-java-`) and package +(`io.github.dfa1.typesafe.`, dashes as dots) share one name. See +[ADR 0003](adr/0003-model-in-core-contract-in-client.md) for why it's split this way. ## Docs diff --git a/acceptance/pom.xml b/acceptance/pom.xml index 2683635..72f3293 100644 --- a/acceptance/pom.xml +++ b/acceptance/pom.xml @@ -13,7 +13,7 @@ TypeSafe Acceptance Tests Tests TypeSafeClient end-to-end against the live TypeSafe API, once per HttpTransport/ - JsonCodec combination. Not published — exists only to run its own tests. + Codec combination. Not published — exists only to run its own tests. @@ -36,35 +36,42 @@ io.github.dfa1.typesafe-java - typesafe-java-client-jdk + typesafe-java-client-http ${project.version} test io.github.dfa1.typesafe-java - typesafe-java-client-okhttp + typesafe-java-client-http-jdk ${project.version} test io.github.dfa1.typesafe-java - typesafe-java-jackson2 + typesafe-java-client-http-okhttp ${project.version} test io.github.dfa1.typesafe-java - typesafe-java-jackson3 + typesafe-java-codec-jackson2 ${project.version} test io.github.dfa1.typesafe-java - typesafe-java-mapping + typesafe-java-codec-jackson3 + ${project.version} + test + + + + io.github.dfa1.typesafe-java + typesafe-java-client-mapping ${project.version} test diff --git a/acceptance/src/test/java/io/github/dfa1/typesafe/acceptance/AbstractTypeSafeClientAcceptanceTest.java b/acceptance/src/test/java/io/github/dfa1/typesafe/acceptance/AbstractTypeSafeClientAcceptanceTest.java index ec62761..91e8eb6 100644 --- a/acceptance/src/test/java/io/github/dfa1/typesafe/acceptance/AbstractTypeSafeClientAcceptanceTest.java +++ b/acceptance/src/test/java/io/github/dfa1/typesafe/acceptance/AbstractTypeSafeClientAcceptanceTest.java @@ -1,19 +1,20 @@ package io.github.dfa1.typesafe.acceptance; -import io.github.dfa1.typesafe.core.ApiKey; +import io.github.dfa1.typesafe.client.http.DefaultTypeSafeClient; +import io.github.dfa1.typesafe.client.http.ApiKey; import io.github.dfa1.typesafe.core.Answer; import io.github.dfa1.typesafe.core.EvaluateRequest; import io.github.dfa1.typesafe.core.EvaluateResponse; import io.github.dfa1.typesafe.core.Question; import io.github.dfa1.typesafe.core.Content; -import io.github.dfa1.typesafe.core.TypeSafeClient; -import io.github.dfa1.typesafe.json.JsonCodec; -import io.github.dfa1.typesafe.mapping.Choice; -import io.github.dfa1.typesafe.mapping.MappingTypeSafeClient; -import io.github.dfa1.typesafe.mapping.Noul; -import io.github.dfa1.typesafe.mapping.Option; -import io.github.dfa1.typesafe.mapping.Score; -import io.github.dfa1.typesafe.transport.HttpTransport; +import io.github.dfa1.typesafe.client.TypeSafeClient; +import io.github.dfa1.typesafe.codec.Codec; +import io.github.dfa1.typesafe.client.mapping.Choice; +import io.github.dfa1.typesafe.client.mapping.MappingTypeSafeClient; +import io.github.dfa1.typesafe.client.mapping.Noul; +import io.github.dfa1.typesafe.client.mapping.Option; +import io.github.dfa1.typesafe.client.mapping.Score; +import io.github.dfa1.typesafe.client.http.HttpTransport; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -31,7 +32,7 @@ /** * Acceptance tests against the live TypeSafe API, run once per {@link HttpTransport}/ - * {@link JsonCodec} combination by a concrete subclass (e.g. + * {@link Codec} combination by a concrete subclass (e.g. * {@code JdkHttpClientWithJackson2AcceptanceTest}). The model's judgment can change over time, * so assertions on {@code Choice}/{@code Score} answers only check the response is structurally * well-formed (values in range, probabilities summing to ~1, keys matching what was asked) @@ -45,13 +46,13 @@ abstract class AbstractTypeSafeClientAcceptanceTest { protected abstract HttpTransport httpTransport(); - protected abstract JsonCodec jsonCodec(); + protected abstract Codec codec(); @BeforeEach void setUp() throws Exception { - sut = TypeSafeClient.builder().apiKey(ApiKey.fromDefaultFile()) + sut = DefaultTypeSafeClient.builder().apiKey(ApiKey.fromDefaultFile()) .httpTransport(httpTransport()) - .jsonCodec(jsonCodec()) + .codec(codec()) .build(); } diff --git a/acceptance/src/test/java/io/github/dfa1/typesafe/acceptance/JdkHttpClientWithJackson2AcceptanceTest.java b/acceptance/src/test/java/io/github/dfa1/typesafe/acceptance/JdkHttpClientWithJackson2AcceptanceTest.java index b29db5d..6855991 100644 --- a/acceptance/src/test/java/io/github/dfa1/typesafe/acceptance/JdkHttpClientWithJackson2AcceptanceTest.java +++ b/acceptance/src/test/java/io/github/dfa1/typesafe/acceptance/JdkHttpClientWithJackson2AcceptanceTest.java @@ -1,9 +1,9 @@ package io.github.dfa1.typesafe.acceptance; -import io.github.dfa1.typesafe.jackson2.Jackson2Codec; -import io.github.dfa1.typesafe.jdk.JdkHttpTransport; -import io.github.dfa1.typesafe.json.JsonCodec; -import io.github.dfa1.typesafe.transport.HttpTransport; +import io.github.dfa1.typesafe.codec.jackson2.Jackson2Codec; +import io.github.dfa1.typesafe.client.http.jdk.JdkHttpTransport; +import io.github.dfa1.typesafe.codec.Codec; +import io.github.dfa1.typesafe.client.http.HttpTransport; class JdkHttpClientWithJackson2AcceptanceTest extends AbstractTypeSafeClientAcceptanceTest { @@ -13,7 +13,7 @@ protected HttpTransport httpTransport() { } @Override - protected JsonCodec jsonCodec() { + protected Codec codec() { return new Jackson2Codec(); } } diff --git a/acceptance/src/test/java/io/github/dfa1/typesafe/acceptance/JdkHttpClientWithJackson3AcceptanceTest.java b/acceptance/src/test/java/io/github/dfa1/typesafe/acceptance/JdkHttpClientWithJackson3AcceptanceTest.java index 0785994..d69f6e4 100644 --- a/acceptance/src/test/java/io/github/dfa1/typesafe/acceptance/JdkHttpClientWithJackson3AcceptanceTest.java +++ b/acceptance/src/test/java/io/github/dfa1/typesafe/acceptance/JdkHttpClientWithJackson3AcceptanceTest.java @@ -1,9 +1,9 @@ package io.github.dfa1.typesafe.acceptance; -import io.github.dfa1.typesafe.jackson3.Jackson3Codec; -import io.github.dfa1.typesafe.jdk.JdkHttpTransport; -import io.github.dfa1.typesafe.json.JsonCodec; -import io.github.dfa1.typesafe.transport.HttpTransport; +import io.github.dfa1.typesafe.codec.jackson3.Jackson3Codec; +import io.github.dfa1.typesafe.client.http.jdk.JdkHttpTransport; +import io.github.dfa1.typesafe.codec.Codec; +import io.github.dfa1.typesafe.client.http.HttpTransport; class JdkHttpClientWithJackson3AcceptanceTest extends AbstractTypeSafeClientAcceptanceTest { @@ -13,7 +13,7 @@ protected HttpTransport httpTransport() { } @Override - protected JsonCodec jsonCodec() { + protected Codec codec() { return new Jackson3Codec(); } } diff --git a/acceptance/src/test/java/io/github/dfa1/typesafe/acceptance/OkHttpClientWithJackson2AcceptanceTest.java b/acceptance/src/test/java/io/github/dfa1/typesafe/acceptance/OkHttpClientWithJackson2AcceptanceTest.java index e668b27..4d4e0cc 100644 --- a/acceptance/src/test/java/io/github/dfa1/typesafe/acceptance/OkHttpClientWithJackson2AcceptanceTest.java +++ b/acceptance/src/test/java/io/github/dfa1/typesafe/acceptance/OkHttpClientWithJackson2AcceptanceTest.java @@ -1,9 +1,9 @@ package io.github.dfa1.typesafe.acceptance; -import io.github.dfa1.typesafe.jackson2.Jackson2Codec; -import io.github.dfa1.typesafe.json.JsonCodec; -import io.github.dfa1.typesafe.okhttp.OkHttpTransport; -import io.github.dfa1.typesafe.transport.HttpTransport; +import io.github.dfa1.typesafe.codec.jackson2.Jackson2Codec; +import io.github.dfa1.typesafe.codec.Codec; +import io.github.dfa1.typesafe.client.http.okhttp.OkHttpTransport; +import io.github.dfa1.typesafe.client.http.HttpTransport; class OkHttpClientWithJackson2AcceptanceTest extends AbstractTypeSafeClientAcceptanceTest { @@ -13,7 +13,7 @@ protected HttpTransport httpTransport() { } @Override - protected JsonCodec jsonCodec() { + protected Codec codec() { return new Jackson2Codec(); } } diff --git a/acceptance/src/test/java/io/github/dfa1/typesafe/acceptance/OkHttpClientWithJackson3AcceptanceTest.java b/acceptance/src/test/java/io/github/dfa1/typesafe/acceptance/OkHttpClientWithJackson3AcceptanceTest.java index c876daa..6cff7a9 100644 --- a/acceptance/src/test/java/io/github/dfa1/typesafe/acceptance/OkHttpClientWithJackson3AcceptanceTest.java +++ b/acceptance/src/test/java/io/github/dfa1/typesafe/acceptance/OkHttpClientWithJackson3AcceptanceTest.java @@ -1,9 +1,9 @@ package io.github.dfa1.typesafe.acceptance; -import io.github.dfa1.typesafe.jackson3.Jackson3Codec; -import io.github.dfa1.typesafe.json.JsonCodec; -import io.github.dfa1.typesafe.okhttp.OkHttpTransport; -import io.github.dfa1.typesafe.transport.HttpTransport; +import io.github.dfa1.typesafe.codec.jackson3.Jackson3Codec; +import io.github.dfa1.typesafe.codec.Codec; +import io.github.dfa1.typesafe.client.http.okhttp.OkHttpTransport; +import io.github.dfa1.typesafe.client.http.HttpTransport; class OkHttpClientWithJackson3AcceptanceTest extends AbstractTypeSafeClientAcceptanceTest { @@ -13,7 +13,7 @@ protected HttpTransport httpTransport() { } @Override - protected JsonCodec jsonCodec() { + protected Codec codec() { return new Jackson3Codec(); } } diff --git a/adr/0003-model-in-core-contract-in-client.md b/adr/0003-model-in-core-contract-in-client.md new file mode 100644 index 0000000..130a0d1 --- /dev/null +++ b/adr/0003-model-in-core-contract-in-client.md @@ -0,0 +1,65 @@ +# 3. The model in `core`, the contract in `client`, each implementation in a `client-*` module + +Date: 2026-10-04 + +## Status + +Accepted. Refines the module layout of [ADR 0001](0001-multi-module-layout-with-pluggable-json-codec.md); +its reasons for the `Codec`/`HttpTransport` SPIs still hold. + +## Context + +`core` held everything but the concrete libraries: the model, the `TypeSafeClient` interface and its +decorators, and the HTTP client itself (`DefaultTypeSafeClient`, `ApiKey`, the `HttpTransport` SPI). +That was fine while the HTTP client was the only `TypeSafeClient`. Then `local` added three in-process +ones, and the layout stopped saying what depended on what: + +- `local` depended on `core`, and so on the HTTP client it doesn't use. +- `TypeSafeClient.builder()` returned the HTTP client's builder, as if it were the only implementation. +- Serializing the model (e.g. for Kafka) pulled in the client contract and the HTTP client with it. +- "`core` never imports the HTTP client" was a convention, not something the build enforced. +- Artifact names didn't match their packages: `typesafe-java-client-jdk` held `...jdk`, + `typesafe-java-jackson2` held `...jackson2`, and `local`/`mapping`/`testkit` gave no hint they're + all built on `TypeSafeClient`. + +## Decision + +Layers, each a module (or a family of modules) that only depends on the layers below it: + +- **`core`, the model**, as plain records with no dependencies: `Answer`, `Question`, `Content`, + `EvaluateRequest`, `EvaluateResponse`, `Usage`, `RequestId`, `Model`, `ModelDetails`. +- **`codec`, the serialization SPI** (`Codec`), with no dependencies. With a `codec-*` module, `core` + serializes TypeSafe payloads without any client code. +- **`client`, the contract**: the `TypeSafeClient` interface, `TypeSafeException`, and the decorators + that wrap any implementation (`RetryingTypeSafeClient`, `DeadlineTypeSafeClient`, `TokenCounter`). +- **`client-*`, the implementations and what builds on the contract**, none depending on another: + `client-http` (`DefaultTypeSafeClient`, `ApiKey`, the `HttpTransport` SPI) with its transports + `client-http-jdk` and `client-http-okhttp`; `client-local`; `client-mapping`; `client-testkit`. +- **`codec-*`** modules implement `Codec` for the model, on `codec` and `core`: `codec-jackson2`, + `codec-jackson3`. `client-http` and `client-local` depend on `codec` too. + +**One name per module**: its directory, its artifact (`typesafe-java-`) and its single package +(`io.github.dfa1.typesafe.`, dashes as dots) are the same, and no package spans two modules. A +module named `X-Y` builds on module `X`. + +`TypeSafeClient.builder()` is removed: `client` can't reference `client-http`. Callers write +`DefaultTypeSafeClient.builder()`. + +The serialization SPI is `Codec` (it was `JsonCodec`), with `byte[]` in and out: the JSON codecs +write UTF-8 straight to the wire with no `String` copy, and a binary format (e.g. protobuf, #37) can +implement the same interface. `client-http` still needs a JSON one, since the API speaks JSON. + +`Codec` is a module of its own rather than a class in `core`: it is the only behaviour `core` would +otherwise hold, and without a `codec` module the `codec-*` modules would break the `X-Y` rule. + +## Consequences + +- The compiler enforces every boundary. javadoc can't `{@link}` upwards (from `core` into `client`, or + from `client` into `client-http`), so it names those types in `{@code ...}`. `DeadlineTypeSafeClient` + keeps its own copy of the small helper that blocks on a future, rather than reaching into + `client-http` for it. +- A typical API user still adds two dependencies, a transport and a codec; `client-http`, `client` and + `core` come transitively. +- Breaking: every artifact except `core`, `cli` and `bom` is renamed, every package except `core` and + `cli` moves, and `TypeSafeClient.builder()` is gone. There are no users yet, so there are no + deprecation shims; the artifacts already published under the old names stay as they are. diff --git a/bom/pom.xml b/bom/pom.xml index 91863e7..5f24dbe 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -12,7 +12,7 @@ TypeSafe BOM Bill of Materials for typesafe-java. Import in dependencyManagement to get - consistent versions of core/client-jdk/client-okhttp/jackson2/jackson3/testkit/mapping. + consistent versions of every published module (core, codec-*, client-*). @@ -25,43 +25,61 @@ io.github.dfa1.typesafe-java - typesafe-java-client-jdk + typesafe-java-codec ${project.version} io.github.dfa1.typesafe-java - typesafe-java-client-okhttp + typesafe-java-client ${project.version} io.github.dfa1.typesafe-java - typesafe-java-jackson2 + typesafe-java-client-http ${project.version} io.github.dfa1.typesafe-java - typesafe-java-jackson3 + typesafe-java-client-http-jdk ${project.version} io.github.dfa1.typesafe-java - typesafe-java-testkit + typesafe-java-client-http-okhttp ${project.version} io.github.dfa1.typesafe-java - typesafe-java-mapping + typesafe-java-codec-jackson2 ${project.version} io.github.dfa1.typesafe-java - typesafe-java-local + typesafe-java-codec-jackson3 + ${project.version} + + + + io.github.dfa1.typesafe-java + typesafe-java-client-testkit + ${project.version} + + + + io.github.dfa1.typesafe-java + typesafe-java-client-mapping + ${project.version} + + + + io.github.dfa1.typesafe-java + typesafe-java-client-local ${project.version} diff --git a/cli/pom.xml b/cli/pom.xml index c9ec490..ae0a0fd 100644 --- a/cli/pom.xml +++ b/cli/pom.xml @@ -12,7 +12,7 @@ TypeSafe CLI - Command-line entry point over the JDK HttpTransport and Jackson 3 JsonCodec. The plain + Command-line entry point over the JDK HttpTransport and Jackson 3 Codec. The plain artifact is a normal (non-executable) jar of just this module's own classes; the executable uber-jar for `java -jar` is published separately under the `all` classifier (typesafe-java-cli-VERSION-all.jar), so a plain dependency on typesafe-java-cli never @@ -22,19 +22,19 @@ io.github.dfa1.typesafe-java - typesafe-java-core + typesafe-java-client-http ${project.version} io.github.dfa1.typesafe-java - typesafe-java-client-jdk + typesafe-java-client-http-jdk ${project.version} io.github.dfa1.typesafe-java - typesafe-java-jackson3 + typesafe-java-codec-jackson3 ${project.version} diff --git a/cli/src/main/java/io/github/dfa1/typesafe/cli/Main.java b/cli/src/main/java/io/github/dfa1/typesafe/cli/Main.java index ab7ecf0..29ed1b6 100644 --- a/cli/src/main/java/io/github/dfa1/typesafe/cli/Main.java +++ b/cli/src/main/java/io/github/dfa1/typesafe/cli/Main.java @@ -1,17 +1,19 @@ package io.github.dfa1.typesafe.cli; +import io.github.dfa1.typesafe.client.http.DefaultTypeSafeClient; import io.github.dfa1.typesafe.core.Answer; -import io.github.dfa1.typesafe.core.ApiKey; +import io.github.dfa1.typesafe.client.http.ApiKey; import io.github.dfa1.typesafe.core.EvaluateRequest; import io.github.dfa1.typesafe.core.EvaluateResponse; import io.github.dfa1.typesafe.core.Model; import io.github.dfa1.typesafe.core.Question; import io.github.dfa1.typesafe.core.Content; -import io.github.dfa1.typesafe.core.TypeSafeClient; -import io.github.dfa1.typesafe.jackson3.Jackson3Codec; -import io.github.dfa1.typesafe.jdk.JdkHttpTransport; -import io.github.dfa1.typesafe.json.JsonCodec; +import io.github.dfa1.typesafe.client.TypeSafeClient; +import io.github.dfa1.typesafe.codec.jackson3.Jackson3Codec; +import io.github.dfa1.typesafe.client.http.jdk.JdkHttpTransport; +import io.github.dfa1.typesafe.codec.Codec; +import java.nio.charset.StandardCharsets; import java.io.PrintStream; import java.util.ArrayList; import java.util.Arrays; @@ -23,7 +25,7 @@ * Command-line entry point, built as an uber-jar. Evaluates a single string {@code --state} * against any number of {@code --noul}/{@code --choice}/{@code --score} questions. Prints * nothing to stdout by default — pass {@code --print } for specific answers, or - * {@code --verbose} for the full {@link EvaluateResponse} as pretty-printed JSON. + * {@code --verbose} for the full {@link EvaluateResponse} as JSON. * {@code --help}/{@code -h} prints usage and exits without calling the API, same as * {@code --version}. */ @@ -39,7 +41,7 @@ public final class Main { + "(name defaults to noul/choice/score, so name it explicitly if you use more than one; " + "--min compares a noul/score answer's value, exits 1 if any is below its threshold; " + "--print prints just that answer's value; without --print, stdout is silent unless " - + "--verbose, which prints the full response as pretty-printed JSON)"; + + "--verbose, which prints the full response as JSON)"; private Main() { } @@ -67,8 +69,8 @@ static int run(String[] args, PrintStream out, PrintStream err) throws Exception } Jackson3Codec codec = new Jackson3Codec(); - try (TypeSafeClient client = TypeSafeClient.builder().apiKey(ApiKey.fromDefaultFile()) - .jsonCodec(codec) + try (TypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(ApiKey.fromDefaultFile()) + .codec(codec) .httpTransport(new JdkHttpTransport()) .build()) { return run(client, codec, parsed, out, err); @@ -77,18 +79,18 @@ static int run(String[] args, PrintStream out, PrintStream err) throws Exception /** The evaluate-and-print flow, taking an already-built client so it's testable without a * network call. */ - static int run(TypeSafeClient client, JsonCodec codec, ParsedArgs parsed, PrintStream out, PrintStream err) + static int run(TypeSafeClient client, Codec codec, ParsedArgs parsed, PrintStream out, PrintStream err) throws Exception { EvaluateRequest request = EvaluateRequest.of(Content.text(parsed.state()), parsed.model(), parsed.questions()); if (parsed.verbose()) { - err.println("request: " + codec.writeValueAsPrettyString(request)); + err.println("request: " + json(codec, request)); } EvaluateResponse response = client.evaluate(request); if (parsed.verbose()) { - err.println("response: " + codec.writeValueAsPrettyString(response)); + err.println("response: " + json(codec, response)); err.println("request-id: " + response.metadata().requestId()); } if (parsed.timing()) { @@ -98,7 +100,7 @@ static int run(TypeSafeClient client, JsonCodec codec, ParsedArgs parsed, PrintS if (!parsed.printNames().isEmpty()) { parsed.printNames().forEach(name -> out.println(answerValue(response, name))); } else if (parsed.verbose()) { - out.println(codec.writeValueAsPrettyString(response)); + out.println(json(codec, response)); } List failures = minFailures(response, parsed.minSpecs()); @@ -211,4 +213,8 @@ private static int fail(PrintStream err, String message) { err.println(USAGE); return 1; } + + private static String json(Codec codec, Object value) { + return new String(codec.writeValueAsBytes(value), StandardCharsets.UTF_8); + } } diff --git a/cli/src/test/java/io/github/dfa1/typesafe/cli/MainTest.java b/cli/src/test/java/io/github/dfa1/typesafe/cli/MainTest.java index 38c7dc3..5875878 100644 --- a/cli/src/test/java/io/github/dfa1/typesafe/cli/MainTest.java +++ b/cli/src/test/java/io/github/dfa1/typesafe/cli/MainTest.java @@ -4,9 +4,9 @@ import io.github.dfa1.typesafe.core.EvaluateResponse; import io.github.dfa1.typesafe.core.Question; import io.github.dfa1.typesafe.core.Model; -import io.github.dfa1.typesafe.core.TypeSafeClient; +import io.github.dfa1.typesafe.client.TypeSafeClient; import io.github.dfa1.typesafe.core.Usage; -import io.github.dfa1.typesafe.jackson3.Jackson3Codec; +import io.github.dfa1.typesafe.codec.jackson3.Jackson3Codec; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; @@ -262,7 +262,7 @@ void runPrintsNothingToStdoutByDefault() throws Exception { } @Test - void runPrintsTheFullResponseAsPrettyJsonToStdoutWhenVerbose() throws Exception { + void runPrintsTheFullResponseAsJsonToStdoutWhenVerbose() throws Exception { // Given given(client.evaluate(any())).willReturn(response(Map.of("urgent", new Answer.Noul(0.5)))); Main.ParsedArgs parsed = new Main.ParsedArgs("hi", Model.LATEST, @@ -273,7 +273,7 @@ void runPrintsTheFullResponseAsPrettyJsonToStdoutWhenVerbose() throws Exception // Then assertThat(result).isZero(); - assertThat(outBuffer.toString()).contains("\"noul\" : 0.5").contains("\n"); + assertThat(outBuffer.toString()).contains("\"noul\":0.5"); } @Test diff --git a/client-jdk/pom.xml b/client-http-jdk/pom.xml similarity index 84% rename from client-jdk/pom.xml rename to client-http-jdk/pom.xml index d42deab..cc6ec9d 100644 --- a/client-jdk/pom.xml +++ b/client-http-jdk/pom.xml @@ -7,18 +7,18 @@ 0.7.0-SNAPSHOT - typesafe-java-client-jdk + typesafe-java-client-http-jdk jar - TypeSafe JDK HTTP Transport + TypeSafe Client HTTP JDK HttpTransport SPI implementation backed by java.net.http. Depends only on - typesafe-java-core; discovered via ServiceLoader. + typesafe-java-client-http; discovered via ServiceLoader. io.github.dfa1.typesafe-java - typesafe-java-core + typesafe-java-client-http ${project.version} diff --git a/client-jdk/src/main/java/io/github/dfa1/typesafe/jdk/JdkHttpTransport.java b/client-http-jdk/src/main/java/io/github/dfa1/typesafe/client/http/jdk/JdkHttpTransport.java similarity index 81% rename from client-jdk/src/main/java/io/github/dfa1/typesafe/jdk/JdkHttpTransport.java rename to client-http-jdk/src/main/java/io/github/dfa1/typesafe/client/http/jdk/JdkHttpTransport.java index 36b63f0..034cb74 100644 --- a/client-jdk/src/main/java/io/github/dfa1/typesafe/jdk/JdkHttpTransport.java +++ b/client-http-jdk/src/main/java/io/github/dfa1/typesafe/client/http/jdk/JdkHttpTransport.java @@ -1,13 +1,12 @@ -package io.github.dfa1.typesafe.jdk; +package io.github.dfa1.typesafe.client.http.jdk; -import io.github.dfa1.typesafe.transport.HttpTransport; -import io.github.dfa1.typesafe.transport.HttpTransportResponse; +import io.github.dfa1.typesafe.client.http.HttpTransport; +import io.github.dfa1.typesafe.client.http.HttpTransportResponse; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.LinkedHashMap; import java.util.Map; @@ -18,8 +17,7 @@ public final class JdkHttpTransport implements HttpTransport { /** Applied to every request unless overridden via the {@code (HttpClient, Duration)} constructor. */ public static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(10); - private static final HttpResponse.BodyHandler BODY_HANDLER = - HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8); + private static final HttpResponse.BodyHandler BODY_HANDLER = HttpResponse.BodyHandlers.ofByteArray(); private final HttpClient http; private final Duration timeout; @@ -42,9 +40,9 @@ public JdkHttpTransport(HttpClient http, Duration timeout) { } @Override - public CompletableFuture post(URI uri, Map headers, String body) { + public CompletableFuture post(URI uri, Map headers, byte[] body) { HttpRequest.Builder builder = HttpRequest.newBuilder(uri) - .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)); + .POST(HttpRequest.BodyPublishers.ofByteArray(body)); return http.sendAsync(request(builder, headers), BODY_HANDLER) .thenApply(JdkHttpTransport::toTransportResponse); } @@ -69,7 +67,7 @@ private HttpRequest request(HttpRequest.Builder builder, Map hea return builder.build(); } - private static HttpTransportResponse toTransportResponse(HttpResponse response) { + private static HttpTransportResponse toTransportResponse(HttpResponse response) { Map headers = new LinkedHashMap<>(); response.headers().map().forEach((name, values) -> headers.put(name, values.get(0))); return new HttpTransportResponse(response.statusCode(), headers, response.body()); diff --git a/client-http-jdk/src/main/resources/META-INF/services/io.github.dfa1.typesafe.client.http.HttpTransport b/client-http-jdk/src/main/resources/META-INF/services/io.github.dfa1.typesafe.client.http.HttpTransport new file mode 100644 index 0000000..42dce07 --- /dev/null +++ b/client-http-jdk/src/main/resources/META-INF/services/io.github.dfa1.typesafe.client.http.HttpTransport @@ -0,0 +1 @@ +io.github.dfa1.typesafe.client.http.jdk.JdkHttpTransport diff --git a/client-jdk/src/test/java/io/github/dfa1/typesafe/jdk/JdkHttpTransportTest.java b/client-http-jdk/src/test/java/io/github/dfa1/typesafe/client/http/jdk/JdkHttpTransportTest.java similarity index 79% rename from client-jdk/src/test/java/io/github/dfa1/typesafe/jdk/JdkHttpTransportTest.java rename to client-http-jdk/src/test/java/io/github/dfa1/typesafe/client/http/jdk/JdkHttpTransportTest.java index cd04308..d548e93 100644 --- a/client-jdk/src/test/java/io/github/dfa1/typesafe/jdk/JdkHttpTransportTest.java +++ b/client-http-jdk/src/test/java/io/github/dfa1/typesafe/client/http/jdk/JdkHttpTransportTest.java @@ -1,6 +1,6 @@ -package io.github.dfa1.typesafe.jdk; +package io.github.dfa1.typesafe.client.http.jdk; -import io.github.dfa1.typesafe.transport.HttpTransportResponse; +import io.github.dfa1.typesafe.client.http.HttpTransportResponse; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -8,6 +8,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.nio.charset.StandardCharsets; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpHeaders; @@ -32,7 +33,7 @@ class JdkHttpTransportTest { private HttpClient httpClient; @Mock - private HttpResponse httpResponse; + private HttpResponse httpResponse; @Test void postSendsAHeadedJsonPostAndMapsTheResponse() throws Exception { @@ -41,17 +42,17 @@ void postSendsAHeadedJsonPostAndMapsTheResponse() throws Exception { given(httpResponse.statusCode()).willReturn(200); given(httpResponse.headers()).willReturn( HttpHeaders.of(Map.of("Content-Type", List.of("application/json")), (name, value) -> true)); - given(httpResponse.body()).willReturn("{\"ok\":true}"); - given(httpClient.sendAsync(any(), any())).willReturn(CompletableFuture.completedFuture(httpResponse)); + given(httpResponse.body()).willReturn(bytes("{\"ok\":true}")); + given(httpClient.sendAsync(any(), any())).willReturn(CompletableFuture.completedFuture(httpResponse)); // When HttpTransportResponse result = - sut.post(ENDPOINT, Map.of("Authorization", "Bearer secret"), "{\"a\":1}").get(); + sut.post(ENDPOINT, Map.of("Authorization", "Bearer secret"), bytes("{\"a\":1}")).get(); // Then assertThat(result.statusCode()).isEqualTo(200); assertThat(result.headers()).containsEntry("Content-Type", "application/json"); - assertThat(result.body()).isEqualTo("{\"ok\":true}"); + assertThat(result.body()).asString(StandardCharsets.UTF_8).isEqualTo("{\"ok\":true}"); ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); then(httpClient).should().sendAsync(captor.capture(), any()); @@ -68,11 +69,11 @@ void postAppliesACustomTimeoutWhenGiven() throws Exception { JdkHttpTransport sut = new JdkHttpTransport(httpClient, Duration.ofSeconds(3)); given(httpResponse.statusCode()).willReturn(200); given(httpResponse.headers()).willReturn(HttpHeaders.of(Map.of(), (name, value) -> true)); - given(httpResponse.body()).willReturn("{}"); - given(httpClient.sendAsync(any(), any())).willReturn(CompletableFuture.completedFuture(httpResponse)); + given(httpResponse.body()).willReturn(bytes("{}")); + given(httpClient.sendAsync(any(), any())).willReturn(CompletableFuture.completedFuture(httpResponse)); // When - sut.post(ENDPOINT, Map.of(), "{}").get(); + sut.post(ENDPOINT, Map.of(), bytes("{}")).get(); // Then ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); @@ -86,11 +87,11 @@ void postAppliesNoTimeoutWhenGivenNull() throws Exception { JdkHttpTransport sut = new JdkHttpTransport(httpClient, null); given(httpResponse.statusCode()).willReturn(200); given(httpResponse.headers()).willReturn(HttpHeaders.of(Map.of(), (name, value) -> true)); - given(httpResponse.body()).willReturn("{}"); - given(httpClient.sendAsync(any(), any())).willReturn(CompletableFuture.completedFuture(httpResponse)); + given(httpResponse.body()).willReturn(bytes("{}")); + given(httpClient.sendAsync(any(), any())).willReturn(CompletableFuture.completedFuture(httpResponse)); // When - sut.post(ENDPOINT, Map.of(), "{}").get(); + sut.post(ENDPOINT, Map.of(), bytes("{}")).get(); // Then ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); @@ -104,15 +105,15 @@ void getSendsAHeadedGetAndMapsTheResponse() throws Exception { JdkHttpTransport sut = new JdkHttpTransport(httpClient); given(httpResponse.statusCode()).willReturn(200); given(httpResponse.headers()).willReturn(HttpHeaders.of(Map.of(), (name, value) -> true)); - given(httpResponse.body()).willReturn("{\"models\":[]}"); - given(httpClient.sendAsync(any(), any())).willReturn(CompletableFuture.completedFuture(httpResponse)); + given(httpResponse.body()).willReturn(bytes("{\"models\":[]}")); + given(httpClient.sendAsync(any(), any())).willReturn(CompletableFuture.completedFuture(httpResponse)); // When HttpTransportResponse result = sut.get(ENDPOINT, Map.of("Authorization", "Bearer secret")).get(); // Then assertThat(result.statusCode()).isEqualTo(200); - assertThat(result.body()).isEqualTo("{\"models\":[]}"); + assertThat(result.body()).asString(StandardCharsets.UTF_8).isEqualTo("{\"models\":[]}"); ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); then(httpClient).should().sendAsync(captor.capture(), any()); @@ -134,4 +135,8 @@ void closeClosesTheUnderlyingHttpClient() { // Then then(httpClient).should().close(); } + + private static byte[] bytes(String text) { + return text.getBytes(StandardCharsets.UTF_8); + } } diff --git a/client-okhttp/pom.xml b/client-http-okhttp/pom.xml similarity index 82% rename from client-okhttp/pom.xml rename to client-http-okhttp/pom.xml index aa64221..0075e3e 100644 --- a/client-okhttp/pom.xml +++ b/client-http-okhttp/pom.xml @@ -7,19 +7,19 @@ 0.7.0-SNAPSHOT - typesafe-java-client-okhttp + typesafe-java-client-http-okhttp jar - TypeSafe OkHttp Transport + TypeSafe Client HTTP OkHttp HttpTransport SPI implementation backed by OkHttp. Depends only on - typesafe-java-core (plus OkHttp itself); discovered via ServiceLoader. An alternative to - typesafe-java-client-jdk for environments java.net.http doesn't cover, e.g. Android. + typesafe-java-client-http (plus OkHttp itself); discovered via ServiceLoader. An alternative to + typesafe-java-client-http-jdk for environments java.net.http doesn't cover, e.g. Android. io.github.dfa1.typesafe-java - typesafe-java-core + typesafe-java-client-http ${project.version} diff --git a/client-okhttp/src/main/java/io/github/dfa1/typesafe/okhttp/OkHttpTransport.java b/client-http-okhttp/src/main/java/io/github/dfa1/typesafe/client/http/okhttp/OkHttpTransport.java similarity index 93% rename from client-okhttp/src/main/java/io/github/dfa1/typesafe/okhttp/OkHttpTransport.java rename to client-http-okhttp/src/main/java/io/github/dfa1/typesafe/client/http/okhttp/OkHttpTransport.java index 4df38e0..bd84da7 100644 --- a/client-okhttp/src/main/java/io/github/dfa1/typesafe/okhttp/OkHttpTransport.java +++ b/client-http-okhttp/src/main/java/io/github/dfa1/typesafe/client/http/okhttp/OkHttpTransport.java @@ -1,7 +1,7 @@ -package io.github.dfa1.typesafe.okhttp; +package io.github.dfa1.typesafe.client.http.okhttp; -import io.github.dfa1.typesafe.transport.HttpTransport; -import io.github.dfa1.typesafe.transport.HttpTransportResponse; +import io.github.dfa1.typesafe.client.http.HttpTransport; +import io.github.dfa1.typesafe.client.http.HttpTransportResponse; import okhttp3.Call; import okhttp3.Callback; @@ -50,7 +50,7 @@ public OkHttpTransport(OkHttpClient http, Duration timeout) { } @Override - public CompletableFuture post(URI uri, Map headers, String body) { + public CompletableFuture post(URI uri, Map headers, byte[] body) { Request request = request(uri, headers) .post(RequestBody.create(body, null)) .build(); @@ -91,7 +91,7 @@ public void onResponse(Call call, Response response) throws IOException { Map responseHeaders = new LinkedHashMap<>(); response.headers().toMultimap().forEach((name, values) -> responseHeaders.put(name, values.get(0))); // OkHttp 5: body() is never null (an empty body for a bodiless response) - future.complete(new HttpTransportResponse(response.code(), responseHeaders, response.body().string())); + future.complete(new HttpTransportResponse(response.code(), responseHeaders, response.body().bytes())); } catch (IOException e) { future.completeExceptionally(e); } diff --git a/client-http-okhttp/src/main/resources/META-INF/services/io.github.dfa1.typesafe.client.http.HttpTransport b/client-http-okhttp/src/main/resources/META-INF/services/io.github.dfa1.typesafe.client.http.HttpTransport new file mode 100644 index 0000000..881edb3 --- /dev/null +++ b/client-http-okhttp/src/main/resources/META-INF/services/io.github.dfa1.typesafe.client.http.HttpTransport @@ -0,0 +1 @@ +io.github.dfa1.typesafe.client.http.okhttp.OkHttpTransport diff --git a/client-okhttp/src/test/java/io/github/dfa1/typesafe/okhttp/OkHttpTransportTest.java b/client-http-okhttp/src/test/java/io/github/dfa1/typesafe/client/http/okhttp/OkHttpTransportTest.java similarity index 92% rename from client-okhttp/src/test/java/io/github/dfa1/typesafe/okhttp/OkHttpTransportTest.java rename to client-http-okhttp/src/test/java/io/github/dfa1/typesafe/client/http/okhttp/OkHttpTransportTest.java index 017bacc..5392062 100644 --- a/client-okhttp/src/test/java/io/github/dfa1/typesafe/okhttp/OkHttpTransportTest.java +++ b/client-http-okhttp/src/test/java/io/github/dfa1/typesafe/client/http/okhttp/OkHttpTransportTest.java @@ -1,6 +1,6 @@ -package io.github.dfa1.typesafe.okhttp; +package io.github.dfa1.typesafe.client.http.okhttp; -import io.github.dfa1.typesafe.transport.HttpTransportResponse; +import io.github.dfa1.typesafe.client.http.HttpTransportResponse; import okhttp3.Call; import okhttp3.Callback; @@ -16,6 +16,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.nio.charset.StandardCharsets; import java.io.IOException; import java.net.URI; import java.time.Duration; @@ -49,7 +50,7 @@ void postSendsAHeadedJsonPostAndMapsTheResponse() throws Exception { // When CompletableFuture future = - sut.post(ENDPOINT, Map.of("Authorization", "Bearer secret"), "{\"a\":1}"); + sut.post(ENDPOINT, Map.of("Authorization", "Bearer secret"), bytes("{\"a\":1}")); // Then ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(Request.class); @@ -66,7 +67,7 @@ void postSendsAHeadedJsonPostAndMapsTheResponse() throws Exception { HttpTransportResponse result = future.get(); assertThat(result.statusCode()).isEqualTo(200); assertThat(result.header("Content-Type")).contains("application/json"); - assertThat(result.body()).isEqualTo("{\"ok\":true}"); + assertThat(result.body()).asString(StandardCharsets.UTF_8).isEqualTo("{\"ok\":true}"); } @Test @@ -116,7 +117,7 @@ void getSendsAHeadedGetAndMapsTheResponse() throws Exception { HttpTransportResponse result = future.get(); assertThat(result.statusCode()).isEqualTo(200); - assertThat(result.body()).isEqualTo("{\"models\":[]}"); + assertThat(result.body()).asString(StandardCharsets.UTF_8).isEqualTo("{\"models\":[]}"); } @Test @@ -172,4 +173,8 @@ private static Response responseTo(Request request, int code, String contentType } return builder.build(); } + + private static byte[] bytes(String text) { + return text.getBytes(StandardCharsets.UTF_8); + } } diff --git a/client-http/pom.xml b/client-http/pom.xml new file mode 100644 index 0000000..5f0738b --- /dev/null +++ b/client-http/pom.xml @@ -0,0 +1,56 @@ + + 4.0.0 + + + io.github.dfa1.typesafe-java + typesafe-java + 0.7.0-SNAPSHOT + + + typesafe-java-client-http + jar + + TypeSafe Client HTTP + The TypeSafeClient that calls the TypeSafe API over HTTP: DefaultTypeSafeClient and its + Builder, plus the HttpTransport SPI it sends through. Depends only on typesafe-java-client and typesafe-java-codec; needs one + transport (typesafe-java-client-http-jdk or -okhttp) and one codec (typesafe-java-codec-jackson2 or + -jackson3) at run time, discovered via ServiceLoader. + + + + + io.github.dfa1.typesafe-java + typesafe-java-client + ${project.version} + + + io.github.dfa1.typesafe-java + typesafe-java-codec + ${project.version} + + + + org.junit.jupiter + junit-jupiter + test + + + + org.assertj + assertj-core + test + + + + org.mockito + mockito-junit-jupiter + test + + + + org.junit-pioneer + junit-pioneer + test + + + diff --git a/core/src/main/java/io/github/dfa1/typesafe/core/ApiKey.java b/client-http/src/main/java/io/github/dfa1/typesafe/client/http/ApiKey.java similarity index 97% rename from core/src/main/java/io/github/dfa1/typesafe/core/ApiKey.java rename to client-http/src/main/java/io/github/dfa1/typesafe/client/http/ApiKey.java index 10f75f7..09de9f3 100644 --- a/core/src/main/java/io/github/dfa1/typesafe/core/ApiKey.java +++ b/client-http/src/main/java/io/github/dfa1/typesafe/client/http/ApiKey.java @@ -1,4 +1,4 @@ -package io.github.dfa1.typesafe.core; +package io.github.dfa1.typesafe.client.http; import java.io.IOException; import java.nio.file.Files; diff --git a/core/src/main/java/io/github/dfa1/typesafe/core/DefaultTypeSafeClient.java b/client-http/src/main/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClient.java similarity index 88% rename from core/src/main/java/io/github/dfa1/typesafe/core/DefaultTypeSafeClient.java rename to client-http/src/main/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClient.java index 6963967..91ef07b 100644 --- a/core/src/main/java/io/github/dfa1/typesafe/core/DefaultTypeSafeClient.java +++ b/client-http/src/main/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClient.java @@ -1,13 +1,19 @@ -package io.github.dfa1.typesafe.core; +package io.github.dfa1.typesafe.client.http; -import io.github.dfa1.typesafe.json.JsonCodec; -import io.github.dfa1.typesafe.transport.HttpTransport; -import io.github.dfa1.typesafe.transport.HttpTransportResponse; +import io.github.dfa1.typesafe.core.EvaluateRequest; +import io.github.dfa1.typesafe.core.EvaluateResponse; +import io.github.dfa1.typesafe.core.ModelDetails; +import io.github.dfa1.typesafe.core.RequestId; +import io.github.dfa1.typesafe.client.RetryingTypeSafeClient; +import io.github.dfa1.typesafe.client.TypeSafeClient; +import io.github.dfa1.typesafe.client.TypeSafeException; +import io.github.dfa1.typesafe.codec.Codec; import java.io.IOException; import java.io.InterruptedIOException; import java.net.URI; import java.net.http.HttpTimeoutException; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -26,15 +32,15 @@ public final class DefaultTypeSafeClient implements TypeSafeClient { private final HttpTransport transport; - private final JsonCodec jsonCodec; + private final Codec codec; private final ApiKey apiKey; private final URI endpoint; private final URI modelsEndpoint; - private DefaultTypeSafeClient(ApiKey apiKey, HttpTransport transport, JsonCodec jsonCodec, URI endpoint) { + private DefaultTypeSafeClient(ApiKey apiKey, HttpTransport transport, Codec codec, URI endpoint) { this.apiKey = apiKey; this.transport = transport; - this.jsonCodec = jsonCodec; + this.codec = codec; this.endpoint = endpoint; this.modelsEndpoint = URI.create(endpoint.getScheme() + "://" + endpoint.getAuthority() + "/v1/models"); } @@ -51,9 +57,9 @@ public EvaluateResponse evaluate(EvaluateRequest request) { @Override public CompletableFuture evaluateAsync(EvaluateRequest request) { Map headers = requestHeaders(); - String body; + byte[] body; try { - body = jsonCodec.writeValueAsString(request); + body = codec.writeValueAsBytes(request); } catch (RuntimeException e) { return CompletableFuture.failedFuture(e); } @@ -80,15 +86,20 @@ private CompletableFuture send( try { return decode.apply(response); } catch (RuntimeException e) { - throw new CompletionException(new TypeSafeException.ResponseDecoding(response.body(), e)); + throw new CompletionException(new TypeSafeException.ResponseDecoding(text(response), e)); } }); } + /** The body as text, for an error message; the success path never builds a {@code String}. */ + private static String text(HttpTransportResponse response) { + return new String(response.body(), StandardCharsets.UTF_8); + } + /** Most specific {@link TypeSafeException} subclass for {@code status}, or the plain * base class as a catch-all when no subclass matches. */ private static TypeSafeException toException(int status, HttpTransportResponse response) { - String body = response.body(); + String body = text(response); return switch (status) { case 400 -> new TypeSafeException.BadRequest(body); case 401 -> new TypeSafeException.Authentication(body); @@ -172,7 +183,7 @@ private void authorize(Map headers) { } private EvaluateResponse toEvaluateResponse(HttpTransportResponse response) { - EvaluateResponse body = jsonCodec.readValue(response.body(), EvaluateResponse.class); + EvaluateResponse body = codec.readValue(response.body(), EvaluateResponse.class); EvaluateResponse.Metadata metadata = new EvaluateResponse.Metadata( response.header("x-typesafe-request-id").map(RequestId::new).orElse(null), response.header("x-envoy-upstream-service-time") @@ -181,7 +192,7 @@ private EvaluateResponse toEvaluateResponse(HttpTransportResponse response) { } private List toModelDetails(HttpTransportResponse response) { - return jsonCodec.readValue(response.body(), ModelsResponse.class).models(); + return codec.readValue(response.body(), ModelsResponse.class).models(); } /** @@ -196,7 +207,7 @@ private List toModelDetails(HttpTransportResponse response) { * {@link #decorateWith}: * *
{@code
-     * MappingTypeSafeClient client = TypeSafeClient.builder().apiKey(apiKey)
+     * MappingTypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(apiKey)
      *         .decorateWith(RetryingTypeSafeClient::decorate)
      *         .decorateWith(c -> DeadlineTypeSafeClient.decorate(c, Duration.ofSeconds(20)))
      *         .build(MappingTypeSafeClient::decorate);
@@ -207,7 +218,7 @@ public static final class Builder {
 
         private ApiKey apiKey;
         private HttpTransport transport;
-        private JsonCodec jsonCodec;
+        private Codec codec;
         private URI endpoint = DEFAULT_ENDPOINT;
         private final List> decorators = new ArrayList<>();
 
@@ -224,8 +235,8 @@ public Builder httpTransport(HttpTransport transport) {
             return this;
         }
 
-        public Builder jsonCodec(JsonCodec jsonCodec) {
-            this.jsonCodec = jsonCodec;
+        public Builder codec(Codec codec) {
+            this.codec = codec;
             return this;
         }
 
@@ -270,7 +281,7 @@ public Builder decorateWith(Function d
         /**
          * Builds the client, applying every {@link #decorateWith} decorator in the order added.
          *
-         * @throws IllegalStateException if no {@link HttpTransport} or {@link JsonCodec} is set or
+         * @throws IllegalStateException if no {@link HttpTransport} or {@link Codec} is set or
          *         discoverable, or if more than one {@link RetryingTypeSafeClient} was added (the
          *         attempts would multiply)
          */
@@ -292,7 +303,7 @@ public TypeSafeClient build() {
          */
         public  T build(Function decorate) {
             HttpTransport resolvedTransport = transport != null ? transport : loadDefaultHttpTransport();
-            JsonCodec resolvedCodec = jsonCodec != null ? jsonCodec : loadDefaultJsonCodec();
+            Codec resolvedCodec = codec != null ? codec : loadDefaultCodec();
             TypeSafeClient client = new DefaultTypeSafeClient(apiKey, resolvedTransport, resolvedCodec, endpoint);
             int retrying = 0;
             for (Function decorator : decorators) {
@@ -312,15 +323,15 @@ public  T build(Function decorate)
         private static HttpTransport loadDefaultHttpTransport() {
             return ServiceLoader.load(HttpTransport.class).findFirst()
                     .orElseThrow(() -> new IllegalStateException(
-                            "No HttpTransport found on the classpath. Add typesafe-java-client-jdk "
+                            "No HttpTransport found on the classpath. Add typesafe-java-client-http-jdk "
                                     + "as a dependency, or call Builder.httpTransport(...)."));
         }
 
-        private static JsonCodec loadDefaultJsonCodec() {
-            return ServiceLoader.load(JsonCodec.class).findFirst()
+        private static Codec loadDefaultCodec() {
+            return ServiceLoader.load(Codec.class).findFirst()
                     .orElseThrow(() -> new IllegalStateException(
-                            "No JsonCodec found on the classpath. Add typesafe-java-jackson2 or "
-                                    + "typesafe-java-jackson3 as a dependency, or call Builder.jsonCodec(...)."));
+                            "No Codec found on the classpath. Add typesafe-java-codec-jackson2 or "
+                                    + "typesafe-java-codec-jackson3 as a dependency, or call Builder.codec(...)."));
         }
     }
 }
diff --git a/core/src/main/java/io/github/dfa1/typesafe/transport/HttpTransport.java b/client-http/src/main/java/io/github/dfa1/typesafe/client/http/HttpTransport.java
similarity index 73%
rename from core/src/main/java/io/github/dfa1/typesafe/transport/HttpTransport.java
rename to client-http/src/main/java/io/github/dfa1/typesafe/client/http/HttpTransport.java
index 9435cf5..7c3e595 100644
--- a/core/src/main/java/io/github/dfa1/typesafe/transport/HttpTransport.java
+++ b/client-http/src/main/java/io/github/dfa1/typesafe/client/http/HttpTransport.java
@@ -1,4 +1,4 @@
-package io.github.dfa1.typesafe.transport;
+package io.github.dfa1.typesafe.client.http;
 
 import java.net.URI;
 import java.util.Map;
@@ -8,12 +8,13 @@
  * Sends the HTTP calls TypeSafeClient needs (a JSON POST for evaluate, a GET for listing
  * models) without tying it to a particular HTTP library. Every call is asynchronous;
  * {@code TypeSafeClient}'s synchronous methods block on the returned future. Implementations
- * are discovered via {@link java.util.ServiceLoader} (see typesafe-java-client-jdk) or
- * wired explicitly via {@code TypeSafeClient.builder().apiKey(...).httpTransport(...)}.
+ * are discovered via {@link java.util.ServiceLoader} (see typesafe-java-client-http-jdk) or
+ * wired explicitly via {@code DefaultTypeSafeClient.builder().apiKey(...).httpTransport(...)}.
  */
 public interface HttpTransport extends AutoCloseable {
 
-    CompletableFuture post(URI uri, Map headers, String body);
+    /** POSTs {@code body} (UTF-8 JSON) to {@code uri}. */
+    CompletableFuture post(URI uri, Map headers, byte[] body);
 
     CompletableFuture get(URI uri, Map headers);
 
diff --git a/core/src/main/java/io/github/dfa1/typesafe/transport/HttpTransportResponse.java b/client-http/src/main/java/io/github/dfa1/typesafe/client/http/HttpTransportResponse.java
similarity index 68%
rename from core/src/main/java/io/github/dfa1/typesafe/transport/HttpTransportResponse.java
rename to client-http/src/main/java/io/github/dfa1/typesafe/client/http/HttpTransportResponse.java
index a8e527b..9f03ecc 100644
--- a/core/src/main/java/io/github/dfa1/typesafe/transport/HttpTransportResponse.java
+++ b/client-http/src/main/java/io/github/dfa1/typesafe/client/http/HttpTransportResponse.java
@@ -1,9 +1,10 @@
-package io.github.dfa1.typesafe.transport;
+package io.github.dfa1.typesafe.client.http;
 
 import java.util.Map;
 import java.util.Optional;
 
-public record HttpTransportResponse(int statusCode, Map headers, String body) {
+/** An HTTP response; {@code body} is the raw bytes, decoded by the {@code Codec} without a {@code String} in between. */
+public record HttpTransportResponse(int statusCode, Map headers, byte[] body) {
 
     public HttpTransportResponse {
         headers = Map.copyOf(headers);
diff --git a/core/src/test/java/io/github/dfa1/typesafe/core/ApiKeyTest.java b/client-http/src/test/java/io/github/dfa1/typesafe/client/http/ApiKeyTest.java
similarity index 98%
rename from core/src/test/java/io/github/dfa1/typesafe/core/ApiKeyTest.java
rename to client-http/src/test/java/io/github/dfa1/typesafe/client/http/ApiKeyTest.java
index f2e2c1b..f0cd86e 100644
--- a/core/src/test/java/io/github/dfa1/typesafe/core/ApiKeyTest.java
+++ b/client-http/src/test/java/io/github/dfa1/typesafe/client/http/ApiKeyTest.java
@@ -1,4 +1,4 @@
-package io.github.dfa1.typesafe.core;
+package io.github.dfa1.typesafe.client.http;
 
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
diff --git a/core/src/test/java/io/github/dfa1/typesafe/core/TypeSafeClientTest.java b/client-http/src/test/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClientTest.java
similarity index 77%
rename from core/src/test/java/io/github/dfa1/typesafe/core/TypeSafeClientTest.java
rename to client-http/src/test/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClientTest.java
index 56ebb3d..cf9f6cf 100644
--- a/core/src/test/java/io/github/dfa1/typesafe/core/TypeSafeClientTest.java
+++ b/client-http/src/test/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClientTest.java
@@ -1,8 +1,18 @@
-package io.github.dfa1.typesafe.core;
-
-import io.github.dfa1.typesafe.json.JsonCodec;
-import io.github.dfa1.typesafe.transport.HttpTransport;
-import io.github.dfa1.typesafe.transport.HttpTransportResponse;
+package io.github.dfa1.typesafe.client.http;
+
+import java.nio.charset.StandardCharsets;
+import io.github.dfa1.typesafe.core.Content;
+import io.github.dfa1.typesafe.client.DeadlineTypeSafeClient;
+import io.github.dfa1.typesafe.core.EvaluateRequest;
+import io.github.dfa1.typesafe.core.EvaluateResponse;
+import io.github.dfa1.typesafe.core.Model;
+import io.github.dfa1.typesafe.core.ModelDetails;
+import io.github.dfa1.typesafe.core.RequestId;
+import io.github.dfa1.typesafe.client.RetryingTypeSafeClient;
+import io.github.dfa1.typesafe.client.TypeSafeClient;
+import io.github.dfa1.typesafe.client.TypeSafeException;
+import io.github.dfa1.typesafe.core.Usage;
+import io.github.dfa1.typesafe.codec.Codec;
 
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -31,7 +41,7 @@
 
 @SuppressWarnings("resource")
 @ExtendWith(MockitoExtension.class)
-class TypeSafeClientTest {
+class DefaultTypeSafeClientTest {
 
     private static final URI ENDPOINT = URI.create("https://example.test/systemone");
     private static final Duration NO_BACKOFF = Duration.ZERO;
@@ -40,37 +50,37 @@ class TypeSafeClientTest {
     private HttpTransport httpTransport;
 
     @Mock
-    private JsonCodec jsonCodec;
+    private Codec codec;
 
     @Test
-    void evaluateDelegatesToTheConfiguredHttpTransportAndJsonCodec() {
+    void evaluateDelegatesToTheConfiguredHttpTransportAndCodec() {
         // Given
-        TypeSafeClient sut = TypeSafeClient.builder().apiKey(ApiKey.of("secret"))
+        TypeSafeClient sut = DefaultTypeSafeClient.builder().apiKey(ApiKey.of("secret"))
                 .endpoint(ENDPOINT)
                 .httpTransport(httpTransport)
-                .jsonCodec(jsonCodec)
+                .codec(codec)
                 .build();
 
         EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of());
         Map expectedHeaders = Map.of(
                 "Authorization", "Bearer secret",
                 "Content-Type", "application/json");
-        String requestBody = "{\"request\":true}";
-        String responseBody = "{\"response\":true}";
+        byte[] requestBody = bytes("{\"request\":true}");
+        byte[] responseBody = bytes("{\"response\":true}");
         EvaluateResponse decodedResponse = new EvaluateResponse(Model.LATEST, Map.of(), new Usage(10, 5), null);
 
-        given(jsonCodec.writeValueAsString(request)).willReturn(requestBody);
+        given(codec.writeValueAsBytes(request)).willReturn(requestBody);
         given(httpTransport.post(ENDPOINT, expectedHeaders, requestBody))
                 .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(200, Map.of(), responseBody)));
-        given(jsonCodec.readValue(responseBody, EvaluateResponse.class)).willReturn(decodedResponse);
+        given(codec.readValue(responseBody, EvaluateResponse.class)).willReturn(decodedResponse);
 
         // When
         EvaluateResponse result = sut.evaluate(request);
 
         // Then
-        then(jsonCodec).should().writeValueAsString(request);
+        then(codec).should().writeValueAsBytes(request);
         then(httpTransport).should().post(ENDPOINT, expectedHeaders, requestBody);
-        then(jsonCodec).should().readValue(responseBody, EvaluateResponse.class);
+        then(codec).should().readValue(responseBody, EvaluateResponse.class);
         assertThat(result.model()).isEqualTo(Model.LATEST);
         assertThat(result.usage().inputTokens()).isEqualTo(10);
     }
@@ -78,22 +88,22 @@ void evaluateDelegatesToTheConfiguredHttpTransportAndJsonCodec() {
     @Test
     void withoutAnApiKeySendsNoAuthorizationHeader() {
         // Given
-        TypeSafeClient sut = TypeSafeClient.builder()
+        TypeSafeClient sut = DefaultTypeSafeClient.builder()
                 .endpoint(ENDPOINT)
                 .httpTransport(httpTransport)
-                .jsonCodec(jsonCodec)
+                .codec(codec)
                 .build();
 
         EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of());
         Map expectedHeaders = Map.of("Content-Type", "application/json");
-        String requestBody = "{\"request\":true}";
-        String responseBody = "{\"response\":true}";
+        byte[] requestBody = bytes("{\"request\":true}");
+        byte[] responseBody = bytes("{\"response\":true}");
         EvaluateResponse decodedResponse = new EvaluateResponse(Model.LATEST, Map.of(), new Usage(10, 0), null);
 
-        given(jsonCodec.writeValueAsString(request)).willReturn(requestBody);
+        given(codec.writeValueAsBytes(request)).willReturn(requestBody);
         given(httpTransport.post(ENDPOINT, expectedHeaders, requestBody))
                 .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(200, Map.of(), responseBody)));
-        given(jsonCodec.readValue(responseBody, EvaluateResponse.class)).willReturn(decodedResponse);
+        given(codec.readValue(responseBody, EvaluateResponse.class)).willReturn(decodedResponse);
 
         // When
         EvaluateResponse result = sut.evaluate(request);
@@ -104,12 +114,12 @@ void withoutAnApiKeySendsNoAuthorizationHeader() {
     }
 
     @Test
-    void listModelsDelegatesToTheConfiguredHttpTransportAndJsonCodec() {
+    void listModelsDelegatesToTheConfiguredHttpTransportAndCodec() {
         // Given
-        TypeSafeClient sut = TypeSafeClient.builder().apiKey(ApiKey.of("secret"))
+        TypeSafeClient sut = DefaultTypeSafeClient.builder().apiKey(ApiKey.of("secret"))
                 .endpoint(ENDPOINT)
                 .httpTransport(httpTransport)
-                .jsonCodec(jsonCodec)
+                .codec(codec)
                 .build();
 
         URI modelsEndpoint = URI.create("https://example.test/v1/models");
@@ -117,8 +127,8 @@ void listModelsDelegatesToTheConfiguredHttpTransportAndJsonCodec() {
         List models = List.of(new ModelDetails("jev-latest", "Most recent stable release.", "2026-01-01"));
 
         given(httpTransport.get(modelsEndpoint, expectedHeaders))
-                .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(200, Map.of(), "models-json")));
-        given(jsonCodec.readValue("models-json", DefaultTypeSafeClient.ModelsResponse.class))
+                .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(200, Map.of(), bytes("models-json"))));
+        given(codec.readValue(bytes("models-json"), DefaultTypeSafeClient.ModelsResponse.class))
                 .willReturn(new DefaultTypeSafeClient.ModelsResponse(models));
 
         // When
@@ -132,9 +142,9 @@ void listModelsDelegatesToTheConfiguredHttpTransportAndJsonCodec() {
     @Test
     void closeClosesTheUnderlyingHttpTransport() {
         // Given
-        TypeSafeClient sut = TypeSafeClient.builder().apiKey(ApiKey.of("secret"))
+        TypeSafeClient sut = DefaultTypeSafeClient.builder().apiKey(ApiKey.of("secret"))
                 .httpTransport(httpTransport)
-                .jsonCodec(jsonCodec)
+                .codec(codec)
                 .build();
 
         // When
@@ -150,9 +160,9 @@ void evaluateThrowsOnANonRetryableErrorStatus() {
         TypeSafeClient sut = clientWith(NO_BACKOFF);
         EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of());
 
-        given(jsonCodec.writeValueAsString(request)).willReturn("{}");
+        given(codec.writeValueAsBytes(request)).willReturn(bytes("{}"));
         given(httpTransport.post(any(), any(), any()))
-                .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(400, Map.of(), "bad request")));
+                .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(400, Map.of(), bytes("bad request"))));
 
         // When / Then
         assertThatThrownBy(() -> sut.evaluate(request))
@@ -170,9 +180,9 @@ void evaluateThrowsTheSubclassMatchingTheStatusCode() {
         TypeSafeClient sut = clientWith(NO_BACKOFF);
         EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of());
 
-        given(jsonCodec.writeValueAsString(request)).willReturn("{}");
+        given(codec.writeValueAsBytes(request)).willReturn(bytes("{}"));
         given(httpTransport.post(any(), any(), any()))
-                .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(401, Map.of(), "no token")));
+                .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(401, Map.of(), bytes("no token"))));
 
         // When / Then
         assertThatThrownBy(() -> sut.evaluate(request))
@@ -185,10 +195,10 @@ void evaluateThrowsRateLimitWithTheRetryAfterHeader() {
         TypeSafeClient sut = clientWith(NO_BACKOFF, 0);
         EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of());
 
-        given(jsonCodec.writeValueAsString(request)).willReturn("{}");
+        given(codec.writeValueAsBytes(request)).willReturn(bytes("{}"));
         given(httpTransport.post(any(), any(), any()))
                 .willReturn(CompletableFuture.completedFuture(
-                        new HttpTransportResponse(429, Map.of("retry-after-ms", "1500"), "slow down")));
+                        new HttpTransportResponse(429, Map.of("retry-after-ms", "1500"), bytes("slow down"))));
 
         // When / Then
         assertThatThrownBy(() -> sut.evaluate(request))
@@ -204,11 +214,11 @@ void evaluateRetriesOnRateLimitThenSucceeds() {
         EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of());
         EvaluateResponse decodedResponse = new EvaluateResponse(Model.LATEST, Map.of(), new Usage(1, 1), null);
 
-        given(jsonCodec.writeValueAsString(request)).willReturn("{}");
+        given(codec.writeValueAsBytes(request)).willReturn(bytes("{}"));
         given(httpTransport.post(any(), any(), any()))
-                .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(429, Map.of(), "slow down")))
-                .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(200, Map.of(), "ok")));
-        given(jsonCodec.readValue("ok", EvaluateResponse.class)).willReturn(decodedResponse);
+                .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(429, Map.of(), bytes("slow down"))))
+                .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(200, Map.of(), bytes("ok"))));
+        given(codec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse);
 
         // When
         EvaluateResponse result = sut.evaluate(request);
@@ -224,9 +234,9 @@ void evaluateThrowsAfterExhaustingRetries() {
         TypeSafeClient sut = clientWith(NO_BACKOFF, 1);
         EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of());
 
-        given(jsonCodec.writeValueAsString(request)).willReturn("{}");
+        given(codec.writeValueAsBytes(request)).willReturn(bytes("{}"));
         given(httpTransport.post(any(), any(), any()))
-                .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(529, Map.of(), "overloaded")));
+                .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(529, Map.of(), bytes("overloaded"))));
 
         // When / Then
         assertThatThrownBy(() -> sut.evaluate(request))
@@ -242,11 +252,11 @@ void evaluateRetriesOnAnyServerErrorStatus() {
         EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of());
         EvaluateResponse decodedResponse = new EvaluateResponse(Model.LATEST, Map.of(), new Usage(1, 1), null);
 
-        given(jsonCodec.writeValueAsString(request)).willReturn("{}");
+        given(codec.writeValueAsBytes(request)).willReturn(bytes("{}"));
         given(httpTransport.post(any(), any(), any()))
-                .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(503, Map.of(), "unavailable")))
-                .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(200, Map.of(), "ok")));
-        given(jsonCodec.readValue("ok", EvaluateResponse.class)).willReturn(decodedResponse);
+                .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(503, Map.of(), bytes("unavailable"))))
+                .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(200, Map.of(), bytes("ok"))));
+        given(codec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse);
 
         // When
         EvaluateResponse result = sut.evaluate(request);
@@ -262,11 +272,11 @@ void evaluateRetriesOnRequestTimeoutStatus() {
         EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of());
         EvaluateResponse decodedResponse = new EvaluateResponse(Model.LATEST, Map.of(), new Usage(1, 1), null);
 
-        given(jsonCodec.writeValueAsString(request)).willReturn("{}");
+        given(codec.writeValueAsBytes(request)).willReturn(bytes("{}"));
         given(httpTransport.post(any(), any(), any()))
-                .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(408, Map.of(), "request timeout")))
-                .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(200, Map.of(), "ok")));
-        given(jsonCodec.readValue("ok", EvaluateResponse.class)).willReturn(decodedResponse);
+                .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(408, Map.of(), bytes("request timeout"))))
+                .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(200, Map.of(), bytes("ok"))));
+        given(codec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse);
 
         // When
         EvaluateResponse result = sut.evaluate(request);
@@ -282,11 +292,11 @@ void evaluateRetriesOnAConnectionFailureThenSucceeds() {
         EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of());
         EvaluateResponse decodedResponse = new EvaluateResponse(Model.LATEST, Map.of(), new Usage(1, 1), null);
 
-        given(jsonCodec.writeValueAsString(request)).willReturn("{}");
+        given(codec.writeValueAsBytes(request)).willReturn(bytes("{}"));
         given(httpTransport.post(any(), any(), any()))
                 .willReturn(CompletableFuture.failedFuture(new IOException("connection reset")))
-                .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(200, Map.of(), "ok")));
-        given(jsonCodec.readValue("ok", EvaluateResponse.class)).willReturn(decodedResponse);
+                .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(200, Map.of(), bytes("ok"))));
+        given(codec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse);
 
         // When
         EvaluateResponse result = sut.evaluate(request);
@@ -303,7 +313,7 @@ void evaluateThrowsAfterExhaustingRetriesOnAConnectionFailure() {
         EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of());
         IOException connectionFailure = new IOException("connection reset");
 
-        given(jsonCodec.writeValueAsString(request)).willReturn("{}");
+        given(codec.writeValueAsBytes(request)).willReturn(bytes("{}"));
         given(httpTransport.post(any(), any(), any())).willReturn(CompletableFuture.failedFuture(connectionFailure));
 
         // When / Then
@@ -320,7 +330,7 @@ void evaluateThrowsTypeSafeTimeoutExceptionAfterExhaustingRetriesOnATimeout() {
         EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of());
         HttpTimeoutException timeout = new HttpTimeoutException("request timed out");
 
-        given(jsonCodec.writeValueAsString(request)).willReturn("{}");
+        given(codec.writeValueAsBytes(request)).willReturn(bytes("{}"));
         given(httpTransport.post(any(), any(), any())).willReturn(CompletableFuture.failedFuture(timeout));
 
         // When / Then
@@ -336,7 +346,7 @@ void evaluateThrowsTypeSafeInterruptedAndRestoresTheInterruptFlagWhenTheCallingT
         EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of());
         CompletableFuture neverCompletes = new CompletableFuture<>();
 
-        given(jsonCodec.writeValueAsString(request)).willReturn("{}");
+        given(codec.writeValueAsBytes(request)).willReturn(bytes("{}"));
         given(httpTransport.post(any(), any(), any())).willReturn(neverCompletes);
 
         AtomicReference thrown = new AtomicReference<>();
@@ -371,7 +381,7 @@ void evaluateRethrowsAnErrorFromTheTransportWithoutRetrying() {
         EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of());
         Error transportError = new StackOverflowError("boom");
 
-        given(jsonCodec.writeValueAsString(request)).willReturn("{}");
+        given(codec.writeValueAsBytes(request)).willReturn(bytes("{}"));
         given(httpTransport.post(any(), any(), any())).willReturn(CompletableFuture.failedFuture(transportError));
 
         // When / Then
@@ -389,10 +399,10 @@ void evaluatePopulatesMetadataFromResponseHeaders() {
                 "x-typesafe-request-id", "req_123",
                 "x-envoy-upstream-service-time", "42");
 
-        given(jsonCodec.writeValueAsString(request)).willReturn("{}");
+        given(codec.writeValueAsBytes(request)).willReturn(bytes("{}"));
         given(httpTransport.post(any(), any(), any()))
-                .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(200, responseHeaders, "ok")));
-        given(jsonCodec.readValue("ok", EvaluateResponse.class)).willReturn(decodedResponse);
+                .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(200, responseHeaders, bytes("ok"))));
+        given(codec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse);
 
         // When
         EvaluateResponse result = sut.evaluate(request);
@@ -409,11 +419,11 @@ void evaluateAsyncSucceeds() {
         EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of());
         EvaluateResponse decodedResponse = new EvaluateResponse(Model.LATEST, Map.of(), new Usage(1, 1), null);
 
-        given(jsonCodec.writeValueAsString(request)).willReturn("{}");
+        given(codec.writeValueAsBytes(request)).willReturn(bytes("{}"));
         given(httpTransport.post(any(), any(), any()))
                 .willReturn(CompletableFuture.completedFuture(
-                        new HttpTransportResponse(200, Map.of(), "ok")));
-        given(jsonCodec.readValue("ok", EvaluateResponse.class)).willReturn(decodedResponse);
+                        new HttpTransportResponse(200, Map.of(), bytes("ok"))));
+        given(codec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse);
 
         // When
         EvaluateResponse result = sut.evaluateAsync(request).join();
@@ -429,13 +439,13 @@ void evaluateAsyncRetriesOnRateLimitThenSucceeds() {
         EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of());
         EvaluateResponse decodedResponse = new EvaluateResponse(Model.LATEST, Map.of(), new Usage(1, 1), null);
 
-        given(jsonCodec.writeValueAsString(request)).willReturn("{}");
+        given(codec.writeValueAsBytes(request)).willReturn(bytes("{}"));
         given(httpTransport.post(any(), any(), any()))
                 .willReturn(CompletableFuture.completedFuture(
-                        new HttpTransportResponse(429, Map.of(), "slow down")))
+                        new HttpTransportResponse(429, Map.of(), bytes("slow down"))))
                 .willReturn(CompletableFuture.completedFuture(
-                        new HttpTransportResponse(200, Map.of(), "ok")));
-        given(jsonCodec.readValue("ok", EvaluateResponse.class)).willReturn(decodedResponse);
+                        new HttpTransportResponse(200, Map.of(), bytes("ok"))));
+        given(codec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse);
 
         // When
         EvaluateResponse result = sut.evaluateAsync(request).join();
@@ -451,10 +461,10 @@ void evaluateAsyncFailsAfterExhaustingRetries() {
         TypeSafeClient sut = clientWith(NO_BACKOFF, 1);
         EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of());
 
-        given(jsonCodec.writeValueAsString(request)).willReturn("{}");
+        given(codec.writeValueAsBytes(request)).willReturn(bytes("{}"));
         given(httpTransport.post(any(), any(), any()))
                 .willReturn(CompletableFuture.completedFuture(
-                        new HttpTransportResponse(529, Map.of(), "overloaded")));
+                        new HttpTransportResponse(529, Map.of(), bytes("overloaded"))));
 
         // When / Then
         assertThatThrownBy(() -> sut.evaluateAsync(request).get())
@@ -469,11 +479,11 @@ void evaluateAsyncRetriesOnAConnectionFailureThenSucceeds() {
         EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of());
         EvaluateResponse decodedResponse = new EvaluateResponse(Model.LATEST, Map.of(), new Usage(1, 1), null);
 
-        given(jsonCodec.writeValueAsString(request)).willReturn("{}");
+        given(codec.writeValueAsBytes(request)).willReturn(bytes("{}"));
         given(httpTransport.post(any(), any(), any()))
                 .willReturn(CompletableFuture.failedFuture(new IOException("connection reset")))
-                .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(200, Map.of(), "ok")));
-        given(jsonCodec.readValue("ok", EvaluateResponse.class)).willReturn(decodedResponse);
+                .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(200, Map.of(), bytes("ok"))));
+        given(codec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse);
 
         // When
         EvaluateResponse result = sut.evaluateAsync(request).join();
@@ -490,7 +500,7 @@ void evaluateAsyncFailsAfterExhaustingRetriesOnAConnectionFailure() {
         EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of());
         IOException connectionFailure = new IOException("connection reset");
 
-        given(jsonCodec.writeValueAsString(request)).willReturn("{}");
+        given(codec.writeValueAsBytes(request)).willReturn(bytes("{}"));
         given(httpTransport.post(any(), any(), any()))
                 .willReturn(CompletableFuture.failedFuture(connectionFailure));
 
@@ -509,7 +519,7 @@ void evaluateAsyncFailsFastWhenEncodingTheRequestThrows() {
         EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of());
         RuntimeException encodingFailure = new RuntimeException("boom");
 
-        given(jsonCodec.writeValueAsString(request)).willThrow(encodingFailure);
+        given(codec.writeValueAsBytes(request)).willThrow(encodingFailure);
 
         // When / Then
         assertThatThrownBy(() -> sut.evaluateAsync(request).get())
@@ -524,11 +534,11 @@ void evaluateAsyncFailsFastWhenDecodingTheResponseThrows() {
         EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of());
         RuntimeException decodingFailure = new RuntimeException("boom");
 
-        given(jsonCodec.writeValueAsString(request)).willReturn("{}");
+        given(codec.writeValueAsBytes(request)).willReturn(bytes("{}"));
         given(httpTransport.post(any(), any(), any()))
                 .willReturn(CompletableFuture.completedFuture(
-                        new HttpTransportResponse(200, Map.of(), "not json")));
-        given(jsonCodec.readValue("not json", EvaluateResponse.class)).willThrow(decodingFailure);
+                        new HttpTransportResponse(200, Map.of(), bytes("not json"))));
+        given(codec.readValue(bytes("not json"), EvaluateResponse.class)).willThrow(decodingFailure);
 
         // When / Then
         assertThatThrownBy(() -> sut.evaluateAsync(request).get())
@@ -540,7 +550,7 @@ void evaluateAsyncFailsFastWhenDecodingTheResponseThrows() {
     @Test
     void builderThrowsWhenNoHttpTransportIsConfiguredOrDiscoverable() {
         // Given
-        DefaultTypeSafeClient.Builder sut = TypeSafeClient.builder().apiKey(ApiKey.of("secret")).jsonCodec(jsonCodec);
+        DefaultTypeSafeClient.Builder sut = DefaultTypeSafeClient.builder().apiKey(ApiKey.of("secret")).codec(codec);
 
         // When / Then
         assertThatThrownBy(sut::build)
@@ -549,22 +559,22 @@ void builderThrowsWhenNoHttpTransportIsConfiguredOrDiscoverable() {
     }
 
     @Test
-    void builderThrowsWhenNoJsonCodecIsConfiguredOrDiscoverable() {
+    void builderThrowsWhenNoCodecIsConfiguredOrDiscoverable() {
         // Given
-        DefaultTypeSafeClient.Builder sut = TypeSafeClient.builder().apiKey(ApiKey.of("secret")).httpTransport(httpTransport);
+        DefaultTypeSafeClient.Builder sut = DefaultTypeSafeClient.builder().apiKey(ApiKey.of("secret")).httpTransport(httpTransport);
 
         // When / Then
         assertThatThrownBy(sut::build)
                 .isInstanceOf(IllegalStateException.class)
-                .hasMessageContaining("JsonCodec");
+                .hasMessageContaining("Codec");
     }
 
     @Test
     void buildWithADecoratorAppliesItToTheBuiltClient() {
         // Given
-        DefaultTypeSafeClient.Builder sut = TypeSafeClient.builder().apiKey(ApiKey.of("secret"))
+        DefaultTypeSafeClient.Builder sut = DefaultTypeSafeClient.builder().apiKey(ApiKey.of("secret"))
                 .httpTransport(httpTransport)
-                .jsonCodec(jsonCodec);
+                .codec(codec);
 
         // When
         WrappingTypeSafeClient result = sut.build(WrappingTypeSafeClient::new);
@@ -576,9 +586,9 @@ void buildWithADecoratorAppliesItToTheBuiltClient() {
     @Test
     void buildWrapsTheClientInAWrapper() {
         // Given
-        DefaultTypeSafeClient.Builder sut = TypeSafeClient.builder().apiKey(ApiKey.of("secret"))
+        DefaultTypeSafeClient.Builder sut = DefaultTypeSafeClient.builder().apiKey(ApiKey.of("secret"))
                 .httpTransport(httpTransport)
-                .jsonCodec(jsonCodec)
+                .codec(codec)
                 .decorateWith(WrappingTypeSafeClient::new);
 
         // When
@@ -592,9 +602,9 @@ void buildWrapsTheClientInAWrapper() {
     @Test
     void buildAppliesDecoratorsInCallOrderWithTheBuildDecoratorOutermost() {
         // Given
-        DefaultTypeSafeClient.Builder sut = TypeSafeClient.builder().apiKey(ApiKey.of("secret"))
+        DefaultTypeSafeClient.Builder sut = DefaultTypeSafeClient.builder().apiKey(ApiKey.of("secret"))
                 .httpTransport(httpTransport)
-                .jsonCodec(jsonCodec)
+                .codec(codec)
                 .decorateWith(WrappingTypeSafeClient::new)
                 .decorateWith(c -> DeadlineTypeSafeClient.decorate(c, Duration.ofSeconds(1)));
 
@@ -608,9 +618,9 @@ void buildAppliesDecoratorsInCallOrderWithTheBuildDecoratorOutermost() {
     @Test
     void buildThrowsWhenRetryingIsWrappedTwice() {
         // Given
-        DefaultTypeSafeClient.Builder sut = TypeSafeClient.builder().apiKey(ApiKey.of("secret"))
+        DefaultTypeSafeClient.Builder sut = DefaultTypeSafeClient.builder().apiKey(ApiKey.of("secret"))
                 .httpTransport(httpTransport)
-                .jsonCodec(jsonCodec)
+                .codec(codec)
                 .decorateWith(RetryingTypeSafeClient::decorate)
                 .decorateWith(WrappingTypeSafeClient::new)
                 .decorateWith(RetryingTypeSafeClient::decorate);
@@ -625,9 +635,9 @@ void buildThrowsWhenRetryingIsWrappedTwice() {
     @Test
     void buildThrowsWhenRetryingIsBothWrappedAndTheBuildDecorator() {
         // Given
-        DefaultTypeSafeClient.Builder sut = TypeSafeClient.builder().apiKey(ApiKey.of("secret"))
+        DefaultTypeSafeClient.Builder sut = DefaultTypeSafeClient.builder().apiKey(ApiKey.of("secret"))
                 .httpTransport(httpTransport)
-                .jsonCodec(jsonCodec)
+                .codec(codec)
                 .decorateWith(RetryingTypeSafeClient::decorate);
 
         // When / Then
@@ -638,9 +648,9 @@ void buildThrowsWhenRetryingIsBothWrappedAndTheBuildDecorator() {
     @Test
     void buildWithoutWrappersReturnsAPlainDefaultTypeSafeClient() {
         // Given
-        DefaultTypeSafeClient.Builder sut = TypeSafeClient.builder().apiKey(ApiKey.of("secret"))
+        DefaultTypeSafeClient.Builder sut = DefaultTypeSafeClient.builder().apiKey(ApiKey.of("secret"))
                 .httpTransport(httpTransport)
-                .jsonCodec(jsonCodec);
+                .codec(codec);
 
         // When
         TypeSafeClient result = sut.build();
@@ -655,10 +665,10 @@ void evaluateThrowsInternalServerWithTheRetryAfterHeader() {
         TypeSafeClient sut = clientWith(NO_BACKOFF, 0);
         EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of());
 
-        given(jsonCodec.writeValueAsString(request)).willReturn("{}");
+        given(codec.writeValueAsBytes(request)).willReturn(bytes("{}"));
         given(httpTransport.post(any(), any(), any()))
                 .willReturn(CompletableFuture.completedFuture(
-                        new HttpTransportResponse(503, Map.of("retry-after", "3"), "unavailable")));
+                        new HttpTransportResponse(503, Map.of("retry-after", "3"), bytes("unavailable"))));
 
         // When / Then
         assertThatThrownBy(() -> sut.evaluate(request))
@@ -670,9 +680,9 @@ void evaluateThrowsInternalServerWithTheRetryAfterHeader() {
     @Test
     void buildComposesMultipleDecoratorsViaFunctionAndThen() {
         // Given
-        DefaultTypeSafeClient.Builder sut = TypeSafeClient.builder().apiKey(ApiKey.of("secret"))
+        DefaultTypeSafeClient.Builder sut = DefaultTypeSafeClient.builder().apiKey(ApiKey.of("secret"))
                 .httpTransport(httpTransport)
-                .jsonCodec(jsonCodec);
+                .codec(codec);
         Function innermost = WrappingTypeSafeClient::new;
         Function both = innermost.andThen(WrappingTypeSafeClient::new);
 
@@ -717,7 +727,7 @@ public void close() {
     @Test
     void retryAfterParsesSeconds() {
         // Given
-        HttpTransportResponse response = new HttpTransportResponse(429, Map.of("retry-after", "2"), "");
+        HttpTransportResponse response = new HttpTransportResponse(429, Map.of("retry-after", "2"), bytes(""));
 
         // When
         Optional result = DefaultTypeSafeClient.retryAfter(response);
@@ -730,7 +740,7 @@ void retryAfterParsesSeconds() {
     void retryAfterPrefersMillisecondsOverSeconds() {
         // Given
         HttpTransportResponse response =
-                new HttpTransportResponse(429, Map.of("retry-after-ms", "250", "retry-after", "5"), "");
+                new HttpTransportResponse(429, Map.of("retry-after-ms", "250", "retry-after", "5"), bytes(""));
 
         // When
         Optional result = DefaultTypeSafeClient.retryAfter(response);
@@ -743,9 +753,9 @@ void retryAfterPrefersMillisecondsOverSeconds() {
     void retryAfterIsEmptyWhenTheHeaderIsAnHttpDateOrAbsent() {
         // When / Then
         assertThat(DefaultTypeSafeClient.retryAfter(
-                new HttpTransportResponse(429, Map.of("retry-after", "Wed, 21 Oct 2026 07:28:00 GMT"), "")))
+                new HttpTransportResponse(429, Map.of("retry-after", "Wed, 21 Oct 2026 07:28:00 GMT"), bytes(""))))
                 .isEmpty();
-        assertThat(DefaultTypeSafeClient.retryAfter(new HttpTransportResponse(429, Map.of(), ""))).isEmpty();
+        assertThat(DefaultTypeSafeClient.retryAfter(new HttpTransportResponse(429, Map.of(), bytes("")))).isEmpty();
     }
 
     private TypeSafeClient clientWith(Duration backoff) {
@@ -753,11 +763,15 @@ private TypeSafeClient clientWith(Duration backoff) {
     }
 
     private TypeSafeClient clientWith(Duration backoff, int maxRetries) {
-        return TypeSafeClient.builder().apiKey(ApiKey.of("secret"))
+        return DefaultTypeSafeClient.builder().apiKey(ApiKey.of("secret"))
                 .endpoint(ENDPOINT)
                 .httpTransport(httpTransport)
-                .jsonCodec(jsonCodec)
+                .codec(codec)
                 .decorateWith(c -> RetryingTypeSafeClient.decorate(c, maxRetries, backoff))
                 .build();
     }
+
+    private static byte[] bytes(String text) {
+        return text.getBytes(StandardCharsets.UTF_8);
+    }
 }
diff --git a/core/src/test/java/io/github/dfa1/typesafe/transport/HttpTransportResponseTest.java b/client-http/src/test/java/io/github/dfa1/typesafe/client/http/HttpTransportResponseTest.java
similarity index 78%
rename from core/src/test/java/io/github/dfa1/typesafe/transport/HttpTransportResponseTest.java
rename to client-http/src/test/java/io/github/dfa1/typesafe/client/http/HttpTransportResponseTest.java
index 1b9919e..c32b4fb 100644
--- a/core/src/test/java/io/github/dfa1/typesafe/transport/HttpTransportResponseTest.java
+++ b/client-http/src/test/java/io/github/dfa1/typesafe/client/http/HttpTransportResponseTest.java
@@ -1,5 +1,6 @@
-package io.github.dfa1.typesafe.transport;
+package io.github.dfa1.typesafe.client.http;
 
+import java.nio.charset.StandardCharsets;
 import org.junit.jupiter.api.Test;
 
 import java.util.HashMap;
@@ -13,7 +14,7 @@ class HttpTransportResponseTest {
     @Test
     void headerLooksUpCaseInsensitively() {
         // Given
-        HttpTransportResponse sut = new HttpTransportResponse(200, Map.of("X-Request-Id", "abc"), "body");
+        HttpTransportResponse sut = new HttpTransportResponse(200, Map.of("X-Request-Id", "abc"), bytes("body"));
 
         // When
         var result = sut.header("x-request-id");
@@ -26,7 +27,7 @@ void headerLooksUpCaseInsensitively() {
     void copiesHeadersDefensivelySoLaterMutationIsNotVisible() {
         // Given
         Map headers = new HashMap<>(Map.of("X-A", "1"));
-        HttpTransportResponse sut = new HttpTransportResponse(200, headers, "body");
+        HttpTransportResponse sut = new HttpTransportResponse(200, headers, bytes("body"));
 
         // When
         headers.put("X-A", "mutated");
@@ -38,10 +39,14 @@ void copiesHeadersDefensivelySoLaterMutationIsNotVisible() {
     @Test
     void headersAreImmutable() {
         // Given
-        HttpTransportResponse sut = new HttpTransportResponse(200, Map.of("X-A", "1"), "body");
+        HttpTransportResponse sut = new HttpTransportResponse(200, Map.of("X-A", "1"), bytes("body"));
 
         // When / Then
         assertThatThrownBy(() -> sut.headers().put("X-B", "2"))
                 .isInstanceOf(UnsupportedOperationException.class);
     }
+
+    private static byte[] bytes(String text) {
+        return text.getBytes(StandardCharsets.UTF_8);
+    }
 }
diff --git a/client-jdk/src/main/resources/META-INF/services/io.github.dfa1.typesafe.transport.HttpTransport b/client-jdk/src/main/resources/META-INF/services/io.github.dfa1.typesafe.transport.HttpTransport
deleted file mode 100644
index 49c287f..0000000
--- a/client-jdk/src/main/resources/META-INF/services/io.github.dfa1.typesafe.transport.HttpTransport
+++ /dev/null
@@ -1 +0,0 @@
-io.github.dfa1.typesafe.jdk.JdkHttpTransport
diff --git a/local/pom.xml b/client-local/pom.xml
similarity index 78%
rename from local/pom.xml
rename to client-local/pom.xml
index 4e47752..2356fe2 100644
--- a/local/pom.xml
+++ b/client-local/pom.xml
@@ -7,21 +7,21 @@
     0.7.0-SNAPSHOT
   
 
-  typesafe-java-local
+  typesafe-java-client-local
   jar
 
-  TypeSafe Local
+  TypeSafe Client Local
   A TypeSafeClient that evaluates in-process on ONNX Runtime instead of calling the
     TypeSafe API: Laya (a ModernBERT decision model), Clef-flash (Qwen3.5-9B + joint schema head) or
     Qwen2.5 (next-token logits), read from a
     local model directory. API parity, not model parity with Jev. Depends on typesafe-java-core and
-    ONNX Runtime, plus a JsonCodec module (jackson2 or jackson3) at run time, like the API client.
+    ONNX Runtime, plus a Codec module (codec-jackson2 or codec-jackson3) at run time, like the API client.
   
 
   
     1.30.0
     1.37
-    
     acceptance,model
   
@@ -29,7 +29,12 @@
   
     
       io.github.dfa1.typesafe-java
-      typesafe-java-core
+      typesafe-java-client
+      ${project.version}
+    
+    
+      io.github.dfa1.typesafe-java
+      typesafe-java-codec
       ${project.version}
     
     
@@ -57,13 +62,19 @@
     
     
       io.github.dfa1.typesafe-java
-      typesafe-java-client-jdk
+      typesafe-java-client-http
+      ${project.version}
+      test
+    
+    
+      io.github.dfa1.typesafe-java
+      typesafe-java-client-http-jdk
       ${project.version}
       test
     
     
       io.github.dfa1.typesafe-java
-      typesafe-java-jackson2
+      typesafe-java-codec-jackson2
       ${project.version}
       test
     
diff --git a/local/scripts/clef/quantize_q4.py b/client-local/scripts/clef/quantize_q4.py
similarity index 100%
rename from local/scripts/clef/quantize_q4.py
rename to client-local/scripts/clef/quantize_q4.py
diff --git a/local/scripts/clef/reference.py b/client-local/scripts/clef/reference.py
similarity index 100%
rename from local/scripts/clef/reference.py
rename to client-local/scripts/clef/reference.py
diff --git a/local/scripts/laya/export_onnx.py b/client-local/scripts/laya/export_onnx.py
similarity index 100%
rename from local/scripts/laya/export_onnx.py
rename to client-local/scripts/laya/export_onnx.py
diff --git a/local/scripts/tokenizer/reference.py b/client-local/scripts/tokenizer/reference.py
similarity index 100%
rename from local/scripts/tokenizer/reference.py
rename to client-local/scripts/tokenizer/reference.py
diff --git a/local/src/main/java/io/github/dfa1/typesafe/local/BpeTokenizer.java b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/BpeTokenizer.java
similarity index 98%
rename from local/src/main/java/io/github/dfa1/typesafe/local/BpeTokenizer.java
rename to client-local/src/main/java/io/github/dfa1/typesafe/client/local/BpeTokenizer.java
index e23d743..89feac8 100644
--- a/local/src/main/java/io/github/dfa1/typesafe/local/BpeTokenizer.java
+++ b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/BpeTokenizer.java
@@ -1,4 +1,4 @@
-package io.github.dfa1.typesafe.local;
+package io.github.dfa1.typesafe.client.local;
 
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
@@ -66,7 +66,7 @@ private static Pattern split(List added) {
 
     @SuppressWarnings("unchecked")
     static BpeTokenizer load(Path tokenizerJson) throws IOException {
-        Map root = Onnx.json().readValue(Files.readString(tokenizerJson), Map.class);
+        Map root = Onnx.codec().readValue(Files.readAllBytes(tokenizerJson), Map.class);
         Map normalizer = (Map) root.get("normalizer");
         require(normalizer == null || "NFC".equals(normalizer.get("type")), "normalizer " + normalizer);
 
diff --git a/local/src/main/java/io/github/dfa1/typesafe/local/ClefEngine.java b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/ClefEngine.java
similarity index 98%
rename from local/src/main/java/io/github/dfa1/typesafe/local/ClefEngine.java
rename to client-local/src/main/java/io/github/dfa1/typesafe/client/local/ClefEngine.java
index fec2b49..45ffd4e 100644
--- a/local/src/main/java/io/github/dfa1/typesafe/local/ClefEngine.java
+++ b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/ClefEngine.java
@@ -1,4 +1,4 @@
-package io.github.dfa1.typesafe.local;
+package io.github.dfa1.typesafe.client.local;
 
 import ai.onnxruntime.OnnxTensor;
 import ai.onnxruntime.OrtException;
@@ -7,7 +7,7 @@
 import io.github.dfa1.typesafe.core.Content;
 import io.github.dfa1.typesafe.core.Model;
 import io.github.dfa1.typesafe.core.Question;
-import io.github.dfa1.typesafe.core.TypeSafeException;
+import io.github.dfa1.typesafe.client.TypeSafeException;
 
 import java.io.IOException;
 import java.io.InputStream;
@@ -232,8 +232,8 @@ private static Content instructions(Question question) {
     static String render(Content content) {
         return switch (content) {
             case Content.Text(String value) -> value;
-            case Content.Fields(Map fields) -> Onnx.json().writeValueAsString(sorted(fields));
-            case Content.Messages(List values) -> Onnx.json().writeValueAsString(values);
+            case Content.Fields(Map fields) -> Onnx.toJson(sorted(fields));
+            case Content.Messages(List values) -> Onnx.toJson(values);
         };
     }
 
diff --git a/local/src/main/java/io/github/dfa1/typesafe/local/Engine.java b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Engine.java
similarity index 81%
rename from local/src/main/java/io/github/dfa1/typesafe/local/Engine.java
rename to client-local/src/main/java/io/github/dfa1/typesafe/client/local/Engine.java
index 15e7d9b..afcdd57 100644
--- a/local/src/main/java/io/github/dfa1/typesafe/local/Engine.java
+++ b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Engine.java
@@ -1,4 +1,4 @@
-package io.github.dfa1.typesafe.local;
+package io.github.dfa1.typesafe.client.local;
 
 import ai.onnxruntime.OrtException;
 import io.github.dfa1.typesafe.core.Answer;
@@ -15,7 +15,7 @@ interface Engine extends AutoCloseable {
     record Answers(Map answers, int inputTokens) {
     }
 
-    /** @throws io.github.dfa1.typesafe.core.TypeSafeException.BadRequest for a question this engine can't express */
+    /** @throws io.github.dfa1.typesafe.client.TypeSafeException.BadRequest for a question this engine can't express */
     Answers answer(Content state, Map questions) throws OrtException;
 
     Model model();
diff --git a/local/src/main/java/io/github/dfa1/typesafe/local/LayaEngine.java b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LayaEngine.java
similarity index 96%
rename from local/src/main/java/io/github/dfa1/typesafe/local/LayaEngine.java
rename to client-local/src/main/java/io/github/dfa1/typesafe/client/local/LayaEngine.java
index 04a6125..6ce7720 100644
--- a/local/src/main/java/io/github/dfa1/typesafe/local/LayaEngine.java
+++ b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LayaEngine.java
@@ -1,4 +1,4 @@
-package io.github.dfa1.typesafe.local;
+package io.github.dfa1.typesafe.client.local;
 
 import ai.onnxruntime.OnnxTensor;
 import ai.onnxruntime.OrtException;
@@ -7,7 +7,7 @@
 import io.github.dfa1.typesafe.core.Content;
 import io.github.dfa1.typesafe.core.Model;
 import io.github.dfa1.typesafe.core.Question;
-import io.github.dfa1.typesafe.core.TypeSafeException;
+import io.github.dfa1.typesafe.client.TypeSafeException;
 
 import java.io.IOException;
 import java.nio.FloatBuffer;
@@ -84,7 +84,7 @@ record Sequence(long[] ids, int[] markers, int qtype) {
     /** Sequence and calibration settings: the "laya" section of onnx-community's config.json (Laya's rl_agent_config.json values). */
     @SuppressWarnings("unchecked")
     private static Map config(Path dir) throws IOException {
-        Map config = Onnx.json().readValue(Files.readString(Onnx.require(dir, "config.json")), Map.class);
+        Map config = Onnx.codec().readValue(Files.readAllBytes(Onnx.require(dir, "config.json")), Map.class);
         Object laya = config.get("laya");
         if (!(laya instanceof Map)) {
             throw new IllegalArgumentException(dir.resolve("config.json") + " has no \"laya\" section");
@@ -271,12 +271,12 @@ private static String orDefault(String value, String fallback) {
     }
 
     /** rl_common.serialize_state: text as is, anything else as JSON. Laya was trained on Python's json.dumps spacing;
-     *  JsonCodec's compact form measured no different against Jev (104 requests), so it isn't reproduced. */
+     *  Codec's compact form measured no different against Jev (104 requests), so it isn't reproduced. */
     static String serialize(Content content) {
         return switch (content) {
             case Content.Text(String value) -> value;
-            case Content.Fields(Map fields) -> Onnx.json().writeValueAsString(fields);
-            case Content.Messages(List values) -> Onnx.json().writeValueAsString(values);
+            case Content.Fields(Map fields) -> Onnx.toJson(fields);
+            case Content.Messages(List values) -> Onnx.toJson(values);
         };
     }
 
diff --git a/local/src/main/java/io/github/dfa1/typesafe/local/LocalClefTypeSafeClient.java b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LocalClefTypeSafeClient.java
similarity index 91%
rename from local/src/main/java/io/github/dfa1/typesafe/local/LocalClefTypeSafeClient.java
rename to client-local/src/main/java/io/github/dfa1/typesafe/client/local/LocalClefTypeSafeClient.java
index 0670c2b..c3e30bc 100644
--- a/local/src/main/java/io/github/dfa1/typesafe/local/LocalClefTypeSafeClient.java
+++ b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LocalClefTypeSafeClient.java
@@ -1,11 +1,11 @@
-package io.github.dfa1.typesafe.local;
+package io.github.dfa1.typesafe.client.local;
 
 import java.nio.file.Path;
 
 /**
  * Clef-flash (Cloudflare/clef-flash, Apache-2.0), in-process on ONNX Runtime: Qwen3.5-9B with a joint schema head,
  * every question of a request decided in one forward pass. The closest to Jev, and by far the heaviest: convert it
- * to 4-bit weights first ({@code local/scripts/clef/quantize_q4.py}). On a Mac, the same model is faster through MLX
+ * to 4-bit weights first ({@code client-local/scripts/clef/quantize_q4.py}). On a Mac, the same model is faster through MLX
  * with the regular client: see the how-to.
  *
  * 

Same request in, same response out as {@code api.typesafe.ai}, so everything built on typesafe-java diff --git a/local/src/main/java/io/github/dfa1/typesafe/local/LocalLayaTypeSafeClient.java b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LocalLayaTypeSafeClient.java similarity index 96% rename from local/src/main/java/io/github/dfa1/typesafe/local/LocalLayaTypeSafeClient.java rename to client-local/src/main/java/io/github/dfa1/typesafe/client/local/LocalLayaTypeSafeClient.java index e371d70..bbe8df8 100644 --- a/local/src/main/java/io/github/dfa1/typesafe/local/LocalLayaTypeSafeClient.java +++ b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LocalLayaTypeSafeClient.java @@ -1,4 +1,4 @@ -package io.github.dfa1.typesafe.local; +package io.github.dfa1.typesafe.client.local; import java.nio.file.Path; diff --git a/local/src/main/java/io/github/dfa1/typesafe/local/LocalQwenTypeSafeClient.java b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LocalQwenTypeSafeClient.java similarity index 96% rename from local/src/main/java/io/github/dfa1/typesafe/local/LocalQwenTypeSafeClient.java rename to client-local/src/main/java/io/github/dfa1/typesafe/client/local/LocalQwenTypeSafeClient.java index 2dfb4da..3a47125 100644 --- a/local/src/main/java/io/github/dfa1/typesafe/local/LocalQwenTypeSafeClient.java +++ b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LocalQwenTypeSafeClient.java @@ -1,4 +1,4 @@ -package io.github.dfa1.typesafe.local; +package io.github.dfa1.typesafe.client.local; import java.nio.file.Path; diff --git a/local/src/main/java/io/github/dfa1/typesafe/local/LocalTypeSafeClient.java b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LocalTypeSafeClient.java similarity index 96% rename from local/src/main/java/io/github/dfa1/typesafe/local/LocalTypeSafeClient.java rename to client-local/src/main/java/io/github/dfa1/typesafe/client/local/LocalTypeSafeClient.java index 4e5f70a..75fd647 100644 --- a/local/src/main/java/io/github/dfa1/typesafe/local/LocalTypeSafeClient.java +++ b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LocalTypeSafeClient.java @@ -1,4 +1,4 @@ -package io.github.dfa1.typesafe.local; +package io.github.dfa1.typesafe.client.local; import ai.onnxruntime.OrtException; import io.github.dfa1.typesafe.core.EvaluateRequest; @@ -6,8 +6,8 @@ import io.github.dfa1.typesafe.core.Model; import io.github.dfa1.typesafe.core.ModelDetails; import io.github.dfa1.typesafe.core.Question; -import io.github.dfa1.typesafe.core.TypeSafeClient; -import io.github.dfa1.typesafe.core.TypeSafeException; +import io.github.dfa1.typesafe.client.TypeSafeClient; +import io.github.dfa1.typesafe.client.TypeSafeException; import io.github.dfa1.typesafe.core.Usage; import java.time.Duration; diff --git a/local/src/main/java/io/github/dfa1/typesafe/local/Onnx.java b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Onnx.java similarity index 79% rename from local/src/main/java/io/github/dfa1/typesafe/local/Onnx.java rename to client-local/src/main/java/io/github/dfa1/typesafe/client/local/Onnx.java index 18abf35..5d227ba 100644 --- a/local/src/main/java/io/github/dfa1/typesafe/local/Onnx.java +++ b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Onnx.java @@ -1,14 +1,15 @@ -package io.github.dfa1.typesafe.local; +package io.github.dfa1.typesafe.client.local; import ai.onnxruntime.OnnxTensor; import ai.onnxruntime.OrtEnvironment; import ai.onnxruntime.OrtException; import ai.onnxruntime.OrtSession; -import io.github.dfa1.typesafe.json.JsonCodec; +import io.github.dfa1.typesafe.codec.Codec; import java.io.IOException; import java.io.UncheckedIOException; import java.nio.LongBuffer; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; @@ -45,15 +46,21 @@ static OnnxTensor longs(long[] data, long... shape) throws OrtException { return OnnxTensor.createTensor(ENV, LongBuffer.wrap(data), shape); } - /** typesafe-java's JsonCodec (jackson2 or jackson3), found the way the API client finds it. */ - static JsonCodec json() { - return Codec.INSTANCE; + /** The Codec on the classpath (codec-jackson2 or -jackson3), found via ServiceLoader as client-http finds it; a JSON one, since + * model configs, tokenizer.json and {@link #toJson} are JSON. */ + static Codec codec() { + return LazyCodec.INSTANCE; } - private static final class Codec { - static final JsonCodec INSTANCE = ServiceLoader.load(JsonCodec.class).findFirst() - .orElseThrow(() -> new IllegalStateException("No JsonCodec found on the classpath. Add typesafe-java-jackson2 " - + "or typesafe-java-jackson3 as a dependency.")); + /** {@code value} as compact JSON text, for a model that reads JSON as part of its prompt. */ + static String toJson(Object value) { + return new String(codec().writeValueAsBytes(value), StandardCharsets.UTF_8); + } + + private static final class LazyCodec { + static final Codec INSTANCE = ServiceLoader.load(Codec.class).findFirst() + .orElseThrow(() -> new IllegalStateException("No Codec found on the classpath. Add typesafe-java-codec-jackson2 " + + "or typesafe-java-codec-jackson3 as a dependency.")); } /** diff --git a/local/src/main/java/io/github/dfa1/typesafe/local/Probabilities.java b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Probabilities.java similarity index 98% rename from local/src/main/java/io/github/dfa1/typesafe/local/Probabilities.java rename to client-local/src/main/java/io/github/dfa1/typesafe/client/local/Probabilities.java index 7fad303..8494780 100644 --- a/local/src/main/java/io/github/dfa1/typesafe/local/Probabilities.java +++ b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Probabilities.java @@ -1,4 +1,4 @@ -package io.github.dfa1.typesafe.local; +package io.github.dfa1.typesafe.client.local; import io.github.dfa1.typesafe.core.Answer; diff --git a/local/src/main/java/io/github/dfa1/typesafe/local/QwenEngine.java b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/QwenEngine.java similarity index 99% rename from local/src/main/java/io/github/dfa1/typesafe/local/QwenEngine.java rename to client-local/src/main/java/io/github/dfa1/typesafe/client/local/QwenEngine.java index 6816b4b..7ff20ad 100644 --- a/local/src/main/java/io/github/dfa1/typesafe/local/QwenEngine.java +++ b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/QwenEngine.java @@ -1,4 +1,4 @@ -package io.github.dfa1.typesafe.local; +package io.github.dfa1.typesafe.client.local; import ai.onnxruntime.NodeInfo; import ai.onnxruntime.OnnxJavaType; @@ -10,7 +10,7 @@ import io.github.dfa1.typesafe.core.Content; import io.github.dfa1.typesafe.core.Model; import io.github.dfa1.typesafe.core.Question; -import io.github.dfa1.typesafe.core.TypeSafeException; +import io.github.dfa1.typesafe.client.TypeSafeException; import java.io.IOException; import java.nio.FloatBuffer; diff --git a/local/src/test/java/io/github/dfa1/typesafe/local/BpeTokenizerTest.java b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/BpeTokenizerTest.java similarity index 93% rename from local/src/test/java/io/github/dfa1/typesafe/local/BpeTokenizerTest.java rename to client-local/src/test/java/io/github/dfa1/typesafe/client/local/BpeTokenizerTest.java index 279182d..c13c5b0 100644 --- a/local/src/test/java/io/github/dfa1/typesafe/local/BpeTokenizerTest.java +++ b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/BpeTokenizerTest.java @@ -1,6 +1,6 @@ -package io.github.dfa1.typesafe.local; +package io.github.dfa1.typesafe.client.local; -import io.github.dfa1.typesafe.jackson2.Jackson2Codec; +import io.github.dfa1.typesafe.codec.jackson2.Jackson2Codec; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -61,7 +61,7 @@ void encodeTextKeepsSpecialTokensAsTextButMatchesTheOthers() throws Exception { void matchesHuggingFaceTokenizersOnEveryString(String name, Engines engine) throws Exception { // Given BpeTokenizer sut = BpeTokenizer.load(engine.dir().resolve("tokenizer.json")); - List expected = new Jackson2Codec().readValue(Files.readString(Path.of("src/test/resources/tokenizer/expected-" + name + ".json")), List.class); + List expected = new Jackson2Codec().readValue(Files.readAllBytes(Path.of("src/test/resources/tokenizer/expected-" + name + ".json")), List.class); List mismatches = new ArrayList<>(); for (Object o : expected) { diff --git a/local/src/test/java/io/github/dfa1/typesafe/local/ClefEngineTest.java b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/ClefEngineTest.java similarity index 96% rename from local/src/test/java/io/github/dfa1/typesafe/local/ClefEngineTest.java rename to client-local/src/test/java/io/github/dfa1/typesafe/client/local/ClefEngineTest.java index fccb6f8..b071c06 100644 --- a/local/src/test/java/io/github/dfa1/typesafe/local/ClefEngineTest.java +++ b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/ClefEngineTest.java @@ -1,8 +1,8 @@ -package io.github.dfa1.typesafe.local; +package io.github.dfa1.typesafe.client.local; import io.github.dfa1.typesafe.core.Content; import io.github.dfa1.typesafe.core.Question; -import io.github.dfa1.typesafe.jackson2.Jackson2Codec; +import io.github.dfa1.typesafe.codec.jackson2.Jackson2Codec; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -58,7 +58,7 @@ void sequencesMatchPythonTokenForToken() throws Exception { // Given Jackson2Codec codec = new Jackson2Codec(); ClefEngine sut = new ClefEngine(null, BpeTokenizer.load(Engines.CLEF.dir().resolve("tokenizer.json")), null); - Map fixture = codec.readValue(Files.readString(Path.of("src/test/resources/clef/expected.json")), Map.class); + Map fixture = codec.readValue(Files.readAllBytes(Path.of("src/test/resources/clef/expected.json")), Map.class); List requests = (List) fixture.get("requests"); List> expected = (List>) fixture.get("expected"); diff --git a/local/src/test/java/io/github/dfa1/typesafe/local/Engines.java b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/Engines.java similarity index 97% rename from local/src/test/java/io/github/dfa1/typesafe/local/Engines.java rename to client-local/src/test/java/io/github/dfa1/typesafe/client/local/Engines.java index fa0087c..3912191 100644 --- a/local/src/test/java/io/github/dfa1/typesafe/local/Engines.java +++ b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/Engines.java @@ -1,4 +1,4 @@ -package io.github.dfa1.typesafe.local; +package io.github.dfa1.typesafe.client.local; import java.nio.file.Path; import java.util.Locale; diff --git a/local/src/test/java/io/github/dfa1/typesafe/local/JevCases.java b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/JevCases.java similarity index 99% rename from local/src/test/java/io/github/dfa1/typesafe/local/JevCases.java rename to client-local/src/test/java/io/github/dfa1/typesafe/client/local/JevCases.java index cb52aed..0f504a3 100644 --- a/local/src/test/java/io/github/dfa1/typesafe/local/JevCases.java +++ b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/JevCases.java @@ -1,4 +1,4 @@ -package io.github.dfa1.typesafe.local; +package io.github.dfa1.typesafe.client.local; import io.github.dfa1.typesafe.core.Content; import io.github.dfa1.typesafe.core.EvaluateRequest; diff --git a/local/src/test/java/io/github/dfa1/typesafe/local/JevComparison.java b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/JevComparison.java similarity index 90% rename from local/src/test/java/io/github/dfa1/typesafe/local/JevComparison.java rename to client-local/src/test/java/io/github/dfa1/typesafe/client/local/JevComparison.java index 4093f34..19ce89f 100644 --- a/local/src/test/java/io/github/dfa1/typesafe/local/JevComparison.java +++ b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/JevComparison.java @@ -1,13 +1,15 @@ -package io.github.dfa1.typesafe.local; +package io.github.dfa1.typesafe.client.local; +import io.github.dfa1.typesafe.client.http.DefaultTypeSafeClient; import io.github.dfa1.typesafe.core.Answer; -import io.github.dfa1.typesafe.core.ApiKey; +import io.github.dfa1.typesafe.client.http.ApiKey; import io.github.dfa1.typesafe.core.EvaluateRequest; import io.github.dfa1.typesafe.core.EvaluateResponse; -import io.github.dfa1.typesafe.core.TypeSafeClient; -import io.github.dfa1.typesafe.jackson2.Jackson2Codec; -import io.github.dfa1.typesafe.json.JsonCodec; +import io.github.dfa1.typesafe.client.TypeSafeClient; +import io.github.dfa1.typesafe.codec.jackson2.Jackson2Codec; +import io.github.dfa1.typesafe.codec.Codec; +import java.nio.charset.StandardCharsets; import java.io.IOException; import java.io.UncheckedIOException; import java.nio.file.Files; @@ -23,7 +25,7 @@ * answers are cached in {@code src/test/resources/jev/.jsonl} (first run calls the TypeSafe API, * needs {@code ~/.typesafe.apikey}); later runs only evaluate locally. Run: * {@code mvn test-compile exec:exec -Dexec.classpathScope=test -Dexec.executable=java - * "-Dexec.args=-cp %classpath io.github.dfa1.typesafe.local.JevComparison laya"} (or qwen; append -gpu for WebGPU). + * "-Dexec.args=-cp %classpath io.github.dfa1.typesafe.client.local.JevComparison laya"} (or qwen; append -gpu for WebGPU). */ public final class JevComparison { @@ -73,7 +75,7 @@ static EvaluateRequest request(String message) { public static void main(String[] args) throws Exception { String model = args.length > 0 ? args[0] : "laya"; - JsonCodec codec = new Jackson2Codec(); + Codec codec = new Jackson2Codec(); List cases = JevCases.all(); List jev = jevAnswers(codec, cases); @@ -150,7 +152,7 @@ private static double[] col(List pairs, int i) { /** JEV's answers, one JSON line per request in jev/.jsonl; requests missing from the cache (first run, * or cases added to a suite) are fetched from the real TypeSafe API and appended. */ - private static List jevAnswers(JsonCodec codec, List cases) throws IOException { + private static List jevAnswers(Codec codec, List cases) throws IOException { Files.createDirectories(CACHE); List result = new ArrayList<>(); Map> cached = new LinkedHashMap<>(); @@ -161,15 +163,15 @@ private static List jevAnswers(JsonCodec codec, List lines = cached.computeIfAbsent(c.suite(), k -> readLines(file)); if (c.index() >= lines.size()) { if (api == null) { - api = TypeSafeClient.builder().apiKey(ApiKey.fromDefaultFile()).jsonCodec(codec).build(); + api = DefaultTypeSafeClient.builder().apiKey(ApiKey.fromDefaultFile()).codec(codec).build(); } EvaluateResponse r = api.evaluate(c.request()); // metadata (request id, timing) is per call, not part of the answer - String line = codec.writeValueAsString(new EvaluateResponse(r.model(), r.answers(), r.usage(), null)); + String line = new String(codec.writeValueAsBytes(new EvaluateResponse(r.model(), r.answers(), r.usage(), null)), StandardCharsets.UTF_8); Files.writeString(file, line + "\n", StandardOpenOption.CREATE, StandardOpenOption.APPEND); lines.add(line); } - result.add(codec.readValue(lines.get(c.index()), EvaluateResponse.class)); + result.add(codec.readValue(lines.get(c.index()).getBytes(StandardCharsets.UTF_8), EvaluateResponse.class)); } } finally { if (api != null) { diff --git a/local/src/test/java/io/github/dfa1/typesafe/local/LayaEngineTest.java b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/LayaEngineTest.java similarity index 98% rename from local/src/test/java/io/github/dfa1/typesafe/local/LayaEngineTest.java rename to client-local/src/test/java/io/github/dfa1/typesafe/client/local/LayaEngineTest.java index 6b3f2b9..db6b876 100644 --- a/local/src/test/java/io/github/dfa1/typesafe/local/LayaEngineTest.java +++ b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/LayaEngineTest.java @@ -1,4 +1,4 @@ -package io.github.dfa1.typesafe.local; +package io.github.dfa1.typesafe.client.local; import io.github.dfa1.typesafe.core.Content; import io.github.dfa1.typesafe.core.Question; diff --git a/local/src/test/java/io/github/dfa1/typesafe/local/LocalTypeSafeClientBenchmark.java b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/LocalTypeSafeClientBenchmark.java similarity index 98% rename from local/src/test/java/io/github/dfa1/typesafe/local/LocalTypeSafeClientBenchmark.java rename to client-local/src/test/java/io/github/dfa1/typesafe/client/local/LocalTypeSafeClientBenchmark.java index f4597c4..4e45de3 100644 --- a/local/src/test/java/io/github/dfa1/typesafe/local/LocalTypeSafeClientBenchmark.java +++ b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/LocalTypeSafeClientBenchmark.java @@ -1,4 +1,4 @@ -package io.github.dfa1.typesafe.local; +package io.github.dfa1.typesafe.client.local; import io.github.dfa1.typesafe.core.Content; import io.github.dfa1.typesafe.core.EvaluateRequest; diff --git a/local/src/test/java/io/github/dfa1/typesafe/local/LocalTypeSafeClientModelTest.java b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/LocalTypeSafeClientModelTest.java similarity index 94% rename from local/src/test/java/io/github/dfa1/typesafe/local/LocalTypeSafeClientModelTest.java rename to client-local/src/test/java/io/github/dfa1/typesafe/client/local/LocalTypeSafeClientModelTest.java index 6ca7c06..01527e8 100644 --- a/local/src/test/java/io/github/dfa1/typesafe/local/LocalTypeSafeClientModelTest.java +++ b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/LocalTypeSafeClientModelTest.java @@ -1,4 +1,4 @@ -package io.github.dfa1.typesafe.local; +package io.github.dfa1.typesafe.client.local; import io.github.dfa1.typesafe.core.Answer; import io.github.dfa1.typesafe.core.Content; @@ -14,7 +14,7 @@ import static org.assertj.core.api.Assertions.assertThat; -/** Real model from ~/.cache/typesafe-local (see local/scripts). Opt in: {@code -DexcludedGroups=acceptance}; +/** Real model from ~/.cache/typesafe-local (see client-local/scripts). Opt in: {@code -DexcludedGroups=acceptance}; * pick the engine with {@code -Dengine=laya|qwen} (append {@code -gpu} for WebGPU). */ @Tag("model") class LocalTypeSafeClientModelTest { diff --git a/local/src/test/java/io/github/dfa1/typesafe/local/LocalTypeSafeClientTest.java b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/LocalTypeSafeClientTest.java similarity index 97% rename from local/src/test/java/io/github/dfa1/typesafe/local/LocalTypeSafeClientTest.java rename to client-local/src/test/java/io/github/dfa1/typesafe/client/local/LocalTypeSafeClientTest.java index 1c248fc..cd4c48a 100644 --- a/local/src/test/java/io/github/dfa1/typesafe/local/LocalTypeSafeClientTest.java +++ b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/LocalTypeSafeClientTest.java @@ -1,4 +1,4 @@ -package io.github.dfa1.typesafe.local; +package io.github.dfa1.typesafe.client.local; import ai.onnxruntime.OrtException; import io.github.dfa1.typesafe.core.Answer; @@ -8,7 +8,7 @@ import io.github.dfa1.typesafe.core.Model; import io.github.dfa1.typesafe.core.ModelDetails; import io.github.dfa1.typesafe.core.Question; -import io.github.dfa1.typesafe.core.TypeSafeException; +import io.github.dfa1.typesafe.client.TypeSafeException; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; diff --git a/local/src/test/java/io/github/dfa1/typesafe/local/OnnxTest.java b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/OnnxTest.java similarity index 98% rename from local/src/test/java/io/github/dfa1/typesafe/local/OnnxTest.java rename to client-local/src/test/java/io/github/dfa1/typesafe/client/local/OnnxTest.java index 80bf710..dd9cc6f 100644 --- a/local/src/test/java/io/github/dfa1/typesafe/local/OnnxTest.java +++ b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/OnnxTest.java @@ -1,4 +1,4 @@ -package io.github.dfa1.typesafe.local; +package io.github.dfa1.typesafe.client.local; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; diff --git a/local/src/test/java/io/github/dfa1/typesafe/local/ProbabilitiesTest.java b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/ProbabilitiesTest.java similarity index 94% rename from local/src/test/java/io/github/dfa1/typesafe/local/ProbabilitiesTest.java rename to client-local/src/test/java/io/github/dfa1/typesafe/client/local/ProbabilitiesTest.java index 4371ca2..0e5632b 100644 --- a/local/src/test/java/io/github/dfa1/typesafe/local/ProbabilitiesTest.java +++ b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/ProbabilitiesTest.java @@ -1,4 +1,4 @@ -package io.github.dfa1.typesafe.local; +package io.github.dfa1.typesafe.client.local; import org.junit.jupiter.api.Test; diff --git a/local/src/test/java/io/github/dfa1/typesafe/local/QwenEngineTest.java b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/QwenEngineTest.java similarity index 98% rename from local/src/test/java/io/github/dfa1/typesafe/local/QwenEngineTest.java rename to client-local/src/test/java/io/github/dfa1/typesafe/client/local/QwenEngineTest.java index e544c2e..1c9a9c7 100644 --- a/local/src/test/java/io/github/dfa1/typesafe/local/QwenEngineTest.java +++ b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/QwenEngineTest.java @@ -1,4 +1,4 @@ -package io.github.dfa1.typesafe.local; +package io.github.dfa1.typesafe.client.local; import io.github.dfa1.typesafe.core.Answer; import io.github.dfa1.typesafe.core.Content; @@ -6,7 +6,7 @@ import io.github.dfa1.typesafe.core.EvaluateResponse; import io.github.dfa1.typesafe.core.Model; import io.github.dfa1.typesafe.core.Question; -import io.github.dfa1.typesafe.core.TypeSafeException; +import io.github.dfa1.typesafe.client.TypeSafeException; import org.junit.jupiter.api.Test; import java.util.ArrayList; diff --git a/local/src/test/java/io/github/dfa1/typesafe/local/TokenizerInputs.java b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/TokenizerInputs.java similarity index 89% rename from local/src/test/java/io/github/dfa1/typesafe/local/TokenizerInputs.java rename to client-local/src/test/java/io/github/dfa1/typesafe/client/local/TokenizerInputs.java index f1ac513..0986c75 100644 --- a/local/src/test/java/io/github/dfa1/typesafe/local/TokenizerInputs.java +++ b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/TokenizerInputs.java @@ -1,8 +1,8 @@ -package io.github.dfa1.typesafe.local; +package io.github.dfa1.typesafe.client.local; import io.github.dfa1.typesafe.core.Content; import io.github.dfa1.typesafe.core.Question; -import io.github.dfa1.typesafe.jackson2.Jackson2Codec; +import io.github.dfa1.typesafe.codec.jackson2.Jackson2Codec; import java.nio.file.Files; import java.nio.file.Path; @@ -58,8 +58,8 @@ public static void main(String[] args) throws Exception { } } Path dir = Files.createDirectories(Path.of("src/test/resources/tokenizer")); - Files.writeString(dir.resolve("inputs-laya.json"), new Jackson2Codec().writeValueAsString(new ArrayList<>(laya))); - Files.writeString(dir.resolve("inputs-qwen.json"), new Jackson2Codec().writeValueAsString(new ArrayList<>(qwen))); + Files.write(dir.resolve("inputs-laya.json"), new Jackson2Codec().writeValueAsBytes(new ArrayList<>(laya))); + Files.write(dir.resolve("inputs-qwen.json"), new Jackson2Codec().writeValueAsBytes(new ArrayList<>(qwen))); System.out.println(laya.size() + " laya / " + qwen.size() + " qwen strings"); } } diff --git a/local/src/test/resources/clef/expected.json b/client-local/src/test/resources/clef/expected.json similarity index 100% rename from local/src/test/resources/clef/expected.json rename to client-local/src/test/resources/clef/expected.json diff --git a/local/src/test/resources/clef/requests.json b/client-local/src/test/resources/clef/requests.json similarity index 100% rename from local/src/test/resources/clef/requests.json rename to client-local/src/test/resources/clef/requests.json diff --git a/local/src/test/resources/jev/chat.jsonl b/client-local/src/test/resources/jev/chat.jsonl similarity index 100% rename from local/src/test/resources/jev/chat.jsonl rename to client-local/src/test/resources/jev/chat.jsonl diff --git a/local/src/test/resources/jev/email.jsonl b/client-local/src/test/resources/jev/email.jsonl similarity index 100% rename from local/src/test/resources/jev/email.jsonl rename to client-local/src/test/resources/jev/email.jsonl diff --git a/local/src/test/resources/jev/moderation.jsonl b/client-local/src/test/resources/jev/moderation.jsonl similarity index 100% rename from local/src/test/resources/jev/moderation.jsonl rename to client-local/src/test/resources/jev/moderation.jsonl diff --git a/local/src/test/resources/jev/reviews.jsonl b/client-local/src/test/resources/jev/reviews.jsonl similarity index 100% rename from local/src/test/resources/jev/reviews.jsonl rename to client-local/src/test/resources/jev/reviews.jsonl diff --git a/local/src/test/resources/jev/support.jsonl b/client-local/src/test/resources/jev/support.jsonl similarity index 100% rename from local/src/test/resources/jev/support.jsonl rename to client-local/src/test/resources/jev/support.jsonl diff --git a/local/src/test/resources/laya/fixture.json b/client-local/src/test/resources/laya/fixture.json similarity index 100% rename from local/src/test/resources/laya/fixture.json rename to client-local/src/test/resources/laya/fixture.json diff --git a/local/src/test/resources/tokenizer/expected-laya.json b/client-local/src/test/resources/tokenizer/expected-laya.json similarity index 100% rename from local/src/test/resources/tokenizer/expected-laya.json rename to client-local/src/test/resources/tokenizer/expected-laya.json diff --git a/local/src/test/resources/tokenizer/expected-qwen.json b/client-local/src/test/resources/tokenizer/expected-qwen.json similarity index 100% rename from local/src/test/resources/tokenizer/expected-qwen.json rename to client-local/src/test/resources/tokenizer/expected-qwen.json diff --git a/local/src/test/resources/tokenizer/inputs-laya.json b/client-local/src/test/resources/tokenizer/inputs-laya.json similarity index 100% rename from local/src/test/resources/tokenizer/inputs-laya.json rename to client-local/src/test/resources/tokenizer/inputs-laya.json diff --git a/local/src/test/resources/tokenizer/inputs-qwen.json b/client-local/src/test/resources/tokenizer/inputs-qwen.json similarity index 100% rename from local/src/test/resources/tokenizer/inputs-qwen.json rename to client-local/src/test/resources/tokenizer/inputs-qwen.json diff --git a/mapping/pom.xml b/client-mapping/pom.xml similarity index 87% rename from mapping/pom.xml rename to client-mapping/pom.xml index 136dc83..71ab44f 100644 --- a/mapping/pom.xml +++ b/client-mapping/pom.xml @@ -7,10 +7,10 @@ 0.7.0-SNAPSHOT - typesafe-java-mapping + typesafe-java-client-mapping jar - TypeSafe Answer Mapping + TypeSafe Client Mapping MappingTypeSafeClient, a TypeSafeClient decorator that maps a record's @Noul/@Choice/@Score-annotated components into an EvaluateRequest and the response back into an instance of that same record, via reflection, so a caller gets a typed record instead of @@ -20,13 +20,13 @@ io.github.dfa1.typesafe-java - typesafe-java-core + typesafe-java-client ${project.version} io.github.dfa1.typesafe-java - typesafe-java-testkit + typesafe-java-client-testkit ${project.version} test diff --git a/mapping/src/main/java/io/github/dfa1/typesafe/mapping/Choice.java b/client-mapping/src/main/java/io/github/dfa1/typesafe/client/mapping/Choice.java similarity index 93% rename from mapping/src/main/java/io/github/dfa1/typesafe/mapping/Choice.java rename to client-mapping/src/main/java/io/github/dfa1/typesafe/client/mapping/Choice.java index 7a5d4ae..e4bd3f7 100644 --- a/mapping/src/main/java/io/github/dfa1/typesafe/mapping/Choice.java +++ b/client-mapping/src/main/java/io/github/dfa1/typesafe/client/mapping/Choice.java @@ -1,4 +1,4 @@ -package io.github.dfa1.typesafe.mapping; +package io.github.dfa1.typesafe.client.mapping; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/mapping/src/main/java/io/github/dfa1/typesafe/mapping/MappingTypeSafeClient.java b/client-mapping/src/main/java/io/github/dfa1/typesafe/client/mapping/MappingTypeSafeClient.java similarity index 96% rename from mapping/src/main/java/io/github/dfa1/typesafe/mapping/MappingTypeSafeClient.java rename to client-mapping/src/main/java/io/github/dfa1/typesafe/client/mapping/MappingTypeSafeClient.java index ff8c061..a0fb619 100644 --- a/mapping/src/main/java/io/github/dfa1/typesafe/mapping/MappingTypeSafeClient.java +++ b/client-mapping/src/main/java/io/github/dfa1/typesafe/client/mapping/MappingTypeSafeClient.java @@ -1,4 +1,4 @@ -package io.github.dfa1.typesafe.mapping; +package io.github.dfa1.typesafe.client.mapping; import io.github.dfa1.typesafe.core.Answer; import io.github.dfa1.typesafe.core.EvaluateRequest; @@ -7,7 +7,7 @@ import io.github.dfa1.typesafe.core.ModelDetails; import io.github.dfa1.typesafe.core.Question; import io.github.dfa1.typesafe.core.Content; -import io.github.dfa1.typesafe.core.TypeSafeClient; +import io.github.dfa1.typesafe.client.TypeSafeClient; import java.lang.reflect.Constructor; import java.lang.reflect.RecordComponent; @@ -36,7 +36,7 @@ * canonical constructor) is computed once and cached for the lifetime of this instance, so * repeated calls for the same record type don't re-walk it. * - *

A {@link io.github.dfa1.typesafe.core.TypeSafeException} the delegate throws (already final + *

A {@link io.github.dfa1.typesafe.client.TypeSafeException} the delegate throws (already final * — its own retries, if any, are already exhausted) propagates unchanged; this class never wraps * or reclassifies it. The exceptions this class raises itself — * {@link IllegalArgumentException} for a record whose annotations don't validate, and @@ -56,9 +56,8 @@ private MappingTypeSafeClient(TypeSafeClient delegate) { } /** Wraps {@code delegate} in a {@link MappingTypeSafeClient}. Pass this as the {@code decorate} - * function to {@link io.github.dfa1.typesafe.core.DefaultTypeSafeClient.Builder#build(java.util.function.Function)}, - * e.g. - * {@code builder().apiKey(apiKey).build(MappingTypeSafeClient::decorate)}. */ + * function to {@code DefaultTypeSafeClient.Builder.build(Function)}, e.g. + * {@code DefaultTypeSafeClient.builder().apiKey(apiKey).build(MappingTypeSafeClient::decorate)}. */ public static MappingTypeSafeClient decorate(TypeSafeClient delegate) { return new MappingTypeSafeClient(delegate); } diff --git a/mapping/src/main/java/io/github/dfa1/typesafe/mapping/Noul.java b/client-mapping/src/main/java/io/github/dfa1/typesafe/client/mapping/Noul.java similarity index 92% rename from mapping/src/main/java/io/github/dfa1/typesafe/mapping/Noul.java rename to client-mapping/src/main/java/io/github/dfa1/typesafe/client/mapping/Noul.java index 4632158..f883f6b 100644 --- a/mapping/src/main/java/io/github/dfa1/typesafe/mapping/Noul.java +++ b/client-mapping/src/main/java/io/github/dfa1/typesafe/client/mapping/Noul.java @@ -1,4 +1,4 @@ -package io.github.dfa1.typesafe.mapping; +package io.github.dfa1.typesafe.client.mapping; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/mapping/src/main/java/io/github/dfa1/typesafe/mapping/Option.java b/client-mapping/src/main/java/io/github/dfa1/typesafe/client/mapping/Option.java similarity index 91% rename from mapping/src/main/java/io/github/dfa1/typesafe/mapping/Option.java rename to client-mapping/src/main/java/io/github/dfa1/typesafe/client/mapping/Option.java index ae781c9..88f407f 100644 --- a/mapping/src/main/java/io/github/dfa1/typesafe/mapping/Option.java +++ b/client-mapping/src/main/java/io/github/dfa1/typesafe/client/mapping/Option.java @@ -1,4 +1,4 @@ -package io.github.dfa1.typesafe.mapping; +package io.github.dfa1.typesafe.client.mapping; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; diff --git a/mapping/src/main/java/io/github/dfa1/typesafe/mapping/Score.java b/client-mapping/src/main/java/io/github/dfa1/typesafe/client/mapping/Score.java similarity index 93% rename from mapping/src/main/java/io/github/dfa1/typesafe/mapping/Score.java rename to client-mapping/src/main/java/io/github/dfa1/typesafe/client/mapping/Score.java index 4970391..9dc764d 100644 --- a/mapping/src/main/java/io/github/dfa1/typesafe/mapping/Score.java +++ b/client-mapping/src/main/java/io/github/dfa1/typesafe/client/mapping/Score.java @@ -1,4 +1,4 @@ -package io.github.dfa1.typesafe.mapping; +package io.github.dfa1.typesafe.client.mapping; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/mapping/src/test/java/io/github/dfa1/typesafe/mapping/MappingTypeSafeClientTest.java b/client-mapping/src/test/java/io/github/dfa1/typesafe/client/mapping/MappingTypeSafeClientTest.java similarity index 98% rename from mapping/src/test/java/io/github/dfa1/typesafe/mapping/MappingTypeSafeClientTest.java rename to client-mapping/src/test/java/io/github/dfa1/typesafe/client/mapping/MappingTypeSafeClientTest.java index 931ab20..63564d1 100644 --- a/mapping/src/test/java/io/github/dfa1/typesafe/mapping/MappingTypeSafeClientTest.java +++ b/client-mapping/src/test/java/io/github/dfa1/typesafe/client/mapping/MappingTypeSafeClientTest.java @@ -1,4 +1,4 @@ -package io.github.dfa1.typesafe.mapping; +package io.github.dfa1.typesafe.client.mapping; import io.github.dfa1.typesafe.core.Answer; import io.github.dfa1.typesafe.core.EvaluateRequest; @@ -6,10 +6,10 @@ import io.github.dfa1.typesafe.core.Model; import io.github.dfa1.typesafe.core.Question; import io.github.dfa1.typesafe.core.Content; -import io.github.dfa1.typesafe.core.TypeSafeException; +import io.github.dfa1.typesafe.client.TypeSafeException; import io.github.dfa1.typesafe.core.Usage; -import io.github.dfa1.typesafe.testkit.FailingTypeSafeClient; -import io.github.dfa1.typesafe.testkit.RecordingTypeSafeClient; +import io.github.dfa1.typesafe.client.testkit.FailingTypeSafeClient; +import io.github.dfa1.typesafe.client.testkit.RecordingTypeSafeClient; import org.junit.jupiter.api.Test; diff --git a/client-okhttp/src/main/resources/META-INF/services/io.github.dfa1.typesafe.transport.HttpTransport b/client-okhttp/src/main/resources/META-INF/services/io.github.dfa1.typesafe.transport.HttpTransport deleted file mode 100644 index 3534c9f..0000000 --- a/client-okhttp/src/main/resources/META-INF/services/io.github.dfa1.typesafe.transport.HttpTransport +++ /dev/null @@ -1 +0,0 @@ -io.github.dfa1.typesafe.okhttp.OkHttpTransport diff --git a/testkit/pom.xml b/client-testkit/pom.xml similarity index 89% rename from testkit/pom.xml rename to client-testkit/pom.xml index 01aad70..bae92ef 100644 --- a/testkit/pom.xml +++ b/client-testkit/pom.xml @@ -7,10 +7,10 @@ 0.7.0-SNAPSHOT - typesafe-java-testkit + typesafe-java-client-testkit jar - TypeSafe Testkit + TypeSafe Client Testkit Two TypeSafeClient test doubles for unit-testing code that calls it without hitting the real API: RecordingTypeSafeClient (queue responses, record requests) and FailingTypeSafeClient (a decorator that fails every Nth call). Depends only on @@ -20,7 +20,7 @@ io.github.dfa1.typesafe-java - typesafe-java-core + typesafe-java-client ${project.version} diff --git a/testkit/src/main/java/io/github/dfa1/typesafe/testkit/FailingTypeSafeClient.java b/client-testkit/src/main/java/io/github/dfa1/typesafe/client/testkit/FailingTypeSafeClient.java similarity index 95% rename from testkit/src/main/java/io/github/dfa1/typesafe/testkit/FailingTypeSafeClient.java rename to client-testkit/src/main/java/io/github/dfa1/typesafe/client/testkit/FailingTypeSafeClient.java index d19e6c0..7261c91 100644 --- a/testkit/src/main/java/io/github/dfa1/typesafe/testkit/FailingTypeSafeClient.java +++ b/client-testkit/src/main/java/io/github/dfa1/typesafe/client/testkit/FailingTypeSafeClient.java @@ -1,9 +1,9 @@ -package io.github.dfa1.typesafe.testkit; +package io.github.dfa1.typesafe.client.testkit; import io.github.dfa1.typesafe.core.EvaluateRequest; import io.github.dfa1.typesafe.core.EvaluateResponse; import io.github.dfa1.typesafe.core.ModelDetails; -import io.github.dfa1.typesafe.core.TypeSafeClient; +import io.github.dfa1.typesafe.client.TypeSafeClient; import java.util.List; import java.util.concurrent.CompletableFuture; diff --git a/testkit/src/main/java/io/github/dfa1/typesafe/testkit/RecordingTypeSafeClient.java b/client-testkit/src/main/java/io/github/dfa1/typesafe/client/testkit/RecordingTypeSafeClient.java similarity index 96% rename from testkit/src/main/java/io/github/dfa1/typesafe/testkit/RecordingTypeSafeClient.java rename to client-testkit/src/main/java/io/github/dfa1/typesafe/client/testkit/RecordingTypeSafeClient.java index fefc7af..55f1558 100644 --- a/testkit/src/main/java/io/github/dfa1/typesafe/testkit/RecordingTypeSafeClient.java +++ b/client-testkit/src/main/java/io/github/dfa1/typesafe/client/testkit/RecordingTypeSafeClient.java @@ -1,9 +1,9 @@ -package io.github.dfa1.typesafe.testkit; +package io.github.dfa1.typesafe.client.testkit; import io.github.dfa1.typesafe.core.EvaluateRequest; import io.github.dfa1.typesafe.core.EvaluateResponse; import io.github.dfa1.typesafe.core.ModelDetails; -import io.github.dfa1.typesafe.core.TypeSafeClient; +import io.github.dfa1.typesafe.client.TypeSafeClient; import java.util.List; import java.util.Queue; diff --git a/testkit/src/test/java/io/github/dfa1/typesafe/testkit/FailingTypeSafeClientTest.java b/client-testkit/src/test/java/io/github/dfa1/typesafe/client/testkit/FailingTypeSafeClientTest.java similarity index 97% rename from testkit/src/test/java/io/github/dfa1/typesafe/testkit/FailingTypeSafeClientTest.java rename to client-testkit/src/test/java/io/github/dfa1/typesafe/client/testkit/FailingTypeSafeClientTest.java index 4be7235..c0d63a0 100644 --- a/testkit/src/test/java/io/github/dfa1/typesafe/testkit/FailingTypeSafeClientTest.java +++ b/client-testkit/src/test/java/io/github/dfa1/typesafe/client/testkit/FailingTypeSafeClientTest.java @@ -1,10 +1,10 @@ -package io.github.dfa1.typesafe.testkit; +package io.github.dfa1.typesafe.client.testkit; import io.github.dfa1.typesafe.core.EvaluateRequest; import io.github.dfa1.typesafe.core.EvaluateResponse; import io.github.dfa1.typesafe.core.Model; import io.github.dfa1.typesafe.core.Content; -import io.github.dfa1.typesafe.core.TypeSafeException; +import io.github.dfa1.typesafe.client.TypeSafeException; import io.github.dfa1.typesafe.core.Usage; import org.junit.jupiter.api.Test; diff --git a/testkit/src/test/java/io/github/dfa1/typesafe/testkit/RecordingTypeSafeClientTest.java b/client-testkit/src/test/java/io/github/dfa1/typesafe/client/testkit/RecordingTypeSafeClientTest.java similarity index 98% rename from testkit/src/test/java/io/github/dfa1/typesafe/testkit/RecordingTypeSafeClientTest.java rename to client-testkit/src/test/java/io/github/dfa1/typesafe/client/testkit/RecordingTypeSafeClientTest.java index 4aeb9a7..b70f02d 100644 --- a/testkit/src/test/java/io/github/dfa1/typesafe/testkit/RecordingTypeSafeClientTest.java +++ b/client-testkit/src/test/java/io/github/dfa1/typesafe/client/testkit/RecordingTypeSafeClientTest.java @@ -1,4 +1,4 @@ -package io.github.dfa1.typesafe.testkit; +package io.github.dfa1.typesafe.client.testkit; import io.github.dfa1.typesafe.core.EvaluateRequest; import io.github.dfa1.typesafe.core.EvaluateResponse; diff --git a/client/pom.xml b/client/pom.xml new file mode 100644 index 0000000..ced5d05 --- /dev/null +++ b/client/pom.xml @@ -0,0 +1,45 @@ + + 4.0.0 + + + io.github.dfa1.typesafe-java + typesafe-java + 0.7.0-SNAPSHOT + + + typesafe-java-client + jar + + TypeSafe Client + The client contract: the TypeSafeClient interface, TypeSafeException, and the decorators + that wrap any implementation (RetryingTypeSafeClient, DeadlineTypeSafeClient, TokenCounter). + Depends only on typesafe-java-core. Implemented by typesafe-java-client-http (the TypeSafe API over + HTTP) and typesafe-java-client-local (in-process models). + + + + + io.github.dfa1.typesafe-java + typesafe-java-core + ${project.version} + + + + org.junit.jupiter + junit-jupiter + test + + + + org.assertj + assertj-core + test + + + + org.mockito + mockito-junit-jupiter + test + + + diff --git a/core/src/main/java/io/github/dfa1/typesafe/core/DeadlineTypeSafeClient.java b/client/src/main/java/io/github/dfa1/typesafe/client/DeadlineTypeSafeClient.java similarity index 69% rename from core/src/main/java/io/github/dfa1/typesafe/core/DeadlineTypeSafeClient.java rename to client/src/main/java/io/github/dfa1/typesafe/client/DeadlineTypeSafeClient.java index 1f88066..480a2a7 100644 --- a/core/src/main/java/io/github/dfa1/typesafe/core/DeadlineTypeSafeClient.java +++ b/client/src/main/java/io/github/dfa1/typesafe/client/DeadlineTypeSafeClient.java @@ -1,11 +1,14 @@ -package io.github.dfa1.typesafe.core; +package io.github.dfa1.typesafe.client; +import io.github.dfa1.typesafe.core.EvaluateRequest; +import io.github.dfa1.typesafe.core.EvaluateResponse; +import io.github.dfa1.typesafe.core.ModelDetails; import java.time.Duration; import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import java.util.function.Function; /** * {@link TypeSafeClient} decorator that bounds how long one {@link #evaluate}/{@link #evaluateAsync} @@ -30,7 +33,7 @@ public final class DeadlineTypeSafeClient implements TypeSafeClient { } /** Wraps {@code delegate} with {@code deadline}. Pass it to - * {@link DefaultTypeSafeClient.Builder#decorateWith(Function)}, e.g. + * {@code DefaultTypeSafeClient.Builder.decorateWith(...)}, e.g. * {@code decorateWith(c -> DeadlineTypeSafeClient.decorate(c, Duration.ofSeconds(20)))}. */ public static DeadlineTypeSafeClient decorate(TypeSafeClient delegate, Duration deadline) { return new DeadlineTypeSafeClient(delegate, deadline); @@ -39,7 +42,7 @@ public static DeadlineTypeSafeClient decorate(TypeSafeClient delegate, Duration /** Runs through {@link #evaluateAsync}, so the deadline holds without a watchdog thread. */ @Override public EvaluateResponse evaluate(EvaluateRequest request) { - return DefaultTypeSafeClient.await(evaluateAsync(request)); + return await(evaluateAsync(request)); } @Override @@ -62,4 +65,24 @@ public List listModels() { public void close() { delegate.close(); } + + /** Blocks on {@code future}, rethrowing its failure as the {@link TypeSafeException} it already is (or wrapping + * any other cause), like {@code DefaultTypeSafeClient}'s blocking calls. */ + private static T await(CompletableFuture future) { + try { + return future.get(); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException re) { + throw re; + } + if (cause instanceof Error er) { + throw er; + } + throw new TypeSafeException.Connection(cause); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new TypeSafeException.Interrupted(e); + } + } } diff --git a/core/src/main/java/io/github/dfa1/typesafe/core/RetryingTypeSafeClient.java b/client/src/main/java/io/github/dfa1/typesafe/client/RetryingTypeSafeClient.java similarity index 94% rename from core/src/main/java/io/github/dfa1/typesafe/core/RetryingTypeSafeClient.java rename to client/src/main/java/io/github/dfa1/typesafe/client/RetryingTypeSafeClient.java index 1ba0aba..c8c8be0 100644 --- a/core/src/main/java/io/github/dfa1/typesafe/core/RetryingTypeSafeClient.java +++ b/client/src/main/java/io/github/dfa1/typesafe/client/RetryingTypeSafeClient.java @@ -1,12 +1,14 @@ -package io.github.dfa1.typesafe.core; +package io.github.dfa1.typesafe.client; +import io.github.dfa1.typesafe.core.EvaluateRequest; +import io.github.dfa1.typesafe.core.EvaluateResponse; +import io.github.dfa1.typesafe.core.ModelDetails; import java.time.Duration; import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.TimeUnit; -import java.util.function.Function; import java.util.function.Supplier; /** @@ -17,7 +19,7 @@ * ({@link TypeSafeException.RateLimit#retryAfter()}/{@link TypeSafeException.InternalServer#retryAfter()}), * exponential backoff from {@code initialBackoff} otherwise. Anything else propagates unchanged. * - *

Add one via {@link DefaultTypeSafeClient.Builder#decorateWith(Function)}, e.g. + *

Add one via {@code DefaultTypeSafeClient.Builder.decorateWith(...)}, e.g. * {@code builder().apiKey(apiKey).decorateWith(RetryingTypeSafeClient::decorate).build()}. Don't stack two: the * attempts multiply, and {@code build()} rejects it. */ @@ -34,7 +36,7 @@ public final class RetryingTypeSafeClient implements TypeSafeClient { } /** Wraps {@code delegate} with 5 retries, backoff starting at 500ms (500ms, 1s, 2s, 4s, 8s). - * Pass it to {@link DefaultTypeSafeClient.Builder#decorateWith(Function)} as + * Pass it to {@code DefaultTypeSafeClient.Builder.decorateWith(...)} as * {@code decorateWith(RetryingTypeSafeClient::decorate)}. */ public static RetryingTypeSafeClient decorate(TypeSafeClient delegate) { return decorate(delegate, 5, Duration.ofMillis(500)); diff --git a/core/src/main/java/io/github/dfa1/typesafe/core/TokenCounter.java b/client/src/main/java/io/github/dfa1/typesafe/client/TokenCounter.java similarity index 86% rename from core/src/main/java/io/github/dfa1/typesafe/core/TokenCounter.java rename to client/src/main/java/io/github/dfa1/typesafe/client/TokenCounter.java index 9bd4135..674dc41 100644 --- a/core/src/main/java/io/github/dfa1/typesafe/core/TokenCounter.java +++ b/client/src/main/java/io/github/dfa1/typesafe/client/TokenCounter.java @@ -1,5 +1,9 @@ -package io.github.dfa1.typesafe.core; +package io.github.dfa1.typesafe.client; +import io.github.dfa1.typesafe.core.EvaluateRequest; +import io.github.dfa1.typesafe.core.EvaluateResponse; +import io.github.dfa1.typesafe.core.ModelDetails; +import io.github.dfa1.typesafe.core.Usage; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.LongAdder; @@ -10,7 +14,7 @@ * *

{@code
  * TokenCounter tokens = new TokenCounter();
- * TypeSafeClient client = TypeSafeClient.builder().apiKey(apiKey)
+ * TypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(apiKey)
  *         .decorateWith(tokens::decorate)
  *         .build();
  * // ...
@@ -29,7 +33,7 @@ public final class TokenCounter {
     private final LongAdder outputTokens = new LongAdder();
 
     /** Wraps {@code delegate} so every successful {@code evaluate}/{@code evaluateAsync} adds its
-     *  usage to this counter. Pass it to {@link DefaultTypeSafeClient.Builder#decorateWith} as
+     *  usage to this counter. Pass it to {@code DefaultTypeSafeClient.Builder.decorateWith(...)} as
      *  {@code tokens::decorate}. */
     public TypeSafeClient decorate(TypeSafeClient delegate) {
         return new Counting(delegate);
diff --git a/core/src/main/java/io/github/dfa1/typesafe/core/TypeSafeClient.java b/client/src/main/java/io/github/dfa1/typesafe/client/TypeSafeClient.java
similarity index 58%
rename from core/src/main/java/io/github/dfa1/typesafe/core/TypeSafeClient.java
rename to client/src/main/java/io/github/dfa1/typesafe/client/TypeSafeClient.java
index 1610b9a..571aff7 100644
--- a/core/src/main/java/io/github/dfa1/typesafe/core/TypeSafeClient.java
+++ b/client/src/main/java/io/github/dfa1/typesafe/client/TypeSafeClient.java
@@ -1,25 +1,23 @@
-package io.github.dfa1.typesafe.core;
-
-import io.github.dfa1.typesafe.transport.HttpTransport;
+package io.github.dfa1.typesafe.client;
 
+import io.github.dfa1.typesafe.core.EvaluateRequest;
+import io.github.dfa1.typesafe.core.EvaluateResponse;
+import io.github.dfa1.typesafe.core.ModelDetails;
 import java.util.List;
 import java.util.concurrent.CompletableFuture;
 
 /**
- * An interface, not a final class, so a caller can wrap one behind a decorator (caching,
- * metrics, a circuit breaker, ...) that's itself substitutable anywhere a {@code TypeSafeClient}
- * is expected. {@link #builder} is the only supported way to obtain a real one, backed by
- * {@link DefaultTypeSafeClient}, the sole implementation this library ships.
+ * The TypeSafe contract: a request in, a response out. An interface, so a caller can wrap one
+ * behind a decorator (caching, metrics, a circuit breaker, ...) that's itself substitutable
+ * anywhere a {@code TypeSafeClient} is expected. Implemented by {@code DefaultTypeSafeClient}
+ * (typesafe-java-client-http, the TypeSafe API over HTTP) and the in-process clients of
+ * typesafe-java-client-local.
  */
 public interface TypeSafeClient extends AutoCloseable {
 
-    static DefaultTypeSafeClient.Builder builder() {
-        return DefaultTypeSafeClient.builder();
-    }
-
     /**
      * Evaluates {@code request} synchronously, blocking until a response arrives. A client from
-     * {@link #builder} makes exactly one attempt; wrap it in {@link RetryingTypeSafeClient} to
+     * {@code DefaultTypeSafeClient} makes exactly one attempt; wrap it in {@link RetryingTypeSafeClient} to
      * retry. All of the
      * following throw {@link TypeSafeException}: any other non-{@code 200} status; a connection
      * failure/timeout ({@link TypeSafeException.Connection}/
@@ -39,7 +37,7 @@ static DefaultTypeSafeClient.Builder builder() {
     /** Lists the models available to the account. */
     List listModels();
 
-    /** Closes the underlying {@link HttpTransport}, releasing any resources it holds. */
+    /** Releases whatever the client holds (an HTTP transport, a model session, ...). */
     @Override
     void close();
 }
diff --git a/core/src/main/java/io/github/dfa1/typesafe/core/TypeSafeException.java b/client/src/main/java/io/github/dfa1/typesafe/client/TypeSafeException.java
similarity index 97%
rename from core/src/main/java/io/github/dfa1/typesafe/core/TypeSafeException.java
rename to client/src/main/java/io/github/dfa1/typesafe/client/TypeSafeException.java
index a654a7a..e1f9545 100644
--- a/core/src/main/java/io/github/dfa1/typesafe/core/TypeSafeException.java
+++ b/client/src/main/java/io/github/dfa1/typesafe/client/TypeSafeException.java
@@ -1,4 +1,4 @@
-package io.github.dfa1.typesafe.core;
+package io.github.dfa1.typesafe.client;
 
 import java.time.Duration;
 import java.util.Optional;
@@ -99,7 +99,7 @@ public Optional retryAfter() {
         }
     }
 
-    /** {@code 200}, but the configured {@link io.github.dfa1.typesafe.json.JsonCodec} couldn't
+    /** {@code 200}, but the configured {@code Codec} couldn't
      *  decode the response body. */
     public static final class ResponseDecoding extends TypeSafeException {
         public ResponseDecoding(String body, Throwable cause) {
diff --git a/core/src/test/java/io/github/dfa1/typesafe/core/DeadlineTypeSafeClientTest.java b/client/src/test/java/io/github/dfa1/typesafe/client/DeadlineTypeSafeClientTest.java
similarity index 92%
rename from core/src/test/java/io/github/dfa1/typesafe/core/DeadlineTypeSafeClientTest.java
rename to client/src/test/java/io/github/dfa1/typesafe/client/DeadlineTypeSafeClientTest.java
index 63ad3f1..c84db7f 100644
--- a/core/src/test/java/io/github/dfa1/typesafe/core/DeadlineTypeSafeClientTest.java
+++ b/client/src/test/java/io/github/dfa1/typesafe/client/DeadlineTypeSafeClientTest.java
@@ -1,5 +1,11 @@
-package io.github.dfa1.typesafe.core;
-
+package io.github.dfa1.typesafe.client;
+
+import io.github.dfa1.typesafe.core.Content;
+import io.github.dfa1.typesafe.core.EvaluateRequest;
+import io.github.dfa1.typesafe.core.EvaluateResponse;
+import io.github.dfa1.typesafe.core.Model;
+import io.github.dfa1.typesafe.core.ModelDetails;
+import io.github.dfa1.typesafe.core.Usage;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.Mock;
diff --git a/core/src/test/java/io/github/dfa1/typesafe/core/RetryingTypeSafeClientTest.java b/client/src/test/java/io/github/dfa1/typesafe/client/RetryingTypeSafeClientTest.java
similarity index 95%
rename from core/src/test/java/io/github/dfa1/typesafe/core/RetryingTypeSafeClientTest.java
rename to client/src/test/java/io/github/dfa1/typesafe/client/RetryingTypeSafeClientTest.java
index bc126b0..d5f4f57 100644
--- a/core/src/test/java/io/github/dfa1/typesafe/core/RetryingTypeSafeClientTest.java
+++ b/client/src/test/java/io/github/dfa1/typesafe/client/RetryingTypeSafeClientTest.java
@@ -1,5 +1,11 @@
-package io.github.dfa1.typesafe.core;
-
+package io.github.dfa1.typesafe.client;
+
+import io.github.dfa1.typesafe.core.Content;
+import io.github.dfa1.typesafe.core.EvaluateRequest;
+import io.github.dfa1.typesafe.core.EvaluateResponse;
+import io.github.dfa1.typesafe.core.Model;
+import io.github.dfa1.typesafe.core.ModelDetails;
+import io.github.dfa1.typesafe.core.Usage;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.Mock;
diff --git a/core/src/test/java/io/github/dfa1/typesafe/core/TokenCounterTest.java b/client/src/test/java/io/github/dfa1/typesafe/client/TokenCounterTest.java
similarity index 89%
rename from core/src/test/java/io/github/dfa1/typesafe/core/TokenCounterTest.java
rename to client/src/test/java/io/github/dfa1/typesafe/client/TokenCounterTest.java
index af9b4c8..f9408c1 100644
--- a/core/src/test/java/io/github/dfa1/typesafe/core/TokenCounterTest.java
+++ b/client/src/test/java/io/github/dfa1/typesafe/client/TokenCounterTest.java
@@ -1,5 +1,11 @@
-package io.github.dfa1.typesafe.core;
-
+package io.github.dfa1.typesafe.client;
+
+import io.github.dfa1.typesafe.core.Content;
+import io.github.dfa1.typesafe.core.EvaluateRequest;
+import io.github.dfa1.typesafe.core.EvaluateResponse;
+import io.github.dfa1.typesafe.core.Model;
+import io.github.dfa1.typesafe.core.ModelDetails;
+import io.github.dfa1.typesafe.core.Usage;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.Mock;
diff --git a/core/src/test/java/io/github/dfa1/typesafe/core/TypeSafeExceptionTest.java b/client/src/test/java/io/github/dfa1/typesafe/client/TypeSafeExceptionTest.java
similarity index 98%
rename from core/src/test/java/io/github/dfa1/typesafe/core/TypeSafeExceptionTest.java
rename to client/src/test/java/io/github/dfa1/typesafe/client/TypeSafeExceptionTest.java
index 8cfb8af..63a2f5a 100644
--- a/core/src/test/java/io/github/dfa1/typesafe/core/TypeSafeExceptionTest.java
+++ b/client/src/test/java/io/github/dfa1/typesafe/client/TypeSafeExceptionTest.java
@@ -1,4 +1,4 @@
-package io.github.dfa1.typesafe.core;
+package io.github.dfa1.typesafe.client;
 
 import org.junit.jupiter.api.Test;
 
diff --git a/jackson2/pom.xml b/codec-jackson2/pom.xml
similarity index 72%
rename from jackson2/pom.xml
rename to codec-jackson2/pom.xml
index cb1a5a8..4aa5d29 100644
--- a/jackson2/pom.xml
+++ b/codec-jackson2/pom.xml
@@ -7,15 +7,20 @@
     0.7.0-SNAPSHOT
   
 
-  typesafe-java-jackson2
+  typesafe-java-codec-jackson2
   jar
 
-  TypeSafe Jackson 2 Codec
-  JsonCodec SPI implementation backed by Jackson 2.x. Depends only on
-    typesafe-java-core; discovered via ServiceLoader.
+  TypeSafe Codec Jackson 2
+  Codec implementation backed by Jackson 2.x. Depends only on
+    typesafe-java-codec and typesafe-java-core; discovered via ServiceLoader.
   
 
   
+    
+      io.github.dfa1.typesafe-java
+      typesafe-java-codec
+      ${project.version}
+    
     
       io.github.dfa1.typesafe-java
       typesafe-java-core
diff --git a/jackson2/src/main/java/io/github/dfa1/typesafe/jackson2/ContentSerializer.java b/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/ContentSerializer.java
similarity index 94%
rename from jackson2/src/main/java/io/github/dfa1/typesafe/jackson2/ContentSerializer.java
rename to codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/ContentSerializer.java
index 140c3fb..19527dd 100644
--- a/jackson2/src/main/java/io/github/dfa1/typesafe/jackson2/ContentSerializer.java
+++ b/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/ContentSerializer.java
@@ -1,4 +1,4 @@
-package io.github.dfa1.typesafe.jackson2;
+package io.github.dfa1.typesafe.codec.jackson2;
 
 import io.github.dfa1.typesafe.core.Content;
 import com.fasterxml.jackson.core.JsonGenerator;
diff --git a/jackson2/src/main/java/io/github/dfa1/typesafe/jackson2/Jackson2Codec.java b/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2Codec.java
similarity index 73%
rename from jackson2/src/main/java/io/github/dfa1/typesafe/jackson2/Jackson2Codec.java
rename to codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2Codec.java
index 782a8c0..8cd3abf 100644
--- a/jackson2/src/main/java/io/github/dfa1/typesafe/jackson2/Jackson2Codec.java
+++ b/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2Codec.java
@@ -1,29 +1,36 @@
-package io.github.dfa1.typesafe.jackson2;
+package io.github.dfa1.typesafe.codec.jackson2;
 
 import io.github.dfa1.typesafe.core.Answer;
 import io.github.dfa1.typesafe.core.Content;
 import io.github.dfa1.typesafe.core.Model;
 import io.github.dfa1.typesafe.core.Question;
 import io.github.dfa1.typesafe.core.RequestId;
-import io.github.dfa1.typesafe.json.JsonCodec;
+import io.github.dfa1.typesafe.codec.Codec;
 import com.fasterxml.jackson.annotation.JsonSubTypes;
 import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.core.JsonFactory;
 import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.json.JsonWriteFeature;
 import com.fasterxml.jackson.databind.DeserializationFeature;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.PropertyNamingStrategies;
 import com.fasterxml.jackson.databind.module.SimpleModule;
 
+import java.io.IOException;
 import java.io.UncheckedIOException;
 
 /**
- * JsonCodec backed by Jackson 2.x. Owns the polymorphic {@code type} discriminator for
+ * Codec backed by Jackson 2.x. Owns the polymorphic {@code type} discriminator for
  * {@link Answer} and {@link Question} via mixins, since the client DTOs carry no Jackson
  * annotations of their own.
  */
-public final class Jackson2Codec implements JsonCodec {
+public final class Jackson2Codec implements Codec {
 
-    private final ObjectMapper mapper = new ObjectMapper()
+    // UTF-8 output escapes characters outside the BMP (e.g. emoji) as surrogate pairs unless told otherwise;
+    // a local model tokenizes the text, so they must stay one character, as Jackson 3 writes them
+    private final ObjectMapper mapper = new ObjectMapper(JsonFactory.builder()
+            .enable(JsonWriteFeature.COMBINE_UNICODE_SURROGATES_IN_UTF8)
+            .build())
             .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) // a field the API adds must not break old clients; Jackson 3's default
             .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
             .addMixIn(Answer.class, AnswerMixIn.class)
@@ -36,28 +43,19 @@ public final class Jackson2Codec implements JsonCodec {
                     .addDeserializer(RequestId.class, new RequestIdDeserializer()));
 
     @Override
-    public String writeValueAsString(Object value) {
+    public byte[] writeValueAsBytes(Object value) {
         try {
-            return mapper.writeValueAsString(value);
+            return mapper.writeValueAsBytes(value);
         } catch (JsonProcessingException e) {
             throw new UncheckedIOException(e);
         }
     }
 
     @Override
-    public String writeValueAsPrettyString(Object value) {
-        try {
-            return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);
-        } catch (JsonProcessingException e) {
-            throw new UncheckedIOException(e);
-        }
-    }
-
-    @Override
-    public  T readValue(String content, Class type) {
+    public  T readValue(byte[] content, Class type) {
         try {
             return mapper.readValue(content, type);
-        } catch (JsonProcessingException e) {
+        } catch (IOException e) {
             throw new UncheckedIOException(e);
         }
     }
diff --git a/jackson2/src/main/java/io/github/dfa1/typesafe/jackson2/ModelDeserializer.java b/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/ModelDeserializer.java
similarity index 92%
rename from jackson2/src/main/java/io/github/dfa1/typesafe/jackson2/ModelDeserializer.java
rename to codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/ModelDeserializer.java
index 6a3ae18..a414814 100644
--- a/jackson2/src/main/java/io/github/dfa1/typesafe/jackson2/ModelDeserializer.java
+++ b/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/ModelDeserializer.java
@@ -1,4 +1,4 @@
-package io.github.dfa1.typesafe.jackson2;
+package io.github.dfa1.typesafe.codec.jackson2;
 
 import io.github.dfa1.typesafe.core.Model;
 import com.fasterxml.jackson.core.JsonParser;
diff --git a/jackson2/src/main/java/io/github/dfa1/typesafe/jackson2/ModelSerializer.java b/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/ModelSerializer.java
similarity index 92%
rename from jackson2/src/main/java/io/github/dfa1/typesafe/jackson2/ModelSerializer.java
rename to codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/ModelSerializer.java
index b4de4a6..51a8027 100644
--- a/jackson2/src/main/java/io/github/dfa1/typesafe/jackson2/ModelSerializer.java
+++ b/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/ModelSerializer.java
@@ -1,4 +1,4 @@
-package io.github.dfa1.typesafe.jackson2;
+package io.github.dfa1.typesafe.codec.jackson2;
 
 import io.github.dfa1.typesafe.core.Model;
 import com.fasterxml.jackson.core.JsonGenerator;
diff --git a/jackson2/src/main/java/io/github/dfa1/typesafe/jackson2/RequestIdDeserializer.java b/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/RequestIdDeserializer.java
similarity index 92%
rename from jackson2/src/main/java/io/github/dfa1/typesafe/jackson2/RequestIdDeserializer.java
rename to codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/RequestIdDeserializer.java
index e28b4b9..d72f4f9 100644
--- a/jackson2/src/main/java/io/github/dfa1/typesafe/jackson2/RequestIdDeserializer.java
+++ b/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/RequestIdDeserializer.java
@@ -1,4 +1,4 @@
-package io.github.dfa1.typesafe.jackson2;
+package io.github.dfa1.typesafe.codec.jackson2;
 
 import io.github.dfa1.typesafe.core.RequestId;
 import com.fasterxml.jackson.core.JsonParser;
diff --git a/jackson2/src/main/java/io/github/dfa1/typesafe/jackson2/RequestIdSerializer.java b/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/RequestIdSerializer.java
similarity index 92%
rename from jackson2/src/main/java/io/github/dfa1/typesafe/jackson2/RequestIdSerializer.java
rename to codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/RequestIdSerializer.java
index 12cf717..82e00a0 100644
--- a/jackson2/src/main/java/io/github/dfa1/typesafe/jackson2/RequestIdSerializer.java
+++ b/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/RequestIdSerializer.java
@@ -1,4 +1,4 @@
-package io.github.dfa1.typesafe.jackson2;
+package io.github.dfa1.typesafe.codec.jackson2;
 
 import io.github.dfa1.typesafe.core.RequestId;
 import com.fasterxml.jackson.core.JsonGenerator;
diff --git a/codec-jackson2/src/main/resources/META-INF/services/io.github.dfa1.typesafe.codec.Codec b/codec-jackson2/src/main/resources/META-INF/services/io.github.dfa1.typesafe.codec.Codec
new file mode 100644
index 0000000..7474175
--- /dev/null
+++ b/codec-jackson2/src/main/resources/META-INF/services/io.github.dfa1.typesafe.codec.Codec
@@ -0,0 +1 @@
+io.github.dfa1.typesafe.codec.jackson2.Jackson2Codec
diff --git a/jackson2/src/test/java/io/github/dfa1/typesafe/jackson2/Jackson2CodecTest.java b/codec-jackson2/src/test/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2CodecTest.java
similarity index 79%
rename from jackson2/src/test/java/io/github/dfa1/typesafe/jackson2/Jackson2CodecTest.java
rename to codec-jackson2/src/test/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2CodecTest.java
index 1738797..89bc024 100644
--- a/jackson2/src/test/java/io/github/dfa1/typesafe/jackson2/Jackson2CodecTest.java
+++ b/codec-jackson2/src/test/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2CodecTest.java
@@ -1,5 +1,6 @@
-package io.github.dfa1.typesafe.jackson2;
+package io.github.dfa1.typesafe.codec.jackson2;
 
+import java.nio.charset.StandardCharsets;
 import io.github.dfa1.typesafe.core.Answer;
 import io.github.dfa1.typesafe.core.EvaluateRequest;
 import io.github.dfa1.typesafe.core.EvaluateResponse;
@@ -36,7 +37,7 @@ void serializesEachQuestionTypeWithItsDiscriminator() {
                 ));
 
         // When
-        String result = sut.writeValueAsString(request);
+        String result = json(sut.writeValueAsBytes(request));
 
         // Then
         assertThat(result)
@@ -47,18 +48,6 @@ void serializesEachQuestionTypeWithItsDiscriminator() {
                 .contains("\"state\":\"Help! My payouts have been failing for 3 days.\"");
     }
 
-    @Test
-    void writeValueAsPrettyStringIndentsTheOutput() {
-        // Given
-        Content state = Content.fields(Map.of("order_id", "A-104"));
-
-        // When
-        String result = sut.writeValueAsPrettyString(state);
-
-        // Then
-        assertThat(result).isEqualTo("{\n  \"order_id\" : \"A-104\"\n}");
-    }
-
     @Test
     void serializesStructuredInstructionsAsARawJsonObject() {
         // Given
@@ -67,7 +56,7 @@ void serializesStructuredInstructionsAsARawJsonObject() {
                 "question", "Is the resume for the same person as `potential_duplicate`?")));
 
         // When
-        String result = sut.writeValueAsString(question);
+        String result = json(sut.writeValueAsBytes(question));
 
         // Then
         assertThat(result)
@@ -79,10 +68,10 @@ void serializesStructuredInstructionsAsARawJsonObject() {
     @Test
     void serializesEachStateShapeAsItsRawJsonType() {
         // When / Then
-        assertThat(sut.writeValueAsString(Content.text("hi"))).isEqualTo("\"hi\"");
-        assertThat(sut.writeValueAsString(Content.fields(Map.of("order_id", "A-104"))))
+        assertThat(json(sut.writeValueAsBytes(Content.text("hi")))).isEqualTo("\"hi\"");
+        assertThat(json(sut.writeValueAsBytes(Content.fields(Map.of("order_id", "A-104")))))
                 .isEqualTo("{\"order_id\":\"A-104\"}");
-        assertThat(sut.writeValueAsString(Content.messages(List.of("hi", "there"))))
+        assertThat(json(sut.writeValueAsBytes(Content.messages(List.of("hi", "there")))))
                 .isEqualTo("[\"hi\",\"there\"]");
     }
 
@@ -105,7 +94,7 @@ void deserializesEachAnswerTypeFromItsDiscriminator() {
                 """;
 
         // When
-        EvaluateResponse result = sut.readValue(json, EvaluateResponse.class);
+        EvaluateResponse result = sut.readValue(bytes(json), EvaluateResponse.class);
 
         // Then
         assertThat(result.model()).isEqualTo(Model.LATEST);
@@ -126,7 +115,7 @@ void deserializesModelDetailsFromTheModelsListingShape() {
                 """;
 
         // When
-        ModelDetails result = sut.readValue(json, ModelDetails.class);
+        ModelDetails result = sut.readValue(bytes(json), ModelDetails.class);
 
         // Then
         assertThat(result).isEqualTo(new ModelDetails("jev-1.13.0", "System One model.", "2026-01-01"));
@@ -145,7 +134,7 @@ void ignoresFieldsItDoesNotKnow() {
                 """;
 
         // When
-        EvaluateResponse result = sut.readValue(json, EvaluateResponse.class);
+        EvaluateResponse result = sut.readValue(bytes(json), EvaluateResponse.class);
 
         // Then
         assertThat(result.usage().inputTokens()).isEqualTo(312);
@@ -159,11 +148,11 @@ void serializesAndDeserializesRequestIdAsItsBareValue() {
         RequestId requestId = new RequestId("req_01a0c08d990e7e44ba9a80416308258a");
 
         // When
-        String result = sut.writeValueAsString(requestId);
+        String result = json(sut.writeValueAsBytes(requestId));
 
         // Then
         assertThat(result).isEqualTo("\"req_01a0c08d990e7e44ba9a80416308258a\"");
-        assertThat(sut.readValue(result, RequestId.class)).isEqualTo(requestId);
+        assertThat(sut.readValue(bytes(result), RequestId.class)).isEqualTo(requestId);
     }
 
     @Test
@@ -178,13 +167,30 @@ public String getValue() {
 
         // When / Then
         assertThatExceptionOfType(UncheckedIOException.class)
-                .isThrownBy(() -> sut.writeValueAsString(unserializable));
+                .isThrownBy(() -> json(sut.writeValueAsBytes(unserializable)));
     }
 
     @Test
     void readValueWrapsAJsonProcessingExceptionInAnUncheckedIOException() {
         // When / Then
         assertThatExceptionOfType(UncheckedIOException.class)
-                .isThrownBy(() -> sut.readValue("not json", EvaluateResponse.class));
+                .isThrownBy(() -> sut.readValue(bytes("not json"), EvaluateResponse.class));
+    }
+
+    @Test
+    void writesCharactersOutsideTheBmpAsTheyAreNotAsEscapedSurrogates() {
+        // When
+        String result = json(sut.writeValueAsBytes(Content.text("refund now \uD83D\uDE21")));
+
+        // Then — a local model tokenizes this text, so the emoji must survive as one UTF-8 character
+        assertThat(result).isEqualTo("\"refund now \uD83D\uDE21\"");
+    }
+
+    private static byte[] bytes(String text) {
+        return text.getBytes(StandardCharsets.UTF_8);
+    }
+
+    private static String json(byte[] utf8) {
+        return new String(utf8, StandardCharsets.UTF_8);
     }
 }
diff --git a/jackson3/pom.xml b/codec-jackson3/pom.xml
similarity index 72%
rename from jackson3/pom.xml
rename to codec-jackson3/pom.xml
index 38f463b..504c8eb 100644
--- a/jackson3/pom.xml
+++ b/codec-jackson3/pom.xml
@@ -7,15 +7,20 @@
     0.7.0-SNAPSHOT
   
 
-  typesafe-java-jackson3
+  typesafe-java-codec-jackson3
   jar
 
-  TypeSafe Jackson 3 Codec
-  JsonCodec SPI implementation backed by Jackson 3.x. Depends only on
-    typesafe-java-core; discovered via ServiceLoader.
+  TypeSafe Codec Jackson 3
+  Codec implementation backed by Jackson 3.x. Depends only on
+    typesafe-java-codec and typesafe-java-core; discovered via ServiceLoader.
   
 
   
+    
+      io.github.dfa1.typesafe-java
+      typesafe-java-codec
+      ${project.version}
+    
     
       io.github.dfa1.typesafe-java
       typesafe-java-core
diff --git a/jackson3/src/main/java/io/github/dfa1/typesafe/jackson3/ContentSerializer.java b/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/ContentSerializer.java
similarity index 94%
rename from jackson3/src/main/java/io/github/dfa1/typesafe/jackson3/ContentSerializer.java
rename to codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/ContentSerializer.java
index 5d63454..d6faf9f 100644
--- a/jackson3/src/main/java/io/github/dfa1/typesafe/jackson3/ContentSerializer.java
+++ b/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/ContentSerializer.java
@@ -1,4 +1,4 @@
-package io.github.dfa1.typesafe.jackson3;
+package io.github.dfa1.typesafe.codec.jackson3;
 
 import io.github.dfa1.typesafe.core.Content;
 import tools.jackson.core.JsonGenerator;
diff --git a/jackson3/src/main/java/io/github/dfa1/typesafe/jackson3/Jackson3Codec.java b/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3Codec.java
similarity index 79%
rename from jackson3/src/main/java/io/github/dfa1/typesafe/jackson3/Jackson3Codec.java
rename to codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3Codec.java
index cfc73d5..d92a6e0 100644
--- a/jackson3/src/main/java/io/github/dfa1/typesafe/jackson3/Jackson3Codec.java
+++ b/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3Codec.java
@@ -1,11 +1,11 @@
-package io.github.dfa1.typesafe.jackson3;
+package io.github.dfa1.typesafe.codec.jackson3;
 
 import io.github.dfa1.typesafe.core.Answer;
 import io.github.dfa1.typesafe.core.Content;
 import io.github.dfa1.typesafe.core.Model;
 import io.github.dfa1.typesafe.core.Question;
 import io.github.dfa1.typesafe.core.RequestId;
-import io.github.dfa1.typesafe.json.JsonCodec;
+import io.github.dfa1.typesafe.codec.Codec;
 import com.fasterxml.jackson.annotation.JsonSubTypes;
 import com.fasterxml.jackson.annotation.JsonTypeInfo;
 import tools.jackson.databind.ObjectMapper;
@@ -14,11 +14,11 @@
 import tools.jackson.databind.module.SimpleModule;
 
 /**
- * JsonCodec backed by Jackson 3.x. Owns the polymorphic {@code type} discriminator for
+ * Codec backed by Jackson 3.x. Owns the polymorphic {@code type} discriminator for
  * {@link Answer} and {@link Question} via mixins, since the client DTOs carry no Jackson
  * annotations of their own.
  */
-public final class Jackson3Codec implements JsonCodec {
+public final class Jackson3Codec implements Codec {
 
     private final ObjectMapper mapper = JsonMapper.builder()
             .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
@@ -33,17 +33,12 @@ public final class Jackson3Codec implements JsonCodec {
             .build();
 
     @Override
-    public String writeValueAsString(Object value) {
-        return mapper.writeValueAsString(value);
+    public byte[] writeValueAsBytes(Object value) {
+        return mapper.writeValueAsBytes(value);
     }
 
     @Override
-    public String writeValueAsPrettyString(Object value) {
-        return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);
-    }
-
-    @Override
-    public  T readValue(String content, Class type) {
+    public  T readValue(byte[] content, Class type) {
         return mapper.readValue(content, type);
     }
 
diff --git a/jackson3/src/main/java/io/github/dfa1/typesafe/jackson3/ModelDeserializer.java b/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/ModelDeserializer.java
similarity index 91%
rename from jackson3/src/main/java/io/github/dfa1/typesafe/jackson3/ModelDeserializer.java
rename to codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/ModelDeserializer.java
index 01c743f..472fed4 100644
--- a/jackson3/src/main/java/io/github/dfa1/typesafe/jackson3/ModelDeserializer.java
+++ b/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/ModelDeserializer.java
@@ -1,4 +1,4 @@
-package io.github.dfa1.typesafe.jackson3;
+package io.github.dfa1.typesafe.codec.jackson3;
 
 import io.github.dfa1.typesafe.core.Model;
 import tools.jackson.core.JsonParser;
diff --git a/jackson3/src/main/java/io/github/dfa1/typesafe/jackson3/ModelSerializer.java b/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/ModelSerializer.java
similarity index 91%
rename from jackson3/src/main/java/io/github/dfa1/typesafe/jackson3/ModelSerializer.java
rename to codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/ModelSerializer.java
index 90773c9..27868e3 100644
--- a/jackson3/src/main/java/io/github/dfa1/typesafe/jackson3/ModelSerializer.java
+++ b/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/ModelSerializer.java
@@ -1,4 +1,4 @@
-package io.github.dfa1.typesafe.jackson3;
+package io.github.dfa1.typesafe.codec.jackson3;
 
 import io.github.dfa1.typesafe.core.Model;
 import tools.jackson.core.JsonGenerator;
diff --git a/jackson3/src/main/java/io/github/dfa1/typesafe/jackson3/RequestIdDeserializer.java b/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/RequestIdDeserializer.java
similarity index 91%
rename from jackson3/src/main/java/io/github/dfa1/typesafe/jackson3/RequestIdDeserializer.java
rename to codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/RequestIdDeserializer.java
index 3665211..62fbbd6 100644
--- a/jackson3/src/main/java/io/github/dfa1/typesafe/jackson3/RequestIdDeserializer.java
+++ b/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/RequestIdDeserializer.java
@@ -1,4 +1,4 @@
-package io.github.dfa1.typesafe.jackson3;
+package io.github.dfa1.typesafe.codec.jackson3;
 
 import io.github.dfa1.typesafe.core.RequestId;
 import tools.jackson.core.JsonParser;
diff --git a/jackson3/src/main/java/io/github/dfa1/typesafe/jackson3/RequestIdSerializer.java b/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/RequestIdSerializer.java
similarity index 91%
rename from jackson3/src/main/java/io/github/dfa1/typesafe/jackson3/RequestIdSerializer.java
rename to codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/RequestIdSerializer.java
index d3de05a..36ef083 100644
--- a/jackson3/src/main/java/io/github/dfa1/typesafe/jackson3/RequestIdSerializer.java
+++ b/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/RequestIdSerializer.java
@@ -1,4 +1,4 @@
-package io.github.dfa1.typesafe.jackson3;
+package io.github.dfa1.typesafe.codec.jackson3;
 
 import io.github.dfa1.typesafe.core.RequestId;
 import tools.jackson.core.JsonGenerator;
diff --git a/codec-jackson3/src/main/resources/META-INF/services/io.github.dfa1.typesafe.codec.Codec b/codec-jackson3/src/main/resources/META-INF/services/io.github.dfa1.typesafe.codec.Codec
new file mode 100644
index 0000000..06e6c79
--- /dev/null
+++ b/codec-jackson3/src/main/resources/META-INF/services/io.github.dfa1.typesafe.codec.Codec
@@ -0,0 +1 @@
+io.github.dfa1.typesafe.codec.jackson3.Jackson3Codec
diff --git a/jackson3/src/test/java/io/github/dfa1/typesafe/jackson3/Jackson3CodecTest.java b/codec-jackson3/src/test/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3CodecTest.java
similarity index 79%
rename from jackson3/src/test/java/io/github/dfa1/typesafe/jackson3/Jackson3CodecTest.java
rename to codec-jackson3/src/test/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3CodecTest.java
index 143c715..773afa8 100644
--- a/jackson3/src/test/java/io/github/dfa1/typesafe/jackson3/Jackson3CodecTest.java
+++ b/codec-jackson3/src/test/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3CodecTest.java
@@ -1,5 +1,6 @@
-package io.github.dfa1.typesafe.jackson3;
+package io.github.dfa1.typesafe.codec.jackson3;
 
+import java.nio.charset.StandardCharsets;
 import io.github.dfa1.typesafe.core.Answer;
 import io.github.dfa1.typesafe.core.EvaluateRequest;
 import io.github.dfa1.typesafe.core.EvaluateResponse;
@@ -34,7 +35,7 @@ void serializesEachQuestionTypeWithItsDiscriminator() {
                 ));
 
         // When
-        String result = sut.writeValueAsString(request);
+        String result = json(sut.writeValueAsBytes(request));
 
         // Then
         assertThat(result)
@@ -45,18 +46,6 @@ void serializesEachQuestionTypeWithItsDiscriminator() {
                 .contains("\"state\":\"Help! My payouts have been failing for 3 days.\"");
     }
 
-    @Test
-    void writeValueAsPrettyStringIndentsTheOutput() {
-        // Given
-        Content state = Content.fields(Map.of("order_id", "A-104"));
-
-        // When
-        String result = sut.writeValueAsPrettyString(state);
-
-        // Then
-        assertThat(result).isEqualTo("{\n  \"order_id\" : \"A-104\"\n}");
-    }
-
     @Test
     void serializesStructuredInstructionsAsARawJsonObject() {
         // Given
@@ -65,7 +54,7 @@ void serializesStructuredInstructionsAsARawJsonObject() {
                 "question", "Is the resume for the same person as `potential_duplicate`?")));
 
         // When
-        String result = sut.writeValueAsString(question);
+        String result = json(sut.writeValueAsBytes(question));
 
         // Then
         assertThat(result)
@@ -77,10 +66,10 @@ void serializesStructuredInstructionsAsARawJsonObject() {
     @Test
     void serializesEachStateShapeAsItsRawJsonType() {
         // When / Then
-        assertThat(sut.writeValueAsString(Content.text("hi"))).isEqualTo("\"hi\"");
-        assertThat(sut.writeValueAsString(Content.fields(Map.of("order_id", "A-104"))))
+        assertThat(json(sut.writeValueAsBytes(Content.text("hi")))).isEqualTo("\"hi\"");
+        assertThat(json(sut.writeValueAsBytes(Content.fields(Map.of("order_id", "A-104")))))
                 .isEqualTo("{\"order_id\":\"A-104\"}");
-        assertThat(sut.writeValueAsString(Content.messages(List.of("hi", "there"))))
+        assertThat(json(sut.writeValueAsBytes(Content.messages(List.of("hi", "there")))))
                 .isEqualTo("[\"hi\",\"there\"]");
     }
 
@@ -103,7 +92,7 @@ void deserializesEachAnswerTypeFromItsDiscriminator() {
                 """;
 
         // When
-        EvaluateResponse result = sut.readValue(json, EvaluateResponse.class);
+        EvaluateResponse result = sut.readValue(bytes(json), EvaluateResponse.class);
 
         // Then
         assertThat(result.model()).isEqualTo(Model.LATEST);
@@ -124,7 +113,7 @@ void deserializesModelDetailsFromTheModelsListingShape() {
                 """;
 
         // When
-        ModelDetails result = sut.readValue(json, ModelDetails.class);
+        ModelDetails result = sut.readValue(bytes(json), ModelDetails.class);
 
         // Then
         assertThat(result).isEqualTo(new ModelDetails("jev-1.13.0", "System One model.", "2026-01-01"));
@@ -143,7 +132,7 @@ void ignoresFieldsItDoesNotKnow() {
                 """;
 
         // When
-        EvaluateResponse result = sut.readValue(json, EvaluateResponse.class);
+        EvaluateResponse result = sut.readValue(bytes(json), EvaluateResponse.class);
 
         // Then
         assertThat(result.usage().inputTokens()).isEqualTo(312);
@@ -157,10 +146,27 @@ void serializesAndDeserializesRequestIdAsItsBareValue() {
         RequestId requestId = new RequestId("req_01a0c08d990e7e44ba9a80416308258a");
 
         // When
-        String result = sut.writeValueAsString(requestId);
+        String result = json(sut.writeValueAsBytes(requestId));
 
         // Then
         assertThat(result).isEqualTo("\"req_01a0c08d990e7e44ba9a80416308258a\"");
-        assertThat(sut.readValue(result, RequestId.class)).isEqualTo(requestId);
+        assertThat(sut.readValue(bytes(result), RequestId.class)).isEqualTo(requestId);
+    }
+
+    @Test
+    void writesCharactersOutsideTheBmpAsTheyAreNotAsEscapedSurrogates() {
+        // When
+        String result = json(sut.writeValueAsBytes(Content.text("refund now \uD83D\uDE21")));
+
+        // Then — a local model tokenizes this text, so the emoji must survive as one UTF-8 character
+        assertThat(result).isEqualTo("\"refund now \uD83D\uDE21\"");
+    }
+
+    private static byte[] bytes(String text) {
+        return text.getBytes(StandardCharsets.UTF_8);
+    }
+
+    private static String json(byte[] utf8) {
+        return new String(utf8, StandardCharsets.UTF_8);
     }
 }
diff --git a/codec/pom.xml b/codec/pom.xml
new file mode 100644
index 0000000..c312bb1
--- /dev/null
+++ b/codec/pom.xml
@@ -0,0 +1,17 @@
+
+  4.0.0
+
+  
+    io.github.dfa1.typesafe-java
+    typesafe-java
+    0.7.0-SNAPSHOT
+  
+
+  typesafe-java-codec
+  jar
+
+  TypeSafe Codec
+  The Codec SPI: serialization to and from bytes, implemented by the codec-* modules
+    (codec-jackson2, codec-jackson3) and discovered via ServiceLoader. No dependencies.
+  
+
diff --git a/codec/src/main/java/io/github/dfa1/typesafe/codec/Codec.java b/codec/src/main/java/io/github/dfa1/typesafe/codec/Codec.java
new file mode 100644
index 0000000..24f03a5
--- /dev/null
+++ b/codec/src/main/java/io/github/dfa1/typesafe/codec/Codec.java
@@ -0,0 +1,14 @@
+package io.github.dfa1.typesafe.codec;
+
+/**
+ * Serialization of the model to and from bytes, plugged in by a codec module, with no {@code String}
+ * in between. The ones shipped (typesafe-java-codec-jackson2, -jackson3) write UTF-8 JSON, which is
+ * what typesafe-java-client-http sends, since the TypeSafe API speaks JSON. Implementations are
+ * discovered via {@link java.util.ServiceLoader}.
+ */
+public interface Codec {
+
+    byte[] writeValueAsBytes(Object value);
+
+     T readValue(byte[] content, Class type);
+}
diff --git a/core/pom.xml b/core/pom.xml
index 60dcc6c..828dde8 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -12,11 +12,9 @@
 
   TypeSafe Core
   
-    TypeSafeClient, ApiKey, TypeSafeException, the wire DTOs, and the JsonCodec/HttpTransport
-    SPIs. Zero dependency on any particular JSON or HTTP library — plug in
-    typesafe-java-jackson2/typesafe-java-jackson3 and typesafe-java-client-jdk (or your
-    own implementations). The DTOs and JsonCodec SPI alone are also reusable by any other
-    integration (e.g. a Kafka producer/consumer) that needs to (de)serialize TypeSafe payloads.
+    The TypeSafe wire model, as plain records: Answer, Question, Content, EvaluateRequest/EvaluateResponse,
+    Usage, RequestId, Model, ModelDetails. No dependencies. With a codec-* module it serializes TypeSafe
+    payloads without any client code, e.g. for a Kafka producer/consumer.
   
 
   
@@ -31,17 +29,5 @@
       assertj-core
       test
     
-
-    
-      org.mockito
-      mockito-junit-jupiter
-      test
-    
-
-    
-      org.junit-pioneer
-      junit-pioneer
-      test
-    
   
 
diff --git a/core/src/main/java/io/github/dfa1/typesafe/core/EvaluateResponse.java b/core/src/main/java/io/github/dfa1/typesafe/core/EvaluateResponse.java
index a9ade37..228366a 100644
--- a/core/src/main/java/io/github/dfa1/typesafe/core/EvaluateResponse.java
+++ b/core/src/main/java/io/github/dfa1/typesafe/core/EvaluateResponse.java
@@ -10,12 +10,12 @@
  * docs.typesafe.ai/api.
  *
  * @param model    the versioned model that actually processed the request (e.g.
- *                 {@code "jev-1.13.0"}) — see {@link TypeSafeClient#listModels()} for its
+ *                 {@code "jev-1.13.0"}) — see {@code TypeSafeClient#listModels()} for its
  *                 description and release date
  * @param answers  one {@link Answer} per question, keyed identically to the request's
  *                 {@link EvaluateRequest#questions()}
  * @param usage    token accounting for the request
- * @param metadata not part of the response body itself — populated by {@link TypeSafeClient}
+ * @param metadata not part of the response body itself — populated by {@code TypeSafeClient}
  *                 from response headers
  */
 public record EvaluateResponse(Model model, Map answers, Usage usage, Metadata metadata) {
diff --git a/core/src/main/java/io/github/dfa1/typesafe/core/Model.java b/core/src/main/java/io/github/dfa1/typesafe/core/Model.java
index e3258f8..68c06a2 100644
--- a/core/src/main/java/io/github/dfa1/typesafe/core/Model.java
+++ b/core/src/main/java/io/github/dfa1/typesafe/core/Model.java
@@ -4,7 +4,7 @@
  * A model: either a symbolic alias the server resolves ({@link #LATEST}/{@link #PREVIEW}) or a
  * concrete, versioned id, e.g. {@code new Model("jev-1.13.0")}. Usable directly as an
  * {@link EvaluateRequest}'s model, and reported back by {@link EvaluateResponse#model()}. See
- * {@link TypeSafeClient#listModels()} for descriptions and release dates.
+ * {@code TypeSafeClient#listModels()} for descriptions and release dates.
  *
  * @param name the model's id (e.g. {@code "jev-latest"}, {@code "jev-1.13.0"})
  */
diff --git a/core/src/main/java/io/github/dfa1/typesafe/core/ModelDetails.java b/core/src/main/java/io/github/dfa1/typesafe/core/ModelDetails.java
index 3a5df19..8d55142 100644
--- a/core/src/main/java/io/github/dfa1/typesafe/core/ModelDetails.java
+++ b/core/src/main/java/io/github/dfa1/typesafe/core/ModelDetails.java
@@ -2,7 +2,7 @@
 
 /**
  * Metadata about one model available to the account, as returned by
- * {@link TypeSafeClient#listModels()}.
+ * {@code TypeSafeClient#listModels()}.
  *
  * @param name        model name or alias accepted by a request's {@code model} field (e.g.
  *                    {@code "jev-1.13.0"})
diff --git a/core/src/main/java/io/github/dfa1/typesafe/json/JsonCodec.java b/core/src/main/java/io/github/dfa1/typesafe/json/JsonCodec.java
deleted file mode 100644
index 4ae7b9d..0000000
--- a/core/src/main/java/io/github/dfa1/typesafe/json/JsonCodec.java
+++ /dev/null
@@ -1,15 +0,0 @@
-package io.github.dfa1.typesafe.json;
-
-/**
- * JSON serialization plugged in by a codec module (typesafe-jackson2 or typesafe-jackson3).
- * Implementations are discovered via {@link java.util.ServiceLoader}.
- */
-public interface JsonCodec {
-
-    String writeValueAsString(Object value);
-
-    /** Same as {@link #writeValueAsString(Object)}, indented for human reading. */
-    String writeValueAsPrettyString(Object value);
-
-     T readValue(String content, Class type);
-}
diff --git a/docs/explanation.md b/docs/explanation.md
index 3beb6ae..3bad682 100644
--- a/docs/explanation.md
+++ b/docs/explanation.md
@@ -3,11 +3,11 @@
 Background reading on the design decisions behind this library. For "what exists," see
 [reference.md](reference.md); for "how do I," see [how-to.md](how-to.md).
 
-## Why `local` exists, and why it reads answers instead of generating them
+## Why `client-local` exists, and why it reads answers instead of generating them
 
 `TypeSafeClient` is an interface, so an in-process engine is just another implementation: everything built on it
 (`MappingTypeSafeClient`, `RetryingTypeSafeClient`, `TokenCounter`, the testkit) keeps working. Jev's weights aren't
-public, so `local` offers API parity with a different model behind it, and says so.
+public, so `client-local` offers API parity with a different model behind it, and says so.
 
 Jev's answers are probability distributions, never text, and every engine produces distributions directly. Laya, a
 ModernBERT encoder trained for Choice/Score/Noul, scores each option with a decision head; Clef-flash, a 9B decision
@@ -23,7 +23,7 @@ slightly better on choices (68% vs 64%). Clef-flash, trained for exactly these d
 The module depends on `core` and ONNX Runtime. Tokenization is a small pure-Java byte-level BPE reading
 `tokenizer.json`, checked id for id against HuggingFace `tokenizers`. The alternatives were an 18 MB native tokenizer
 library, or a Java ML library whose tokenizer silently dropped newlines and special tokens. JSON (`tokenizer.json`,
-Laya's config, structured states) goes through the same `JsonCodec` the API client uses. Laya was trained on
+Laya's config, structured states) goes through the same `Codec` the API client uses. Laya was trained on
 Python's `json.dumps` spacing, but Jackson's compact output measured no different against Jev. Model files come from a
 directory the caller fills with `hf download`, never from a download at run time: the client works offline and a
 deployment pins exactly the files it runs. Laya's ONNX is
@@ -71,7 +71,7 @@ Jackson 2, and the DTOs themselves couldn't be reused (e.g. to serialize the sam
 Kafka topic) without dragging in `java.net.http`-specific code too.
 
 Splitting the DTOs into `typesafe-java-core` with zero Jackson dependency, and moving the `type`
-discriminator logic into private mixins inside `jackson2`/`jackson3` (`ObjectMapper.addMixIn` /
+discriminator logic into private mixins inside `codec-jackson2`/`codec-jackson3` (`ObjectMapper.addMixIn` /
 `JsonMapper.Builder.addMixIn`), means:
 
 - `core` alone is a valid dependency for anything that just needs the payload shapes.
@@ -82,31 +82,40 @@ discriminator logic into private mixins inside `jackson2`/`jackson3` (`ObjectMap
 See [ADR 0001](../adr/0001-multi-module-layout-with-pluggable-json-codec.md) for the full
 decision record.
 
-## Why `JsonCodec` and `HttpTransport` are resolved via `ServiceLoader`, not a compile dependency
+## Why `Codec` and `HttpTransport` are resolved via `ServiceLoader`, not a compile dependency
 
-`core` cannot declare a compile dependency on `jackson2`/`jackson3` or on `client-jdk` —
-any of those choices would undo the whole point of splitting them out. `ServiceLoader` lets
-`TypeSafeClient` stay agnostic to both while still getting real implementations automatically
+`client-http` cannot declare a compile dependency on `codec-jackson2`/`codec-jackson3` or on
+`client-http-jdk` — any of those choices would undo the whole point of splitting them out.
+`ServiceLoader` lets `DefaultTypeSafeClient` stay agnostic to both while still getting real implementations automatically
 the moment one codec module and one transport module are on the classpath, the same pattern the
 JDK itself uses for `java.sql.Driver` or `java.nio.file.spi.FileSystemProvider`. The tradeoff: a
-missing codec or transport module fails at `TypeSafeClient.Builder.build()` time with a runtime
+missing codec or transport module fails at `DefaultTypeSafeClient.Builder.build()` time with a runtime
 `IllegalStateException`, not at compile time — deliberately, since a compile-time check here
 would mean picking one codec/transport as "the real dependency," which is exactly what this
 design avoids.
 
-## Why `HttpTransport` exists (and why `client` isn't a separate module)
+## Why `HttpTransport` exists
 
 `TypeSafeClient` originally called `java.net.http.HttpClient` directly. Abstracting that behind
-`HttpTransport` — mirroring `JsonCodec` — means someone who wants Apache HttpClient, OkHttp, or a
+`HttpTransport` — mirroring `Codec` — means someone who wants Apache HttpClient, OkHttp, or a
 mocked transport for tests can implement one interface (`post`/`get`, both already
 `CompletableFuture`-returning) instead of forking the client.
 
-Once that abstraction exists, `TypeSafeClient` itself has no HTTP-library dependency any more —
-its only import from `java.net` is `URI`, which every JDK module already has. That removed the
-original reason for a separate `client` module (keeping `core` free of `java.net.http`), so
-`TypeSafeClient`/`ApiKey`/`TypeSafeException` live in `core` next to the DTOs: one fewer module
-to version and depend on, with `core` exactly as dependency-free as before. See
-[ADR 0001](../adr/0001-multi-module-layout-with-pluggable-json-codec.md) for the full decision record.
+## Why the model is in `core`, the contract in `client`, and each client in its own module
+
+`core` is the model, as plain records, and nothing else. `client` is what every client agrees on:
+the `TypeSafeClient` interface, the decorators that wrap any implementation (retries, a deadline,
+token counting), and `TypeSafeException`. Each implementation is a `client-*` module on top of it:
+`client-http` calls the API, `client-local` runs a model in-process, and
+`client-mapping`/`client-testkit` build on the interface. None of them depends on another, and the
+compiler enforces it: `client` can't see `DefaultTypeSafeClient`, which is why `TypeSafeClient` has
+no `builder()`.
+
+`Codec`, the serialization SPI, is its own `codec` module, so `core` stays pure data and every
+`codec-*` module builds on `codec`, the way every `client-http-*` module builds on `client-http`.
+`client-http` and `client-local` use it; `core` plus a `codec-*` module is enough to serialize
+TypeSafe payloads (e.g. onto a Kafka topic) without any client code. Every module's directory,
+artifact and package share one name. See [ADR 0003](../adr/0003-model-in-core-contract-in-client.md).
 
 ## Why `TypeSafeClient` is an interface, not a final class
 
@@ -119,8 +128,8 @@ classic Decorator shape has no supertype to implement — they'd have to invent
 interface with the same three methods and get every call site to depend on that instead of on
 `TypeSafeClient` directly.
 
-Making it an interface costs nothing observable at existing call sites: `TypeSafeClient.builder().apiKey(key).build()`
-still type-checks and behaves identically, since `Builder.build()` always returned the interface
+Making it an interface cost nothing observable at existing call sites: `TypeSafeClient.builder().apiKey(key).build()`
+still type-checked and behaved identically, since `Builder.build()` always returned the interface
 type as far as callers could tell. What moved is the implementation — the retry/backoff/header/
 decode logic, previously `TypeSafeClient`'s own body, now lives in `DefaultTypeSafeClient`, the
 only concrete `TypeSafeClient` this library produces. A consumer can now write
@@ -129,14 +138,13 @@ was expected.
 
 `Builder` moved with it, onto `DefaultTypeSafeClient` rather than staying on the `TypeSafeClient`
 interface: constructing a `DefaultTypeSafeClient` — picking defaults, discovering a
-`HttpTransport`/`JsonCodec` via `ServiceLoader` — is that class's own concern, not something a
+`HttpTransport`/`Codec` via `ServiceLoader` — is that class's own concern, not something a
 pure contract interface should carry. That required making `DefaultTypeSafeClient` itself
-public (a nested class can't be more accessible than its enclosing class), so it's no longer
-hidden — but `TypeSafeClient.builder()` still exists as a one-line delegating static method
-on the interface, so nothing at the call site changes; a consumer only sees `DefaultTypeSafeClient`
-by name if they explicitly go looking for it.
+public (a nested class can't be more accessible than its enclosing class). The interface kept a
+one-line `TypeSafeClient.builder()` delegating to it until the module split moved
+`DefaultTypeSafeClient` into `client-http` (ADR 0003); callers now name it directly.
 
-## Why `testkit` ships a `TypeSafeClient` fake instead of "just mock it with Mockito"
+## Why `client-testkit` ships a `TypeSafeClient` fake instead of "just mock it with Mockito"
 
 Once `TypeSafeClient` became an interface (see above), `Mockito.mock(TypeSafeClient.class)` was
 already enough to stub `evaluate()`/`listModels()` — so `RecordingTypeSafeClient` isn't there to
@@ -147,7 +155,7 @@ every such test wants don't get rewritten by hand each time. It's deliberately a
 controls call order — it's the one deciding when to call `evaluate()`/`listModels()` — so
 matching by request content would only restate what the test already knows.
 
-## Why `mapping` uses reflection over records, not an annotation processor or a fluent builder
+## Why `client-mapping` uses reflection over records, not an annotation processor or a fluent builder
 
 [Issue #2](https://github.com/dfa1/typesafe-java/issues/2) flagged that reading an answer back
 means `Map` plus a manual `(Answer.Noul)`-style cast, and that a `Noul` question
@@ -160,11 +168,11 @@ Three ways to build that mapping, in ascending complexity: a fluent builder (no
 just explicit `.noul("isUrgent", "...")` calls mapped to record positions by hand — doesn't
 remove the cast, only moves it into the builder's own return type); reflection over
 `Class#getRecordComponents()` (no new build step, matches how the rest of this project already
-avoids codegen — `jackson2`/`jackson3`'s polymorphism is hand-written mixins, not generated); or
+avoids codegen — `codec-jackson2`/`codec-jackson3`'s polymorphism is hand-written mixins, not generated); or
 an annotation processor generating a real mapper class at compile time (fully typed at compile
 time, zero reflection cost per call, but a new `javac`-time dependency and generated-sources
 step nothing else in this repo has). Reflection won: even repeated, its cost is dwarfed by the
-network round trip each call wraps, and it keeps `mapping`'s dependency footprint identical to
+network round trip each call wraps, and it keeps `client-mapping`'s dependency footprint identical to
 every other module here (`core` only).
 
 That said, `MappingTypeSafeClient` still caches each record type's reflection metadata — its
@@ -193,14 +201,14 @@ the full type. Same annotation, same validation path, no new concepts.
 ## Why `MappingTypeSafeClient` doesn't have its own `Builder`
 
 The natural-looking ask — `MappingTypeSafeClient.builder()...build()`, mirroring
-`TypeSafeClient.builder()` — was rejected. `TypeSafeClient.builder` works because
+`DefaultTypeSafeClient.builder()` — was rejected. `TypeSafeClient.builder` works because
 `TypeSafeClient` has exactly one production implementation to build. `MappingTypeSafeClient` is
 a decorator, meant to wrap *any* `TypeSafeClient` (a plain one, one already wrapped in caching,
 a `FailingTypeSafeClient` for testing, a test double) — a builder that constructs its own
 `DefaultTypeSafeClient` internally would bake in "wrap a fresh default client" as the only path,
 against the entire reason the decorator shape exists (see "Why `TypeSafeClient` is an interface,
 not a final class" above). It would also duplicate `DefaultTypeSafeClient.Builder`'s whole
-surface (`httpTransport`, `jsonCodec`, `endpoint`, ...) as forwarding
+surface (`httpTransport`, `codec`, `endpoint`, ...) as forwarding
 methods that go stale the moment the original gains an option this copy doesn't.
 
 What shipped instead is a single addition to the *existing* `Builder`:
@@ -217,7 +225,7 @@ decorator" below). It returns the builder, not the decorator's type, so `build()
 
 ## Why `Content` is a sealed interface, not `Object`
 
-`EvaluateRequest.state()` used to be a bare `Object` — "whatever the caller's `JsonCodec` can
+`EvaluateRequest.state()` used to be a bare `Object` — "whatever the caller's `Codec` can
 serialize." But [docs.typesafe.ai/concepts/state](https://docs.typesafe.ai/concepts/state)
 documents `state` as exactly three shapes: a string, a JSON object, or an array of text values —
 not open-ended JSON. `Object` was strictly looser than the real API contract: a caller could pass
@@ -264,7 +272,7 @@ and an unbounded retry loop against a struggling upstream only makes the overloa
 
 Retry/backoff used to live inside `DefaultTypeSafeClient`, next to the request/response
 plumbing. That made it the one piece of cross-cutting behavior that *wasn't* a `TypeSafeClient`
-decorator like everything else here (`mapping`, `testkit`'s `FailingTypeSafeClient`, a caller's
+decorator like everything else here (`client-mapping`, `client-testkit`'s `FailingTypeSafeClient`, a caller's
 own cache) — so it couldn't be turned off, reordered relative to other decorators, or replaced
 without reimplementing the client. `RetryingTypeSafeClient` now does it purely in terms of the
 `TypeSafeException` the client throws, which is why `InternalServer` carries `retryAfter()` too:
diff --git a/docs/how-to.md b/docs/how-to.md
index 0972a5f..823c669 100644
--- a/docs/how-to.md
+++ b/docs/how-to.md
@@ -13,62 +13,62 @@ for a local TypeSafe-compatible server (`Builder.endpoint(...)`): without a key
 ```java
 // 1. Default file (~/.typesafe.apikey)
 ApiKey token = ApiKey.fromDefaultFile();
-TypeSafeClient client = TypeSafeClient.builder().apiKey(token).build();
+TypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(token).build();
 
 // 2. A specific file
 ApiKey token = ApiKey.fromFile(Path.of("/secrets/typesafe.token"));
-TypeSafeClient client = TypeSafeClient.builder().apiKey(token).build();
+TypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(token).build();
 
 // 3. TYPESAFE_API_KEY environment variable
 ApiKey token = ApiKey.fromEnv();
-TypeSafeClient client = TypeSafeClient.builder().apiKey(token).build();
+TypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(token).build();
 
 // 4. Any other in-memory value (e.g. a secrets manager)
 ApiKey token = ApiKey.of(secretsManager.getSecret("typesafe-token"));
-TypeSafeClient client = TypeSafeClient.builder().apiKey(token).build();
+TypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(token).build();
 ```
 
 ## Choose a JSON codec
 
-`TypeSafeClient` doesn't depend on Jackson directly — it resolves a `JsonCodec` via
+`TypeSafeClient` doesn't depend on Jackson directly — it resolves a `Codec` via
 `ServiceLoader` from whatever codec module is on your classpath. Add exactly one of:
 
 ```xml
 
   io.github.dfa1.typesafe-java
-  typesafe-java-jackson2
+  typesafe-java-codec-jackson2
 
 
 
   io.github.dfa1.typesafe-java
-  typesafe-java-jackson3
+  typesafe-java-codec-jackson3
 
 ```
 
-If neither is present, `TypeSafeClient.Builder.build()` throws `IllegalStateException` with a
+If neither is present, `DefaultTypeSafeClient.Builder.build()` throws `IllegalStateException` with a
 message telling you to add one. To bypass discovery and wire a codec explicitly (e.g. in tests,
-or if you have your own `JsonCodec` implementation):
+or if you have your own `Codec` implementation):
 
 ```java
-TypeSafeClient client = TypeSafeClient.builder().apiKey(token)
-        .jsonCodec(new Jackson2Codec())
+TypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(token)
+        .codec(new Jackson2Codec())
         .build();
 ```
 
 ## Choose an HTTP transport
 
-Likewise, `TypeSafeClient` doesn't depend on any HTTP library directly — it resolves an
-`HttpTransport` via `ServiceLoader`. Add one of:
+Likewise, `DefaultTypeSafeClient` doesn't depend on any HTTP library directly — it resolves an
+`HttpTransport` via `ServiceLoader`. Add one of (each brings `typesafe-java-client-http`):
 
 ```xml
 
   io.github.dfa1.typesafe-java
-  typesafe-java-client-jdk
+  typesafe-java-client-http-jdk
 
 
 
   io.github.dfa1.typesafe-java
-  typesafe-java-client-okhttp
+  typesafe-java-client-http-okhttp
 
 ```
 
@@ -77,7 +77,7 @@ the classpath makes `ServiceLoader` resolution between them non-deterministic. T
 explicitly, or to use your own `HttpTransport` (e.g. backed by Apache HttpClient):
 
 ```java
-TypeSafeClient client = TypeSafeClient.builder().apiKey(token)
+TypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(token)
         .httpTransport(new JdkHttpTransport())
         // or: .httpTransport(new OkHttpTransport())
         .build();
@@ -192,7 +192,7 @@ Each is `answers()` narrowed to that subtype, recomputed on every call.
 
 ## Get typed answers instead of `Map`
 
-`typesafe-java-mapping` maps a **record**'s annotated components into the request's questions and
+`typesafe-java-client-mapping` maps a **record**'s annotated components into the request's questions and
 the response back into a new instance of that record — no `answers().get("name")`, no
 `(Answer.Noul)` cast:
 
@@ -205,7 +205,7 @@ record TicketUrgency(
         @Score(value = "How spicy?", levels = {"Mild", "Medium", "Hot", "Face-melting"}) double spiciness) {
 }
 
-MappingTypeSafeClient client = TypeSafeClient.builder().apiKey(token).build(MappingTypeSafeClient::decorate);
+MappingTypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(token).build(MappingTypeSafeClient::decorate);
 TicketUrgency result = client.evaluateTyped(Content.text("..."), TicketUrgency.class);
 result.isUrgent();   // double, from Answer.Noul#noul()
 result.culprit();    // String, from Answer.Choice#choice()
@@ -294,7 +294,7 @@ unless you [add retries](#retry-transient-failures).
 
 ```java
 HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
-TypeSafeClient client = TypeSafeClient.builder().apiKey(token).httpTransport(new JdkHttpTransport(http)).build();
+TypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(token).httpTransport(new JdkHttpTransport(http)).build();
 ```
 
 ## Configure the per-request timeout
@@ -305,7 +305,7 @@ distinct from `HttpClient`'s `connectTimeout` above. Override it with the `(Http
 constructor, or pass `null` to disable it entirely:
 
 ```java
-TypeSafeClient client = TypeSafeClient.builder().apiKey(token)
+TypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(token)
         .httpTransport(new JdkHttpTransport(HttpClient.newHttpClient(), Duration.ofSeconds(30)))
         .build();
 ```
@@ -321,7 +321,7 @@ A timed-out request surfaces as `TypeSafeException.Timeout`, not the transport's
 down the `OkHttpClient`'s dispatcher executor, evicts its connection pool, and closes its cache:
 
 ```java
-try (TypeSafeClient client = TypeSafeClient.builder().apiKey(token).build()) {
+try (TypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(token).build()) {
     client.evaluate(request);
 }
 ```
@@ -332,7 +332,7 @@ need closing.
 ## Configure the endpoint
 
 ```java
-TypeSafeClient client = TypeSafeClient.builder().apiKey(token)
+TypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(token)
         .endpoint(URI.create("https://staging.typesafe.ai/v1/systemone"))
         .build();
 ```
@@ -343,11 +343,11 @@ A client from `build()` makes exactly one attempt per call. Add retries with
 `RetryingTypeSafeClient`:
 
 ```java
-TypeSafeClient client = TypeSafeClient.builder().apiKey(token)
+TypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(token)
         .decorateWith(RetryingTypeSafeClient::decorate)       // 5 retries, backoff from 500ms
         .build();
 
-TypeSafeClient tuned = TypeSafeClient.builder().apiKey(token)
+TypeSafeClient tuned = DefaultTypeSafeClient.builder().apiKey(token)
         .decorateWith(c -> RetryingTypeSafeClient.decorate(c, 2, Duration.ofMillis(100)))
         .build();
 ```
@@ -369,7 +369,7 @@ a minute in the worst case. Decorate the retrying client with a `DeadlineTypeSaf
 the whole call:
 
 ```java
-TypeSafeClient client = TypeSafeClient.builder().apiKey(token)
+TypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(token)
         .decorateWith(RetryingTypeSafeClient::decorate)
         .decorateWith(c -> DeadlineTypeSafeClient.decorate(c, Duration.ofSeconds(20)))
         .build();
@@ -387,7 +387,7 @@ total, create a `TokenCounter` and add its decorator:
 
 ```java
 TokenCounter tokens = new TokenCounter();
-TypeSafeClient client = TypeSafeClient.builder().apiKey(token)
+TypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(token)
         .decorateWith(RetryingTypeSafeClient::decorate)
         .decorateWith(tokens::decorate)
         .build();
@@ -408,7 +408,7 @@ Each `decorateWith(...)` adds a decorator *around* everything added before it, a
 the stack from the inside out:
 
 ```java
-TypeSafeClient.builder().apiKey(token)
+DefaultTypeSafeClient.builder().apiKey(token)
         .decorateWith(RetryingTypeSafeClient::decorate)                                  // 1
         .decorateWith(c -> DeadlineTypeSafeClient.decorate(c, Duration.ofSeconds(20)))  // 2
         .build(MappingTypeSafeClient::decorate);                                         // 3
@@ -459,7 +459,7 @@ record CachingTypeSafeClient(TypeSafeClient delegate, Map());
+TypeSafeClient client = new CachingTypeSafeClient(DefaultTypeSafeClient.builder().apiKey(token).build(), new ConcurrentHashMap<>());
 ```
 
 `Builder#build(Function)` applies a decorator to the built client in one call,
@@ -467,7 +467,7 @@ returning `T` (the decorator's own type, e.g. `MappingTypeSafeClient` — no cas
 its extra methods) instead of the plain `TypeSafeClient`:
 
 ```java
-CachingTypeSafeClient client = TypeSafeClient.builder().apiKey(token)
+CachingTypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(token)
         .build(base -> new CachingTypeSafeClient(base, new ConcurrentHashMap<>()));
 ```
 
@@ -479,7 +479,7 @@ Stack more with `Builder#decorateWith(...)` (see
 ```java
 Function caching = base -> new CachingTypeSafeClient(base, new ConcurrentHashMap<>());
 
-MappingTypeSafeClient client = TypeSafeClient.builder().apiKey(token)
+MappingTypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(token)
         .decorateWith(caching)
         .decorateWith(RetryingTypeSafeClient::decorate)
         .build(MappingTypeSafeClient::decorate);
@@ -489,7 +489,7 @@ MappingTypeSafeClient client = TypeSafeClient.builder().apiKey(token)
 
 Two options:
 
-1. **Add `typesafe-java-testkit` (test scope) and use `RecordingTypeSafeClient`, or mock
+1. **Add `typesafe-java-client-testkit` (test scope) and use `RecordingTypeSafeClient`, or mock
    `TypeSafeClient` directly.** Both work at the `EvaluateRequest`/`EvaluateResponse` level, with
    no setup — the simplest option for testing code that just calls `evaluate()`/`listModels()`
    and reacts to the result:
@@ -515,7 +515,7 @@ Two options:
    in your domain code, or need a shape it doesn't have (e.g. a synchronous-only facade). Mock
    *that* interface instead.
 
-Don't mock the `HttpTransport`/`JsonCodec` SPIs directly, though — they're lower-level than
+Don't mock the `HttpTransport`/`Codec` SPIs directly, though — they're lower-level than
 anything your code calls (they don't even appear in `TypeSafeClient`'s public methods), and a
 test built on them breaks whenever this library's internals change for reasons that have nothing
 to do with your code.
@@ -542,22 +542,19 @@ each time, or a different one depending on external state. `failEvery` must be p
 
 ## Reuse the DTOs without pulling in an HTTP or JSON library
 
-`typesafe-java-core` has zero runtime dependencies — `TypeSafeClient` talks to `HttpTransport`/
-`JsonCodec`, never to a concrete HTTP or JSON library directly. If you only need to
-(de)serialize `EvaluateRequest`/`EvaluateResponse` payloads — for example to publish or consume
-them on a Kafka topic — depend on `typesafe-java-core` plus a codec module, and ignore
-`TypeSafeClient` entirely:
+`typesafe-java-core` has no dependencies and no HTTP code: the HTTP client is a separate module,
+`typesafe-java-client-http`. If you only need to (de)serialize `EvaluateRequest`/`EvaluateResponse`
+payloads — for example to publish or consume them on a Kafka topic — depend on a codec module
+(`typesafe-java-codec-jackson2` or `-jackson3`, which brings `core`) and nothing else:
 
 ```java
-JsonCodec codec = new Jackson2Codec();
-String json = codec.writeValueAsString(request);
+Codec codec = new Jackson2Codec();
+byte[] json = codec.writeValueAsBytes(request);              // UTF-8 JSON
 EvaluateResponse response = codec.readValue(json, EvaluateResponse.class);
 ```
 
-(A Kafka producer/consumer using a raw-`byte[]` serializer converts once at that boundary —
-`json.getBytes(UTF_8)` / `new String(bytes, UTF_8)` — the same one-line conversion any
-non-`String`-based transport needs; `TypeSafeClient` itself needs none, since `HttpTransport`
-is `String`-based too.)
+The codec speaks UTF-8 bytes, so a Kafka `Serializer`/`Deserializer` is one call each, with no
+`String` in between.
 
 ## Run a quick check from the command line
 
@@ -587,8 +584,8 @@ type, since unnamed ones of the same type overwrite each other. `--model ` (
 alias, any other id is pinned directly. Reads the token from `~/.typesafe.apikey`.
 
 Stdout is silent by default — reach for `--print`/`--verbose` below to see anything. `--verbose`
-prints the full `EvaluateResponse` as pretty-printed JSON to stdout, plus the outgoing request
-(also pretty-printed) and the response's request id to stderr; `--timing` prints how long the
+prints the full `EvaluateResponse` as compact JSON to stdout (pipe it into `jq` to read it), plus the
+outgoing request and the response's request id to stderr; `--timing` prints how long the
 API took, to stderr. Run with `--version` alone to print the jar's version, or `--help`/`-h`
 alone to print usage, and exit without calling the API.
 
@@ -617,7 +614,7 @@ java -jar cli/target/typesafe-java-cli-*-all.jar \
 
 ## Run without the API, on a local model
 
-`typesafe-java-local` evaluates in-process on ONNX Runtime: no network, no API key, same `EvaluateRequest`/
+`typesafe-java-client-local` evaluates in-process on ONNX Runtime: no network, no API key, same `EvaluateRequest`/
 `EvaluateResponse`. Jev's weights aren't public, so the answers come from an open model — Jev's contract, not Jev's
 judgement.
 
@@ -631,11 +628,11 @@ library for anything else, Intel Macs included — and [uv](https://docs.astral.
 ```xml
 
   io.github.dfa1.typesafe-java
-  typesafe-java-local
+  typesafe-java-client-local
 
 
   io.github.dfa1.typesafe-java
-  typesafe-java-jackson2
+  typesafe-java-codec-jackson2
 
 ```
 
@@ -664,7 +661,7 @@ try (TypeSafeClient client = LocalLayaTypeSafeClient.load(laya)) {
 
 Loading reads the whole model, so keep one client for the life of your application. If it fails:
 
-- `IllegalStateException: No JsonCodec found` — add `typesafe-java-jackson2` or `typesafe-java-jackson3`.
+- `IllegalStateException: No Codec found` — add `typesafe-java-codec-jackson2` or `typesafe-java-codec-jackson3`.
 - `IllegalArgumentException: ... not found` / `no .onnx file` — the directory isn't the one step 2 wrote.
 - an error from `ai.onnxruntime` loading its native library — a platform its jar doesn't cover (see above).
 
@@ -707,7 +704,7 @@ memory and a minute per request. To make it practical, convert it to 4-bit weigh
 the new `clef-flash-q4` directory hard-links the original files, so no extra copy):
 
 ```bash
-uv run https://raw.githubusercontent.com/dfa1/typesafe-java/main/local/scripts/clef/quantize_q4.py
+uv run https://raw.githubusercontent.com/dfa1/typesafe-java/main/client-local/scripts/clef/quantize_q4.py
 ```
 
 Then `LocalClefTypeSafeClient.load(Path.of(..., "clef-flash-q4"))`, or `loadOnGpu(...)` on macOS on Apple Silicon
@@ -720,7 +717,7 @@ The `Local engines` GitHub workflow re-measures agreement and throughput on Linu
 tables to its job summary. To reproduce from a checkout:
 
 ```bash
-./mvnw -pl local -am test -DexcludedGroups=acceptance -Dengine=laya      # real-model tests (laya, qwen, clef, ...)
+./mvnw -pl client-local -am test -DexcludedGroups=acceptance -Dengine=laya      # real-model tests (laya, qwen, clef, ...)
 ```
 
 ## Run Clef-flash on a Mac with MLX
@@ -728,7 +725,7 @@ tables to its job summary. To reproduce from a checkout:
 [Clef-flash](https://huggingface.co/Cloudflare/clef-flash) is the local model closest to Jev, and on Apple Silicon
 the fastest way to run it is [MLX](https://github.com/ml-explore/mlx), Apple's array framework for its GPUs.
 [mlx-community's 4-bit port](https://huggingface.co/mlx-community/clef-flash-4bit) ships a small server speaking
-TypeSafe's `POST /v1/systemone`, so the regular client talks to it: no `local` module, no API key.
+TypeSafe's `POST /v1/systemone`, so the regular client talks to it: no `client-local` module, no API key.
 
 Download the model (6.2 GB) and start the server (needs [uv](https://docs.astral.sh/uv/); it serves one request
 at a time):
@@ -742,7 +739,7 @@ uv run --with 'mlx-vlm>=0.7.4,<0.8' python $M/clef_mlx.py serve --model $M --por
 Then point the client at it:
 
 ```java
-try (TypeSafeClient client = TypeSafeClient.builder()
+try (TypeSafeClient client = DefaultTypeSafeClient.builder()
         .endpoint(URI.create("http://localhost:8000/v1/systemone"))
         .build()) {
     EvaluateResponse response = client.evaluate(request);
@@ -767,7 +764,7 @@ Things to know:
 ## Run the acceptance tests against the live API
 
 The acceptance tests in the `acceptance` module run every scenario once per HttpTransport/
-JsonCodec combination and are excluded from a routine build. Opt in once you have
+Codec combination and are excluded from a routine build. Opt in once you have
 `~/.typesafe.apikey` in place:
 
 ```bash
diff --git a/docs/reference.md b/docs/reference.md
index 5c9a148..74949a5 100644
--- a/docs/reference.md
+++ b/docs/reference.md
@@ -5,7 +5,7 @@ For task-oriented usage see [how-to.md](how-to.md); for design rationale see [ex
 
 - [Module layout](#module-layout)
 - [Core types](#core-types)
-- [JsonCodec SPI](#jsoncodec-spi)
+- [Codec SPI](#codec-spi)
 - [HttpTransport SPI](#httptransport-spi)
 - [Testkit](#testkit)
 - [Answer mapping](#answer-mapping)
@@ -15,19 +15,23 @@ For task-oriented usage see [how-to.md](how-to.md); for design rationale see [ex
 
 | Module | Depends on | Contains |
 |---|---|---|
-| `typesafe-java-core` | — | `Answer`, `Question`, `Content`, `EvaluateRequest`, `EvaluateResponse`, `Usage`, `TokenCounter`, `RequestId`, `Model`, `ModelDetails`, `JsonCodec`, `HttpTransport`, `TypeSafeClient`, `ApiKey`, `TypeSafeException` |
-| `typesafe-java-client-jdk` | `core` | `JdkHttpTransport` (java.net.http) |
-| `typesafe-java-client-okhttp` | `core` | `OkHttpTransport` (OkHttp) |
-| `typesafe-java-jackson2` | `core` | `Jackson2Codec` (Jackson 2.x) |
-| `typesafe-java-jackson3` | `core` | `Jackson3Codec` (Jackson 3.x) |
-| `typesafe-java-testkit` | `core` | `RecordingTypeSafeClient`, `FailingTypeSafeClient` |
-| `typesafe-java-mapping` | `core` | `MappingTypeSafeClient`, `@Noul`/`@Choice`/`@Score`/`@Option` |
-| `typesafe-java-local` | `core`, ONNX Runtime | `LocalLayaTypeSafeClient`, `LocalQwenTypeSafeClient`, `LocalClefTypeSafeClient` (in-process models) |
-| `typesafe-java-bom` | — | dependency management for the eight above |
-
-`core` has zero runtime dependency on any HTTP or JSON library — `TypeSafeClient` talks to
-`HttpTransport`/`JsonCodec`, not to `java.net.http`/Jackson directly, so it's safe to bundle
-alongside the DTOs without pulling anything extra in.
+| `typesafe-java-core` | — | the model (`Answer`, `Question`, `Content`, `EvaluateRequest`, `EvaluateResponse`, `Usage`, `RequestId`, `Model`, `ModelDetails`) |
+| `typesafe-java-codec` | — | `Codec` |
+| `typesafe-java-codec-jackson2` | `codec`, `core` | `Jackson2Codec` (Jackson 2.x) |
+| `typesafe-java-codec-jackson3` | `codec`, `core` | `Jackson3Codec` (Jackson 3.x) |
+| `typesafe-java-client` | `core` | `TypeSafeClient`, `TypeSafeException`, `RetryingTypeSafeClient`, `DeadlineTypeSafeClient`, `TokenCounter` |
+| `typesafe-java-client-http` | `client`, `codec` | `DefaultTypeSafeClient` (the TypeSafe API over HTTP), `ApiKey`, `HttpTransport` |
+| `typesafe-java-client-http-jdk` | `client-http` | `JdkHttpTransport` (java.net.http) |
+| `typesafe-java-client-http-okhttp` | `client-http` | `OkHttpTransport` (OkHttp) |
+| `typesafe-java-client-local` | `client`, `codec`, ONNX Runtime | `LocalLayaTypeSafeClient`, `LocalQwenTypeSafeClient`, `LocalClefTypeSafeClient` (in-process models) |
+| `typesafe-java-client-mapping` | `client` | `MappingTypeSafeClient`, `@Noul`/`@Choice`/`@Score`/`@Option` |
+| `typesafe-java-client-testkit` | `client` | `RecordingTypeSafeClient`, `FailingTypeSafeClient` |
+| `typesafe-java-bom` | — | dependency management for the eleven above |
+
+A module's package is its name with dashes as dots: `typesafe-java-client-http-jdk` holds
+`io.github.dfa1.typesafe.client.http.jdk`. `core` (the model) has no dependencies; `client` is the
+contract every `client-*` module implements or builds on; the model plus a `codec-*` module
+serializes TypeSafe payloads without any client code.
 
 ## Core types
 
@@ -159,40 +163,42 @@ record ModelDetails(String name, String description, String releaseDate)
 One entry of `TypeSafeClient.listModels()`'s result. `model()` returns this model's id as a
 plain `Model`, usable directly as an `EvaluateRequest`'s model.
 
-## JsonCodec SPI
+## Codec SPI
+
+In `typesafe-java-codec`.
 
 ```java
-package io.github.dfa1.typesafe.json;
+package io.github.dfa1.typesafe.codec;
 
-public interface JsonCodec {
-    String writeValueAsString(Object value);
-    String writeValueAsPrettyString(Object value); // same, indented for human reading
-     T readValue(String content, Class type);
+public interface Codec {
+    byte[] writeValueAsBytes(Object value);
+     T readValue(byte[] content, Class type);
 }
 ```
 
 Implementations (`Jackson2Codec`, `Jackson3Codec`) are discovered via
-`ServiceLoader.load(JsonCodec.class)` and registered through
-`META-INF/services/io.github.dfa1.typesafe.json.JsonCodec`. Both own the `Answer`/`Question` polymorphic
+`ServiceLoader.load(Codec.class)` and registered through
+`META-INF/services/io.github.dfa1.typesafe.codec.Codec`. Both own the `Answer`/`Question` polymorphic
 `type` discriminator via Jackson mixins — `core`'s DTOs carry no serialization annotations.
 Both ignore fields they don't know, so a field the API adds to a response doesn't break an older client.
-`readValue`'s `content` is `String`, not `byte[]`: this is always JSON text, which is UTF-8 by
-construction (RFC 8259) — a caller integrating with a raw-`byte[]` system (e.g. Kafka) converts
-once at that boundary (`.getBytes(UTF_8)` / `new String(bytes, UTF_8)`), same reasoning as
-`HttpTransport`.
+JSON is UTF-8 bytes in and out (RFC 8259), so nothing builds a `String` on the way to or from the
+wire, and a `byte[]` system (e.g. Kafka) uses the codec as is. Characters outside the BMP (e.g.
+emoji) are written as themselves, not as escaped surrogate pairs.
 
 ## HttpTransport SPI
 
+In `typesafe-java-client-http`.
+
 ```java
-package io.github.dfa1.typesafe.transport;
+package io.github.dfa1.typesafe.client.http;
 
 public interface HttpTransport extends AutoCloseable {
-    CompletableFuture post(URI uri, Map headers, String body);
+    CompletableFuture post(URI uri, Map headers, byte[] body);
     CompletableFuture get(URI uri, Map headers);
     void close();   // no default -- every implementation must define one, even a no-op
 }
 
-public record HttpTransportResponse(int statusCode, Map headers, String body) {
+public record HttpTransportResponse(int statusCode, Map headers, byte[] body) {
     Optional header(String name);   // case-insensitive lookup
 }
 ```
@@ -206,16 +212,16 @@ the async-native shape most HTTP libraries actually provide.
 
 Bodies are `String`, not `byte[]`: `TypeSafeClient` only ever sends/receives JSON over this SPI,
 and JSON text is UTF-8 by construction (RFC 8259), so there's no charset this layer needs to
-guess at. `JsonCodec` stays `byte[]`-based (it's reused standalone, e.g. for a Kafka producer/
+guess at. `Codec` stays `byte[]`-based (it's reused standalone, e.g. for a Kafka producer/
 consumer, where messages are raw bytes); `TypeSafeClient` converts once at the boundary between
 the two SPIs. `HttpTransportResponse` copies `headers` defensively (`Map.copyOf`) so a caller
 that mutates the map it passed in afterward can't reach back into an already-returned response.
 
 The HTTP calls `TypeSafeClient` needs (a JSON POST for `evaluate`, a GET for `listModels`),
 abstracted away from any particular HTTP library. `JdkHttpTransport` (in
-`typesafe-java-client-jdk`) and `OkHttpTransport` (in `typesafe-java-client-okhttp`) are each
+`typesafe-java-client-http-jdk`) and `OkHttpTransport` (in `typesafe-java-client-http-okhttp`) are each
 discovered via `ServiceLoader.load(HttpTransport.class)` through
-`META-INF/services/io.github.dfa1.typesafe.transport.HttpTransport`. Implement `HttpTransport`
+`META-INF/services/io.github.dfa1.typesafe.client.http.HttpTransport`. Implement `HttpTransport`
 yourself (e.g. backed by Apache HttpClient) and wire it in the same way, or pass any
 implementation explicitly via `Builder.httpTransport(...)`. `JdkHttpTransport.close()` closes
 its `HttpClient` (JDK 21+); `OkHttpTransport.close()` shuts down its `OkHttpClient`'s dispatcher
@@ -238,7 +244,7 @@ to an empty metadata artifact with no classes.
 
 ## Testkit
 
-`io.github.dfa1.typesafe.testkit` (module `typesafe-java-testkit`).
+`io.github.dfa1.typesafe.client.testkit` (module `typesafe-java-client-testkit`).
 
 ```java
 public final class RecordingTypeSafeClient implements TypeSafeClient {
@@ -271,7 +277,7 @@ through. The constructor throws `IllegalArgumentException` if `failEvery` isn't
 
 ## Answer mapping
 
-`io.github.dfa1.typesafe.mapping` (module `typesafe-java-mapping`).
+`io.github.dfa1.typesafe.client.mapping` (module `typesafe-java-client-mapping`).
 
 ```java
 public final class MappingTypeSafeClient implements TypeSafeClient {
@@ -319,7 +325,7 @@ with the original `ClassCastException` as its cause) rather than a bare, unexpla
 
 ## Local
 
-`io.github.dfa1.typesafe.local` (module `typesafe-java-local`).
+`io.github.dfa1.typesafe.client.local` (module `typesafe-java-client-local`).
 
 ```java
 public final class LocalLayaTypeSafeClient implements TypeSafeClient {
@@ -336,8 +342,8 @@ public final class LocalClefTypeSafeClient implements TypeSafeClient {
 
 One class per model, each a `TypeSafeClient` that evaluates on ONNX Runtime in-process, reading the model from `dir`: `tokenizer.json`
 (plus `config.json` for Laya) and the one `.onnx` file in `dir/onnx` or `dir`, as `hf download` lays them out (see the
-[how-to](how-to.md#run-without-the-api-on-a-local-model)). Nothing is downloaded at run time. A `JsonCodec` module
-(`typesafe-java-jackson2` or `-jackson3`) must be on the classpath, as for the API client. The module depends on
+[how-to](how-to.md#run-without-the-api-on-a-local-model)). Nothing is downloaded at run time. A `Codec` module
+(`typesafe-java-codec-jackson2` or `-jackson3`) must be on the classpath, as for the API client. The module depends on
 `com.microsoft.onnxruntime:onnxruntime` (56 MB, native code for Linux x64/ARM64, macOS Apple Silicon and Windows x64
 only).
 
@@ -365,7 +371,10 @@ only).
 
 ## Client
 
-Also in `io.github.dfa1.typesafe.core`.
+`TypeSafeClient`, its decorators and `TypeSafeException` are in `io.github.dfa1.typesafe.client`
+(module `typesafe-java-client`);
+`ApiKey` and `DefaultTypeSafeClient` are in `io.github.dfa1.typesafe.client.http` (module
+`typesafe-java-client-http`).
 
 ### `ApiKey`
 
@@ -385,8 +394,6 @@ final class ApiKey   // no public constructor
 ### `TypeSafeClient`
 
 ```java
-static DefaultTypeSafeClient.Builder builder()
-
 EvaluateResponse evaluate(EvaluateRequest request)
 CompletableFuture evaluateAsync(EvaluateRequest request)
 List listModels()
@@ -394,11 +401,9 @@ List listModels()
 
 `TypeSafeClient` is an interface, not a final class, so it can be wrapped in a decorator (a
 caching layer, metrics, a circuit breaker, ...) implementing the same interface — anywhere a
-`TypeSafeClient` is expected, a decorator around one works too. `builder()` is a thin
-static factory on the interface that delegates to `DefaultTypeSafeClient.builder()` — the
-implementation class is public and owns its own `Builder`, since constructing a
-`DefaultTypeSafeClient` (defaults, `ServiceLoader` discovery, ...) is squarely that class's
-concern, not the interface's.
+`TypeSafeClient` is expected, a decorator around one works too. It has no factory: `client` can't
+see its implementations. `DefaultTypeSafeClient.builder()` builds the one that calls the API;
+`client-local`'s `load(Path)` factories build the in-process ones.
 
 `Builder.apiKey(ApiKey)` is optional. Without it no `Authorization` header is sent: right for a
 local TypeSafe-compatible server set with `Builder.endpoint(URI)`. Against TypeSafe's own API a
@@ -414,19 +419,19 @@ None of the three methods declares a checked exception — every failure is an u
 being interrupted while waiting. See
 [ADR 0002](../adr/0002-no-checked-exceptions.md) for why.
 
-`TypeSafeClient` implements `AutoCloseable`; `close()` closes the configured `HttpTransport`,
+`TypeSafeClient` implements `AutoCloseable`; `DefaultTypeSafeClient.close()` closes the configured `HttpTransport`,
 so a client built from `JdkHttpTransport` releases its underlying `HttpClient`. Use
 try-with-resources, or skip closing for a client that lives as long as the process.
 
-#### `TypeSafeClient.Builder`
+#### `DefaultTypeSafeClient.Builder`
 
 | Method | Default |
 |---|---|
 | `httpTransport(HttpTransport)` | resolved via `ServiceLoader` at `build()` time |
-| `jsonCodec(JsonCodec)` | resolved via `ServiceLoader` at `build()` time |
+| `codec(Codec)` | resolved via `ServiceLoader` at `build()` time |
 | `endpoint(URI)` | `https://api.typesafe.ai/v1/systemone` |
 | `decorateWith(Function)` | none — adds a decorator around the built client; repeatable, each one around everything added before it (last added outermost). See [how-to](how-to.md#choose-the-order-of-decorators) |
-| `build()` | throws `IllegalStateException` if no `HttpTransport` or `JsonCodec` is set or discoverable, or if more than one `RetryingTypeSafeClient` was added |
+| `build()` | throws `IllegalStateException` if no `HttpTransport` or `Codec` is set or discoverable, or if more than one `RetryingTypeSafeClient` was added |
 | ` build(Function decorate)` | `decorate.apply(build())` — wraps the built client in a decorator (e.g. `MappingTypeSafeClient::decorate`) in one call, returning `T` instead of the plain `TypeSafeClient`. Applied outermost, after every `decorateWith(...)`. |
 
 ### `RetryingTypeSafeClient`
@@ -488,7 +493,7 @@ sealed class TypeSafeException extends RuntimeException {
 ```
 
 The base class is also the catch-all: constructed directly for a status with no dedicated
-subclass below. A `200` response the configured `JsonCodec` couldn't decode throws
+subclass below. A `200` response the configured `Codec` couldn't decode throws
 `TypeSafeException.ResponseDecoding` (`statusCode()` `200`, `getCause()` the codec's original
 exception) instead of that exception escaping directly. This mirrors the per-status hierarchy of
 the Python SDK (`typesafe-ai/typesafe-sdk-python`'s `TypeSafeAPIError` subclasses); unlike
diff --git a/docs/tutorial.md b/docs/tutorial.md
index 7147c93..903c55c 100644
--- a/docs/tutorial.md
+++ b/docs/tutorial.md
@@ -12,7 +12,7 @@ a support message is.
 
 Import the BOM, then add an HTTP transport (the JDK one, unless you have your own) plus one JSON
 codec (Jackson 2 or Jackson 3 — pick whichever your project already uses). Both pull in
-`typesafe-java-core`, which holds `TypeSafeClient` itself, transitively (see the
+`typesafe-java-client`, which holds `TypeSafeClient` itself, transitively (see the
 [Maven Central badge](../README.md) for the latest version):
 
 ```xml
@@ -31,11 +31,11 @@ codec (Jackson 2 or Jackson 3 — pick whichever your project already uses). Bot
 
   
     io.github.dfa1.typesafe-java
-    typesafe-java-client-jdk
+    typesafe-java-client-http-jdk
   
   
     io.github.dfa1.typesafe-java
-    typesafe-java-jackson2
+    typesafe-java-codec-jackson2
   
 
 ```
@@ -52,10 +52,11 @@ echo "your-token-here" > ~/.typesafe.apikey
 ## 3. Build the client
 
 ```java
-import io.github.dfa1.typesafe.core.ApiKey;
-import io.github.dfa1.typesafe.core.TypeSafeClient;
+import io.github.dfa1.typesafe.client.http.ApiKey;
+import io.github.dfa1.typesafe.client.http.DefaultTypeSafeClient;
+import io.github.dfa1.typesafe.client.TypeSafeClient;
 
-TypeSafeClient client = TypeSafeClient.builder().apiKey(ApiKey.fromDefaultFile()).build();
+TypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(ApiKey.fromDefaultFile()).build();
 ```
 
 ## 4. Ask a question
@@ -90,24 +91,24 @@ System.out.println("request id: " + response.metadata().requestId().value());
 
 ## 6. Optional: get a typed record back instead
 
-Add `typesafe-java-mapping` alongside the BOM, and the same round trip becomes one typed method
+Add `typesafe-java-client-mapping` alongside the BOM, and the same round trip becomes one typed method
 call — no question map to build, no `nouls()`/cast to read back:
 
 ```xml
 
   io.github.dfa1.typesafe-java
-  typesafe-java-mapping
+  typesafe-java-client-mapping
 
 ```
 
 ```java
-import io.github.dfa1.typesafe.mapping.MappingTypeSafeClient;
-import io.github.dfa1.typesafe.mapping.Noul;
+import io.github.dfa1.typesafe.client.mapping.MappingTypeSafeClient;
+import io.github.dfa1.typesafe.client.mapping.Noul;
 
 record UrgencyCheck(@Noul("Does this convey urgency?") double isUrgent) {
 }
 
-MappingTypeSafeClient typedClient = TypeSafeClient.builder().apiKey(ApiKey.fromDefaultFile())
+MappingTypeSafeClient typedClient = DefaultTypeSafeClient.builder().apiKey(ApiKey.fromDefaultFile())
         .build(MappingTypeSafeClient::decorate);
 
 UrgencyCheck result = typedClient.evaluateTyped(
diff --git a/jackson2/src/main/resources/META-INF/services/io.github.dfa1.typesafe.json.JsonCodec b/jackson2/src/main/resources/META-INF/services/io.github.dfa1.typesafe.json.JsonCodec
deleted file mode 100644
index 6e813c2..0000000
--- a/jackson2/src/main/resources/META-INF/services/io.github.dfa1.typesafe.json.JsonCodec
+++ /dev/null
@@ -1 +0,0 @@
-io.github.dfa1.typesafe.jackson2.Jackson2Codec
diff --git a/jackson3/src/main/resources/META-INF/services/io.github.dfa1.typesafe.json.JsonCodec b/jackson3/src/main/resources/META-INF/services/io.github.dfa1.typesafe.json.JsonCodec
deleted file mode 100644
index bc96cb2..0000000
--- a/jackson3/src/main/resources/META-INF/services/io.github.dfa1.typesafe.json.JsonCodec
+++ /dev/null
@@ -1 +0,0 @@
-io.github.dfa1.typesafe.jackson3.Jackson3Codec
diff --git a/pom.xml b/pom.xml
index 67b2b0d..78c62cd 100644
--- a/pom.xml
+++ b/pom.xml
@@ -38,13 +38,16 @@
 
   
     core
-    client-jdk
-    client-okhttp
-    jackson2
-    jackson3
-    testkit
-    mapping
-    local
+    codec
+    codec-jackson2
+    codec-jackson3
+    client
+    client-http
+    client-http-jdk
+    client-http-okhttp
+    client-testkit
+    client-mapping
+    client-local
     bom
     acceptance
     cli