Guidance for Claude Code working in this repository.
Java 21 client for the TypeSafe API (https://api.typesafe.ai). Send a state (any JSON value)
plus a set of Noul/Choice/Score questions, get back typed answers.
core — TypeSafeClient (interface; the only implementation, DefaultTypeSafeClient, is
public and owns Builder — TypeSafeClient.builder(...) is a one-line delegate to
DefaultTypeSafeClient.builder(...), so call sites don't change. A consumer can
implement TypeSafeClient itself to decorate one, e.g. with caching).
DefaultTypeSafeClient does one request, one response, no retries by default.
Retry/backoff is the opt-in RetryingTypeSafeClient decorator (classifies by
TypeSafeException, not raw responses); DeadlineTypeSafeClient caps a call's total
time. Both are added via Builder.decorateWith(RetryingTypeSafeClient::decorate) etc. (each
one goes outside the previous; build(Function) is outermost and keeps its type).
Every decorator's static decorate(...) takes the delegate first, like
MappingTypeSafeClient.decorate. build() throws if RetryingTypeSafeClient is added
more than once. TokenCounter (LongAdder totals of EvaluateResponse#usage()) is an
object the caller keeps, handing out its decorator via tokens::decorate, so the
totals stay readable once the decorator is inside a stack.
RetryingTypeSafeClient stops retrying once its returned future is done. ApiKey,
TypeSafeException, and the wire DTOs (Answer, Question, Content,
EvaluateRequest/EvaluateResponse, Usage, RequestId, Model, ModelDetails), all in
io.github.dfa1.typesafe.core; plus the JsonCodec (io.github.dfa1.typesafe.json) +
HttpTransport (io.github.dfa1.typesafe.transport) SPIs. Zero dependency on any
JSON or HTTP library — DefaultTypeSafeClient talks to HttpTransport/JsonCodec,
never to a concrete library directly, so the DTOs + JsonCodec alone are reusable
(e.g. by a Kafka producer/consumer) without pulling in TypeSafeClient's HTTP
concerns.
client-jdk — HttpTransport backed by java.net.http (artifact
typesafe-java-client-jdk, class JdkHttpTransport, package
io.github.dfa1.typesafe.jdk). Depends only on core. Discovered via
ServiceLoader at Builder.build() time (or an explicit
Builder.httpTransport(...) override).
client-okhttp — HttpTransport backed by OkHttp (artifact typesafe-java-client-okhttp, class
OkHttpTransport, package io.github.dfa1.typesafe.okhttp). Depends only on core
(plus OkHttp). An alternative to client-jdk for environments java.net.http doesn't
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
empty common-metadata jar with no classes; `okhttp-jvm` is the real JVM artifact.
OkHttp lowercases response header names internally, unlike JdkHttpTransport (which
preserves wire casing) — harmless since HttpTransportResponse#header(String) is a
case-insensitive lookup, but `OkHttpTransportTest` asserts through `header(...)`
rather than the raw `headers()` map for exactly this reason.
jackson2 — JsonCodec backed by Jackson 2.x. Depends only on core. Owns the `type`
discriminator for Answer/Question via private Jackson mixins (addMixIn); Content
(no discriminator — string/object/array on the wire; backs both
EvaluateRequest.state and Question.instructions) via a custom serializer,
registered through META-INF/services.
jackson3 — same, backed by Jackson 3.x (tools.jackson.databind).
testkit — two TypeSafeClient test doubles, in io.github.dfa1.typesafe.testkit, depending
only on core. RecordingTypeSafeClient implements TypeSafeClient directly, at the
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;
`evaluateRequests()` records every evaluate()/evaluateAsync() call, in order. A
call with nothing left queued throws (or, for evaluateAsync, fails its future with)
an AssertionError. FailingTypeSafeClient decorates any TypeSafeClient (e.g. a
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
adding evaluateTyped(Content, Class<T>)/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
one; @Choice's options are a nested @Option[] since an annotation can't hold a Map).
Reflects over T's record components (RecordComponent, not a codegen'd mapper) to
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<String, Answer> 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
consumer of this module would reach for.
local — one public TypeSafeClient per model (io.github.dfa1.typesafe.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;
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
through BpeTokenizer.encodeText (special tokens stay text); only templates use encode. No int8 Laya: dynamic int8 gave CPU-dependent answers,
silently flat distributions on some x86 CPUs. Three engines behind
a package-private Engine: LayaEngine (ModernBERT + decision head scoring [MASK] markers, a
request's questions in one batch; a port of Laya's Python sequence builder), QwenEngine
(one prefill per question, softmax over Yes/No/letter/digit logits) and ClefEngine (Qwen3.5-9B +
joint schema head, a request in one forward pass). Own pure-Java BpeTokenizer
(tokenizer.json); JSON through typesafe-java's JsonCodec (ServiceLoader, like the API client),
so a jackson2/jackson3 module is needed at run time; BpeTokenizerTest checks ids
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.
JevComparison (test scope, main) replays 104 requests against cached real-Jev answers
(src/test/resources/jev); LocalTypeSafeClientBenchmark is JMH. WebGPU (Metal, macOS-only
native lib): public only as LocalClefTypeSafeClient.loadOnGpu (stable, same answers as CPU); Laya/Qwen keep
the package-private load(dir, gpu) for the harness — Laya's WebGPU answers drift between launches. LayaEngine reads onnx-community's layout
(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.
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
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
exercise a `@Noul`/`@Choice`/`@Score`-annotated record against the live API.
cli — command-line entry point (`Main`), over client-jdk + 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
dependency on `typesafe-java-cli` never pulls in unrelocated, bundled copies of
its dependencies. Flat flags: `--state <text>` (the only `Content` shape it
supports — plain text), repeatable
`--noul`/`--choice`/`--score <name>=<instructions>[|opt1,opt2,...]`,
optional `--model <id>`, `--verbose`/`--timing` (request id / response time to
stderr), `--version` (prints the jar's `Implementation-Version` manifest entry,
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 <name>` (that answer's value) or `--verbose` (the full
`EvaluateResponse` as pretty-printed JSON, via `JsonCodec.writeValueAsPrettyString`)
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 for why the SPIs
exist at all.
./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=falseNo step here uses install — a routine build has no reason to write into ~/.m2/repository.
-am ("also make") rebuilds a module's dependencies within the same reactor run instead of
resolving them from the local repo, so a single-module command works right after a fresh clone.
-Dsurefire.failIfNoSpecifiedTests=false is only needed alongside a -Dtest= filter + -am:
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
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:
./mvnw test -pl acceptance -am -DexcludedGroups=corehas zero Jackson dependency and the DTOs carry zero Jackson annotations. Polymorphism (Answer/Question'stypediscriminator) 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-jdkdon't change.JsonCodecis discovered viaServiceLoader, not a hard compile dependency. A consumer that depends onclient-jdkbut forgets a codec module gets a clearIllegalStateExceptionfromBuilder.build(), not aNoClassDefFoundError.- Small public API. Don't expose internals — when in doubt, leave it out or make it package-private.
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;
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
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
method on); the value produced by the operation under test in // When is named result. A
static factory call with nothing further invoked on it just produces result — there's no
separate sut.
Docs live under docs/, structured by Diataxis:
tutorial.md (learning-oriented walkthrough), how-to.md (task-oriented recipes),
reference.md (API surface), explanation.md (design rationale). A change to the
public API, module structure, or a documented behavior updates whichever of these
apply, in the same commit — plus CHANGELOG.md under [Unreleased]. adr/ and
released CHANGELOG.md sections are exempt — they describe the past.
Each [Unreleased] entry is one line: a bold title and a short one-sentence description — not a
multi-paragraph write-up. The publish workflow copies the released section verbatim into the
GitHub release notes, so a long entry is a long release note.
- **Title** — one-sentence description of what changed.
Code, docs and the CHANGELOG entry go in the same commit — no follow-up "CHANGELOG: …"
commits. A commit can't link to its own hash, so a direct commit's entry has no link; append
(#<PR-number>) when the change lands as a PR.
Prerequisites (one-time): namespace io.github.dfa1 registered at central.sonatype.com
(GitHub auto-validates), a user token from there added to ~/.m2/settings.xml as server id
central, and a published GPG key. In CI, the publish workflow (triggered by pushing a v*
tag) needs GPG_PRIVATE_KEY/GPG_PASSPHRASE/CENTRAL_USERNAME/CENTRAL_PASSWORD secrets in
the maven environment.
./mvnw --batch-mode release:clean release:prepare \
-DreleaseVersion=<version> -DdevelopmentVersion=<next>-SNAPSHOT
git push && git push --tags # GitHub Actions deploys the tag to Maven Centralrelease:prepare bumps every module to <version> (autoVersionSubmodules=true), tags it
v<version>, then bumps to <next>-SNAPSHOT — two local commits, nothing pushed
(pushChanges=false) until the explicit git push above. acceptance opts out of publishing
(maven.deploy.skip/skipPublishing in its pom) — it only exists to run its own tests. cli
is published: a plain main jar plus the runnable uber-jar under the all classifier (see
Module structure above), GPG-signed like every other artifact. CHANGELOG.md needs a
## [<version>] - <date> section before tagging: the publish workflow extracts that section
verbatim as the GitHub release notes, then appends a direct link to the all jar (and its
.asc) on Maven Central — waitUntil=published on the deploy step means it's already live by
the time the release is created, so publish.yml links to it instead of re-uploading the same
bytes as a release asset.