From 4145dec047d30a7c617e3e444076e1f81bc3529d Mon Sep 17 00:00:00 2001 From: Davide Angelocola Date: Sun, 4 Oct 2026 08:33:21 +0200 Subject: [PATCH 1/8] Split modules into contract (core) and implementations (client-*), align names core keeps the TypeSafeClient interface, its decorators, TypeSafeException, the model and the JsonCodec SPI. DefaultTypeSafeClient, ApiKey and the HttpTransport SPI move to the new client-http module, so the compiler now enforces that core never sees the HTTP client; TypeSafeClient.builder() becomes DefaultTypeSafeClient.builder(). Every module's directory, artifact and package share one name: client-http-jdk, client-http-okhttp, codec-jackson2, codec-jackson3, client-local, client-mapping, client-testkit (io.github.dfa1.typesafe., dashes as dots). No client-* module depends on another in production. ADR 0003 records the layout (closes #12). Co-Authored-By: Claude Sonnet 5 --- .github/workflows/local.yml | 16 +-- CHANGELOG.md | 13 +- CLAUDE.md | 125 +++++++++--------- README.md | 34 ++--- acceptance/pom.xml | 17 ++- .../AbstractTypeSafeClientAcceptanceTest.java | 19 +-- ...kHttpClientWithJackson2AcceptanceTest.java | 8 +- ...kHttpClientWithJackson3AcceptanceTest.java | 8 +- ...kHttpClientWithJackson2AcceptanceTest.java | 8 +- ...kHttpClientWithJackson3AcceptanceTest.java | 8 +- ...-core-implementations-in-client-modules.md | 56 ++++++++ bom/pom.xml | 22 +-- cli/pom.xml | 6 +- .../io/github/dfa1/typesafe/cli/Main.java | 11 +- .../io/github/dfa1/typesafe/cli/MainTest.java | 2 +- {client-jdk => client-http-jdk}/pom.xml | 8 +- .../client/http}/jdk/JdkHttpTransport.java | 6 +- ...ub.dfa1.typesafe.client.http.HttpTransport | 1 + .../http}/jdk/JdkHttpTransportTest.java | 4 +- {client-okhttp => client-http-okhttp}/pom.xml | 10 +- .../client/http}/okhttp/OkHttpTransport.java | 6 +- ...ub.dfa1.typesafe.client.http.HttpTransport | 1 + .../http}/okhttp/OkHttpTransportTest.java | 4 +- client-http/pom.xml | 51 +++++++ .../dfa1/typesafe/client/http}/ApiKey.java | 2 +- .../client/http}/DefaultTypeSafeClient.java | 21 +-- .../typesafe/client/http}/HttpTransport.java | 6 +- .../client/http}/HttpTransportResponse.java | 2 +- .../typesafe/client/http}/ApiKeyTest.java | 2 +- .../http/DefaultTypeSafeClientTest.java | 49 ++++--- .../http}/HttpTransportResponseTest.java | 2 +- ...thub.dfa1.typesafe.transport.HttpTransport | 1 - {local => client-local}/pom.xml | 18 ++- .../scripts/clef/quantize_q4.py | 0 .../scripts/clef/reference.py | 0 .../scripts/laya/export_onnx.py | 0 .../scripts/tokenizer/reference.py | 0 .../typesafe/client}/local/BpeTokenizer.java | 2 +- .../typesafe/client}/local/ClefEngine.java | 2 +- .../dfa1/typesafe/client}/local/Engine.java | 2 +- .../typesafe/client}/local/LayaEngine.java | 2 +- .../local/LocalClefTypeSafeClient.java | 4 +- .../local/LocalLayaTypeSafeClient.java | 2 +- .../local/LocalQwenTypeSafeClient.java | 2 +- .../client}/local/LocalTypeSafeClient.java | 2 +- .../dfa1/typesafe/client}/local/Onnx.java | 8 +- .../typesafe/client}/local/Probabilities.java | 2 +- .../typesafe/client}/local/QwenEngine.java | 2 +- .../client}/local/BpeTokenizerTest.java | 4 +- .../client}/local/ClefEngineTest.java | 4 +- .../dfa1/typesafe/client}/local/Engines.java | 2 +- .../dfa1/typesafe/client}/local/JevCases.java | 2 +- .../typesafe/client}/local/JevComparison.java | 13 +- .../client}/local/LayaEngineTest.java | 2 +- .../local/LocalTypeSafeClientBenchmark.java | 2 +- .../local/LocalTypeSafeClientModelTest.java | 4 +- .../local/LocalTypeSafeClientTest.java | 2 +- .../dfa1/typesafe/client}/local/OnnxTest.java | 2 +- .../client}/local/ProbabilitiesTest.java | 2 +- .../client}/local/QwenEngineTest.java | 2 +- .../client}/local/TokenizerInputs.java | 4 +- .../src/test/resources/clef/expected.json | 0 .../src/test/resources/clef/requests.json | 0 .../src/test/resources/jev/chat.jsonl | 0 .../src/test/resources/jev/email.jsonl | 0 .../src/test/resources/jev/moderation.jsonl | 0 .../src/test/resources/jev/reviews.jsonl | 0 .../src/test/resources/jev/support.jsonl | 0 .../src/test/resources/laya/fixture.json | 0 .../resources/tokenizer/expected-laya.json | 0 .../resources/tokenizer/expected-qwen.json | 0 .../test/resources/tokenizer/inputs-laya.json | 0 .../test/resources/tokenizer/inputs-qwen.json | 0 {mapping => client-mapping}/pom.xml | 6 +- .../dfa1/typesafe/client}/mapping/Choice.java | 2 +- .../mapping/MappingTypeSafeClient.java | 7 +- .../dfa1/typesafe/client}/mapping/Noul.java | 2 +- .../dfa1/typesafe/client}/mapping/Option.java | 2 +- .../dfa1/typesafe/client}/mapping/Score.java | 2 +- .../mapping/MappingTypeSafeClientTest.java | 6 +- ...thub.dfa1.typesafe.transport.HttpTransport | 1 - {testkit => client-testkit}/pom.xml | 4 +- .../testkit/FailingTypeSafeClient.java | 2 +- .../testkit/RecordingTypeSafeClient.java | 2 +- .../testkit/FailingTypeSafeClientTest.java | 2 +- .../testkit/RecordingTypeSafeClientTest.java | 2 +- {jackson2 => codec-jackson2}/pom.xml | 4 +- .../codec}/jackson2/ContentSerializer.java | 2 +- .../codec}/jackson2/Jackson2Codec.java | 4 +- .../codec}/jackson2/ModelDeserializer.java | 2 +- .../codec}/jackson2/ModelSerializer.java | 2 +- .../jackson2/RequestIdDeserializer.java | 2 +- .../codec}/jackson2/RequestIdSerializer.java | 2 +- .../io.github.dfa1.typesafe.codec.JsonCodec | 1 + .../codec}/jackson2/Jackson2CodecTest.java | 2 +- {jackson3 => codec-jackson3}/pom.xml | 4 +- .../codec}/jackson3/ContentSerializer.java | 2 +- .../codec}/jackson3/Jackson3Codec.java | 4 +- .../codec}/jackson3/ModelDeserializer.java | 2 +- .../codec}/jackson3/ModelSerializer.java | 2 +- .../jackson3/RequestIdDeserializer.java | 2 +- .../codec}/jackson3/RequestIdSerializer.java | 2 +- .../io.github.dfa1.typesafe.codec.JsonCodec | 1 + .../codec}/jackson3/Jackson3CodecTest.java | 2 +- core/pom.xml | 16 +-- .../typesafe/{json => codec}/JsonCodec.java | 2 +- .../typesafe/core/DeadlineTypeSafeClient.java | 26 +++- .../typesafe/core/RetryingTypeSafeClient.java | 5 +- .../dfa1/typesafe/core/TokenCounter.java | 4 +- .../dfa1/typesafe/core/TypeSafeClient.java | 19 +-- .../dfa1/typesafe/core/TypeSafeException.java | 2 +- docs/explanation.md | 58 ++++---- docs/how-to.md | 81 ++++++------ docs/reference.md | 66 ++++----- docs/tutorial.md | 19 +-- .../io.github.dfa1.typesafe.json.JsonCodec | 1 - .../io.github.dfa1.typesafe.json.JsonCodec | 1 - pom.xml | 15 ++- 118 files changed, 592 insertions(+), 427 deletions(-) create mode 100644 adr/0003-contract-in-core-implementations-in-client-modules.md rename {client-jdk => client-http-jdk}/pom.xml (84%) rename {client-jdk/src/main/java/io/github/dfa1/typesafe => client-http-jdk/src/main/java/io/github/dfa1/typesafe/client/http}/jdk/JdkHttpTransport.java (93%) create mode 100644 client-http-jdk/src/main/resources/META-INF/services/io.github.dfa1.typesafe.client.http.HttpTransport rename {client-jdk/src/test/java/io/github/dfa1/typesafe => client-http-jdk/src/test/java/io/github/dfa1/typesafe/client/http}/jdk/JdkHttpTransportTest.java (97%) rename {client-okhttp => client-http-okhttp}/pom.xml (82%) rename {client-okhttp/src/main/java/io/github/dfa1/typesafe => client-http-okhttp/src/main/java/io/github/dfa1/typesafe/client/http}/okhttp/OkHttpTransport.java (95%) create mode 100644 client-http-okhttp/src/main/resources/META-INF/services/io.github.dfa1.typesafe.client.http.HttpTransport rename {client-okhttp/src/test/java/io/github/dfa1/typesafe => client-http-okhttp/src/test/java/io/github/dfa1/typesafe/client/http}/okhttp/OkHttpTransportTest.java (98%) create mode 100644 client-http/pom.xml rename {core/src/main/java/io/github/dfa1/typesafe/core => client-http/src/main/java/io/github/dfa1/typesafe/client/http}/ApiKey.java (97%) rename {core/src/main/java/io/github/dfa1/typesafe/core => client-http/src/main/java/io/github/dfa1/typesafe/client/http}/DefaultTypeSafeClient.java (95%) rename {core/src/main/java/io/github/dfa1/typesafe/transport => client-http/src/main/java/io/github/dfa1/typesafe/client/http}/HttpTransport.java (81%) rename {core/src/main/java/io/github/dfa1/typesafe/transport => client-http/src/main/java/io/github/dfa1/typesafe/client/http}/HttpTransportResponse.java (91%) rename {core/src/test/java/io/github/dfa1/typesafe/core => client-http/src/test/java/io/github/dfa1/typesafe/client/http}/ApiKeyTest.java (98%) rename core/src/test/java/io/github/dfa1/typesafe/core/TypeSafeClientTest.java => client-http/src/test/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClientTest.java (93%) rename {core/src/test/java/io/github/dfa1/typesafe/transport => client-http/src/test/java/io/github/dfa1/typesafe/client/http}/HttpTransportResponseTest.java (96%) delete mode 100644 client-jdk/src/main/resources/META-INF/services/io.github.dfa1.typesafe.transport.HttpTransport rename {local => client-local}/pom.xml (83%) rename {local => client-local}/scripts/clef/quantize_q4.py (100%) rename {local => client-local}/scripts/clef/reference.py (100%) rename {local => client-local}/scripts/laya/export_onnx.py (100%) rename {local => client-local}/scripts/tokenizer/reference.py (100%) rename {local/src/main/java/io/github/dfa1/typesafe => client-local/src/main/java/io/github/dfa1/typesafe/client}/local/BpeTokenizer.java (99%) rename {local/src/main/java/io/github/dfa1/typesafe => client-local/src/main/java/io/github/dfa1/typesafe/client}/local/ClefEngine.java (99%) rename {local/src/main/java/io/github/dfa1/typesafe => client-local/src/main/java/io/github/dfa1/typesafe/client}/local/Engine.java (94%) rename {local/src/main/java/io/github/dfa1/typesafe => client-local/src/main/java/io/github/dfa1/typesafe/client}/local/LayaEngine.java (99%) rename {local/src/main/java/io/github/dfa1/typesafe => client-local/src/main/java/io/github/dfa1/typesafe/client}/local/LocalClefTypeSafeClient.java (91%) rename {local/src/main/java/io/github/dfa1/typesafe => client-local/src/main/java/io/github/dfa1/typesafe/client}/local/LocalLayaTypeSafeClient.java (96%) rename {local/src/main/java/io/github/dfa1/typesafe => client-local/src/main/java/io/github/dfa1/typesafe/client}/local/LocalQwenTypeSafeClient.java (96%) rename {local/src/main/java/io/github/dfa1/typesafe => client-local/src/main/java/io/github/dfa1/typesafe/client}/local/LocalTypeSafeClient.java (99%) rename {local/src/main/java/io/github/dfa1/typesafe => client-local/src/main/java/io/github/dfa1/typesafe/client}/local/Onnx.java (94%) rename {local/src/main/java/io/github/dfa1/typesafe => client-local/src/main/java/io/github/dfa1/typesafe/client}/local/Probabilities.java (98%) rename {local/src/main/java/io/github/dfa1/typesafe => client-local/src/main/java/io/github/dfa1/typesafe/client}/local/QwenEngine.java (99%) rename {local/src/test/java/io/github/dfa1/typesafe => client-local/src/test/java/io/github/dfa1/typesafe/client}/local/BpeTokenizerTest.java (96%) rename {local/src/test/java/io/github/dfa1/typesafe => client-local/src/test/java/io/github/dfa1/typesafe/client}/local/ClefEngineTest.java (97%) rename {local/src/test/java/io/github/dfa1/typesafe => client-local/src/test/java/io/github/dfa1/typesafe/client}/local/Engines.java (97%) rename {local/src/test/java/io/github/dfa1/typesafe => client-local/src/test/java/io/github/dfa1/typesafe/client}/local/JevCases.java (99%) rename {local/src/test/java/io/github/dfa1/typesafe => client-local/src/test/java/io/github/dfa1/typesafe/client}/local/JevComparison.java (95%) rename {local/src/test/java/io/github/dfa1/typesafe => client-local/src/test/java/io/github/dfa1/typesafe/client}/local/LayaEngineTest.java (98%) rename {local/src/test/java/io/github/dfa1/typesafe => client-local/src/test/java/io/github/dfa1/typesafe/client}/local/LocalTypeSafeClientBenchmark.java (98%) rename {local/src/test/java/io/github/dfa1/typesafe => client-local/src/test/java/io/github/dfa1/typesafe/client}/local/LocalTypeSafeClientModelTest.java (94%) rename {local/src/test/java/io/github/dfa1/typesafe => client-local/src/test/java/io/github/dfa1/typesafe/client}/local/LocalTypeSafeClientTest.java (99%) rename {local/src/test/java/io/github/dfa1/typesafe => client-local/src/test/java/io/github/dfa1/typesafe/client}/local/OnnxTest.java (98%) rename {local/src/test/java/io/github/dfa1/typesafe => client-local/src/test/java/io/github/dfa1/typesafe/client}/local/ProbabilitiesTest.java (94%) rename {local/src/test/java/io/github/dfa1/typesafe => client-local/src/test/java/io/github/dfa1/typesafe/client}/local/QwenEngineTest.java (99%) rename {local/src/test/java/io/github/dfa1/typesafe => client-local/src/test/java/io/github/dfa1/typesafe/client}/local/TokenizerInputs.java (96%) rename {local => client-local}/src/test/resources/clef/expected.json (100%) rename {local => client-local}/src/test/resources/clef/requests.json (100%) rename {local => client-local}/src/test/resources/jev/chat.jsonl (100%) rename {local => client-local}/src/test/resources/jev/email.jsonl (100%) rename {local => client-local}/src/test/resources/jev/moderation.jsonl (100%) rename {local => client-local}/src/test/resources/jev/reviews.jsonl (100%) rename {local => client-local}/src/test/resources/jev/support.jsonl (100%) rename {local => client-local}/src/test/resources/laya/fixture.json (100%) rename {local => client-local}/src/test/resources/tokenizer/expected-laya.json (100%) rename {local => client-local}/src/test/resources/tokenizer/expected-qwen.json (100%) rename {local => client-local}/src/test/resources/tokenizer/inputs-laya.json (100%) rename {local => client-local}/src/test/resources/tokenizer/inputs-qwen.json (100%) rename {mapping => client-mapping}/pom.xml (90%) rename {mapping/src/main/java/io/github/dfa1/typesafe => client-mapping/src/main/java/io/github/dfa1/typesafe/client}/mapping/Choice.java (93%) rename {mapping/src/main/java/io/github/dfa1/typesafe => client-mapping/src/main/java/io/github/dfa1/typesafe/client}/mapping/MappingTypeSafeClient.java (97%) rename {mapping/src/main/java/io/github/dfa1/typesafe => client-mapping/src/main/java/io/github/dfa1/typesafe/client}/mapping/Noul.java (92%) rename {mapping/src/main/java/io/github/dfa1/typesafe => client-mapping/src/main/java/io/github/dfa1/typesafe/client}/mapping/Option.java (91%) rename {mapping/src/main/java/io/github/dfa1/typesafe => client-mapping/src/main/java/io/github/dfa1/typesafe/client}/mapping/Score.java (93%) rename {mapping/src/test/java/io/github/dfa1/typesafe => client-mapping/src/test/java/io/github/dfa1/typesafe/client}/mapping/MappingTypeSafeClientTest.java (98%) delete mode 100644 client-okhttp/src/main/resources/META-INF/services/io.github.dfa1.typesafe.transport.HttpTransport rename {testkit => client-testkit}/pom.xml (92%) rename {testkit/src/main/java/io/github/dfa1/typesafe => client-testkit/src/main/java/io/github/dfa1/typesafe/client}/testkit/FailingTypeSafeClient.java (97%) rename {testkit/src/main/java/io/github/dfa1/typesafe => client-testkit/src/main/java/io/github/dfa1/typesafe/client}/testkit/RecordingTypeSafeClient.java (98%) rename {testkit/src/test/java/io/github/dfa1/typesafe => client-testkit/src/test/java/io/github/dfa1/typesafe/client}/testkit/FailingTypeSafeClientTest.java (98%) rename {testkit/src/test/java/io/github/dfa1/typesafe => client-testkit/src/test/java/io/github/dfa1/typesafe/client}/testkit/RecordingTypeSafeClientTest.java (98%) rename {jackson2 => codec-jackson2}/pom.xml (93%) rename {jackson2/src/main/java/io/github/dfa1/typesafe => codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec}/jackson2/ContentSerializer.java (94%) rename {jackson2/src/main/java/io/github/dfa1/typesafe => codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec}/jackson2/Jackson2Codec.java (97%) rename {jackson2/src/main/java/io/github/dfa1/typesafe => codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec}/jackson2/ModelDeserializer.java (92%) rename {jackson2/src/main/java/io/github/dfa1/typesafe => codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec}/jackson2/ModelSerializer.java (92%) rename {jackson2/src/main/java/io/github/dfa1/typesafe => codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec}/jackson2/RequestIdDeserializer.java (92%) rename {jackson2/src/main/java/io/github/dfa1/typesafe => codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec}/jackson2/RequestIdSerializer.java (92%) create mode 100644 codec-jackson2/src/main/resources/META-INF/services/io.github.dfa1.typesafe.codec.JsonCodec rename {jackson2/src/test/java/io/github/dfa1/typesafe => codec-jackson2/src/test/java/io/github/dfa1/typesafe/codec}/jackson2/Jackson2CodecTest.java (99%) rename {jackson3 => codec-jackson3}/pom.xml (93%) rename {jackson3/src/main/java/io/github/dfa1/typesafe => codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec}/jackson3/ContentSerializer.java (94%) rename {jackson3/src/main/java/io/github/dfa1/typesafe => codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec}/jackson3/Jackson3Codec.java (96%) rename {jackson3/src/main/java/io/github/dfa1/typesafe => codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec}/jackson3/ModelDeserializer.java (91%) rename {jackson3/src/main/java/io/github/dfa1/typesafe => codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec}/jackson3/ModelSerializer.java (91%) rename {jackson3/src/main/java/io/github/dfa1/typesafe => codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec}/jackson3/RequestIdDeserializer.java (91%) rename {jackson3/src/main/java/io/github/dfa1/typesafe => codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec}/jackson3/RequestIdSerializer.java (91%) create mode 100644 codec-jackson3/src/main/resources/META-INF/services/io.github.dfa1.typesafe.codec.JsonCodec rename {jackson3/src/test/java/io/github/dfa1/typesafe => codec-jackson3/src/test/java/io/github/dfa1/typesafe/codec}/jackson3/Jackson3CodecTest.java (99%) rename core/src/main/java/io/github/dfa1/typesafe/{json => codec}/JsonCodec.java (91%) delete mode 100644 jackson2/src/main/resources/META-INF/services/io.github.dfa1.typesafe.json.JsonCodec delete mode 100644 jackson3/src/main/resources/META-INF/services/io.github.dfa1.typesafe.json.JsonCodec 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..31e6a93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,16 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- **Breaking: modules split into contract (`core`) and implementations (`client-*`), names aligned** — `core` keeps the `TypeSafeClient` interface, its decorators, the model and `JsonCodec`; `DefaultTypeSafeClient`/`ApiKey`/`HttpTransport` move 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. diff --git a/CLAUDE.md b/CLAUDE.md index 2dadb94..6bbfda4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,37 +10,45 @@ 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, +Naming rule: a module's directory, artifact (`typesafe-java-`) and package +(`io.github.dfa1.typesafe.`, dashes as dots) share one name (ADR 0003). + +core — the contract, no dependencies. TypeSafeClient (interface; no builder() — core 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). 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. + TypeSafeException and the wire model (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 + io.github.dfa1.typesafe.core; plus the JsonCodec SPI (io.github.dfa1.typesafe.codec), + kept here so client-local and the codecs need nothing else, and the model + a codec + serializes payloads (e.g. for Kafka) without HTTP code. javadoc in core can't + {@link} into client-* modules (javadoc-check fails); name them in {@code ...}. +codec-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. +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/JsonCodec, + never to a concrete library; both discovered via ServiceLoader at build() time (or + Builder.httpTransport(...)/jsonCodec(...)). +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,13 +57,7 @@ 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 +client-testkit — two TypeSafeClient test doubles, in io.github.dfa1.typesafe.client.testkit, depending only on core. 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 @@ -66,7 +68,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 @@ -76,15 +78,15 @@ mapping — MappingTypeSafeClient (io.github.dfa1.typesafe.mapping), a TypeSaf 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 + 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 @@ -95,7 +97,7 @@ local — one public TypeSafeClient per model (io.github.dfa1.typesafe.local (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 + 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 +108,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 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 @@ -138,21 +140,22 @@ cli — command-line entry point (`Main`), over client-jdk + jackson3. Its 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 → core`, `codec-jackson3 → core`, `client-http → core`, +`client-http-jdk → client-http`, `client-http-okhttp → client-http`, `client-local → core` (plus ONNX +Runtime; its own tests additionally depend on `client-http-jdk` and `codec-jackson2`, test scope +only), `client-mapping → core` (its own tests additionally depend on `client-testkit`, test scope +only), `client-testkit → core`, `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-contract-in-core-implementations-in-client-modules.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`. @@ -176,9 +179,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. + adding one more codec module; `core`/`client-http` 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 + 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 +191,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`/`JsonCodec` +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..e8d5666 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,37 @@ 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 `TypeSafeClient` interface and its decorators (retries, deadline, token counting), the model, and the `JsonCodec` SPI; no dependencies | +| `codec-jackson2` / `codec-jackson3` | `JsonCodec` 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-contract-in-core-implementations-in-client-modules.md) for why it's split this way. ## Docs diff --git a/acceptance/pom.xml b/acceptance/pom.xml index 2683635..19f40e8 100644 --- a/acceptance/pom.xml +++ b/acceptance/pom.xml @@ -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..659a5e9 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.codec.JsonCodec; +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; @@ -49,7 +50,7 @@ abstract class AbstractTypeSafeClientAcceptanceTest { @BeforeEach void setUp() throws Exception { - sut = TypeSafeClient.builder().apiKey(ApiKey.fromDefaultFile()) + sut = DefaultTypeSafeClient.builder().apiKey(ApiKey.fromDefaultFile()) .httpTransport(httpTransport()) .jsonCodec(jsonCodec()) .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..66271ba 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.JsonCodec; +import io.github.dfa1.typesafe.client.http.HttpTransport; class JdkHttpClientWithJackson2AcceptanceTest extends AbstractTypeSafeClientAcceptanceTest { 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..01fb737 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.JsonCodec; +import io.github.dfa1.typesafe.client.http.HttpTransport; class JdkHttpClientWithJackson3AcceptanceTest extends AbstractTypeSafeClientAcceptanceTest { 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..c096ada 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.JsonCodec; +import io.github.dfa1.typesafe.client.http.okhttp.OkHttpTransport; +import io.github.dfa1.typesafe.client.http.HttpTransport; class OkHttpClientWithJackson2AcceptanceTest extends AbstractTypeSafeClientAcceptanceTest { 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..bacde1b 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.JsonCodec; +import io.github.dfa1.typesafe.client.http.okhttp.OkHttpTransport; +import io.github.dfa1.typesafe.client.http.HttpTransport; class OkHttpClientWithJackson3AcceptanceTest extends AbstractTypeSafeClientAcceptanceTest { diff --git a/adr/0003-contract-in-core-implementations-in-client-modules.md b/adr/0003-contract-in-core-implementations-in-client-modules.md new file mode 100644 index 0000000..fd010b2 --- /dev/null +++ b/adr/0003-contract-in-core-implementations-in-client-modules.md @@ -0,0 +1,56 @@ +# 3. The contract in `core`, 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 `JsonCodec`/`HttpTransport` SPIs still hold. + +## Context + +`core` held everything but the concrete libraries: the model, the `TypeSafeClient` interface and its +decorators, and also 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. +- "`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`. + +[Issue #12](https://github.com/dfa1/typesafe-java/issues/12) first proposed moving the interface and +both SPIs into a new `client` module. That would have pushed `local`, `mapping`, `testkit` and the +codecs onto the HTTP client too, the opposite of the goal. + +## Decision + +Split by contract versus implementation: + +- **`core`** is the contract, with no dependencies: the `TypeSafeClient` interface, the decorators that + wrap any implementation (`RetryingTypeSafeClient`, `DeadlineTypeSafeClient`, `TokenCounter`), + `TypeSafeException`, the model, and the `JsonCodec` SPI. `JsonCodec` stays here because + `client-local` reads its model configs through it, and the model plus a codec should serialize + payloads (e.g. for Kafka) without any HTTP code. +- **`client-*`** modules implement or build on `TypeSafeClient`, and none depends 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 `JsonCodec`: `codec-jackson2`, `codec-jackson3`. They depend only on `core`. +- **One name per module**: its directory, its artifact (`typesafe-java-`) and its package + (`io.github.dfa1.typesafe.`, dashes as dots) are the same. + +`TypeSafeClient.builder()` is removed: `core` can't reference `client-http`. Callers write +`DefaultTypeSafeClient.builder()`. + +## Consequences + +- The compiler enforces the boundary. javadoc in `core` can't `{@link}` into a `client-*` module, so it + names `DefaultTypeSafeClient` in `{@code ...}` instead. `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` 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..60f7f2c 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,49 @@ io.github.dfa1.typesafe-java - typesafe-java-client-jdk + typesafe-java-client-http ${project.version} io.github.dfa1.typesafe-java - typesafe-java-client-okhttp + typesafe-java-client-http-jdk ${project.version} io.github.dfa1.typesafe-java - typesafe-java-jackson2 + typesafe-java-client-http-okhttp ${project.version} io.github.dfa1.typesafe-java - typesafe-java-jackson3 + typesafe-java-codec-jackson2 ${project.version} io.github.dfa1.typesafe-java - typesafe-java-testkit + typesafe-java-codec-jackson3 ${project.version} io.github.dfa1.typesafe-java - typesafe-java-mapping + typesafe-java-client-testkit ${project.version} io.github.dfa1.typesafe-java - typesafe-java-local + 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..585d7a4 100644 --- a/cli/pom.xml +++ b/cli/pom.xml @@ -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..0b3fcbc 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,16 +1,17 @@ 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.codec.jackson3.Jackson3Codec; +import io.github.dfa1.typesafe.client.http.jdk.JdkHttpTransport; +import io.github.dfa1.typesafe.codec.JsonCodec; import java.io.PrintStream; import java.util.ArrayList; @@ -67,7 +68,7 @@ static int run(String[] args, PrintStream out, PrintStream err) throws Exception } Jackson3Codec codec = new Jackson3Codec(); - try (TypeSafeClient client = TypeSafeClient.builder().apiKey(ApiKey.fromDefaultFile()) + try (TypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(ApiKey.fromDefaultFile()) .jsonCodec(codec) .httpTransport(new JdkHttpTransport()) .build()) { 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..7d23d4f 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 @@ -6,7 +6,7 @@ import io.github.dfa1.typesafe.core.Model; import io.github.dfa1.typesafe.core.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; 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 93% 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..9ac720c 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,7 +1,7 @@ -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; 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 97% 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..6cfc6a5 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; 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 95% 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..3505f62 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; 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 98% 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..4f220fd 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; diff --git a/client-http/pom.xml b/client-http/pom.xml new file mode 100644 index 0000000..24d6370 --- /dev/null +++ b/client-http/pom.xml @@ -0,0 +1,51 @@ + + 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-core; 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-core + ${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 95% 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..b3c303b 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,8 +1,13 @@ -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.core.RetryingTypeSafeClient; +import io.github.dfa1.typesafe.core.TypeSafeClient; +import io.github.dfa1.typesafe.core.TypeSafeException; +import io.github.dfa1.typesafe.codec.JsonCodec; import java.io.IOException; import java.io.InterruptedIOException; @@ -196,7 +201,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);
@@ -312,15 +317,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()
                     .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 JsonCodec found on the classpath. Add typesafe-java-codec-jackson2 or "
+                                    + "typesafe-java-codec-jackson3 as a dependency, or call Builder.jsonCodec(...)."));
         }
     }
 }
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 81%
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..38a727f 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,8 +8,8 @@
  * 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 {
 
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 91%
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..70faf92 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,4 +1,4 @@
-package io.github.dfa1.typesafe.transport;
+package io.github.dfa1.typesafe.client.http;
 
 import java.util.Map;
 import java.util.Optional;
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 93%
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..98bfecf 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,17 @@
-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 io.github.dfa1.typesafe.core.Content;
+import io.github.dfa1.typesafe.core.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.core.RetryingTypeSafeClient;
+import io.github.dfa1.typesafe.core.TypeSafeClient;
+import io.github.dfa1.typesafe.core.TypeSafeException;
+import io.github.dfa1.typesafe.core.Usage;
+import io.github.dfa1.typesafe.codec.JsonCodec;
 
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -31,7 +40,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;
@@ -45,7 +54,7 @@ class TypeSafeClientTest {
     @Test
     void evaluateDelegatesToTheConfiguredHttpTransportAndJsonCodec() {
         // Given
-        TypeSafeClient sut = TypeSafeClient.builder().apiKey(ApiKey.of("secret"))
+        TypeSafeClient sut = DefaultTypeSafeClient.builder().apiKey(ApiKey.of("secret"))
                 .endpoint(ENDPOINT)
                 .httpTransport(httpTransport)
                 .jsonCodec(jsonCodec)
@@ -78,7 +87,7 @@ void evaluateDelegatesToTheConfiguredHttpTransportAndJsonCodec() {
     @Test
     void withoutAnApiKeySendsNoAuthorizationHeader() {
         // Given
-        TypeSafeClient sut = TypeSafeClient.builder()
+        TypeSafeClient sut = DefaultTypeSafeClient.builder()
                 .endpoint(ENDPOINT)
                 .httpTransport(httpTransport)
                 .jsonCodec(jsonCodec)
@@ -106,7 +115,7 @@ void withoutAnApiKeySendsNoAuthorizationHeader() {
     @Test
     void listModelsDelegatesToTheConfiguredHttpTransportAndJsonCodec() {
         // Given
-        TypeSafeClient sut = TypeSafeClient.builder().apiKey(ApiKey.of("secret"))
+        TypeSafeClient sut = DefaultTypeSafeClient.builder().apiKey(ApiKey.of("secret"))
                 .endpoint(ENDPOINT)
                 .httpTransport(httpTransport)
                 .jsonCodec(jsonCodec)
@@ -132,7 +141,7 @@ 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)
                 .build();
@@ -540,7 +549,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")).jsonCodec(jsonCodec);
 
         // When / Then
         assertThatThrownBy(sut::build)
@@ -551,7 +560,7 @@ void builderThrowsWhenNoHttpTransportIsConfiguredOrDiscoverable() {
     @Test
     void builderThrowsWhenNoJsonCodecIsConfiguredOrDiscoverable() {
         // 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)
@@ -562,7 +571,7 @@ void builderThrowsWhenNoJsonCodecIsConfiguredOrDiscoverable() {
     @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);
 
@@ -576,7 +585,7 @@ 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)
                 .decorateWith(WrappingTypeSafeClient::new);
@@ -592,7 +601,7 @@ 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)
                 .decorateWith(WrappingTypeSafeClient::new)
@@ -608,7 +617,7 @@ 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)
                 .decorateWith(RetryingTypeSafeClient::decorate)
@@ -625,7 +634,7 @@ 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)
                 .decorateWith(RetryingTypeSafeClient::decorate);
@@ -638,7 +647,7 @@ 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);
 
@@ -670,7 +679,7 @@ 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);
         Function innermost = WrappingTypeSafeClient::new;
@@ -753,7 +762,7 @@ 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)
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 96%
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..0b8da3b 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,4 +1,4 @@
-package io.github.dfa1.typesafe.transport;
+package io.github.dfa1.typesafe.client.http;
 
 import org.junit.jupiter.api.Test;
 
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 83%
rename from local/pom.xml
rename to client-local/pom.xml
index 4e47752..f3f5811 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 JsonCodec module (codec-jackson2 or codec-jackson3) at run time, like the API client.
   
 
   
     1.30.0
     1.37
-    
     acceptance,model
   
@@ -57,13 +57,19 @@
     
     
       io.github.dfa1.typesafe-java
-      typesafe-java-client-jdk
+      typesafe-java-client-http
       ${project.version}
       test
     
     
       io.github.dfa1.typesafe-java
-      typesafe-java-jackson2
+      typesafe-java-client-http-jdk
+      ${project.version}
+      test
+    
+    
+      io.github.dfa1.typesafe-java
+      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 99%
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..320a192 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;
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 99%
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..428e10d 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;
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 94%
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..f09c673 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;
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 99%
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..1747f1d 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;
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 99% 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..731ae6c 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; 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 94% 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..771d60e 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,10 +1,10 @@ -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.JsonCodec; import java.io.IOException; import java.io.UncheckedIOException; @@ -52,8 +52,8 @@ static JsonCodec json() { 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.")); + .orElseThrow(() -> new IllegalStateException("No JsonCodec 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..425c7c0 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; 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 96% 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..4d71bcb 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; 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 97% 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..65b5604 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; 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 95% 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..e60dc31 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,12 +1,13 @@ -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.codec.jackson2.Jackson2Codec; +import io.github.dfa1.typesafe.codec.JsonCodec; import java.io.IOException; import java.io.UncheckedIOException; @@ -23,7 +24,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 { @@ -161,7 +162,7 @@ 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()).jsonCodec(codec).build(); } EvaluateResponse r = api.evaluate(c.request()); // metadata (request id, timing) is per call, not part of the answer 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 99% 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..c22309d 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; 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 99% 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..072fa43 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; 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 96% 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..334f4d2 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; 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 90% rename from mapping/pom.xml rename to client-mapping/pom.xml index 136dc83..a1d04fb 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 @@ -26,7 +26,7 @@ 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 97% 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..809b0f6 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; @@ -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..2835e4a 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; @@ -8,8 +8,8 @@ import io.github.dfa1.typesafe.core.Content; import io.github.dfa1.typesafe.core.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 92% rename from testkit/pom.xml rename to client-testkit/pom.xml index 01aad70..6f2798b 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 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 97% 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..3d9063e 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,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/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 98% 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..f7a75f2 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,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/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 98% 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..b1d0fa8 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,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/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/jackson2/pom.xml b/codec-jackson2/pom.xml similarity index 93% rename from jackson2/pom.xml rename to codec-jackson2/pom.xml index cb1a5a8..c4dfe3a 100644 --- a/jackson2/pom.xml +++ b/codec-jackson2/pom.xml @@ -7,10 +7,10 @@ 0.7.0-SNAPSHOT - typesafe-java-jackson2 + typesafe-java-codec-jackson2 jar - TypeSafe Jackson 2 Codec + TypeSafe Codec Jackson 2 JsonCodec SPI implementation backed by Jackson 2.x. Depends only on typesafe-java-core; discovered via ServiceLoader. 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 97% 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..be702d2 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,11 +1,11 @@ -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.JsonCodec; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.core.JsonProcessingException; 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.JsonCodec b/codec-jackson2/src/main/resources/META-INF/services/io.github.dfa1.typesafe.codec.JsonCodec new file mode 100644 index 0000000..7474175 --- /dev/null +++ b/codec-jackson2/src/main/resources/META-INF/services/io.github.dfa1.typesafe.codec.JsonCodec @@ -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 99% 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..145f2fd 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,4 +1,4 @@ -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.EvaluateRequest; diff --git a/jackson3/pom.xml b/codec-jackson3/pom.xml similarity index 93% rename from jackson3/pom.xml rename to codec-jackson3/pom.xml index 38f463b..e8f98c4 100644 --- a/jackson3/pom.xml +++ b/codec-jackson3/pom.xml @@ -7,10 +7,10 @@ 0.7.0-SNAPSHOT - typesafe-java-jackson3 + typesafe-java-codec-jackson3 jar - TypeSafe Jackson 3 Codec + TypeSafe Codec Jackson 3 JsonCodec SPI implementation backed by Jackson 3.x. Depends only on typesafe-java-core; discovered via ServiceLoader. 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 96% 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..1911c18 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.JsonCodec; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; import tools.jackson.databind.ObjectMapper; 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.JsonCodec b/codec-jackson3/src/main/resources/META-INF/services/io.github.dfa1.typesafe.codec.JsonCodec new file mode 100644 index 0000000..06e6c79 --- /dev/null +++ b/codec-jackson3/src/main/resources/META-INF/services/io.github.dfa1.typesafe.codec.JsonCodec @@ -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 99% 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..95844a9 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,4 +1,4 @@ -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.EvaluateRequest; diff --git a/core/pom.xml b/core/pom.xml index 60dcc6c..1867b61 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -12,11 +12,11 @@ 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 contract: the TypeSafeClient interface and its decorators (retries, deadline, token + counting), TypeSafeException, the wire model, and the JsonCodec SPI. No dependencies. Every + client-* module implements or builds on TypeSafeClient (client-http calls the API, client-local + runs a model in-process); the model plus a codec-* module serializes TypeSafe payloads without + any HTTP code, e.g. for a Kafka producer/consumer. @@ -37,11 +37,5 @@ mockito-junit-jupiter test - - - org.junit-pioneer - junit-pioneer - test - diff --git a/core/src/main/java/io/github/dfa1/typesafe/json/JsonCodec.java b/core/src/main/java/io/github/dfa1/typesafe/codec/JsonCodec.java similarity index 91% rename from core/src/main/java/io/github/dfa1/typesafe/json/JsonCodec.java rename to core/src/main/java/io/github/dfa1/typesafe/codec/JsonCodec.java index 4ae7b9d..fccecd6 100644 --- a/core/src/main/java/io/github/dfa1/typesafe/json/JsonCodec.java +++ b/core/src/main/java/io/github/dfa1/typesafe/codec/JsonCodec.java @@ -1,4 +1,4 @@ -package io.github.dfa1.typesafe.json; +package io.github.dfa1.typesafe.codec; /** * JSON serialization plugged in by a codec module (typesafe-jackson2 or typesafe-jackson3). diff --git a/core/src/main/java/io/github/dfa1/typesafe/core/DeadlineTypeSafeClient.java b/core/src/main/java/io/github/dfa1/typesafe/core/DeadlineTypeSafeClient.java index 1f88066..667e58e 100644 --- a/core/src/main/java/io/github/dfa1/typesafe/core/DeadlineTypeSafeClient.java +++ b/core/src/main/java/io/github/dfa1/typesafe/core/DeadlineTypeSafeClient.java @@ -3,9 +3,9 @@ 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 +30,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 +39,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 +62,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/core/src/main/java/io/github/dfa1/typesafe/core/RetryingTypeSafeClient.java index 1ba0aba..69b0042 100644 --- a/core/src/main/java/io/github/dfa1/typesafe/core/RetryingTypeSafeClient.java +++ b/core/src/main/java/io/github/dfa1/typesafe/core/RetryingTypeSafeClient.java @@ -6,7 +6,6 @@ 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 +16,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 +33,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/core/src/main/java/io/github/dfa1/typesafe/core/TokenCounter.java index 9bd4135..5f2bc1d 100644 --- a/core/src/main/java/io/github/dfa1/typesafe/core/TokenCounter.java +++ b/core/src/main/java/io/github/dfa1/typesafe/core/TokenCounter.java @@ -10,7 +10,7 @@ * *

{@code
  * TokenCounter tokens = new TokenCounter();
- * TypeSafeClient client = TypeSafeClient.builder().apiKey(apiKey)
+ * TypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(apiKey)
  *         .decorateWith(tokens::decorate)
  *         .build();
  * // ...
@@ -29,7 +29,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/core/src/main/java/io/github/dfa1/typesafe/core/TypeSafeClient.java
index 1610b9a..6390438 100644
--- a/core/src/main/java/io/github/dfa1/typesafe/core/TypeSafeClient.java
+++ b/core/src/main/java/io/github/dfa1/typesafe/core/TypeSafeClient.java
@@ -1,25 +1,20 @@
 package io.github.dfa1.typesafe.core;
 
-import io.github.dfa1.typesafe.transport.HttpTransport;
-
 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 +34,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/core/src/main/java/io/github/dfa1/typesafe/core/TypeSafeException.java
index a654a7a..6a80da9 100644
--- a/core/src/main/java/io/github/dfa1/typesafe/core/TypeSafeException.java
+++ b/core/src/main/java/io/github/dfa1/typesafe/core/TypeSafeException.java
@@ -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 {@link io.github.dfa1.typesafe.codec.JsonCodec} couldn't
      *  decode the response body. */
     public static final class ResponseDecoding extends TypeSafeException {
         public ResponseDecoding(String body, Throwable cause) {
diff --git a/docs/explanation.md b/docs/explanation.md
index 3beb6ae..dbcfbd9 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
@@ -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.
@@ -84,29 +84,36 @@ decision record.
 
 ## Why `JsonCodec` 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
 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 contract is in `core` and each client is its own module
+
+`core` holds what every client agrees on: the `TypeSafeClient` interface, the decorators that wrap
+any implementation (retries, a deadline, token counting), `TypeSafeException`, the model, and the
+`JsonCodec` SPI. 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: `core` can't see
+`DefaultTypeSafeClient`, which is why `TypeSafeClient` has no `builder()`.
+
+`JsonCodec` stays in `core` rather than moving next to `HttpTransport`: `client-local` reads its
+model configs through it, and the model plus a `codec-*` module is enough to serialize TypeSafe
+payloads (e.g. onto a Kafka topic) without any HTTP code. Every module's directory, artifact and
+package share one name. See [ADR 0003](../adr/0003-contract-in-core-implementations-in-client-modules.md).
 
 ## Why `TypeSafeClient` is an interface, not a final class
 
@@ -119,8 +126,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
@@ -131,12 +138,11 @@ was expected.
 interface: constructing a `DefaultTypeSafeClient` — picking defaults, discovering a
 `HttpTransport`/`JsonCodec` 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` out of `core` (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 +153,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 +166,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,7 +199,7 @@ 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
@@ -264,7 +270,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..ea29722 100644
--- a/docs/how-to.md
+++ b/docs/how-to.md
@@ -13,19 +13,19 @@ 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
@@ -36,39 +36,39 @@ TypeSafeClient client = TypeSafeClient.builder().apiKey(token).build();
 ```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):
 
 ```java
-TypeSafeClient client = TypeSafeClient.builder().apiKey(token)
+TypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(token)
         .jsonCodec(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:
@@ -542,11 +542,10 @@ 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();
@@ -617,7 +616,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 +630,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 +663,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 JsonCodec 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 +706,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 +719,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 +727,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 +741,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);
diff --git a/docs/reference.md b/docs/reference.md
index 5c9a148..2a4f900 100644
--- a/docs/reference.md
+++ b/docs/reference.md
@@ -15,19 +15,21 @@ 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` | — | `TypeSafeClient`, `RetryingTypeSafeClient`, `DeadlineTypeSafeClient`, `TokenCounter`, `TypeSafeException`, the model (`Answer`, `Question`, `Content`, `EvaluateRequest`, `EvaluateResponse`, `Usage`, `RequestId`, `Model`, `ModelDetails`), `JsonCodec` |
+| `typesafe-java-codec-jackson2` | `core` | `Jackson2Codec` (Jackson 2.x) |
+| `typesafe-java-codec-jackson3` | `core` | `Jackson3Codec` (Jackson 3.x) |
+| `typesafe-java-client-http` | `core` | `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` | `core`, ONNX Runtime | `LocalLayaTypeSafeClient`, `LocalQwenTypeSafeClient`, `LocalClefTypeSafeClient` (in-process models) |
+| `typesafe-java-client-mapping` | `core` | `MappingTypeSafeClient`, `@Noul`/`@Choice`/`@Score`/`@Option` |
+| `typesafe-java-client-testkit` | `core` | `RecordingTypeSafeClient`, `FailingTypeSafeClient` |
+| `typesafe-java-bom` | — | dependency management for the nine 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` has no dependencies: it's the contract every
+`client-*` module implements or builds on, and the model plus a `codec-*` module serializes TypeSafe
+payloads without any HTTP code.
 
 ## Core types
 
@@ -162,7 +164,7 @@ plain `Model`, usable directly as an `EvaluateRequest`'s model.
 ## JsonCodec SPI
 
 ```java
-package io.github.dfa1.typesafe.json;
+package io.github.dfa1.typesafe.codec;
 
 public interface JsonCodec {
     String writeValueAsString(Object value);
@@ -173,7 +175,7 @@ public interface JsonCodec {
 
 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
+`META-INF/services/io.github.dfa1.typesafe.codec.JsonCodec`. 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
@@ -183,8 +185,10 @@ once at that boundary (`.getBytes(UTF_8)` / `new String(bytes, UTF_8)`), same re
 
 ## 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);
@@ -213,9 +217,9 @@ that mutates the map it passed in afterward can't reach back into an already-ret
 
 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 +242,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 +275,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 +323,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 {
@@ -337,7 +341,7 @@ 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
+(`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 +369,9 @@ only).
 
 ## Client
 
-Also in `io.github.dfa1.typesafe.core`.
+`TypeSafeClient`, its decorators and `TypeSafeException` are in `io.github.dfa1.typesafe.core`;
+`ApiKey` and `DefaultTypeSafeClient` are in `io.github.dfa1.typesafe.client.http` (module
+`typesafe-java-client-http`).
 
 ### `ApiKey`
 
@@ -385,8 +391,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 +398,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: `core` 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,11 +416,11 @@ 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 |
 |---|---|
diff --git a/docs/tutorial.md b/docs/tutorial.md
index 7147c93..5a54d54 100644
--- a/docs/tutorial.md
+++ b/docs/tutorial.md
@@ -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.client.http.ApiKey;
+import io.github.dfa1.typesafe.client.http.DefaultTypeSafeClient;
 import io.github.dfa1.typesafe.core.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..a3f4d76 100644
--- a/pom.xml
+++ b/pom.xml
@@ -38,13 +38,14 @@
 
   
     core
-    client-jdk
-    client-okhttp
-    jackson2
-    jackson3
-    testkit
-    mapping
-    local
+    codec-jackson2
+    codec-jackson3
+    client-http
+    client-http-jdk
+    client-http-okhttp
+    client-testkit
+    client-mapping
+    client-local
     bom
     acceptance
     cli

From e82d4ca10fe68c76a405d650bb67f701b3c2556c Mon Sep 17 00:00:00 2001
From: Davide Angelocola 
Date: Sun, 4 Oct 2026 08:36:23 +0200
Subject: [PATCH 2/8] core: JsonCodec in io.github.dfa1.typesafe.core, so core
 holds one package

The core artifact held two packages (core, codec); every other module holds
exactly the package its name gives. JsonCodec now sits in core directly, as
HttpTransport sits in client.http.

Co-Authored-By: Claude Sonnet 5 
---
 CLAUDE.md                                                     | 2 +-
 .../acceptance/AbstractTypeSafeClientAcceptanceTest.java      | 2 +-
 .../acceptance/JdkHttpClientWithJackson2AcceptanceTest.java   | 2 +-
 .../acceptance/JdkHttpClientWithJackson3AcceptanceTest.java   | 2 +-
 .../acceptance/OkHttpClientWithJackson2AcceptanceTest.java    | 2 +-
 .../acceptance/OkHttpClientWithJackson3AcceptanceTest.java    | 2 +-
 cli/src/main/java/io/github/dfa1/typesafe/cli/Main.java       | 2 +-
 .../dfa1/typesafe/client/http/DefaultTypeSafeClient.java      | 2 +-
 .../dfa1/typesafe/client/http/DefaultTypeSafeClientTest.java  | 2 +-
 .../main/java/io/github/dfa1/typesafe/client/local/Onnx.java  | 2 +-
 .../io/github/dfa1/typesafe/client/local/JevComparison.java   | 2 +-
 .../io/github/dfa1/typesafe/codec/jackson2/Jackson2Codec.java | 2 +-
 ...codec.JsonCodec => io.github.dfa1.typesafe.core.JsonCodec} | 0
 .../io/github/dfa1/typesafe/codec/jackson3/Jackson3Codec.java | 2 +-
 ...codec.JsonCodec => io.github.dfa1.typesafe.core.JsonCodec} | 0
 .../io/github/dfa1/typesafe/{codec => core}/JsonCodec.java    | 2 +-
 .../java/io/github/dfa1/typesafe/core/TypeSafeException.java  | 2 +-
 docs/reference.md                                             | 4 ++--
 18 files changed, 17 insertions(+), 17 deletions(-)
 rename codec-jackson2/src/main/resources/META-INF/services/{io.github.dfa1.typesafe.codec.JsonCodec => io.github.dfa1.typesafe.core.JsonCodec} (100%)
 rename codec-jackson3/src/main/resources/META-INF/services/{io.github.dfa1.typesafe.codec.JsonCodec => io.github.dfa1.typesafe.core.JsonCodec} (100%)
 rename core/src/main/java/io/github/dfa1/typesafe/{codec => core}/JsonCodec.java (91%)

diff --git a/CLAUDE.md b/CLAUDE.md
index 6bbfda4..265dcf2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -28,7 +28,7 @@ core      — the contract, no dependencies. TypeSafeClient (interface; no build
             stack. RetryingTypeSafeClient stops retrying once its returned future is done.
             TypeSafeException and the wire model (Answer, Question, Content,
             EvaluateRequest/EvaluateResponse, Usage, RequestId, Model, ModelDetails), all in
-            io.github.dfa1.typesafe.core; plus the JsonCodec SPI (io.github.dfa1.typesafe.codec),
+            io.github.dfa1.typesafe.core, as is the JsonCodec SPI,
             kept here so client-local and the codecs need nothing else, and the model + a codec
             serializes payloads (e.g. for Kafka) without HTTP code. javadoc in core can't
             {@link} into client-* modules (javadoc-check fails); name them in {@code ...}.
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 659a5e9..0c6f940 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
@@ -8,7 +8,7 @@
 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.codec.JsonCodec;
+import io.github.dfa1.typesafe.core.JsonCodec;
 import io.github.dfa1.typesafe.client.mapping.Choice;
 import io.github.dfa1.typesafe.client.mapping.MappingTypeSafeClient;
 import io.github.dfa1.typesafe.client.mapping.Noul;
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 66271ba..665a74e 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
@@ -2,7 +2,7 @@
 
 import io.github.dfa1.typesafe.codec.jackson2.Jackson2Codec;
 import io.github.dfa1.typesafe.client.http.jdk.JdkHttpTransport;
-import io.github.dfa1.typesafe.codec.JsonCodec;
+import io.github.dfa1.typesafe.core.JsonCodec;
 import io.github.dfa1.typesafe.client.http.HttpTransport;
 
 class JdkHttpClientWithJackson2AcceptanceTest extends AbstractTypeSafeClientAcceptanceTest {
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 01fb737..c37f2c4 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
@@ -2,7 +2,7 @@
 
 import io.github.dfa1.typesafe.codec.jackson3.Jackson3Codec;
 import io.github.dfa1.typesafe.client.http.jdk.JdkHttpTransport;
-import io.github.dfa1.typesafe.codec.JsonCodec;
+import io.github.dfa1.typesafe.core.JsonCodec;
 import io.github.dfa1.typesafe.client.http.HttpTransport;
 
 class JdkHttpClientWithJackson3AcceptanceTest extends AbstractTypeSafeClientAcceptanceTest {
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 c096ada..d4f7992 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,7 +1,7 @@
 package io.github.dfa1.typesafe.acceptance;
 
 import io.github.dfa1.typesafe.codec.jackson2.Jackson2Codec;
-import io.github.dfa1.typesafe.codec.JsonCodec;
+import io.github.dfa1.typesafe.core.JsonCodec;
 import io.github.dfa1.typesafe.client.http.okhttp.OkHttpTransport;
 import io.github.dfa1.typesafe.client.http.HttpTransport;
 
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 bacde1b..f1d2448 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,7 +1,7 @@
 package io.github.dfa1.typesafe.acceptance;
 
 import io.github.dfa1.typesafe.codec.jackson3.Jackson3Codec;
-import io.github.dfa1.typesafe.codec.JsonCodec;
+import io.github.dfa1.typesafe.core.JsonCodec;
 import io.github.dfa1.typesafe.client.http.okhttp.OkHttpTransport;
 import io.github.dfa1.typesafe.client.http.HttpTransport;
 
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 0b3fcbc..505d7b6 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
@@ -11,7 +11,7 @@
 import io.github.dfa1.typesafe.core.TypeSafeClient;
 import io.github.dfa1.typesafe.codec.jackson3.Jackson3Codec;
 import io.github.dfa1.typesafe.client.http.jdk.JdkHttpTransport;
-import io.github.dfa1.typesafe.codec.JsonCodec;
+import io.github.dfa1.typesafe.core.JsonCodec;
 
 import java.io.PrintStream;
 import java.util.ArrayList;
diff --git a/client-http/src/main/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClient.java b/client-http/src/main/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClient.java
index b3c303b..fa51a09 100644
--- a/client-http/src/main/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClient.java
+++ b/client-http/src/main/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClient.java
@@ -7,7 +7,7 @@
 import io.github.dfa1.typesafe.core.RetryingTypeSafeClient;
 import io.github.dfa1.typesafe.core.TypeSafeClient;
 import io.github.dfa1.typesafe.core.TypeSafeException;
-import io.github.dfa1.typesafe.codec.JsonCodec;
+import io.github.dfa1.typesafe.core.JsonCodec;
 
 import java.io.IOException;
 import java.io.InterruptedIOException;
diff --git a/client-http/src/test/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClientTest.java b/client-http/src/test/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClientTest.java
index 98bfecf..ecbc91b 100644
--- a/client-http/src/test/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClientTest.java
+++ b/client-http/src/test/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClientTest.java
@@ -11,7 +11,7 @@
 import io.github.dfa1.typesafe.core.TypeSafeClient;
 import io.github.dfa1.typesafe.core.TypeSafeException;
 import io.github.dfa1.typesafe.core.Usage;
-import io.github.dfa1.typesafe.codec.JsonCodec;
+import io.github.dfa1.typesafe.core.JsonCodec;
 
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
diff --git a/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Onnx.java b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Onnx.java
index 771d60e..10b7c35 100644
--- a/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Onnx.java
+++ b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Onnx.java
@@ -4,7 +4,7 @@
 import ai.onnxruntime.OrtEnvironment;
 import ai.onnxruntime.OrtException;
 import ai.onnxruntime.OrtSession;
-import io.github.dfa1.typesafe.codec.JsonCodec;
+import io.github.dfa1.typesafe.core.JsonCodec;
 
 import java.io.IOException;
 import java.io.UncheckedIOException;
diff --git a/client-local/src/test/java/io/github/dfa1/typesafe/client/local/JevComparison.java b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/JevComparison.java
index e60dc31..4b6b33b 100644
--- a/client-local/src/test/java/io/github/dfa1/typesafe/client/local/JevComparison.java
+++ b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/JevComparison.java
@@ -7,7 +7,7 @@
 import io.github.dfa1.typesafe.core.EvaluateResponse;
 import io.github.dfa1.typesafe.core.TypeSafeClient;
 import io.github.dfa1.typesafe.codec.jackson2.Jackson2Codec;
-import io.github.dfa1.typesafe.codec.JsonCodec;
+import io.github.dfa1.typesafe.core.JsonCodec;
 
 import java.io.IOException;
 import java.io.UncheckedIOException;
diff --git a/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2Codec.java b/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2Codec.java
index be702d2..f1f2b47 100644
--- a/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2Codec.java
+++ b/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2Codec.java
@@ -5,7 +5,7 @@
 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.codec.JsonCodec;
+import io.github.dfa1.typesafe.core.JsonCodec;
 import com.fasterxml.jackson.annotation.JsonSubTypes;
 import com.fasterxml.jackson.annotation.JsonTypeInfo;
 import com.fasterxml.jackson.core.JsonProcessingException;
diff --git a/codec-jackson2/src/main/resources/META-INF/services/io.github.dfa1.typesafe.codec.JsonCodec b/codec-jackson2/src/main/resources/META-INF/services/io.github.dfa1.typesafe.core.JsonCodec
similarity index 100%
rename from codec-jackson2/src/main/resources/META-INF/services/io.github.dfa1.typesafe.codec.JsonCodec
rename to codec-jackson2/src/main/resources/META-INF/services/io.github.dfa1.typesafe.core.JsonCodec
diff --git a/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3Codec.java b/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3Codec.java
index 1911c18..070ac4c 100644
--- a/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3Codec.java
+++ b/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3Codec.java
@@ -5,7 +5,7 @@
 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.codec.JsonCodec;
+import io.github.dfa1.typesafe.core.JsonCodec;
 import com.fasterxml.jackson.annotation.JsonSubTypes;
 import com.fasterxml.jackson.annotation.JsonTypeInfo;
 import tools.jackson.databind.ObjectMapper;
diff --git a/codec-jackson3/src/main/resources/META-INF/services/io.github.dfa1.typesafe.codec.JsonCodec b/codec-jackson3/src/main/resources/META-INF/services/io.github.dfa1.typesafe.core.JsonCodec
similarity index 100%
rename from codec-jackson3/src/main/resources/META-INF/services/io.github.dfa1.typesafe.codec.JsonCodec
rename to codec-jackson3/src/main/resources/META-INF/services/io.github.dfa1.typesafe.core.JsonCodec
diff --git a/core/src/main/java/io/github/dfa1/typesafe/codec/JsonCodec.java b/core/src/main/java/io/github/dfa1/typesafe/core/JsonCodec.java
similarity index 91%
rename from core/src/main/java/io/github/dfa1/typesafe/codec/JsonCodec.java
rename to core/src/main/java/io/github/dfa1/typesafe/core/JsonCodec.java
index fccecd6..1e85756 100644
--- a/core/src/main/java/io/github/dfa1/typesafe/codec/JsonCodec.java
+++ b/core/src/main/java/io/github/dfa1/typesafe/core/JsonCodec.java
@@ -1,4 +1,4 @@
-package io.github.dfa1.typesafe.codec;
+package io.github.dfa1.typesafe.core;
 
 /**
  * JSON serialization plugged in by a codec module (typesafe-jackson2 or typesafe-jackson3).
diff --git a/core/src/main/java/io/github/dfa1/typesafe/core/TypeSafeException.java b/core/src/main/java/io/github/dfa1/typesafe/core/TypeSafeException.java
index 6a80da9..2137b7b 100644
--- a/core/src/main/java/io/github/dfa1/typesafe/core/TypeSafeException.java
+++ b/core/src/main/java/io/github/dfa1/typesafe/core/TypeSafeException.java
@@ -99,7 +99,7 @@ public Optional retryAfter() {
         }
     }
 
-    /** {@code 200}, but the configured {@link io.github.dfa1.typesafe.codec.JsonCodec} couldn't
+    /** {@code 200}, but the configured {@link io.github.dfa1.typesafe.core.JsonCodec} couldn't
      *  decode the response body. */
     public static final class ResponseDecoding extends TypeSafeException {
         public ResponseDecoding(String body, Throwable cause) {
diff --git a/docs/reference.md b/docs/reference.md
index 2a4f900..8b60663 100644
--- a/docs/reference.md
+++ b/docs/reference.md
@@ -164,7 +164,7 @@ plain `Model`, usable directly as an `EvaluateRequest`'s model.
 ## JsonCodec SPI
 
 ```java
-package io.github.dfa1.typesafe.codec;
+package io.github.dfa1.typesafe.core;
 
 public interface JsonCodec {
     String writeValueAsString(Object value);
@@ -175,7 +175,7 @@ public interface JsonCodec {
 
 Implementations (`Jackson2Codec`, `Jackson3Codec`) are discovered via
 `ServiceLoader.load(JsonCodec.class)` and registered through
-`META-INF/services/io.github.dfa1.typesafe.codec.JsonCodec`. Both own the `Answer`/`Question` polymorphic
+`META-INF/services/io.github.dfa1.typesafe.core.JsonCodec`. 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

From a466da7e2471d554ec915c19327af3d8325ddef7 Mon Sep 17 00:00:00 2001
From: Davide Angelocola 
Date: Sun, 4 Oct 2026 08:52:54 +0200
Subject: [PATCH 3/8] client: TypeSafeClient, TypeSafeException and the
 decorators out of core

core is now the model and JsonCodec only; the client contract (TypeSafeClient,
TypeSafeException, Retrying/DeadlineTypeSafeClient, TokenCounter) is the new
client module, which every client-* module builds on. Serializing the model
(core + a codec) no longer pulls in any client code, and a module named X-Y now
always builds on module X. ADR 0003 rewritten to match.

Co-Authored-By: Claude Sonnet 5 
---
 CHANGELOG.md                                  | 16 ++---
 CLAUDE.md                                     | 42 ++++++-------
 README.md                                     |  5 +-
 .../AbstractTypeSafeClientAcceptanceTest.java |  2 +-
 ...-core-implementations-in-client-modules.md | 56 -----------------
 adr/0003-model-in-core-contract-in-client.md  | 60 +++++++++++++++++++
 bom/pom.xml                                   |  6 ++
 .../io/github/dfa1/typesafe/cli/Main.java     |  2 +-
 .../io/github/dfa1/typesafe/cli/MainTest.java |  2 +-
 client-http/pom.xml                           |  4 +-
 .../client/http/DefaultTypeSafeClient.java    |  6 +-
 .../http/DefaultTypeSafeClientTest.java       |  8 +--
 client-local/pom.xml                          |  2 +-
 .../typesafe/client/local/ClefEngine.java     |  2 +-
 .../dfa1/typesafe/client/local/Engine.java    |  2 +-
 .../typesafe/client/local/LayaEngine.java     |  2 +-
 .../client/local/LocalTypeSafeClient.java     |  4 +-
 .../typesafe/client/local/QwenEngine.java     |  2 +-
 .../typesafe/client/local/JevComparison.java  |  2 +-
 .../client/local/LocalTypeSafeClientTest.java |  2 +-
 .../typesafe/client/local/QwenEngineTest.java |  2 +-
 client-mapping/pom.xml                        |  2 +-
 .../client/mapping/MappingTypeSafeClient.java |  4 +-
 .../mapping/MappingTypeSafeClientTest.java    |  2 +-
 client-testkit/pom.xml                        |  2 +-
 .../client/testkit/FailingTypeSafeClient.java |  2 +-
 .../testkit/RecordingTypeSafeClient.java      |  2 +-
 .../testkit/FailingTypeSafeClientTest.java    |  2 +-
 client/pom.xml                                | 45 ++++++++++++++
 .../client}/DeadlineTypeSafeClient.java       |  5 +-
 .../client}/RetryingTypeSafeClient.java       |  5 +-
 .../dfa1/typesafe/client}/TokenCounter.java   |  6 +-
 .../dfa1/typesafe/client}/TypeSafeClient.java |  5 +-
 .../typesafe/client}/TypeSafeException.java   |  2 +-
 .../client}/DeadlineTypeSafeClientTest.java   | 10 +++-
 .../client}/RetryingTypeSafeClientTest.java   | 10 +++-
 .../typesafe/client}/TokenCounterTest.java    | 10 +++-
 .../client}/TypeSafeExceptionTest.java        |  2 +-
 core/pom.xml                                  | 15 ++---
 .../dfa1/typesafe/core/EvaluateResponse.java  |  4 +-
 .../io/github/dfa1/typesafe/core/Model.java   |  2 +-
 .../dfa1/typesafe/core/ModelDetails.java      |  2 +-
 docs/explanation.md                           | 25 ++++----
 docs/reference.md                             | 24 ++++----
 docs/tutorial.md                              |  4 +-
 pom.xml                                       |  1 +
 46 files changed, 254 insertions(+), 168 deletions(-)
 delete mode 100644 adr/0003-contract-in-core-implementations-in-client-modules.md
 create mode 100644 adr/0003-model-in-core-contract-in-client.md
 create mode 100644 client/pom.xml
 rename {core/src/main/java/io/github/dfa1/typesafe/core => client/src/main/java/io/github/dfa1/typesafe/client}/DeadlineTypeSafeClient.java (94%)
 rename {core/src/main/java/io/github/dfa1/typesafe/core => client/src/main/java/io/github/dfa1/typesafe/client}/RetryingTypeSafeClient.java (96%)
 rename {core/src/main/java/io/github/dfa1/typesafe/core => client/src/main/java/io/github/dfa1/typesafe/client}/TokenCounter.java (91%)
 rename {core/src/main/java/io/github/dfa1/typesafe/core => client/src/main/java/io/github/dfa1/typesafe/client}/TypeSafeClient.java (89%)
 rename {core/src/main/java/io/github/dfa1/typesafe/core => client/src/main/java/io/github/dfa1/typesafe/client}/TypeSafeException.java (99%)
 rename {core/src/test/java/io/github/dfa1/typesafe/core => client/src/test/java/io/github/dfa1/typesafe/client}/DeadlineTypeSafeClientTest.java (92%)
 rename {core/src/test/java/io/github/dfa1/typesafe/core => client/src/test/java/io/github/dfa1/typesafe/client}/RetryingTypeSafeClientTest.java (95%)
 rename {core/src/test/java/io/github/dfa1/typesafe/core => client/src/test/java/io/github/dfa1/typesafe/client}/TokenCounterTest.java (89%)
 rename {core/src/test/java/io/github/dfa1/typesafe/core => client/src/test/java/io/github/dfa1/typesafe/client}/TypeSafeExceptionTest.java (98%)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 31e6a93..bc8c885 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
-- **Breaking: modules split into contract (`core`) and implementations (`client-*`), names aligned** — `core` keeps the `TypeSafeClient` interface, its decorators, the model and `JsonCodec`; `DefaultTypeSafeClient`/`ApiKey`/`HttpTransport` move 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)
+- **Breaking: modules split into model (`core`), contract (`client`) and implementations (`client-*`), names aligned** — `core` keeps the model and `JsonCodec`; `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.
 - **`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.
@@ -19,14 +19,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - **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)
 - **`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 265dcf2..f0c437a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -10,15 +10,21 @@ plus a set of `Noul`/`Choice`/`Score` questions, get back typed answers.
 ## Module structure
 
 ```
-Naming rule: a module's directory, artifact (`typesafe-java-`) and package
-(`io.github.dfa1.typesafe.`, dashes as dots) share one name (ADR 0003).
+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 contract, no dependencies. TypeSafeClient (interface; no builder() — core can't
-            see its implementations: DefaultTypeSafeClient in client-http, the local clients in
+core      — the model, no dependencies: Answer, Question, Content, EvaluateRequest/
+            EvaluateResponse, Usage, RequestId, Model, ModelDetails, plus the JsonCodec SPI, all
+            in io.github.dfa1.typesafe.core. The model + a codec serializes payloads (e.g. for
+            Kafka) without any client code.
+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). 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
+            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
@@ -26,12 +32,8 @@ core      — the contract, no dependencies. TypeSafeClient (interface; no build
             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.
-            TypeSafeException and the wire model (Answer, Question, Content,
-            EvaluateRequest/EvaluateResponse, Usage, RequestId, Model, ModelDetails), all in
-            io.github.dfa1.typesafe.core, as is the JsonCodec SPI,
-            kept here so client-local and the codecs need nothing else, and the model + a codec
-            serializes payloads (e.g. for Kafka) without HTTP code. javadoc in core can't
-            {@link} into client-* modules (javadoc-check fails); name them in {@code ...}.
+            javadoc can't {@link} upwards (core → client, client → client-*: javadoc-check
+            fails); name those types in {@code ...}.
 codec-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
@@ -58,7 +60,7 @@ client-http-okhttp — HttpTransport backed by OkHttp (class OkHttpTransport). D
             case-insensitive lookup, but `OkHttpTransportTest` asserts through `header(...)`
             rather than the raw `headers()` map for exactly this reason.
 client-testkit — two TypeSafeClient test doubles, in io.github.dfa1.typesafe.client.testkit, depending
-            only on core. RecordingTypeSafeClient implements TypeSafeClient directly, at the
+            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;
@@ -77,7 +79,7 @@ client-mapping — MappingTypeSafeClient (io.github.dfa1.typesafe.client.mapping
             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
+            (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.
 client-local — one public TypeSafeClient per model (io.github.dfa1.typesafe.client.local: LocalLayaTypeSafeClient,
@@ -140,14 +142,14 @@ cli       — command-line entry point (`Main`), over client-http-jdk + codec-ja
             is given.
 ```
 
-Dependency rule: `codec-jackson2 → core`, `codec-jackson3 → core`, `client-http → core`,
-`client-http-jdk → client-http`, `client-http-okhttp → client-http`, `client-local → core` (plus ONNX
+Dependency rule: `codec-jackson2 → core`, `codec-jackson3 → core`, `client → core`, `client-http → client`,
+`client-http-jdk → client-http`, `client-http-okhttp → client-http`, `client-local → client` (plus ONNX
 Runtime; its own tests additionally depend on `client-http-jdk` and `codec-jackson2`, test scope
-only), `client-mapping → core` (its own tests additionally depend on `client-testkit`, test scope
-only), `client-testkit → core`, `cli → client-http, client-http-jdk, codec-jackson3`, `acceptance →
+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-contract-in-core-implementations-in-client-modules.md) for the layout and
+[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
diff --git a/README.md b/README.md
index e8d5666..4a846e9 100644
--- a/README.md
+++ b/README.md
@@ -107,7 +107,8 @@ for `typesafe-java-client-http-okhttp`.
 
 | Module | Contains |
 |---|---|
-| `core` | the `TypeSafeClient` interface and its decorators (retries, deadline, token counting), the model, and the `JsonCodec` SPI; no dependencies |
+| `core` | the model (`EvaluateRequest`, `EvaluateResponse`, `Question`, `Answer`, ...) and the `JsonCodec` SPI; no dependencies |
+| `client` | the `TypeSafeClient` interface, `TypeSafeException`, and the decorators that wrap any client (retries, deadline, token counting) |
 | `codec-jackson2` / `codec-jackson3` | `JsonCodec` 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` |
@@ -121,7 +122,7 @@ for `typesafe-java-client-http-okhttp`.
 
 Each module's directory, artifact (`typesafe-java-`) and package
 (`io.github.dfa1.typesafe.`, dashes as dots) share one name. See
-[ADR 0003](adr/0003-contract-in-core-implementations-in-client-modules.md) for why it's split this way.
+[ADR 0003](adr/0003-model-in-core-contract-in-client.md) for why it's split this way.
 
 ## Docs
 
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 0c6f940..969d59f 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
@@ -7,7 +7,7 @@
 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.client.TypeSafeClient;
 import io.github.dfa1.typesafe.core.JsonCodec;
 import io.github.dfa1.typesafe.client.mapping.Choice;
 import io.github.dfa1.typesafe.client.mapping.MappingTypeSafeClient;
diff --git a/adr/0003-contract-in-core-implementations-in-client-modules.md b/adr/0003-contract-in-core-implementations-in-client-modules.md
deleted file mode 100644
index fd010b2..0000000
--- a/adr/0003-contract-in-core-implementations-in-client-modules.md
+++ /dev/null
@@ -1,56 +0,0 @@
-# 3. The contract in `core`, 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 `JsonCodec`/`HttpTransport` SPIs still hold.
-
-## Context
-
-`core` held everything but the concrete libraries: the model, the `TypeSafeClient` interface and its
-decorators, and also 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.
-- "`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`.
-
-[Issue #12](https://github.com/dfa1/typesafe-java/issues/12) first proposed moving the interface and
-both SPIs into a new `client` module. That would have pushed `local`, `mapping`, `testkit` and the
-codecs onto the HTTP client too, the opposite of the goal.
-
-## Decision
-
-Split by contract versus implementation:
-
-- **`core`** is the contract, with no dependencies: the `TypeSafeClient` interface, the decorators that
-  wrap any implementation (`RetryingTypeSafeClient`, `DeadlineTypeSafeClient`, `TokenCounter`),
-  `TypeSafeException`, the model, and the `JsonCodec` SPI. `JsonCodec` stays here because
-  `client-local` reads its model configs through it, and the model plus a codec should serialize
-  payloads (e.g. for Kafka) without any HTTP code.
-- **`client-*`** modules implement or build on `TypeSafeClient`, and none depends 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 `JsonCodec`: `codec-jackson2`, `codec-jackson3`. They depend only on `core`.
-- **One name per module**: its directory, its artifact (`typesafe-java-`) and its package
-  (`io.github.dfa1.typesafe.`, dashes as dots) are the same.
-
-`TypeSafeClient.builder()` is removed: `core` can't reference `client-http`. Callers write
-`DefaultTypeSafeClient.builder()`.
-
-## Consequences
-
-- The compiler enforces the boundary. javadoc in `core` can't `{@link}` into a `client-*` module, so it
-  names `DefaultTypeSafeClient` in `{@code ...}` instead. `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` 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/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..7009ef3
--- /dev/null
+++ b/adr/0003-model-in-core-contract-in-client.md
@@ -0,0 +1,60 @@
+# 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 `JsonCodec`/`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
+
+Three layers, each a module (or a family of modules) that only depends on the layers below it:
+
+- **`core`, the model**, with no dependencies: `Answer`, `Question`, `Content`, `EvaluateRequest`,
+  `EvaluateResponse`, `Usage`, `RequestId`, `Model`, `ModelDetails`, and the `JsonCodec` SPI. With a
+  `codec-*` module it 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 `JsonCodec` on `core` alone: `codec-jackson2`, `codec-jackson3`.
+
+**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()`.
+
+`JsonCodec` stays in `core` rather than in a `codec` module of its own: everything that uses it
+(`client-http`, `client-local`, the codecs) needs `core` anyway, so a separate artifact would always
+travel with it.
+
+## 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 60f7f2c..64cf3c7 100644
--- a/bom/pom.xml
+++ b/bom/pom.xml
@@ -23,6 +23,12 @@
         ${project.version}
       
 
+      
+        io.github.dfa1.typesafe-java
+        typesafe-java-client
+        ${project.version}
+      
+
       
         io.github.dfa1.typesafe-java
         typesafe-java-client-http
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 505d7b6..46ab9a2 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
@@ -8,7 +8,7 @@
 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.client.TypeSafeClient;
 import io.github.dfa1.typesafe.codec.jackson3.Jackson3Codec;
 import io.github.dfa1.typesafe.client.http.jdk.JdkHttpTransport;
 import io.github.dfa1.typesafe.core.JsonCodec;
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 7d23d4f..8e77097 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,7 +4,7 @@
 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.codec.jackson3.Jackson3Codec;
 import org.junit.jupiter.api.Test;
diff --git a/client-http/pom.xml b/client-http/pom.xml
index 24d6370..6ae5a15 100644
--- a/client-http/pom.xml
+++ b/client-http/pom.xml
@@ -12,7 +12,7 @@
 
   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-core; needs one
+    Builder, plus the HttpTransport SPI it sends through. Depends only on typesafe-java-client; 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.
   
@@ -20,7 +20,7 @@
   
     
       io.github.dfa1.typesafe-java
-      typesafe-java-core
+      typesafe-java-client
       ${project.version}
     
 
diff --git a/client-http/src/main/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClient.java b/client-http/src/main/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClient.java
index fa51a09..1dfc0f1 100644
--- a/client-http/src/main/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClient.java
+++ b/client-http/src/main/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClient.java
@@ -4,9 +4,9 @@
 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.core.RetryingTypeSafeClient;
-import io.github.dfa1.typesafe.core.TypeSafeClient;
-import io.github.dfa1.typesafe.core.TypeSafeException;
+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.JsonCodec;
 
 import java.io.IOException;
diff --git a/client-http/src/test/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClientTest.java b/client-http/src/test/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClientTest.java
index ecbc91b..d5b1b84 100644
--- a/client-http/src/test/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClientTest.java
+++ b/client-http/src/test/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClientTest.java
@@ -1,15 +1,15 @@
 package io.github.dfa1.typesafe.client.http;
 
 import io.github.dfa1.typesafe.core.Content;
-import io.github.dfa1.typesafe.core.DeadlineTypeSafeClient;
+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.core.RetryingTypeSafeClient;
-import io.github.dfa1.typesafe.core.TypeSafeClient;
-import io.github.dfa1.typesafe.core.TypeSafeException;
+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.core.JsonCodec;
 
diff --git a/client-local/pom.xml b/client-local/pom.xml
index f3f5811..e15ad75 100644
--- a/client-local/pom.xml
+++ b/client-local/pom.xml
@@ -29,7 +29,7 @@
   
     
       io.github.dfa1.typesafe-java
-      typesafe-java-core
+      typesafe-java-client
       ${project.version}
     
     
diff --git a/client-local/src/main/java/io/github/dfa1/typesafe/client/local/ClefEngine.java b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/ClefEngine.java
index 428e10d..bb2d739 100644
--- a/client-local/src/main/java/io/github/dfa1/typesafe/client/local/ClefEngine.java
+++ b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/ClefEngine.java
@@ -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;
diff --git a/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Engine.java b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Engine.java
index f09c673..afcdd57 100644
--- a/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Engine.java
+++ b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Engine.java
@@ -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/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LayaEngine.java b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LayaEngine.java
index 1747f1d..8ba1f9d 100644
--- a/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LayaEngine.java
+++ b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LayaEngine.java
@@ -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;
diff --git a/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LocalTypeSafeClient.java b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LocalTypeSafeClient.java
index 731ae6c..75fd647 100644
--- a/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LocalTypeSafeClient.java
+++ b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LocalTypeSafeClient.java
@@ -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/client-local/src/main/java/io/github/dfa1/typesafe/client/local/QwenEngine.java b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/QwenEngine.java
index 425c7c0..7ff20ad 100644
--- a/client-local/src/main/java/io/github/dfa1/typesafe/client/local/QwenEngine.java
+++ b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/QwenEngine.java
@@ -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/client-local/src/test/java/io/github/dfa1/typesafe/client/local/JevComparison.java b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/JevComparison.java
index 4b6b33b..baa1258 100644
--- a/client-local/src/test/java/io/github/dfa1/typesafe/client/local/JevComparison.java
+++ b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/JevComparison.java
@@ -5,7 +5,7 @@
 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.client.TypeSafeClient;
 import io.github.dfa1.typesafe.codec.jackson2.Jackson2Codec;
 import io.github.dfa1.typesafe.core.JsonCodec;
 
diff --git a/client-local/src/test/java/io/github/dfa1/typesafe/client/local/LocalTypeSafeClientTest.java b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/LocalTypeSafeClientTest.java
index c22309d..cd4c48a 100644
--- a/client-local/src/test/java/io/github/dfa1/typesafe/client/local/LocalTypeSafeClientTest.java
+++ b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/LocalTypeSafeClientTest.java
@@ -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/client-local/src/test/java/io/github/dfa1/typesafe/client/local/QwenEngineTest.java b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/QwenEngineTest.java
index 072fa43..1c9a9c7 100644
--- a/client-local/src/test/java/io/github/dfa1/typesafe/client/local/QwenEngineTest.java
+++ b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/QwenEngineTest.java
@@ -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/client-mapping/pom.xml b/client-mapping/pom.xml
index a1d04fb..71ab44f 100644
--- a/client-mapping/pom.xml
+++ b/client-mapping/pom.xml
@@ -20,7 +20,7 @@
   
     
       io.github.dfa1.typesafe-java
-      typesafe-java-core
+      typesafe-java-client
       ${project.version}
     
 
diff --git a/client-mapping/src/main/java/io/github/dfa1/typesafe/client/mapping/MappingTypeSafeClient.java b/client-mapping/src/main/java/io/github/dfa1/typesafe/client/mapping/MappingTypeSafeClient.java
index 809b0f6..a0fb619 100644
--- a/client-mapping/src/main/java/io/github/dfa1/typesafe/client/mapping/MappingTypeSafeClient.java
+++ b/client-mapping/src/main/java/io/github/dfa1/typesafe/client/mapping/MappingTypeSafeClient.java
@@ -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 diff --git a/client-mapping/src/test/java/io/github/dfa1/typesafe/client/mapping/MappingTypeSafeClientTest.java b/client-mapping/src/test/java/io/github/dfa1/typesafe/client/mapping/MappingTypeSafeClientTest.java index 2835e4a..63564d1 100644 --- a/client-mapping/src/test/java/io/github/dfa1/typesafe/client/mapping/MappingTypeSafeClientTest.java +++ b/client-mapping/src/test/java/io/github/dfa1/typesafe/client/mapping/MappingTypeSafeClientTest.java @@ -6,7 +6,7 @@ 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.client.testkit.FailingTypeSafeClient; import io.github.dfa1.typesafe.client.testkit.RecordingTypeSafeClient; diff --git a/client-testkit/pom.xml b/client-testkit/pom.xml index 6f2798b..bae92ef 100644 --- a/client-testkit/pom.xml +++ b/client-testkit/pom.xml @@ -20,7 +20,7 @@ io.github.dfa1.typesafe-java - typesafe-java-core + typesafe-java-client ${project.version} diff --git a/client-testkit/src/main/java/io/github/dfa1/typesafe/client/testkit/FailingTypeSafeClient.java b/client-testkit/src/main/java/io/github/dfa1/typesafe/client/testkit/FailingTypeSafeClient.java index 3d9063e..7261c91 100644 --- a/client-testkit/src/main/java/io/github/dfa1/typesafe/client/testkit/FailingTypeSafeClient.java +++ b/client-testkit/src/main/java/io/github/dfa1/typesafe/client/testkit/FailingTypeSafeClient.java @@ -3,7 +3,7 @@ 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/client-testkit/src/main/java/io/github/dfa1/typesafe/client/testkit/RecordingTypeSafeClient.java b/client-testkit/src/main/java/io/github/dfa1/typesafe/client/testkit/RecordingTypeSafeClient.java index f7a75f2..55f1558 100644 --- a/client-testkit/src/main/java/io/github/dfa1/typesafe/client/testkit/RecordingTypeSafeClient.java +++ b/client-testkit/src/main/java/io/github/dfa1/typesafe/client/testkit/RecordingTypeSafeClient.java @@ -3,7 +3,7 @@ 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/client-testkit/src/test/java/io/github/dfa1/typesafe/client/testkit/FailingTypeSafeClientTest.java b/client-testkit/src/test/java/io/github/dfa1/typesafe/client/testkit/FailingTypeSafeClientTest.java index b1d0fa8..c0d63a0 100644 --- a/client-testkit/src/test/java/io/github/dfa1/typesafe/client/testkit/FailingTypeSafeClientTest.java +++ b/client-testkit/src/test/java/io/github/dfa1/typesafe/client/testkit/FailingTypeSafeClientTest.java @@ -4,7 +4,7 @@ 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/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 94% 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 667e58e..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,5 +1,8 @@ -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; 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 96% 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 69b0042..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,5 +1,8 @@ -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; 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 91% 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 5f2bc1d..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; 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 89% 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 6390438..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,5 +1,8 @@ -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.util.List; import java.util.concurrent.CompletableFuture; 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 99% 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 2137b7b..02dfa4d 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; 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/core/pom.xml b/core/pom.xml index 1867b61..902cdea 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -12,11 +12,10 @@ TypeSafe Core - The TypeSafe contract: the TypeSafeClient interface and its decorators (retries, deadline, token - counting), TypeSafeException, the wire model, and the JsonCodec SPI. No dependencies. Every - client-* module implements or builds on TypeSafeClient (client-http calls the API, client-local - runs a model in-process); the model plus a codec-* module serializes TypeSafe payloads without - any HTTP code, e.g. for a Kafka producer/consumer. + The TypeSafe wire model (Answer, Question, Content, EvaluateRequest/EvaluateResponse, Usage, + RequestId, Model, ModelDetails) and the JsonCodec SPI. No dependencies. With a codec-* module it + serializes TypeSafe payloads without any client code, e.g. for a Kafka producer/consumer; the + client contract is typesafe-java-client. @@ -31,11 +30,5 @@ assertj-core test - - - org.mockito - mockito-junit-jupiter - 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/docs/explanation.md b/docs/explanation.md index dbcfbd9..dbb8f78 100644 --- a/docs/explanation.md +++ b/docs/explanation.md @@ -101,19 +101,20 @@ design avoids. mocked transport for tests can implement one interface (`post`/`get`, both already `CompletableFuture`-returning) instead of forking the client. -## Why the contract is in `core` and each client is its own module +## Why the model is in `core`, the contract in `client`, and each client in its own module -`core` holds what every client agrees on: the `TypeSafeClient` interface, the decorators that wrap -any implementation (retries, a deadline, token counting), `TypeSafeException`, the model, and the -`JsonCodec` SPI. 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: `core` can't see -`DefaultTypeSafeClient`, which is why `TypeSafeClient` has no `builder()`. +`core` is the model and the `JsonCodec` SPI, 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()`. -`JsonCodec` stays in `core` rather than moving next to `HttpTransport`: `client-local` reads its -model configs through it, and the model plus a `codec-*` module is enough to serialize TypeSafe -payloads (e.g. onto a Kafka topic) without any HTTP code. Every module's directory, artifact and -package share one name. See [ADR 0003](../adr/0003-contract-in-core-implementations-in-client-modules.md). +`JsonCodec` stays in `core`, next to the model it serializes: `client-local` reads its model configs +through it too, and the model 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 @@ -140,7 +141,7 @@ interface: constructing a `DefaultTypeSafeClient` — picking defaults, discover pure contract interface should carry. That required making `DefaultTypeSafeClient` itself 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` out of `core` (ADR 0003); callers now name it directly. +`DefaultTypeSafeClient` into `client-http` (ADR 0003); callers now name it directly. ## Why `client-testkit` ships a `TypeSafeClient` fake instead of "just mock it with Mockito" diff --git a/docs/reference.md b/docs/reference.md index 8b60663..752355e 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -15,21 +15,22 @@ For task-oriented usage see [how-to.md](how-to.md); for design rationale see [ex | Module | Depends on | Contains | |---|---|---| -| `typesafe-java-core` | — | `TypeSafeClient`, `RetryingTypeSafeClient`, `DeadlineTypeSafeClient`, `TokenCounter`, `TypeSafeException`, the model (`Answer`, `Question`, `Content`, `EvaluateRequest`, `EvaluateResponse`, `Usage`, `RequestId`, `Model`, `ModelDetails`), `JsonCodec` | +| `typesafe-java-core` | — | the model (`Answer`, `Question`, `Content`, `EvaluateRequest`, `EvaluateResponse`, `Usage`, `RequestId`, `Model`, `ModelDetails`), `JsonCodec` | | `typesafe-java-codec-jackson2` | `core` | `Jackson2Codec` (Jackson 2.x) | | `typesafe-java-codec-jackson3` | `core` | `Jackson3Codec` (Jackson 3.x) | -| `typesafe-java-client-http` | `core` | `DefaultTypeSafeClient` (the TypeSafe API over HTTP), `ApiKey`, `HttpTransport` | +| `typesafe-java-client` | `core` | `TypeSafeClient`, `TypeSafeException`, `RetryingTypeSafeClient`, `DeadlineTypeSafeClient`, `TokenCounter` | +| `typesafe-java-client-http` | `client` | `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` | `core`, ONNX Runtime | `LocalLayaTypeSafeClient`, `LocalQwenTypeSafeClient`, `LocalClefTypeSafeClient` (in-process models) | -| `typesafe-java-client-mapping` | `core` | `MappingTypeSafeClient`, `@Noul`/`@Choice`/`@Score`/`@Option` | -| `typesafe-java-client-testkit` | `core` | `RecordingTypeSafeClient`, `FailingTypeSafeClient` | -| `typesafe-java-bom` | — | dependency management for the nine above | +| `typesafe-java-client-local` | `client`, 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 ten 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` has no dependencies: it's the contract every -`client-*` module implements or builds on, and the model plus a `codec-*` module serializes TypeSafe -payloads without any HTTP code. +`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 @@ -369,7 +370,8 @@ only). ## Client -`TypeSafeClient`, its decorators and `TypeSafeException` are 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`). @@ -398,7 +400,7 @@ 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. It has no factory: `core` can't +`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. diff --git a/docs/tutorial.md b/docs/tutorial.md index 5a54d54..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 @@ -54,7 +54,7 @@ echo "your-token-here" > ~/.typesafe.apikey ```java import io.github.dfa1.typesafe.client.http.ApiKey; import io.github.dfa1.typesafe.client.http.DefaultTypeSafeClient; -import io.github.dfa1.typesafe.core.TypeSafeClient; +import io.github.dfa1.typesafe.client.TypeSafeClient; TypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(ApiKey.fromDefaultFile()).build(); ``` diff --git a/pom.xml b/pom.xml index a3f4d76..1f10edc 100644 --- a/pom.xml +++ b/pom.xml @@ -40,6 +40,7 @@ core codec-jackson2 codec-jackson3 + client client-http client-http-jdk client-http-okhttp From 7483f0b98cc5bd20948c1692d093db6a0e38b626 Mon Sep 17 00:00:00 2001 From: Davide Angelocola Date: Sun, 4 Oct 2026 09:01:27 +0200 Subject: [PATCH 4/8] JsonCodec and HttpTransport speak byte[] JSON goes to and from the wire as UTF-8 bytes: writeValueAsBytes / writeValueAsPrettyBytes / readValue(byte[], ...), and a byte[] body on HttpTransport.post and HttpTransportResponse. No String copy on the success path; bodies become text only for error messages. Jackson2Codec enables COMBINE_UNICODE_SURROGATES_IN_UTF8: its UTF-8 writer otherwise escapes emoji as surrogate pairs, which changed what a local model tokenizes (caught by ClefEngineTest; now pinned by a codec test in both codecs). Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../io/github/dfa1/typesafe/cli/Main.java | 11 +- .../client/http/jdk/JdkHttpTransport.java | 10 +- .../client/http/jdk/JdkHttpTransportTest.java | 33 +++-- .../client/http/okhttp/OkHttpTransport.java | 4 +- .../http/okhttp/OkHttpTransportTest.java | 11 +- .../client/http/DefaultTypeSafeClient.java | 14 +- .../typesafe/client/http/HttpTransport.java | 3 +- .../client/http/HttpTransportResponse.java | 3 +- .../http/DefaultTypeSafeClientTest.java | 129 +++++++++--------- .../http/HttpTransportResponseTest.java | 11 +- .../typesafe/client/local/BpeTokenizer.java | 2 +- .../typesafe/client/local/ClefEngine.java | 4 +- .../typesafe/client/local/LayaEngine.java | 6 +- .../dfa1/typesafe/client/local/Onnx.java | 6 + .../client/local/BpeTokenizerTest.java | 2 +- .../typesafe/client/local/ClefEngineTest.java | 2 +- .../typesafe/client/local/JevComparison.java | 5 +- .../client/local/TokenizerInputs.java | 4 +- .../codec/jackson2/Jackson2Codec.java | 21 ++- .../codec/jackson2/Jackson2CodecTest.java | 44 ++++-- .../codec/jackson3/Jackson3Codec.java | 10 +- .../codec/jackson3/Jackson3CodecTest.java | 40 ++++-- .../github/dfa1/typesafe/core/JsonCodec.java | 13 +- docs/how-to.md | 8 +- docs/reference.md | 17 ++- 27 files changed, 248 insertions(+), 168 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc8c885..e0d9c97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- **Breaking: `JsonCodec` and `HttpTransport` speak `byte[]`** — `writeValueAsBytes`/`writeValueAsPrettyBytes`/`readValue(byte[], ...)` and a `byte[]` request/response body, so JSON goes to and from the wire as UTF-8 with no `String` copy; `Jackson2Codec` now writes emoji and other non-BMP characters unescaped, like `Jackson3Codec`. (#35) - **Breaking: modules split into model (`core`), contract (`client`) and implementations (`client-*`), names aligned** — `core` keeps the model and `JsonCodec`; `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. - **`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. diff --git a/CLAUDE.md b/CLAUDE.md index f0c437a..0c53e3c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -138,7 +138,7 @@ cli — command-line entry point (`Main`), over client-http-jdk + codec-ja 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 pretty-printed JSON, via `JsonCodec.writeValueAsPrettyBytes`) is given. ``` 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 46ab9a2..91cdbda 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 @@ -13,6 +13,7 @@ import io.github.dfa1.typesafe.client.http.jdk.JdkHttpTransport; import io.github.dfa1.typesafe.core.JsonCodec; +import java.nio.charset.StandardCharsets; import java.io.PrintStream; import java.util.ArrayList; import java.util.Arrays; @@ -83,13 +84,13 @@ static int run(TypeSafeClient client, JsonCodec codec, ParsedArgs parsed, PrintS EvaluateRequest request = EvaluateRequest.of(Content.text(parsed.state()), parsed.model(), parsed.questions()); if (parsed.verbose()) { - err.println("request: " + codec.writeValueAsPrettyString(request)); + err.println("request: " + pretty(codec, request)); } EvaluateResponse response = client.evaluate(request); if (parsed.verbose()) { - err.println("response: " + codec.writeValueAsPrettyString(response)); + err.println("response: " + pretty(codec, response)); err.println("request-id: " + response.metadata().requestId()); } if (parsed.timing()) { @@ -99,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(pretty(codec, response)); } List failures = minFailures(response, parsed.minSpecs()); @@ -212,4 +213,8 @@ private static int fail(PrintStream err, String message) { err.println(USAGE); return 1; } + + private static String pretty(JsonCodec codec, Object value) { + return new String(codec.writeValueAsPrettyBytes(value), StandardCharsets.UTF_8); + } } diff --git a/client-http-jdk/src/main/java/io/github/dfa1/typesafe/client/http/jdk/JdkHttpTransport.java b/client-http-jdk/src/main/java/io/github/dfa1/typesafe/client/http/jdk/JdkHttpTransport.java index 9ac720c..034cb74 100644 --- a/client-http-jdk/src/main/java/io/github/dfa1/typesafe/client/http/jdk/JdkHttpTransport.java +++ b/client-http-jdk/src/main/java/io/github/dfa1/typesafe/client/http/jdk/JdkHttpTransport.java @@ -7,7 +7,6 @@ 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/test/java/io/github/dfa1/typesafe/client/http/jdk/JdkHttpTransportTest.java b/client-http-jdk/src/test/java/io/github/dfa1/typesafe/client/http/jdk/JdkHttpTransportTest.java index 6cfc6a5..d548e93 100644 --- a/client-http-jdk/src/test/java/io/github/dfa1/typesafe/client/http/jdk/JdkHttpTransportTest.java +++ b/client-http-jdk/src/test/java/io/github/dfa1/typesafe/client/http/jdk/JdkHttpTransportTest.java @@ -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-http-okhttp/src/main/java/io/github/dfa1/typesafe/client/http/okhttp/OkHttpTransport.java b/client-http-okhttp/src/main/java/io/github/dfa1/typesafe/client/http/okhttp/OkHttpTransport.java index 3505f62..bd84da7 100644 --- a/client-http-okhttp/src/main/java/io/github/dfa1/typesafe/client/http/okhttp/OkHttpTransport.java +++ b/client-http-okhttp/src/main/java/io/github/dfa1/typesafe/client/http/okhttp/OkHttpTransport.java @@ -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/test/java/io/github/dfa1/typesafe/client/http/okhttp/OkHttpTransportTest.java b/client-http-okhttp/src/test/java/io/github/dfa1/typesafe/client/http/okhttp/OkHttpTransportTest.java index 4f220fd..5392062 100644 --- a/client-http-okhttp/src/test/java/io/github/dfa1/typesafe/client/http/okhttp/OkHttpTransportTest.java +++ b/client-http-okhttp/src/test/java/io/github/dfa1/typesafe/client/http/okhttp/OkHttpTransportTest.java @@ -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/src/main/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClient.java b/client-http/src/main/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClient.java index 1dfc0f1..0817723 100644 --- a/client-http/src/main/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClient.java +++ b/client-http/src/main/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClient.java @@ -13,6 +13,7 @@ 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; @@ -56,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 = jsonCodec.writeValueAsBytes(request); } catch (RuntimeException e) { return CompletableFuture.failedFuture(e); } @@ -85,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); diff --git a/client-http/src/main/java/io/github/dfa1/typesafe/client/http/HttpTransport.java b/client-http/src/main/java/io/github/dfa1/typesafe/client/http/HttpTransport.java index 38a727f..7c3e595 100644 --- a/client-http/src/main/java/io/github/dfa1/typesafe/client/http/HttpTransport.java +++ b/client-http/src/main/java/io/github/dfa1/typesafe/client/http/HttpTransport.java @@ -13,7 +13,8 @@ */ 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/client-http/src/main/java/io/github/dfa1/typesafe/client/http/HttpTransportResponse.java b/client-http/src/main/java/io/github/dfa1/typesafe/client/http/HttpTransportResponse.java index 70faf92..888a888 100644 --- a/client-http/src/main/java/io/github/dfa1/typesafe/client/http/HttpTransportResponse.java +++ b/client-http/src/main/java/io/github/dfa1/typesafe/client/http/HttpTransportResponse.java @@ -3,7 +3,8 @@ 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 JsonCodec} without a {@code String} in between. */ +public record HttpTransportResponse(int statusCode, Map headers, byte[] body) { public HttpTransportResponse { headers = Map.copyOf(headers); diff --git a/client-http/src/test/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClientTest.java b/client-http/src/test/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClientTest.java index d5b1b84..efe90ab 100644 --- a/client-http/src/test/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClientTest.java +++ b/client-http/src/test/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClientTest.java @@ -1,5 +1,6 @@ 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; @@ -64,11 +65,11 @@ void evaluateDelegatesToTheConfiguredHttpTransportAndJsonCodec() { 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(jsonCodec.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); @@ -77,7 +78,7 @@ void evaluateDelegatesToTheConfiguredHttpTransportAndJsonCodec() { EvaluateResponse result = sut.evaluate(request); // Then - then(jsonCodec).should().writeValueAsString(request); + then(jsonCodec).should().writeValueAsBytes(request); then(httpTransport).should().post(ENDPOINT, expectedHeaders, requestBody); then(jsonCodec).should().readValue(responseBody, EvaluateResponse.class); assertThat(result.model()).isEqualTo(Model.LATEST); @@ -95,11 +96,11 @@ void withoutAnApiKeySendsNoAuthorizationHeader() { 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(jsonCodec.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); @@ -126,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(jsonCodec.readValue(bytes("models-json"), DefaultTypeSafeClient.ModelsResponse.class)) .willReturn(new DefaultTypeSafeClient.ModelsResponse(models)); // When @@ -159,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(jsonCodec.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)) @@ -179,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(jsonCodec.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)) @@ -194,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(jsonCodec.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)) @@ -213,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(jsonCodec.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(jsonCodec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse); // When EvaluateResponse result = sut.evaluate(request); @@ -233,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(jsonCodec.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)) @@ -251,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(jsonCodec.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(jsonCodec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse); // When EvaluateResponse result = sut.evaluate(request); @@ -271,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(jsonCodec.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(jsonCodec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse); // When EvaluateResponse result = sut.evaluate(request); @@ -291,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(jsonCodec.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(jsonCodec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse); // When EvaluateResponse result = sut.evaluate(request); @@ -312,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(jsonCodec.writeValueAsBytes(request)).willReturn(bytes("{}")); given(httpTransport.post(any(), any(), any())).willReturn(CompletableFuture.failedFuture(connectionFailure)); // When / Then @@ -329,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(jsonCodec.writeValueAsBytes(request)).willReturn(bytes("{}")); given(httpTransport.post(any(), any(), any())).willReturn(CompletableFuture.failedFuture(timeout)); // When / Then @@ -345,7 +346,7 @@ void evaluateThrowsTypeSafeInterruptedAndRestoresTheInterruptFlagWhenTheCallingT EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of()); CompletableFuture neverCompletes = new CompletableFuture<>(); - given(jsonCodec.writeValueAsString(request)).willReturn("{}"); + given(jsonCodec.writeValueAsBytes(request)).willReturn(bytes("{}")); given(httpTransport.post(any(), any(), any())).willReturn(neverCompletes); AtomicReference thrown = new AtomicReference<>(); @@ -380,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(jsonCodec.writeValueAsBytes(request)).willReturn(bytes("{}")); given(httpTransport.post(any(), any(), any())).willReturn(CompletableFuture.failedFuture(transportError)); // When / Then @@ -398,10 +399,10 @@ void evaluatePopulatesMetadataFromResponseHeaders() { "x-typesafe-request-id", "req_123", "x-envoy-upstream-service-time", "42"); - given(jsonCodec.writeValueAsString(request)).willReturn("{}"); + given(jsonCodec.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(jsonCodec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse); // When EvaluateResponse result = sut.evaluate(request); @@ -418,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(jsonCodec.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(jsonCodec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse); // When EvaluateResponse result = sut.evaluateAsync(request).join(); @@ -438,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(jsonCodec.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(jsonCodec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse); // When EvaluateResponse result = sut.evaluateAsync(request).join(); @@ -460,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(jsonCodec.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()) @@ -478,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(jsonCodec.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(jsonCodec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse); // When EvaluateResponse result = sut.evaluateAsync(request).join(); @@ -499,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(jsonCodec.writeValueAsBytes(request)).willReturn(bytes("{}")); given(httpTransport.post(any(), any(), any())) .willReturn(CompletableFuture.failedFuture(connectionFailure)); @@ -518,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(jsonCodec.writeValueAsBytes(request)).willThrow(encodingFailure); // When / Then assertThatThrownBy(() -> sut.evaluateAsync(request).get()) @@ -533,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(jsonCodec.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(jsonCodec.readValue(bytes("not json"), EvaluateResponse.class)).willThrow(decodingFailure); // When / Then assertThatThrownBy(() -> sut.evaluateAsync(request).get()) @@ -664,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(jsonCodec.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)) @@ -726,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); @@ -739,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); @@ -752,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) { @@ -769,4 +770,8 @@ private TypeSafeClient clientWith(Duration backoff, int maxRetries) { .decorateWith(c -> RetryingTypeSafeClient.decorate(c, maxRetries, backoff)) .build(); } + + private static byte[] bytes(String text) { + return text.getBytes(StandardCharsets.UTF_8); + } } diff --git a/client-http/src/test/java/io/github/dfa1/typesafe/client/http/HttpTransportResponseTest.java b/client-http/src/test/java/io/github/dfa1/typesafe/client/http/HttpTransportResponseTest.java index 0b8da3b..c32b4fb 100644 --- a/client-http/src/test/java/io/github/dfa1/typesafe/client/http/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.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-local/src/main/java/io/github/dfa1/typesafe/client/local/BpeTokenizer.java b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/BpeTokenizer.java index 320a192..dba71c2 100644 --- a/client-local/src/main/java/io/github/dfa1/typesafe/client/local/BpeTokenizer.java +++ b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/BpeTokenizer.java @@ -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.json().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/client-local/src/main/java/io/github/dfa1/typesafe/client/local/ClefEngine.java b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/ClefEngine.java index bb2d739..45ffd4e 100644 --- a/client-local/src/main/java/io/github/dfa1/typesafe/client/local/ClefEngine.java +++ b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/ClefEngine.java @@ -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/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LayaEngine.java b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LayaEngine.java index 8ba1f9d..322c9ab 100644 --- a/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LayaEngine.java +++ b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LayaEngine.java @@ -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.json().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"); @@ -275,8 +275,8 @@ private static String orDefault(String value, String fallback) { 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/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Onnx.java b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Onnx.java index 10b7c35..3c69a11 100644 --- a/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Onnx.java +++ b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Onnx.java @@ -9,6 +9,7 @@ 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; @@ -50,6 +51,11 @@ static JsonCodec json() { return Codec.INSTANCE; } + /** {@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(json().writeValueAsBytes(value), StandardCharsets.UTF_8); + } + 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-codec-jackson2 " diff --git a/client-local/src/test/java/io/github/dfa1/typesafe/client/local/BpeTokenizerTest.java b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/BpeTokenizerTest.java index 4d71bcb..c13c5b0 100644 --- a/client-local/src/test/java/io/github/dfa1/typesafe/client/local/BpeTokenizerTest.java +++ b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/BpeTokenizerTest.java @@ -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/client-local/src/test/java/io/github/dfa1/typesafe/client/local/ClefEngineTest.java b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/ClefEngineTest.java index 65b5604..b071c06 100644 --- a/client-local/src/test/java/io/github/dfa1/typesafe/client/local/ClefEngineTest.java +++ b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/ClefEngineTest.java @@ -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/client-local/src/test/java/io/github/dfa1/typesafe/client/local/JevComparison.java b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/JevComparison.java index baa1258..305bd2c 100644 --- a/client-local/src/test/java/io/github/dfa1/typesafe/client/local/JevComparison.java +++ b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/JevComparison.java @@ -9,6 +9,7 @@ import io.github.dfa1.typesafe.codec.jackson2.Jackson2Codec; import io.github.dfa1.typesafe.core.JsonCodec; +import java.nio.charset.StandardCharsets; import java.io.IOException; import java.io.UncheckedIOException; import java.nio.file.Files; @@ -166,11 +167,11 @@ private static List jevAnswers(JsonCodec codec, List(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/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2Codec.java b/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2Codec.java index f1f2b47..e31200f 100644 --- a/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2Codec.java +++ b/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2Codec.java @@ -8,12 +8,15 @@ import io.github.dfa1.typesafe.core.JsonCodec; 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; /** @@ -23,7 +26,11 @@ */ public final class Jackson2Codec implements JsonCodec { - 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,28 @@ 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) { + public byte[] writeValueAsPrettyBytes(Object value) { try { - return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(value); + return mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(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/codec-jackson2/src/test/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2CodecTest.java b/codec-jackson2/src/test/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2CodecTest.java index 145f2fd..c5f6211 100644 --- a/codec-jackson2/src/test/java/io/github/dfa1/typesafe/codec/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.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) @@ -53,7 +54,7 @@ void writeValueAsPrettyStringIndentsTheOutput() { Content state = Content.fields(Map.of("order_id", "A-104")); // When - String result = sut.writeValueAsPrettyString(state); + String result = json(sut.writeValueAsPrettyBytes(state)); // Then assertThat(result).isEqualTo("{\n \"order_id\" : \"A-104\"\n}"); @@ -67,7 +68,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 +80,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 +106,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 +127,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 +146,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 +160,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 +179,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/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3Codec.java b/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3Codec.java index 070ac4c..1acaf7c 100644 --- a/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3Codec.java +++ b/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3Codec.java @@ -33,17 +33,17 @@ 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); + public byte[] writeValueAsPrettyBytes(Object value) { + return mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(value); } @Override - public T readValue(String content, Class type) { + public T readValue(byte[] content, Class type) { return mapper.readValue(content, type); } diff --git a/codec-jackson3/src/test/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3CodecTest.java b/codec-jackson3/src/test/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3CodecTest.java index 95844a9..7065c89 100644 --- a/codec-jackson3/src/test/java/io/github/dfa1/typesafe/codec/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.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) @@ -51,7 +52,7 @@ void writeValueAsPrettyStringIndentsTheOutput() { Content state = Content.fields(Map.of("order_id", "A-104")); // When - String result = sut.writeValueAsPrettyString(state); + String result = json(sut.writeValueAsPrettyBytes(state)); // Then assertThat(result).isEqualTo("{\n \"order_id\" : \"A-104\"\n}"); @@ -65,7 +66,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 +78,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 +104,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 +125,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 +144,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 +158,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/core/src/main/java/io/github/dfa1/typesafe/core/JsonCodec.java b/core/src/main/java/io/github/dfa1/typesafe/core/JsonCodec.java index 1e85756..c07c48d 100644 --- a/core/src/main/java/io/github/dfa1/typesafe/core/JsonCodec.java +++ b/core/src/main/java/io/github/dfa1/typesafe/core/JsonCodec.java @@ -1,15 +1,16 @@ package io.github.dfa1.typesafe.core; /** - * JSON serialization plugged in by a codec module (typesafe-jackson2 or typesafe-jackson3). - * Implementations are discovered via {@link java.util.ServiceLoader}. + * JSON serialization plugged in by a codec module (typesafe-java-codec-jackson2 or -jackson3), as + * UTF-8 bytes: what goes over the wire, with no {@code String} in between. Implementations are + * discovered via {@link java.util.ServiceLoader}. */ public interface JsonCodec { - String writeValueAsString(Object value); + byte[] writeValueAsBytes(Object value); - /** Same as {@link #writeValueAsString(Object)}, indented for human reading. */ - String writeValueAsPrettyString(Object value); + /** Same as {@link #writeValueAsBytes(Object)}, indented for human reading. */ + byte[] writeValueAsPrettyBytes(Object value); - T readValue(String content, Class type); + T readValue(byte[] content, Class type); } diff --git a/docs/how-to.md b/docs/how-to.md index ea29722..7b039dc 100644 --- a/docs/how-to.md +++ b/docs/how-to.md @@ -549,14 +549,12 @@ payloads — for example to publish or consume them on a Kafka topic — depend ```java JsonCodec codec = new Jackson2Codec(); -String json = codec.writeValueAsString(request); +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 diff --git a/docs/reference.md b/docs/reference.md index 752355e..135ab6c 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -168,9 +168,9 @@ plain `Model`, usable directly as an `EvaluateRequest`'s model. package io.github.dfa1.typesafe.core; public interface JsonCodec { - String writeValueAsString(Object value); - String writeValueAsPrettyString(Object value); // same, indented for human reading - T readValue(String content, Class type); + byte[] writeValueAsBytes(Object value); + byte[] writeValueAsPrettyBytes(Object value); // same, indented for human reading + T readValue(byte[] content, Class type); } ``` @@ -179,10 +179,9 @@ Implementations (`Jackson2Codec`, `Jackson3Codec`) are discovered via `META-INF/services/io.github.dfa1.typesafe.core.JsonCodec`. 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 @@ -192,12 +191,12 @@ In `typesafe-java-client-http`. 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 } ``` From e2e1ceb9e09244826aed0188fe926cc464fe9bd7 Mon Sep 17 00:00:00 2001 From: Davide Angelocola Date: Sun, 4 Oct 2026 13:31:50 +0200 Subject: [PATCH 5/8] core: JsonCodec is now Codec The SPI is byte[] in and out, so it isn't JSON-specific: a binary codec (#37) can implement it. Builder.jsonCodec(...) becomes codec(...); client-http still needs a JSON codec, since the API speaks JSON. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 4 +- CLAUDE.md | 20 ++-- README.md | 4 +- acceptance/pom.xml | 2 +- .../AbstractTypeSafeClientAcceptanceTest.java | 8 +- ...kHttpClientWithJackson2AcceptanceTest.java | 4 +- ...kHttpClientWithJackson3AcceptanceTest.java | 4 +- ...kHttpClientWithJackson2AcceptanceTest.java | 4 +- ...kHttpClientWithJackson3AcceptanceTest.java | 4 +- adr/0003-model-in-core-contract-in-client.md | 12 +- cli/pom.xml | 2 +- .../io/github/dfa1/typesafe/cli/Main.java | 8 +- .../client/http/DefaultTypeSafeClient.java | 32 ++--- .../client/http/HttpTransportResponse.java | 2 +- .../http/DefaultTypeSafeClientTest.java | 112 +++++++++--------- client-local/pom.xml | 2 +- .../typesafe/client/local/LayaEngine.java | 2 +- .../dfa1/typesafe/client/local/Onnx.java | 14 +-- .../typesafe/client/local/JevComparison.java | 8 +- .../typesafe/client/TypeSafeException.java | 2 +- codec-jackson2/pom.xml | 2 +- .../codec/jackson2/Jackson2Codec.java | 6 +- ...dec => io.github.dfa1.typesafe.core.Codec} | 0 codec-jackson3/pom.xml | 2 +- .../codec/jackson3/Jackson3Codec.java | 6 +- ...dec => io.github.dfa1.typesafe.core.Codec} | 0 core/pom.xml | 2 +- .../core/{JsonCodec.java => Codec.java} | 7 +- docs/explanation.md | 16 +-- docs/how-to.md | 14 +-- docs/reference.md | 22 ++-- 31 files changed, 166 insertions(+), 161 deletions(-) rename codec-jackson2/src/main/resources/META-INF/services/{io.github.dfa1.typesafe.core.JsonCodec => io.github.dfa1.typesafe.core.Codec} (100%) rename codec-jackson3/src/main/resources/META-INF/services/{io.github.dfa1.typesafe.core.JsonCodec => io.github.dfa1.typesafe.core.Codec} (100%) rename core/src/main/java/io/github/dfa1/typesafe/core/{JsonCodec.java => Codec.java} (50%) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0d9c97..1c4deea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- **Breaking: `JsonCodec` and `HttpTransport` speak `byte[]`** — `writeValueAsBytes`/`writeValueAsPrettyBytes`/`readValue(byte[], ...)` and a `byte[]` request/response body, so JSON goes to and from the wire as UTF-8 with no `String` copy; `Jackson2Codec` now writes emoji and other non-BMP characters unescaped, like `Jackson3Codec`. (#35) -- **Breaking: modules split into model (`core`), contract (`client`) and implementations (`client-*`), names aligned** — `core` keeps the model and `JsonCodec`; `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) +- **Breaking: `JsonCodec` is now `Codec`, and it and `HttpTransport` speak `byte[]`** — `writeValueAsBytes`/`writeValueAsPrettyBytes`/`readValue(byte[], ...)`, `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`), contract (`client`) and implementations (`client-*`), names aligned** — `core` keeps the model and `Codec`; `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. - **`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. diff --git a/CLAUDE.md b/CLAUDE.md index 0c53e3c..60843f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ Naming rule: a module's directory, artifact (`typesafe-java-`) and singl and a module named `X-Y` builds on module `X` (ADR 0003). core — the model, no dependencies: Answer, Question, Content, EvaluateRequest/ - EvaluateResponse, Usage, RequestId, Model, ModelDetails, plus the JsonCodec SPI, all + EvaluateResponse, Usage, RequestId, Model, ModelDetails, plus the Codec SPI, all in io.github.dfa1.typesafe.core. The model + a codec serializes payloads (e.g. for Kafka) without any client code. client — the contract, on core: TypeSafeClient (interface; no builder() — client can't see @@ -34,7 +34,7 @@ client — the contract, on core: TypeSafeClient (interface; no builder() — 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 — JsonCodec backed by Jackson 2.x. Depends only on core. Owns the `type` +codec-jackson2 — Codec 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, @@ -43,9 +43,9 @@ 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/JsonCodec, + 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(...)/jsonCodec(...)). + 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). @@ -98,7 +98,7 @@ client-local — one public TypeSafeClient per model (io.github.dfa1.typesafe.cl 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), + (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 @@ -112,7 +112,7 @@ client-local — one public TypeSafeClient per model (io.github.dfa1.typesafe.cl the job summary. 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()`, ...) @@ -138,7 +138,7 @@ cli — command-line entry point (`Main`), over client-http-jdk + codec-ja 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.writeValueAsPrettyBytes`) + `EvaluateResponse` as pretty-printed JSON, via `Codec.writeValueAsPrettyBytes`) is given. ``` @@ -167,7 +167,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: @@ -182,7 +182,7 @@ property (surefire). Opt in with: 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-http` don't change. -- **`JsonCodec` is discovered via `ServiceLoader`, not a hard compile dependency.** A +- **`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 @@ -199,7 +199,7 @@ 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. `DefaultTypeSafeClientTest` mocks `HttpTransport`/`JsonCodec` +`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` diff --git a/README.md b/README.md index 4a846e9..a82b804 100644 --- a/README.md +++ b/README.md @@ -107,9 +107,9 @@ for `typesafe-java-client-http-okhttp`. | Module | Contains | |---|---| -| `core` | the model (`EvaluateRequest`, `EvaluateResponse`, `Question`, `Answer`, ...) and the `JsonCodec` SPI; no dependencies | +| `core` | the model (`EvaluateRequest`, `EvaluateResponse`, `Question`, `Answer`, ...) and the `Codec` SPI; no dependencies | | `client` | the `TypeSafeClient` interface, `TypeSafeException`, and the decorators that wrap any client (retries, deadline, token counting) | -| `codec-jackson2` / `codec-jackson3` | `JsonCodec` backed by Jackson 2.x / 3.x | +| `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 | diff --git a/acceptance/pom.xml b/acceptance/pom.xml index 19f40e8..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. 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 969d59f..e358c48 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 @@ -8,7 +8,7 @@ import io.github.dfa1.typesafe.core.Question; import io.github.dfa1.typesafe.core.Content; import io.github.dfa1.typesafe.client.TypeSafeClient; -import io.github.dfa1.typesafe.core.JsonCodec; +import io.github.dfa1.typesafe.core.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; @@ -32,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) @@ -46,13 +46,13 @@ abstract class AbstractTypeSafeClientAcceptanceTest { protected abstract HttpTransport httpTransport(); - protected abstract JsonCodec jsonCodec(); + protected abstract Codec codec(); @BeforeEach void setUp() throws Exception { 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 665a74e..a285069 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 @@ -2,7 +2,7 @@ import io.github.dfa1.typesafe.codec.jackson2.Jackson2Codec; import io.github.dfa1.typesafe.client.http.jdk.JdkHttpTransport; -import io.github.dfa1.typesafe.core.JsonCodec; +import io.github.dfa1.typesafe.core.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 c37f2c4..c1fabfb 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 @@ -2,7 +2,7 @@ import io.github.dfa1.typesafe.codec.jackson3.Jackson3Codec; import io.github.dfa1.typesafe.client.http.jdk.JdkHttpTransport; -import io.github.dfa1.typesafe.core.JsonCodec; +import io.github.dfa1.typesafe.core.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 d4f7992..5722f0e 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,7 +1,7 @@ package io.github.dfa1.typesafe.acceptance; import io.github.dfa1.typesafe.codec.jackson2.Jackson2Codec; -import io.github.dfa1.typesafe.core.JsonCodec; +import io.github.dfa1.typesafe.core.Codec; import io.github.dfa1.typesafe.client.http.okhttp.OkHttpTransport; import io.github.dfa1.typesafe.client.http.HttpTransport; @@ -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 f1d2448..44ef82f 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,7 +1,7 @@ package io.github.dfa1.typesafe.acceptance; import io.github.dfa1.typesafe.codec.jackson3.Jackson3Codec; -import io.github.dfa1.typesafe.core.JsonCodec; +import io.github.dfa1.typesafe.core.Codec; import io.github.dfa1.typesafe.client.http.okhttp.OkHttpTransport; import io.github.dfa1.typesafe.client.http.HttpTransport; @@ -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 index 7009ef3..5deb6ef 100644 --- a/adr/0003-model-in-core-contract-in-client.md +++ b/adr/0003-model-in-core-contract-in-client.md @@ -5,7 +5,7 @@ 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 `JsonCodec`/`HttpTransport` SPIs still hold. +its reasons for the `Codec`/`HttpTransport` SPIs still hold. ## Context @@ -27,14 +27,14 @@ ones, and the layout stopped saying what depended on what: Three layers, each a module (or a family of modules) that only depends on the layers below it: - **`core`, the model**, with no dependencies: `Answer`, `Question`, `Content`, `EvaluateRequest`, - `EvaluateResponse`, `Usage`, `RequestId`, `Model`, `ModelDetails`, and the `JsonCodec` SPI. With a + `EvaluateResponse`, `Usage`, `RequestId`, `Model`, `ModelDetails`, and the `Codec` SPI. With a `codec-*` module it 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 `JsonCodec` on `core` alone: `codec-jackson2`, `codec-jackson3`. +- **`codec-*`** modules implement `Codec` on `core` alone: `codec-jackson2`, `codec-jackson3`. **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 @@ -43,7 +43,11 @@ module named `X-Y` builds on module `X`. `TypeSafeClient.builder()` is removed: `client` can't reference `client-http`. Callers write `DefaultTypeSafeClient.builder()`. -`JsonCodec` stays in `core` rather than in a `codec` module of its own: everything that uses it +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` stays in `core` rather than in a `codec` module of its own: everything that uses it (`client-http`, `client-local`, the codecs) needs `core` anyway, so a separate artifact would always travel with it. diff --git a/cli/pom.xml b/cli/pom.xml index 585d7a4..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 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 91cdbda..495d1ff 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 @@ -11,7 +11,7 @@ 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.core.JsonCodec; +import io.github.dfa1.typesafe.core.Codec; import java.nio.charset.StandardCharsets; import java.io.PrintStream; @@ -70,7 +70,7 @@ static int run(String[] args, PrintStream out, PrintStream err) throws Exception Jackson3Codec codec = new Jackson3Codec(); try (TypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(ApiKey.fromDefaultFile()) - .jsonCodec(codec) + .codec(codec) .httpTransport(new JdkHttpTransport()) .build()) { return run(client, codec, parsed, out, err); @@ -79,7 +79,7 @@ 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()); @@ -214,7 +214,7 @@ private static int fail(PrintStream err, String message) { return 1; } - private static String pretty(JsonCodec codec, Object value) { + private static String pretty(Codec codec, Object value) { return new String(codec.writeValueAsPrettyBytes(value), StandardCharsets.UTF_8); } } diff --git a/client-http/src/main/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClient.java b/client-http/src/main/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClient.java index 0817723..079a624 100644 --- a/client-http/src/main/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClient.java +++ b/client-http/src/main/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClient.java @@ -7,7 +7,7 @@ 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.JsonCodec; +import io.github.dfa1.typesafe.core.Codec; import java.io.IOException; import java.io.InterruptedIOException; @@ -32,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"); } @@ -59,7 +59,7 @@ public CompletableFuture evaluateAsync(EvaluateRequest request Map headers = requestHeaders(); byte[] body; try { - body = jsonCodec.writeValueAsBytes(request); + body = codec.writeValueAsBytes(request); } catch (RuntimeException e) { return CompletableFuture.failedFuture(e); } @@ -183,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") @@ -192,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(); } /** @@ -218,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<>(); @@ -235,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; } @@ -281,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) */ @@ -303,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) { @@ -327,11 +327,11 @@ private static HttpTransport loadDefaultHttpTransport() { + "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-codec-jackson2 or " - + "typesafe-java-codec-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/client-http/src/main/java/io/github/dfa1/typesafe/client/http/HttpTransportResponse.java b/client-http/src/main/java/io/github/dfa1/typesafe/client/http/HttpTransportResponse.java index 888a888..9f03ecc 100644 --- a/client-http/src/main/java/io/github/dfa1/typesafe/client/http/HttpTransportResponse.java +++ b/client-http/src/main/java/io/github/dfa1/typesafe/client/http/HttpTransportResponse.java @@ -3,7 +3,7 @@ import java.util.Map; import java.util.Optional; -/** An HTTP response; {@code body} is the raw bytes, decoded by the {@code JsonCodec} without a {@code String} in between. */ +/** 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 { diff --git a/client-http/src/test/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClientTest.java b/client-http/src/test/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClientTest.java index efe90ab..2bc272d 100644 --- a/client-http/src/test/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClientTest.java +++ b/client-http/src/test/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClientTest.java @@ -12,7 +12,7 @@ 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.core.JsonCodec; +import io.github.dfa1.typesafe.core.Codec; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -50,15 +50,15 @@ class DefaultTypeSafeClientTest { private HttpTransport httpTransport; @Mock - private JsonCodec jsonCodec; + private Codec codec; @Test - void evaluateDelegatesToTheConfiguredHttpTransportAndJsonCodec() { + void evaluateDelegatesToTheConfiguredHttpTransportAndCodec() { // Given 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()); @@ -69,18 +69,18 @@ void evaluateDelegatesToTheConfiguredHttpTransportAndJsonCodec() { byte[] responseBody = bytes("{\"response\":true}"); EvaluateResponse decodedResponse = new EvaluateResponse(Model.LATEST, Map.of(), new Usage(10, 5), null); - given(jsonCodec.writeValueAsBytes(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().writeValueAsBytes(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); } @@ -91,7 +91,7 @@ void withoutAnApiKeySendsNoAuthorizationHeader() { TypeSafeClient sut = DefaultTypeSafeClient.builder() .endpoint(ENDPOINT) .httpTransport(httpTransport) - .jsonCodec(jsonCodec) + .codec(codec) .build(); EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of()); @@ -100,10 +100,10 @@ void withoutAnApiKeySendsNoAuthorizationHeader() { byte[] responseBody = bytes("{\"response\":true}"); EvaluateResponse decodedResponse = new EvaluateResponse(Model.LATEST, Map.of(), new Usage(10, 0), null); - given(jsonCodec.writeValueAsBytes(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); @@ -114,12 +114,12 @@ void withoutAnApiKeySendsNoAuthorizationHeader() { } @Test - void listModelsDelegatesToTheConfiguredHttpTransportAndJsonCodec() { + void listModelsDelegatesToTheConfiguredHttpTransportAndCodec() { // Given 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"); @@ -128,7 +128,7 @@ void listModelsDelegatesToTheConfiguredHttpTransportAndJsonCodec() { given(httpTransport.get(modelsEndpoint, expectedHeaders)) .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(200, Map.of(), bytes("models-json")))); - given(jsonCodec.readValue(bytes("models-json"), DefaultTypeSafeClient.ModelsResponse.class)) + given(codec.readValue(bytes("models-json"), DefaultTypeSafeClient.ModelsResponse.class)) .willReturn(new DefaultTypeSafeClient.ModelsResponse(models)); // When @@ -144,7 +144,7 @@ void closeClosesTheUnderlyingHttpTransport() { // Given TypeSafeClient sut = DefaultTypeSafeClient.builder().apiKey(ApiKey.of("secret")) .httpTransport(httpTransport) - .jsonCodec(jsonCodec) + .codec(codec) .build(); // When @@ -160,7 +160,7 @@ void evaluateThrowsOnANonRetryableErrorStatus() { TypeSafeClient sut = clientWith(NO_BACKOFF); EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of()); - given(jsonCodec.writeValueAsBytes(request)).willReturn(bytes("{}")); + given(codec.writeValueAsBytes(request)).willReturn(bytes("{}")); given(httpTransport.post(any(), any(), any())) .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(400, Map.of(), bytes("bad request")))); @@ -180,7 +180,7 @@ void evaluateThrowsTheSubclassMatchingTheStatusCode() { TypeSafeClient sut = clientWith(NO_BACKOFF); EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of()); - given(jsonCodec.writeValueAsBytes(request)).willReturn(bytes("{}")); + given(codec.writeValueAsBytes(request)).willReturn(bytes("{}")); given(httpTransport.post(any(), any(), any())) .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(401, Map.of(), bytes("no token")))); @@ -195,7 +195,7 @@ void evaluateThrowsRateLimitWithTheRetryAfterHeader() { TypeSafeClient sut = clientWith(NO_BACKOFF, 0); EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of()); - given(jsonCodec.writeValueAsBytes(request)).willReturn(bytes("{}")); + given(codec.writeValueAsBytes(request)).willReturn(bytes("{}")); given(httpTransport.post(any(), any(), any())) .willReturn(CompletableFuture.completedFuture( new HttpTransportResponse(429, Map.of("retry-after-ms", "1500"), bytes("slow down")))); @@ -214,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.writeValueAsBytes(request)).willReturn(bytes("{}")); + given(codec.writeValueAsBytes(request)).willReturn(bytes("{}")); given(httpTransport.post(any(), any(), any())) .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(429, Map.of(), bytes("slow down")))) .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(200, Map.of(), bytes("ok")))); - given(jsonCodec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse); + given(codec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse); // When EvaluateResponse result = sut.evaluate(request); @@ -234,7 +234,7 @@ void evaluateThrowsAfterExhaustingRetries() { TypeSafeClient sut = clientWith(NO_BACKOFF, 1); EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of()); - given(jsonCodec.writeValueAsBytes(request)).willReturn(bytes("{}")); + given(codec.writeValueAsBytes(request)).willReturn(bytes("{}")); given(httpTransport.post(any(), any(), any())) .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(529, Map.of(), bytes("overloaded")))); @@ -252,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.writeValueAsBytes(request)).willReturn(bytes("{}")); + given(codec.writeValueAsBytes(request)).willReturn(bytes("{}")); given(httpTransport.post(any(), any(), any())) .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(503, Map.of(), bytes("unavailable")))) .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(200, Map.of(), bytes("ok")))); - given(jsonCodec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse); + given(codec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse); // When EvaluateResponse result = sut.evaluate(request); @@ -272,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.writeValueAsBytes(request)).willReturn(bytes("{}")); + given(codec.writeValueAsBytes(request)).willReturn(bytes("{}")); given(httpTransport.post(any(), any(), any())) .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(408, Map.of(), bytes("request timeout")))) .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(200, Map.of(), bytes("ok")))); - given(jsonCodec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse); + given(codec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse); // When EvaluateResponse result = sut.evaluate(request); @@ -292,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.writeValueAsBytes(request)).willReturn(bytes("{}")); + 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(), bytes("ok")))); - given(jsonCodec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse); + given(codec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse); // When EvaluateResponse result = sut.evaluate(request); @@ -313,7 +313,7 @@ void evaluateThrowsAfterExhaustingRetriesOnAConnectionFailure() { EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of()); IOException connectionFailure = new IOException("connection reset"); - given(jsonCodec.writeValueAsBytes(request)).willReturn(bytes("{}")); + given(codec.writeValueAsBytes(request)).willReturn(bytes("{}")); given(httpTransport.post(any(), any(), any())).willReturn(CompletableFuture.failedFuture(connectionFailure)); // When / Then @@ -330,7 +330,7 @@ void evaluateThrowsTypeSafeTimeoutExceptionAfterExhaustingRetriesOnATimeout() { EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of()); HttpTimeoutException timeout = new HttpTimeoutException("request timed out"); - given(jsonCodec.writeValueAsBytes(request)).willReturn(bytes("{}")); + given(codec.writeValueAsBytes(request)).willReturn(bytes("{}")); given(httpTransport.post(any(), any(), any())).willReturn(CompletableFuture.failedFuture(timeout)); // When / Then @@ -346,7 +346,7 @@ void evaluateThrowsTypeSafeInterruptedAndRestoresTheInterruptFlagWhenTheCallingT EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of()); CompletableFuture neverCompletes = new CompletableFuture<>(); - given(jsonCodec.writeValueAsBytes(request)).willReturn(bytes("{}")); + given(codec.writeValueAsBytes(request)).willReturn(bytes("{}")); given(httpTransport.post(any(), any(), any())).willReturn(neverCompletes); AtomicReference thrown = new AtomicReference<>(); @@ -381,7 +381,7 @@ void evaluateRethrowsAnErrorFromTheTransportWithoutRetrying() { EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of()); Error transportError = new StackOverflowError("boom"); - given(jsonCodec.writeValueAsBytes(request)).willReturn(bytes("{}")); + given(codec.writeValueAsBytes(request)).willReturn(bytes("{}")); given(httpTransport.post(any(), any(), any())).willReturn(CompletableFuture.failedFuture(transportError)); // When / Then @@ -399,10 +399,10 @@ void evaluatePopulatesMetadataFromResponseHeaders() { "x-typesafe-request-id", "req_123", "x-envoy-upstream-service-time", "42"); - given(jsonCodec.writeValueAsBytes(request)).willReturn(bytes("{}")); + given(codec.writeValueAsBytes(request)).willReturn(bytes("{}")); given(httpTransport.post(any(), any(), any())) .willReturn(CompletableFuture.completedFuture(new HttpTransportResponse(200, responseHeaders, bytes("ok")))); - given(jsonCodec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse); + given(codec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse); // When EvaluateResponse result = sut.evaluate(request); @@ -419,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.writeValueAsBytes(request)).willReturn(bytes("{}")); + given(codec.writeValueAsBytes(request)).willReturn(bytes("{}")); given(httpTransport.post(any(), any(), any())) .willReturn(CompletableFuture.completedFuture( new HttpTransportResponse(200, Map.of(), bytes("ok")))); - given(jsonCodec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse); + given(codec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse); // When EvaluateResponse result = sut.evaluateAsync(request).join(); @@ -439,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.writeValueAsBytes(request)).willReturn(bytes("{}")); + given(codec.writeValueAsBytes(request)).willReturn(bytes("{}")); given(httpTransport.post(any(), any(), any())) .willReturn(CompletableFuture.completedFuture( new HttpTransportResponse(429, Map.of(), bytes("slow down")))) .willReturn(CompletableFuture.completedFuture( new HttpTransportResponse(200, Map.of(), bytes("ok")))); - given(jsonCodec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse); + given(codec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse); // When EvaluateResponse result = sut.evaluateAsync(request).join(); @@ -461,7 +461,7 @@ void evaluateAsyncFailsAfterExhaustingRetries() { TypeSafeClient sut = clientWith(NO_BACKOFF, 1); EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of()); - given(jsonCodec.writeValueAsBytes(request)).willReturn(bytes("{}")); + given(codec.writeValueAsBytes(request)).willReturn(bytes("{}")); given(httpTransport.post(any(), any(), any())) .willReturn(CompletableFuture.completedFuture( new HttpTransportResponse(529, Map.of(), bytes("overloaded")))); @@ -479,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.writeValueAsBytes(request)).willReturn(bytes("{}")); + 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(), bytes("ok")))); - given(jsonCodec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse); + given(codec.readValue(bytes("ok"), EvaluateResponse.class)).willReturn(decodedResponse); // When EvaluateResponse result = sut.evaluateAsync(request).join(); @@ -500,7 +500,7 @@ void evaluateAsyncFailsAfterExhaustingRetriesOnAConnectionFailure() { EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of()); IOException connectionFailure = new IOException("connection reset"); - given(jsonCodec.writeValueAsBytes(request)).willReturn(bytes("{}")); + given(codec.writeValueAsBytes(request)).willReturn(bytes("{}")); given(httpTransport.post(any(), any(), any())) .willReturn(CompletableFuture.failedFuture(connectionFailure)); @@ -519,7 +519,7 @@ void evaluateAsyncFailsFastWhenEncodingTheRequestThrows() { EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of()); RuntimeException encodingFailure = new RuntimeException("boom"); - given(jsonCodec.writeValueAsBytes(request)).willThrow(encodingFailure); + given(codec.writeValueAsBytes(request)).willThrow(encodingFailure); // When / Then assertThatThrownBy(() -> sut.evaluateAsync(request).get()) @@ -534,11 +534,11 @@ void evaluateAsyncFailsFastWhenDecodingTheResponseThrows() { EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of()); RuntimeException decodingFailure = new RuntimeException("boom"); - given(jsonCodec.writeValueAsBytes(request)).willReturn(bytes("{}")); + given(codec.writeValueAsBytes(request)).willReturn(bytes("{}")); given(httpTransport.post(any(), any(), any())) .willReturn(CompletableFuture.completedFuture( new HttpTransportResponse(200, Map.of(), bytes("not json")))); - given(jsonCodec.readValue(bytes("not json"), EvaluateResponse.class)).willThrow(decodingFailure); + given(codec.readValue(bytes("not json"), EvaluateResponse.class)).willThrow(decodingFailure); // When / Then assertThatThrownBy(() -> sut.evaluateAsync(request).get()) @@ -550,7 +550,7 @@ void evaluateAsyncFailsFastWhenDecodingTheResponseThrows() { @Test void builderThrowsWhenNoHttpTransportIsConfiguredOrDiscoverable() { // Given - DefaultTypeSafeClient.Builder sut = DefaultTypeSafeClient.builder().apiKey(ApiKey.of("secret")).jsonCodec(jsonCodec); + DefaultTypeSafeClient.Builder sut = DefaultTypeSafeClient.builder().apiKey(ApiKey.of("secret")).codec(codec); // When / Then assertThatThrownBy(sut::build) @@ -559,14 +559,14 @@ void builderThrowsWhenNoHttpTransportIsConfiguredOrDiscoverable() { } @Test - void builderThrowsWhenNoJsonCodecIsConfiguredOrDiscoverable() { + void builderThrowsWhenNoCodecIsConfiguredOrDiscoverable() { // Given DefaultTypeSafeClient.Builder sut = DefaultTypeSafeClient.builder().apiKey(ApiKey.of("secret")).httpTransport(httpTransport); // When / Then assertThatThrownBy(sut::build) .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("JsonCodec"); + .hasMessageContaining("Codec"); } @Test @@ -574,7 +574,7 @@ void buildWithADecoratorAppliesItToTheBuiltClient() { // Given DefaultTypeSafeClient.Builder sut = DefaultTypeSafeClient.builder().apiKey(ApiKey.of("secret")) .httpTransport(httpTransport) - .jsonCodec(jsonCodec); + .codec(codec); // When WrappingTypeSafeClient result = sut.build(WrappingTypeSafeClient::new); @@ -588,7 +588,7 @@ void buildWrapsTheClientInAWrapper() { // Given DefaultTypeSafeClient.Builder sut = DefaultTypeSafeClient.builder().apiKey(ApiKey.of("secret")) .httpTransport(httpTransport) - .jsonCodec(jsonCodec) + .codec(codec) .decorateWith(WrappingTypeSafeClient::new); // When @@ -604,7 +604,7 @@ void buildAppliesDecoratorsInCallOrderWithTheBuildDecoratorOutermost() { // Given 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))); @@ -620,7 +620,7 @@ void buildThrowsWhenRetryingIsWrappedTwice() { // Given DefaultTypeSafeClient.Builder sut = DefaultTypeSafeClient.builder().apiKey(ApiKey.of("secret")) .httpTransport(httpTransport) - .jsonCodec(jsonCodec) + .codec(codec) .decorateWith(RetryingTypeSafeClient::decorate) .decorateWith(WrappingTypeSafeClient::new) .decorateWith(RetryingTypeSafeClient::decorate); @@ -637,7 +637,7 @@ void buildThrowsWhenRetryingIsBothWrappedAndTheBuildDecorator() { // Given DefaultTypeSafeClient.Builder sut = DefaultTypeSafeClient.builder().apiKey(ApiKey.of("secret")) .httpTransport(httpTransport) - .jsonCodec(jsonCodec) + .codec(codec) .decorateWith(RetryingTypeSafeClient::decorate); // When / Then @@ -650,7 +650,7 @@ void buildWithoutWrappersReturnsAPlainDefaultTypeSafeClient() { // Given DefaultTypeSafeClient.Builder sut = DefaultTypeSafeClient.builder().apiKey(ApiKey.of("secret")) .httpTransport(httpTransport) - .jsonCodec(jsonCodec); + .codec(codec); // When TypeSafeClient result = sut.build(); @@ -665,7 +665,7 @@ void evaluateThrowsInternalServerWithTheRetryAfterHeader() { TypeSafeClient sut = clientWith(NO_BACKOFF, 0); EvaluateRequest request = EvaluateRequest.of(Content.text("hi"), Map.of()); - given(jsonCodec.writeValueAsBytes(request)).willReturn(bytes("{}")); + given(codec.writeValueAsBytes(request)).willReturn(bytes("{}")); given(httpTransport.post(any(), any(), any())) .willReturn(CompletableFuture.completedFuture( new HttpTransportResponse(503, Map.of("retry-after", "3"), bytes("unavailable")))); @@ -682,7 +682,7 @@ void buildComposesMultipleDecoratorsViaFunctionAndThen() { // Given 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); @@ -766,7 +766,7 @@ private TypeSafeClient clientWith(Duration backoff, int maxRetries) { return DefaultTypeSafeClient.builder().apiKey(ApiKey.of("secret")) .endpoint(ENDPOINT) .httpTransport(httpTransport) - .jsonCodec(jsonCodec) + .codec(codec) .decorateWith(c -> RetryingTypeSafeClient.decorate(c, maxRetries, backoff)) .build(); } diff --git a/client-local/pom.xml b/client-local/pom.xml index e15ad75..4f0acdb 100644 --- a/client-local/pom.xml +++ b/client-local/pom.xml @@ -15,7 +15,7 @@ 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 (codec-jackson2 or codec-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. diff --git a/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LayaEngine.java b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LayaEngine.java index 322c9ab..01e6687 100644 --- a/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LayaEngine.java +++ b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LayaEngine.java @@ -271,7 +271,7 @@ 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; diff --git a/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Onnx.java b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Onnx.java index 3c69a11..a4672c7 100644 --- a/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Onnx.java +++ b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Onnx.java @@ -4,7 +4,7 @@ import ai.onnxruntime.OrtEnvironment; import ai.onnxruntime.OrtException; import ai.onnxruntime.OrtSession; -import io.github.dfa1.typesafe.core.JsonCodec; +import io.github.dfa1.typesafe.core.Codec; import java.io.IOException; import java.io.UncheckedIOException; @@ -46,9 +46,9 @@ 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; + /** typesafe-java's Codec (jackson2 or jackson3), found the way the API client finds it. */ + static Codec json() { + return LazyCodec.INSTANCE; } /** {@code value} as compact JSON text, for a model that reads JSON as part of its prompt. */ @@ -56,9 +56,9 @@ static String toJson(Object value) { return new String(json().writeValueAsBytes(value), StandardCharsets.UTF_8); } - 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-codec-jackson2 " + 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/client-local/src/test/java/io/github/dfa1/typesafe/client/local/JevComparison.java b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/JevComparison.java index 305bd2c..f7b7a6f 100644 --- a/client-local/src/test/java/io/github/dfa1/typesafe/client/local/JevComparison.java +++ b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/JevComparison.java @@ -7,7 +7,7 @@ import io.github.dfa1.typesafe.core.EvaluateResponse; import io.github.dfa1.typesafe.client.TypeSafeClient; import io.github.dfa1.typesafe.codec.jackson2.Jackson2Codec; -import io.github.dfa1.typesafe.core.JsonCodec; +import io.github.dfa1.typesafe.core.Codec; import java.nio.charset.StandardCharsets; import java.io.IOException; @@ -75,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); @@ -152,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<>(); @@ -163,7 +163,7 @@ private static List jevAnswers(JsonCodec codec, List lines = cached.computeIfAbsent(c.suite(), k -> readLines(file)); if (c.index() >= lines.size()) { if (api == null) { - api = DefaultTypeSafeClient.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 diff --git a/client/src/main/java/io/github/dfa1/typesafe/client/TypeSafeException.java b/client/src/main/java/io/github/dfa1/typesafe/client/TypeSafeException.java index 02dfa4d..6ec31d5 100644 --- a/client/src/main/java/io/github/dfa1/typesafe/client/TypeSafeException.java +++ b/client/src/main/java/io/github/dfa1/typesafe/client/TypeSafeException.java @@ -99,7 +99,7 @@ public Optional retryAfter() { } } - /** {@code 200}, but the configured {@link io.github.dfa1.typesafe.core.JsonCodec} couldn't + /** {@code 200}, but the configured {@link io.github.dfa1.typesafe.core.Codec} couldn't * decode the response body. */ public static final class ResponseDecoding extends TypeSafeException { public ResponseDecoding(String body, Throwable cause) { diff --git a/codec-jackson2/pom.xml b/codec-jackson2/pom.xml index c4dfe3a..349d437 100644 --- a/codec-jackson2/pom.xml +++ b/codec-jackson2/pom.xml @@ -11,7 +11,7 @@ jar TypeSafe Codec Jackson 2 - JsonCodec SPI implementation backed by Jackson 2.x. Depends only on + Codec SPI implementation backed by Jackson 2.x. Depends only on typesafe-java-core; discovered via ServiceLoader. diff --git a/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2Codec.java b/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2Codec.java index e31200f..24f87b0 100644 --- a/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2Codec.java +++ b/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2Codec.java @@ -5,7 +5,7 @@ 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.core.JsonCodec; +import io.github.dfa1.typesafe.core.Codec; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.core.JsonFactory; @@ -20,11 +20,11 @@ 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 { // 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 diff --git a/codec-jackson2/src/main/resources/META-INF/services/io.github.dfa1.typesafe.core.JsonCodec b/codec-jackson2/src/main/resources/META-INF/services/io.github.dfa1.typesafe.core.Codec similarity index 100% rename from codec-jackson2/src/main/resources/META-INF/services/io.github.dfa1.typesafe.core.JsonCodec rename to codec-jackson2/src/main/resources/META-INF/services/io.github.dfa1.typesafe.core.Codec diff --git a/codec-jackson3/pom.xml b/codec-jackson3/pom.xml index e8f98c4..78b56c6 100644 --- a/codec-jackson3/pom.xml +++ b/codec-jackson3/pom.xml @@ -11,7 +11,7 @@ jar TypeSafe Codec Jackson 3 - JsonCodec SPI implementation backed by Jackson 3.x. Depends only on + Codec SPI implementation backed by Jackson 3.x. Depends only on typesafe-java-core; discovered via ServiceLoader. diff --git a/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3Codec.java b/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3Codec.java index 1acaf7c..e6a2fbe 100644 --- a/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3Codec.java +++ b/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3Codec.java @@ -5,7 +5,7 @@ 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.core.JsonCodec; +import io.github.dfa1.typesafe.core.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) diff --git a/codec-jackson3/src/main/resources/META-INF/services/io.github.dfa1.typesafe.core.JsonCodec b/codec-jackson3/src/main/resources/META-INF/services/io.github.dfa1.typesafe.core.Codec similarity index 100% rename from codec-jackson3/src/main/resources/META-INF/services/io.github.dfa1.typesafe.core.JsonCodec rename to codec-jackson3/src/main/resources/META-INF/services/io.github.dfa1.typesafe.core.Codec diff --git a/core/pom.xml b/core/pom.xml index 902cdea..7d5995f 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -13,7 +13,7 @@ TypeSafe Core The TypeSafe wire model (Answer, Question, Content, EvaluateRequest/EvaluateResponse, Usage, - RequestId, Model, ModelDetails) and the JsonCodec SPI. No dependencies. With a codec-* module it + RequestId, Model, ModelDetails) and the Codec SPI. No dependencies. With a codec-* module it serializes TypeSafe payloads without any client code, e.g. for a Kafka producer/consumer; the client contract is typesafe-java-client. diff --git a/core/src/main/java/io/github/dfa1/typesafe/core/JsonCodec.java b/core/src/main/java/io/github/dfa1/typesafe/core/Codec.java similarity index 50% rename from core/src/main/java/io/github/dfa1/typesafe/core/JsonCodec.java rename to core/src/main/java/io/github/dfa1/typesafe/core/Codec.java index c07c48d..bf37a2f 100644 --- a/core/src/main/java/io/github/dfa1/typesafe/core/JsonCodec.java +++ b/core/src/main/java/io/github/dfa1/typesafe/core/Codec.java @@ -1,11 +1,12 @@ package io.github.dfa1.typesafe.core; /** - * JSON serialization plugged in by a codec module (typesafe-java-codec-jackson2 or -jackson3), as - * UTF-8 bytes: what goes over the wire, with no {@code String} in between. Implementations are + * 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 JsonCodec { +public interface Codec { byte[] writeValueAsBytes(Object value); diff --git a/docs/explanation.md b/docs/explanation.md index dbb8f78..6ee33df 100644 --- a/docs/explanation.md +++ b/docs/explanation.md @@ -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 @@ -82,7 +82,7 @@ discriminator logic into private mixins inside `codec-jackson2`/`codec-jackson3` 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 `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. @@ -97,13 +97,13 @@ design avoids. ## 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. ## Why the model is in `core`, the contract in `client`, and each client in its own module -`core` is the model and the `JsonCodec` SPI, nothing else. `client` is what every client agrees on: +`core` is the model and the `Codec` SPI, 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 @@ -111,7 +111,7 @@ token counting), and `TypeSafeException`. Each implementation is a `client-*` mo compiler enforces it: `client` can't see `DefaultTypeSafeClient`, which is why `TypeSafeClient` has no `builder()`. -`JsonCodec` stays in `core`, next to the model it serializes: `client-local` reads its model configs +`Codec` stays in `core`, next to the model it serializes: `client-local` reads its model configs through it too, and the model 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). @@ -137,7 +137,7 @@ 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). The interface kept a one-line `TypeSafeClient.builder()` delegating to it until the module split moved @@ -207,7 +207,7 @@ a `FailingTypeSafeClient` for testing, a test double) — a builder that constru `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`: @@ -224,7 +224,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 diff --git a/docs/how-to.md b/docs/how-to.md index 7b039dc..e3951ac 100644 --- a/docs/how-to.md +++ b/docs/how-to.md @@ -30,7 +30,7 @@ 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 @@ -47,11 +47,11 @@ TypeSafeClient client = DefaultTypeSafeClient.builder().apiKey(token).build(); 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 = DefaultTypeSafeClient.builder().apiKey(token) - .jsonCodec(new Jackson2Codec()) + .codec(new Jackson2Codec()) .build(); ``` @@ -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. @@ -548,7 +548,7 @@ payloads — for example to publish or consume them on a Kafka topic — depend (`typesafe-java-codec-jackson2` or `-jackson3`, which brings `core`) and nothing else: ```java -JsonCodec codec = new Jackson2Codec(); +Codec codec = new Jackson2Codec(); byte[] json = codec.writeValueAsBytes(request); // UTF-8 JSON EvaluateResponse response = codec.readValue(json, EvaluateResponse.class); ``` @@ -661,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-codec-jackson2` or `typesafe-java-codec-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). @@ -764,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 135ab6c..8044e7d 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,7 +15,7 @@ For task-oriented usage see [how-to.md](how-to.md); for design rationale see [ex | Module | Depends on | Contains | |---|---|---| -| `typesafe-java-core` | — | the model (`Answer`, `Question`, `Content`, `EvaluateRequest`, `EvaluateResponse`, `Usage`, `RequestId`, `Model`, `ModelDetails`), `JsonCodec` | +| `typesafe-java-core` | — | the model (`Answer`, `Question`, `Content`, `EvaluateRequest`, `EvaluateResponse`, `Usage`, `RequestId`, `Model`, `ModelDetails`), `Codec` | | `typesafe-java-codec-jackson2` | `core` | `Jackson2Codec` (Jackson 2.x) | | `typesafe-java-codec-jackson3` | `core` | `Jackson3Codec` (Jackson 3.x) | | `typesafe-java-client` | `core` | `TypeSafeClient`, `TypeSafeException`, `RetryingTypeSafeClient`, `DeadlineTypeSafeClient`, `TokenCounter` | @@ -162,12 +162,12 @@ 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 ```java package io.github.dfa1.typesafe.core; -public interface JsonCodec { +public interface Codec { byte[] writeValueAsBytes(Object value); byte[] writeValueAsPrettyBytes(Object value); // same, indented for human reading T readValue(byte[] content, Class type); @@ -175,8 +175,8 @@ public interface JsonCodec { ``` Implementations (`Jackson2Codec`, `Jackson3Codec`) are discovered via -`ServiceLoader.load(JsonCodec.class)` and registered through -`META-INF/services/io.github.dfa1.typesafe.core.JsonCodec`. Both own the `Answer`/`Question` polymorphic +`ServiceLoader.load(Codec.class)` and registered through +`META-INF/services/io.github.dfa1.typesafe.core.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. JSON is UTF-8 bytes in and out (RFC 8259), so nothing builds a `String` on the way to or from the @@ -210,7 +210,7 @@ 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. @@ -340,7 +340,7 @@ 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 +[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). @@ -426,10 +426,10 @@ try-with-resources, or skip closing for a client that lives as long as the proce | 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` @@ -491,7 +491,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 From 597d62d4d1d53060852f8e577a694f4104f26911 Mon Sep 17 00:00:00 2001 From: Davide Angelocola Date: Sun, 4 Oct 2026 13:32:16 +0200 Subject: [PATCH 6/8] client-local: Onnx.json() is now codec(), after the Codec rename Co-Authored-By: Claude Sonnet 5 --- .../io/github/dfa1/typesafe/client/local/BpeTokenizer.java | 2 +- .../io/github/dfa1/typesafe/client/local/LayaEngine.java | 2 +- .../java/io/github/dfa1/typesafe/client/local/Onnx.java | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/client-local/src/main/java/io/github/dfa1/typesafe/client/local/BpeTokenizer.java b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/BpeTokenizer.java index dba71c2..89feac8 100644 --- a/client-local/src/main/java/io/github/dfa1/typesafe/client/local/BpeTokenizer.java +++ b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/BpeTokenizer.java @@ -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.readAllBytes(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/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LayaEngine.java b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LayaEngine.java index 01e6687..6ce7720 100644 --- a/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LayaEngine.java +++ b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/LayaEngine.java @@ -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.readAllBytes(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"); diff --git a/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Onnx.java b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Onnx.java index a4672c7..6bde0ab 100644 --- a/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Onnx.java +++ b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Onnx.java @@ -46,14 +46,15 @@ static OnnxTensor longs(long[] data, long... shape) throws OrtException { return OnnxTensor.createTensor(ENV, LongBuffer.wrap(data), shape); } - /** typesafe-java's Codec (jackson2 or jackson3), found the way the API client finds it. */ - static Codec json() { + /** 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; } /** {@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(json().writeValueAsBytes(value), StandardCharsets.UTF_8); + return new String(codec().writeValueAsBytes(value), StandardCharsets.UTF_8); } private static final class LazyCodec { From 39512252f91008d1f60e2357fe51a3bcdf800528 Mon Sep 17 00:00:00 2001 From: Davide Angelocola Date: Sun, 4 Oct 2026 13:34:13 +0200 Subject: [PATCH 7/8] Codec: drop writeValueAsPrettyBytes Its one caller was the CLI's --verbose, and indentation is a JSON notion that doesn't belong on a format-neutral Codec. --verbose now prints compact JSON; pipe it into jq to read it. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 +- CLAUDE.md | 2 +- .../java/io/github/dfa1/typesafe/cli/Main.java | 14 +++++++------- .../java/io/github/dfa1/typesafe/cli/MainTest.java | 4 ++-- .../typesafe/codec/jackson2/Jackson2Codec.java | 9 --------- .../typesafe/codec/jackson2/Jackson2CodecTest.java | 12 ------------ .../typesafe/codec/jackson3/Jackson3Codec.java | 5 ----- .../typesafe/codec/jackson3/Jackson3CodecTest.java | 12 ------------ .../java/io/github/dfa1/typesafe/core/Codec.java | 3 --- docs/how-to.md | 4 ++-- docs/reference.md | 1 - 11 files changed, 13 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c4deea..c8d71e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ 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`/`writeValueAsPrettyBytes`/`readValue(byte[], ...)`, `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: `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`), contract (`client`) and implementations (`client-*`), names aligned** — `core` keeps the model and `Codec`; `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. - **`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. diff --git a/CLAUDE.md b/CLAUDE.md index 60843f6..37ca9cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -138,7 +138,7 @@ cli — command-line entry point (`Main`), over client-http-jdk + codec-ja 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 `Codec.writeValueAsPrettyBytes`) + `EvaluateResponse` as compact JSON — pipe it into `jq` to read it) is given. ``` 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 495d1ff..c98b919 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 @@ -25,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}. */ @@ -41,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() { } @@ -84,13 +84,13 @@ static int run(TypeSafeClient client, Codec codec, ParsedArgs parsed, PrintStrea EvaluateRequest request = EvaluateRequest.of(Content.text(parsed.state()), parsed.model(), parsed.questions()); if (parsed.verbose()) { - err.println("request: " + pretty(codec, request)); + err.println("request: " + json(codec, request)); } EvaluateResponse response = client.evaluate(request); if (parsed.verbose()) { - err.println("response: " + pretty(codec, response)); + err.println("response: " + json(codec, response)); err.println("request-id: " + response.metadata().requestId()); } if (parsed.timing()) { @@ -100,7 +100,7 @@ static int run(TypeSafeClient client, Codec codec, ParsedArgs parsed, PrintStrea if (!parsed.printNames().isEmpty()) { parsed.printNames().forEach(name -> out.println(answerValue(response, name))); } else if (parsed.verbose()) { - out.println(pretty(codec, response)); + out.println(json(codec, response)); } List failures = minFailures(response, parsed.minSpecs()); @@ -214,7 +214,7 @@ private static int fail(PrintStream err, String message) { return 1; } - private static String pretty(Codec codec, Object value) { - return new String(codec.writeValueAsPrettyBytes(value), StandardCharsets.UTF_8); + 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 8e77097..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 @@ -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/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2Codec.java b/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2Codec.java index 24f87b0..8267463 100644 --- a/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2Codec.java +++ b/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2Codec.java @@ -51,15 +51,6 @@ public byte[] writeValueAsBytes(Object value) { } } - @Override - public byte[] writeValueAsPrettyBytes(Object value) { - try { - return mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(value); - } catch (JsonProcessingException e) { - throw new UncheckedIOException(e); - } - } - @Override public T readValue(byte[] content, Class type) { try { diff --git a/codec-jackson2/src/test/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2CodecTest.java b/codec-jackson2/src/test/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2CodecTest.java index c5f6211..89bc024 100644 --- a/codec-jackson2/src/test/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2CodecTest.java +++ b/codec-jackson2/src/test/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2CodecTest.java @@ -48,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 = json(sut.writeValueAsPrettyBytes(state)); - - // Then - assertThat(result).isEqualTo("{\n \"order_id\" : \"A-104\"\n}"); - } - @Test void serializesStructuredInstructionsAsARawJsonObject() { // Given diff --git a/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3Codec.java b/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3Codec.java index e6a2fbe..402ec5a 100644 --- a/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3Codec.java +++ b/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3Codec.java @@ -37,11 +37,6 @@ public byte[] writeValueAsBytes(Object value) { return mapper.writeValueAsBytes(value); } - @Override - public byte[] writeValueAsPrettyBytes(Object value) { - return mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(value); - } - @Override public T readValue(byte[] content, Class type) { return mapper.readValue(content, type); diff --git a/codec-jackson3/src/test/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3CodecTest.java b/codec-jackson3/src/test/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3CodecTest.java index 7065c89..773afa8 100644 --- a/codec-jackson3/src/test/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3CodecTest.java +++ b/codec-jackson3/src/test/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3CodecTest.java @@ -46,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 = json(sut.writeValueAsPrettyBytes(state)); - - // Then - assertThat(result).isEqualTo("{\n \"order_id\" : \"A-104\"\n}"); - } - @Test void serializesStructuredInstructionsAsARawJsonObject() { // Given diff --git a/core/src/main/java/io/github/dfa1/typesafe/core/Codec.java b/core/src/main/java/io/github/dfa1/typesafe/core/Codec.java index bf37a2f..f5742c9 100644 --- a/core/src/main/java/io/github/dfa1/typesafe/core/Codec.java +++ b/core/src/main/java/io/github/dfa1/typesafe/core/Codec.java @@ -10,8 +10,5 @@ public interface Codec { byte[] writeValueAsBytes(Object value); - /** Same as {@link #writeValueAsBytes(Object)}, indented for human reading. */ - byte[] writeValueAsPrettyBytes(Object value); - T readValue(byte[] content, Class type); } diff --git a/docs/how-to.md b/docs/how-to.md index e3951ac..823c669 100644 --- a/docs/how-to.md +++ b/docs/how-to.md @@ -584,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. diff --git a/docs/reference.md b/docs/reference.md index 8044e7d..400727a 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -169,7 +169,6 @@ package io.github.dfa1.typesafe.core; public interface Codec { byte[] writeValueAsBytes(Object value); - byte[] writeValueAsPrettyBytes(Object value); // same, indented for human reading T readValue(byte[] content, Class type); } ``` From 9eab07137eb3133280add71d8a7ad49f2772c48f Mon Sep 17 00:00:00 2001 From: Davide Angelocola Date: Sun, 4 Oct 2026 13:39:51 +0200 Subject: [PATCH 8/8] codec: Codec in its own module, so core is pure data core held one piece of behaviour, the Codec SPI, and the codec-* modules broke the X-Y rule (no codec module to build on). Codec now lives in typesafe-java-codec (package io.github.dfa1.typesafe.codec), with no dependencies; codec-*, client-http and client-local depend on it. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 +- CLAUDE.md | 17 ++++++++++------- README.md | 3 ++- .../AbstractTypeSafeClientAcceptanceTest.java | 2 +- ...kHttpClientWithJackson2AcceptanceTest.java | 2 +- ...kHttpClientWithJackson3AcceptanceTest.java | 2 +- ...kHttpClientWithJackson2AcceptanceTest.java | 2 +- ...kHttpClientWithJackson3AcceptanceTest.java | 2 +- adr/0003-model-in-core-contract-in-client.md | 17 +++++++++-------- bom/pom.xml | 6 ++++++ .../io/github/dfa1/typesafe/cli/Main.java | 2 +- client-http/pom.xml | 7 ++++++- .../client/http/DefaultTypeSafeClient.java | 2 +- .../http/DefaultTypeSafeClientTest.java | 2 +- client-local/pom.xml | 5 +++++ .../dfa1/typesafe/client/local/Onnx.java | 2 +- .../typesafe/client/local/JevComparison.java | 2 +- .../typesafe/client/TypeSafeException.java | 2 +- codec-jackson2/pom.xml | 9 +++++++-- .../codec/jackson2/Jackson2Codec.java | 2 +- ...ec => io.github.dfa1.typesafe.codec.Codec} | 0 codec-jackson3/pom.xml | 9 +++++++-- .../codec/jackson3/Jackson3Codec.java | 2 +- ...ec => io.github.dfa1.typesafe.codec.Codec} | 0 codec/pom.xml | 17 +++++++++++++++++ .../io/github/dfa1/typesafe/codec}/Codec.java | 2 +- core/pom.xml | 7 +++---- docs/explanation.md | 11 ++++++----- docs/reference.md | 19 +++++++++++-------- pom.xml | 1 + 30 files changed, 105 insertions(+), 53 deletions(-) rename codec-jackson2/src/main/resources/META-INF/services/{io.github.dfa1.typesafe.core.Codec => io.github.dfa1.typesafe.codec.Codec} (100%) rename codec-jackson3/src/main/resources/META-INF/services/{io.github.dfa1.typesafe.core.Codec => io.github.dfa1.typesafe.codec.Codec} (100%) create mode 100644 codec/pom.xml rename {core/src/main/java/io/github/dfa1/typesafe/core => codec/src/main/java/io/github/dfa1/typesafe/codec}/Codec.java (92%) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8d71e4..67aad09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ 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`), contract (`client`) and implementations (`client-*`), names aligned** — `core` keeps the model and `Codec`; `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) +- **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. - **`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. diff --git a/CLAUDE.md b/CLAUDE.md index 37ca9cd..298fa4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,10 +14,13 @@ Naming rule: a module's directory, artifact (`typesafe-java-`) and singl (`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, no dependencies: Answer, Question, Content, EvaluateRequest/ - EvaluateResponse, Usage, RequestId, Model, ModelDetails, plus the Codec SPI, all - in io.github.dfa1.typesafe.core. The model + a codec serializes payloads (e.g. for - Kafka) without any client code. +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. @@ -34,7 +37,7 @@ client — the contract, on core: TypeSafeClient (interface; no builder() — 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 core. Owns the `type` +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, @@ -142,8 +145,8 @@ cli — command-line entry point (`Main`), over client-http-jdk + codec-ja is given. ``` -Dependency rule: `codec-jackson2 → core`, `codec-jackson3 → core`, `client → core`, `client-http → client`, -`client-http-jdk → client-http`, `client-http-okhttp → client-http`, `client-local → client` (plus ONNX +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 → diff --git a/README.md b/README.md index a82b804..9c3e2da 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,8 @@ for `typesafe-java-client-http-okhttp`. | Module | Contains | |---|---| -| `core` | the model (`EvaluateRequest`, `EvaluateResponse`, `Question`, `Answer`, ...) and the `Codec` SPI; no dependencies | +| `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 | 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 e358c48..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 @@ -8,7 +8,7 @@ import io.github.dfa1.typesafe.core.Question; import io.github.dfa1.typesafe.core.Content; import io.github.dfa1.typesafe.client.TypeSafeClient; -import io.github.dfa1.typesafe.core.Codec; +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; 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 a285069..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 @@ -2,7 +2,7 @@ import io.github.dfa1.typesafe.codec.jackson2.Jackson2Codec; import io.github.dfa1.typesafe.client.http.jdk.JdkHttpTransport; -import io.github.dfa1.typesafe.core.Codec; +import io.github.dfa1.typesafe.codec.Codec; import io.github.dfa1.typesafe.client.http.HttpTransport; class JdkHttpClientWithJackson2AcceptanceTest extends AbstractTypeSafeClientAcceptanceTest { 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 c1fabfb..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 @@ -2,7 +2,7 @@ import io.github.dfa1.typesafe.codec.jackson3.Jackson3Codec; import io.github.dfa1.typesafe.client.http.jdk.JdkHttpTransport; -import io.github.dfa1.typesafe.core.Codec; +import io.github.dfa1.typesafe.codec.Codec; import io.github.dfa1.typesafe.client.http.HttpTransport; class JdkHttpClientWithJackson3AcceptanceTest extends AbstractTypeSafeClientAcceptanceTest { 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 5722f0e..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,7 +1,7 @@ package io.github.dfa1.typesafe.acceptance; import io.github.dfa1.typesafe.codec.jackson2.Jackson2Codec; -import io.github.dfa1.typesafe.core.Codec; +import io.github.dfa1.typesafe.codec.Codec; import io.github.dfa1.typesafe.client.http.okhttp.OkHttpTransport; import io.github.dfa1.typesafe.client.http.HttpTransport; 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 44ef82f..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,7 +1,7 @@ package io.github.dfa1.typesafe.acceptance; import io.github.dfa1.typesafe.codec.jackson3.Jackson3Codec; -import io.github.dfa1.typesafe.core.Codec; +import io.github.dfa1.typesafe.codec.Codec; import io.github.dfa1.typesafe.client.http.okhttp.OkHttpTransport; import io.github.dfa1.typesafe.client.http.HttpTransport; diff --git a/adr/0003-model-in-core-contract-in-client.md b/adr/0003-model-in-core-contract-in-client.md index 5deb6ef..130a0d1 100644 --- a/adr/0003-model-in-core-contract-in-client.md +++ b/adr/0003-model-in-core-contract-in-client.md @@ -24,17 +24,19 @@ ones, and the layout stopped saying what depended on what: ## Decision -Three layers, each a module (or a family of modules) that only depends on the layers below it: +Layers, each a module (or a family of modules) that only depends on the layers below it: -- **`core`, the model**, with no dependencies: `Answer`, `Question`, `Content`, `EvaluateRequest`, - `EvaluateResponse`, `Usage`, `RequestId`, `Model`, `ModelDetails`, and the `Codec` SPI. With a - `codec-*` module it serializes TypeSafe payloads without any client code. +- **`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` on `core` alone: `codec-jackson2`, `codec-jackson3`. +- **`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 @@ -47,9 +49,8 @@ The serialization SPI is `Codec` (it was `JsonCodec`), with `byte[]` in and out: 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` stays in `core` rather than in a `codec` module of its own: everything that uses it -(`client-http`, `client-local`, the codecs) needs `core` anyway, so a separate artifact would always -travel with it. +`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 diff --git a/bom/pom.xml b/bom/pom.xml index 64cf3c7..5f24dbe 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -23,6 +23,12 @@ ${project.version} + + io.github.dfa1.typesafe-java + typesafe-java-codec + ${project.version} + + io.github.dfa1.typesafe-java typesafe-java-client 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 c98b919..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 @@ -11,7 +11,7 @@ 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.core.Codec; +import io.github.dfa1.typesafe.codec.Codec; import java.nio.charset.StandardCharsets; import java.io.PrintStream; diff --git a/client-http/pom.xml b/client-http/pom.xml index 6ae5a15..5f0738b 100644 --- a/client-http/pom.xml +++ b/client-http/pom.xml @@ -12,7 +12,7 @@ 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; needs one + 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. @@ -23,6 +23,11 @@ typesafe-java-client ${project.version} + + io.github.dfa1.typesafe-java + typesafe-java-codec + ${project.version} + org.junit.jupiter diff --git a/client-http/src/main/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClient.java b/client-http/src/main/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClient.java index 079a624..91ef07b 100644 --- a/client-http/src/main/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClient.java +++ b/client-http/src/main/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClient.java @@ -7,7 +7,7 @@ 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.Codec; +import io.github.dfa1.typesafe.codec.Codec; import java.io.IOException; import java.io.InterruptedIOException; diff --git a/client-http/src/test/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClientTest.java b/client-http/src/test/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClientTest.java index 2bc272d..cf9f6cf 100644 --- a/client-http/src/test/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClientTest.java +++ b/client-http/src/test/java/io/github/dfa1/typesafe/client/http/DefaultTypeSafeClientTest.java @@ -12,7 +12,7 @@ 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.core.Codec; +import io.github.dfa1.typesafe.codec.Codec; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/client-local/pom.xml b/client-local/pom.xml index 4f0acdb..2356fe2 100644 --- a/client-local/pom.xml +++ b/client-local/pom.xml @@ -32,6 +32,11 @@ typesafe-java-client ${project.version} + + io.github.dfa1.typesafe-java + typesafe-java-codec + ${project.version} + com.microsoft.onnxruntime onnxruntime diff --git a/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Onnx.java b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Onnx.java index 6bde0ab..5d227ba 100644 --- a/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Onnx.java +++ b/client-local/src/main/java/io/github/dfa1/typesafe/client/local/Onnx.java @@ -4,7 +4,7 @@ import ai.onnxruntime.OrtEnvironment; import ai.onnxruntime.OrtException; import ai.onnxruntime.OrtSession; -import io.github.dfa1.typesafe.core.Codec; +import io.github.dfa1.typesafe.codec.Codec; import java.io.IOException; import java.io.UncheckedIOException; diff --git a/client-local/src/test/java/io/github/dfa1/typesafe/client/local/JevComparison.java b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/JevComparison.java index f7b7a6f..19ce89f 100644 --- a/client-local/src/test/java/io/github/dfa1/typesafe/client/local/JevComparison.java +++ b/client-local/src/test/java/io/github/dfa1/typesafe/client/local/JevComparison.java @@ -7,7 +7,7 @@ import io.github.dfa1.typesafe.core.EvaluateResponse; import io.github.dfa1.typesafe.client.TypeSafeClient; import io.github.dfa1.typesafe.codec.jackson2.Jackson2Codec; -import io.github.dfa1.typesafe.core.Codec; +import io.github.dfa1.typesafe.codec.Codec; import java.nio.charset.StandardCharsets; import java.io.IOException; diff --git a/client/src/main/java/io/github/dfa1/typesafe/client/TypeSafeException.java b/client/src/main/java/io/github/dfa1/typesafe/client/TypeSafeException.java index 6ec31d5..e1f9545 100644 --- a/client/src/main/java/io/github/dfa1/typesafe/client/TypeSafeException.java +++ b/client/src/main/java/io/github/dfa1/typesafe/client/TypeSafeException.java @@ -99,7 +99,7 @@ public Optional retryAfter() { } } - /** {@code 200}, but the configured {@link io.github.dfa1.typesafe.core.Codec} 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/codec-jackson2/pom.xml b/codec-jackson2/pom.xml index 349d437..4aa5d29 100644 --- a/codec-jackson2/pom.xml +++ b/codec-jackson2/pom.xml @@ -11,11 +11,16 @@ jar TypeSafe Codec Jackson 2 - Codec SPI implementation backed by Jackson 2.x. Depends only on - typesafe-java-core; discovered via ServiceLoader. + 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/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2Codec.java b/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2Codec.java index 8267463..8cd3abf 100644 --- a/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2Codec.java +++ b/codec-jackson2/src/main/java/io/github/dfa1/typesafe/codec/jackson2/Jackson2Codec.java @@ -5,7 +5,7 @@ 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.core.Codec; +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; diff --git a/codec-jackson2/src/main/resources/META-INF/services/io.github.dfa1.typesafe.core.Codec b/codec-jackson2/src/main/resources/META-INF/services/io.github.dfa1.typesafe.codec.Codec similarity index 100% rename from codec-jackson2/src/main/resources/META-INF/services/io.github.dfa1.typesafe.core.Codec rename to codec-jackson2/src/main/resources/META-INF/services/io.github.dfa1.typesafe.codec.Codec diff --git a/codec-jackson3/pom.xml b/codec-jackson3/pom.xml index 78b56c6..504c8eb 100644 --- a/codec-jackson3/pom.xml +++ b/codec-jackson3/pom.xml @@ -11,11 +11,16 @@ jar TypeSafe Codec Jackson 3 - Codec SPI implementation backed by Jackson 3.x. Depends only on - typesafe-java-core; discovered via ServiceLoader. + 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/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3Codec.java b/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3Codec.java index 402ec5a..d92a6e0 100644 --- a/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3Codec.java +++ b/codec-jackson3/src/main/java/io/github/dfa1/typesafe/codec/jackson3/Jackson3Codec.java @@ -5,7 +5,7 @@ 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.core.Codec; +import io.github.dfa1.typesafe.codec.Codec; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; import tools.jackson.databind.ObjectMapper; diff --git a/codec-jackson3/src/main/resources/META-INF/services/io.github.dfa1.typesafe.core.Codec b/codec-jackson3/src/main/resources/META-INF/services/io.github.dfa1.typesafe.codec.Codec similarity index 100% rename from codec-jackson3/src/main/resources/META-INF/services/io.github.dfa1.typesafe.core.Codec rename to codec-jackson3/src/main/resources/META-INF/services/io.github.dfa1.typesafe.codec.Codec 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/core/src/main/java/io/github/dfa1/typesafe/core/Codec.java b/codec/src/main/java/io/github/dfa1/typesafe/codec/Codec.java similarity index 92% rename from core/src/main/java/io/github/dfa1/typesafe/core/Codec.java rename to codec/src/main/java/io/github/dfa1/typesafe/codec/Codec.java index f5742c9..24f03a5 100644 --- a/core/src/main/java/io/github/dfa1/typesafe/core/Codec.java +++ b/codec/src/main/java/io/github/dfa1/typesafe/codec/Codec.java @@ -1,4 +1,4 @@ -package io.github.dfa1.typesafe.core; +package io.github.dfa1.typesafe.codec; /** * Serialization of the model to and from bytes, plugged in by a codec module, with no {@code String} diff --git a/core/pom.xml b/core/pom.xml index 7d5995f..828dde8 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -12,10 +12,9 @@ TypeSafe Core - The TypeSafe wire model (Answer, Question, Content, EvaluateRequest/EvaluateResponse, Usage, - RequestId, Model, ModelDetails) and the Codec SPI. No dependencies. With a codec-* module it - serializes TypeSafe payloads without any client code, e.g. for a Kafka producer/consumer; the - client contract is typesafe-java-client. + 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. diff --git a/docs/explanation.md b/docs/explanation.md index 6ee33df..3bad682 100644 --- a/docs/explanation.md +++ b/docs/explanation.md @@ -103,7 +103,7 @@ mocked transport for tests can implement one interface (`post`/`get`, both alrea ## Why the model is in `core`, the contract in `client`, and each client in its own module -`core` is the model and the `Codec` SPI, nothing else. `client` is what every client agrees on: +`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 @@ -111,10 +111,11 @@ token counting), and `TypeSafeException`. Each implementation is a `client-*` mo compiler enforces it: `client` can't see `DefaultTypeSafeClient`, which is why `TypeSafeClient` has no `builder()`. -`Codec` stays in `core`, next to the model it serializes: `client-local` reads its model configs -through it too, and the model 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). +`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 diff --git a/docs/reference.md b/docs/reference.md index 400727a..74949a5 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -15,17 +15,18 @@ For task-oriented usage see [how-to.md](how-to.md); for design rationale see [ex | Module | Depends on | Contains | |---|---|---| -| `typesafe-java-core` | — | the model (`Answer`, `Question`, `Content`, `EvaluateRequest`, `EvaluateResponse`, `Usage`, `RequestId`, `Model`, `ModelDetails`), `Codec` | -| `typesafe-java-codec-jackson2` | `core` | `Jackson2Codec` (Jackson 2.x) | -| `typesafe-java-codec-jackson3` | `core` | `Jackson3Codec` (Jackson 3.x) | +| `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` | `DefaultTypeSafeClient` (the TypeSafe API over HTTP), `ApiKey`, `HttpTransport` | +| `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`, ONNX Runtime | `LocalLayaTypeSafeClient`, `LocalQwenTypeSafeClient`, `LocalClefTypeSafeClient` (in-process models) | +| `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 ten above | +| `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 @@ -164,8 +165,10 @@ plain `Model`, usable directly as an `EvaluateRequest`'s model. ## Codec SPI +In `typesafe-java-codec`. + ```java -package io.github.dfa1.typesafe.core; +package io.github.dfa1.typesafe.codec; public interface Codec { byte[] writeValueAsBytes(Object value); @@ -175,7 +178,7 @@ public interface Codec { Implementations (`Jackson2Codec`, `Jackson3Codec`) are discovered via `ServiceLoader.load(Codec.class)` and registered through -`META-INF/services/io.github.dfa1.typesafe.core.Codec`. Both own the `Answer`/`Question` polymorphic +`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. JSON is UTF-8 bytes in and out (RFC 8259), so nothing builds a `String` on the way to or from the diff --git a/pom.xml b/pom.xml index 1f10edc..78c62cd 100644 --- a/pom.xml +++ b/pom.xml @@ -38,6 +38,7 @@ core + codec codec-jackson2 codec-jackson3 client