diff --git a/.editorconfig b/.editorconfig index 6fd6be7..993d0cd 100644 --- a/.editorconfig +++ b/.editorconfig @@ -32,8 +32,8 @@ ktlint_standard_import-ordering = disabled # The documentation examples line their trailing comments up in a column, which reads better on the docs site # than ragged comments do. ktlint's no-multi-spaces rule forbids that, so it is off for these files only; the -# library sources in src/main keep it. -[src/test/kotlin/website/*.kt] +# library sources in src/commonMain and the other main source sets keep it. +[src/jvmTest/kotlin/website/*.kt] ktlint_standard_no-multi-spaces = disabled [build/generated/**/*] diff --git a/.env.example b/.env.example index 649fe2b..c12f821 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,11 @@ # Copy to .env (gitignored) and fill in. The com.pambrose.envvar plugin loads these into the -# environment of every Test and JavaExec task, so `make example` and `make live-tests` pick them up. +# environment of every Test and JavaExec task, which are the JVM ones, so `make example` and the JVM half of +# `make live-tests` pick them up. The js, wasmJs and native test tasks see only the shell's environment. TYPESAFE_API_KEY=ts-... # Optional overrides; the defaults are https://api.typesafe.ai and jev-latest. # TYPESAFE_BASE_URL=https://api.typesafe.ai # TYPESAFE_DEFAULT_MODEL=jev-latest -# Don't put JEV4K_LIVE here. It is what enables LiveSmokeTest, and because this file reaches every test +# Don't put JEV4K_LIVE here. It is what enables LiveSmokeTest, and because this file reaches the JVM test # task, setting it would turn each `make tests` into a run that spends tokens. `make live-tests` sets it # for that one invocation, which is the only place it belongs. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ff1286d..5cda487 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,7 +2,8 @@ # Dependabot rewrites the SHA and the trailing version comment together, so the pins keep receiving fixes. # # Gradle dependencies are deliberately not listed here: the version catalog is reviewed with `make versions` -# (the ben-manes plugin), which reports updates without opening pull requests. +# (the ben-manes plugin), which reports updates without opening pull requests. The docs site's website/uv.lock is +# left out for the same reason: `make check-site` reports its updates and `make upgrade-site` applies them. version: 2 updates: - package-ecosystem: github-actions diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a27a553..27ea283 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,8 @@ on: pull_request: workflow_dispatch: -# Supersede a PR's earlier run when it is pushed to again; every master commit is still built. +# Supersede a PR's earlier run when it is pushed to again. On master nothing is cancelled, but GitHub keeps only one +# pending run per group, so a burst of pushes can skip a queued middle commit; the latest one is always built. concurrency: group: ci-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} @@ -16,8 +17,11 @@ permissions: contents: read jobs: + # Linux runs the jvm, js, wasmJs and linuxX64 tests, and linuxArm64's under QEMU. It can't run the Apple or + # Windows test executables, so the native job below covers those. build: runs-on: ubuntu-latest + timeout-minutes: 60 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # JDK 25 is the toolchain in the catalog. @@ -26,15 +30,37 @@ jobs: distribution: temurin java-version: 25 - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + # setup-gradle caches ~/.gradle but not the Kotlin/Native toolchain that compiling the native targets + # downloads. The toolchain depends only on the Kotlin version, so that is the whole key: an exact hit, or + # a fresh download saved under the new version. No restore-keys, which would carry every older toolchain + # forward into each new entry. + - id: kotlin + shell: bash + run: | + version=$(sed -n 's/^kotlin = "\(.*\)"/\1/p' gradle/libs.versions.toml) + test -n "$version" + echo "version=$version" >> "$GITHUB_OUTPUT" + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.konan + key: konan-${{ runner.os }}-${{ steps.kotlin.outputs.version }} - # build runs check without the tests, and check already includes lintKotlin and detekt. - - name: Compile, lint, and detekt (no tests) - run: ./gradlew clean build -x test + # build runs check, which compiles every target this host supports and runs lintKotlin, detekt, the ABI + # check against api/, and the jvm, js, wasmJs and linuxX64 tests. --continue lets independent tasks run + # after a failure, but Kover's reports depend on jvmTest, so a JVM test failure leaves no coverage to + # upload. The live tests stay disabled: they need JEV4K_LIVE=1 (and LiveSmokeTest a TYPESAFE_API_KEY), + # and neither is set here. + - name: Build, lint, check the ABI, run the tests, and generate the coverage report + run: ./gradlew --continue build koverVerify koverXmlReport koverLog - # --continue lets the Kover report tasks run even if some tests fail, so coverage is still uploaded. - # LiveSmokeTest stays disabled: it needs JEV4K_LIVE=1 and TYPESAFE_API_KEY, neither of which is set here. - - name: Run tests and generate coverage report - run: ./gradlew --continue test koverVerify koverXmlReport koverLog + # linuxArm64 is published but has no Gradle test task, and this runner can't execute its binary. QEMU lets + # Docker run it, so the same make target that tests it locally tests it here; linuxX64's tests already ran + # in the step above. + - uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 + with: + platforms: arm64 + - name: Run the linuxArm64 tests in Docker + run: make docker-linux-tests LINUX_TEST_TARGETS=linuxArm64:arm64 - name: Upload test reports on failure if: failure() @@ -63,6 +89,7 @@ jobs: test: name: Tests on JDK ${{ matrix.java }} runs-on: ubuntu-latest + timeout-minutes: 30 strategy: # One JDK failing shouldn't hide the result on the others. fail-fast: false @@ -88,7 +115,7 @@ jobs: # shortcut rather than a requirement. - name: Run tests on JDK ${{ matrix.java }} run: > - ./gradlew test -PtestJavaVersion=${{ matrix.java }} + ./gradlew jvmTest -PtestJavaVersion=${{ matrix.java }} "-Porg.gradle.java.installations.fromEnv=JAVA_HOME_${{ matrix.java }}_X64,JAVA_HOME_25_X64" - name: Upload test reports on failure @@ -100,3 +127,75 @@ jobs: build/reports/tests/ build/test-results/ retention-days: 7 + + # The native tests Linux can't run: the macOS and the iOS, tvOS and watchOS simulator tests on macOS (Gradle skips + # iosX64Test on the arm64 runner, and a tvOS or watchOS test when the runner has no simulator device for it), and + # the mingwX64 tests on Windows. + native: + name: Native tests on ${{ matrix.name }} + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + strategy: + # One OS failing shouldn't hide the result on the other. + fail-fast: false + matrix: + include: + - name: apple + os: macos-latest + tasks: macosArm64Test iosSimulatorArm64Test tvosSimulatorArm64Test watchosSimulatorArm64Test + - name: windows + os: windows-latest + tasks: mingwX64Test + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: temurin + java-version: 25 + - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + # setup-gradle caches ~/.gradle but not the Kotlin/Native toolchain that compiling the native targets + # downloads. The toolchain depends only on the Kotlin version, so that is the whole key: an exact hit, or + # a fresh download saved under the new version. No restore-keys, which would carry every older toolchain + # forward into each new entry. + - id: kotlin + shell: bash + run: | + version=$(sed -n 's/^kotlin = "\(.*\)"/\1/p' gradle/libs.versions.toml) + test -n "$version" + echo "version=$version" >> "$GITHUB_OUTPUT" + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.konan + key: konan-${{ runner.os }}-${{ steps.kotlin.outputs.version }} + + - name: Run the native tests + run: ./gradlew ${{ matrix.tasks }} + + - name: Upload test reports on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: test-reports-${{ matrix.name }} + path: | + build/reports/tests/ + build/test-results/ + retention-days: 7 + + # The one CI check branch protection requires (with the docs site's "docs" check and GitGuardian). It passes only + # when every job above succeeded, so the JDK matrix and both native jobs gate a merge too, and renaming a job or a + # matrix entry can't silently drop a required check. + ci-ok: + if: always() + needs: [ build, test, native ] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Fail unless every CI job succeeded + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + run: | + echo "Job results: $RESULTS" + for result in $RESULTS; do [ "$result" = success ] || exit 1; done diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 6439b58..ba1cae5 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,37 +1,53 @@ name: Documentation on: + # Pull requests and pushes to master only build the site, so a broken snippet, link or Dokka setting fails a + # check before it merges. + pull_request: push: branches: - master + # The site is deployed only when a release is published, after its artifacts are on Maven Central, or on a + # manual run, so jev4k.com never shows install instructions for a version Central doesn't have yet. A release + # runs on its tag, so the github-pages environment allows release tags as well as master. + release: + types: [ published ] workflow_dispatch: # Least privilege by default; each job asks for what it needs. permissions: contents: read +# Deploys share one group and are never cancelled. Build-only runs get a group per ref, so a newer push to a PR +# supersedes its older run, and no build-only run can replace a deploy waiting in the queue. concurrency: - group: pages - cancel-in-progress: false + group: >- + ${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') + && 'pages' || format('docs-{0}', github.ref) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - # Build the docs site + KDocs and upload the Pages artifact. Kept separate from - # the deploy job so that recovering a failed deploy means re-running only the - # deploy job (via "Re-run failed jobs"), which does NOT re-upload the artifact. - # actions/deploy-pages hard-fails when a run contains more than one artifact - # named "github-pages", which is what re-running a combined build+deploy job did. - build: + # Build the docs site + KDocs, and upload the Pages artifact when deploying. Kept separate from the deploy job + # so that recovering a failed deploy means re-running only the deploy job (via "Re-run failed jobs"), which does + # NOT re-upload the artifact. actions/deploy-pages hard-fails when a run contains more than one artifact named + # "github-pages", which is what re-running a combined build+deploy job did. Its check, "docs", is required by + # branch protection. + docs: runs-on: ubuntu-latest permissions: contents: read pages: read + env: + DEPLOY: ${{ github.event_name == 'release' || github.event_name == 'workflow_dispatch' }} steps: - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + if: env.DEPLOY == 'true' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Build the Zensical documentation site from website/uv.lock, the same resolution `make site` - # uses locally. uv reads website/.python-version and installs that Python itself. + # uses locally. uv reads website/.python-version and installs that Python itself. --strict turns + # warnings, such as a link to a missing anchor, into failures. - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 - - run: uv run --locked zensical build --clean + - run: uv run --locked zensical build --clean --strict working-directory: website/jev4k # Build KDoc API documentation (the toolchain in the catalog is JDK 25). @@ -40,20 +56,35 @@ jobs: distribution: temurin java-version: 25 - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + # Dokka documents every source set, the native ones included, so it needs the Kotlin/Native toolchain; + # setup-gradle doesn't cache it. This only restores ci.yml's cache, under the same key: Dokka finishes + # first and never downloads the compiler's dependencies, so a save here would claim the key with a + # toolchain too incomplete for the build job, which would then never save its own. + - id: kotlin + run: | + version=$(sed -n 's/^kotlin = "\(.*\)"/\1/p' gradle/libs.versions.toml) + test -n "$version" + echo "version=$version" >> "$GITHUB_OUTPUT" + - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.konan + key: konan-${{ runner.os }}-${{ steps.kotlin.outputs.version }} - run: ./gradlew dokkaGeneratePublicationHtml # Copy KDocs into the Zensical site output; the site's "KDocs" page links to /kdocs/. - run: cp -r build/dokka/html website/jev4k/site/kdocs - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + if: env.DEPLOY == 'true' with: path: website/jev4k/site - # Deploy the artifact produced by the build job. Deliberately minimal (no - # checkout or build) so re-running just this job re-deploys the existing - # artifact without rebuilding or re-uploading it. + # Deploy the artifact produced by the docs job, for a published release or a manual run only. Deliberately + # minimal (no checkout or build) so re-running just this job re-deploys the existing artifact without + # rebuilding or re-uploading it. deploy: - needs: build + if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' + needs: docs runs-on: ubuntu-latest permissions: pages: write diff --git a/.gitignore b/.gitignore index e26ccce..4a10e5a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ secrets/ .idea/jarRepositories.xml .idea/compiler.xml .idea/libraries/ +.idea/artifacts/ *.iws *.iml *.ipr @@ -53,6 +54,10 @@ __pycache__/ ### it. This negation is what keeps it in git, because a global *.lock ignore would otherwise hide it. !website/uv.lock +### The Node.js lockfiles for the js and wasmJs test toolchains are committed too (the same global *.lock ignore +### would hide them). Regenerate them with kotlinUpgradeYarnLock and kotlinWasmUpgradeYarnLock. +!kotlin-js-store/**/yarn.lock + ### Zensical build output ### website/**/site/ website/**/.cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1097fb7..a5137a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,160 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Narrative notes for each release are in [RELEASE_NOTES.md](RELEASE_NOTES.md). -## [Unreleased] +## [0.2.0] - 2026-09-28 -Nothing yet. +jev4k is now a Kotlin Multiplatform library, published under the new group `com.pambrose.jev4k`. The other platforms +are new, and the JVM API grows. Since every build has to change its coordinates anyway, 0.2.0 also carries a few +small breaking changes, listed first under Changed. + +### Added + +- Targets beyond the JVM: Apple platforms (`macosArm64`, `iosArm64`, `iosX64`, `iosSimulatorArm64`, `tvosArm64`, + `tvosSimulatorArm64`, `watchosArm32`, `watchosArm64`, `watchosSimulatorArm64`, `watchosDeviceArm64`), Linux + (`linuxX64`, `linuxArm64`), Windows (`mingwX64`), and Node.js (`js`, `wasmJs`). The whole API is common code. +- A default Ktor engine per platform: CIO on the JVM, as before, Darwin on Apple platforms, Curl on Linux, WinHttp + on Windows, and the `fetch`-based Js engine on Node.js. CIO can't make HTTPS requests on Kotlin/Native, hence the + others. +- Each engine's own way of reporting a failed connection (a bare `IllegalStateException` from Curl and WinHttp, a + failed fetch from the Js engine) becomes a `JevConnectionException` and is retried like an `IOException`. On + Linux and Windows that covers any bare `IllegalStateException` raised during a call, including one from a + caller-supplied engine or a `MockEngine` handler; the JVM and Apple platforms rethrow those unchanged. +- ABI validation. `api/jev4k.api` (the JVM surface) and `api/jev4k.klib.api` (the other targets) record the public + API; `make tests` and CI fail when it changes, and `make abi-update` rewrites them after an intended change. +- `JevConfigBuilder.allowInsecureHttp`, which allows a plain `http://` `baseUrl` on a host other than this machine. +- `JevCallOptions`, which overrides the timeout, retry policy and headers for single calls and can add top-level + fields to the `evaluate` request body, as both official SDKs allow. Pass it to `evaluate` or `models`, or wrap an + API with `JevApi.withOptions(options)` so `query`, `ask` and the blocking calls use it too. The new `JevApi` + members have default implementations that ignore the options, so an existing fake keeps compiling. +- `ModelList`, which `models()` now returns: a `List` that also carries the call's `requestId`. +- `JevApi.blocking()` (`BlockingJevKt.blocking(api)` from Java), which gives any `JevApi` the blocking calls, so + blocking and Java code can be handed a fake. +- Millisecond counterparts of the `Duration` settings, which Java can't reach: `JevConfigBuilder.timeoutMillis`, + `JevDefaults.TIMEOUT_MILLIS`, `RetryPolicy`'s `with…` methods (`withMaxRetries`, `withInitialBackoffMillis`, and + one for each other setting), `JevCallOptionsBuilder.timeoutMillis` and `JevRateLimitException.retryAfterMillis`. + `jevResult` has `@JvmOverloads`. +- `LiveProbeTest`, two opt-in calls to the real API that spend no tokens, run on every platform: an invalid key must + come back as `JevAuthenticationException` and a 1 ms timeout as `JevTimeoutException`. +- `make docker-linux-tests` runs the linuxX64 and linuxArm64 tests in Docker containers, so any host with Docker can + run them, and linuxArm64, which has no Gradle test task, is tested at all. `make all-tests` includes it when Docker + is running. +- The tvOS and watchOS simulator tests run on a Mac that has a simulator device for them, and are skipped on one + that doesn't; `make tests` and `make native-tests` include them. + +### Changed + +- **Breaking, for code that implements or mocks `JevApi`:** `models()` returns `ModelList`. Code that reads the list + is unaffected; an implementation or a mock returns `ModelList(listOf(...))`. `BlockingJev.models()`'s JVM + signature changes with it, so code compiled against 0.1.0 that calls it must be recompiled. +- **Breaking, for Java:** every `BlockingJev` method declares `InterruptedException`, which `runBlocking` has always + thrown when the waiting thread is interrupted. Java callers now catch or declare it; before, javac wouldn't let them + catch it at all. +- **Breaking, for header lookups:** `JevApiException.headers` has lowercased names on every platform, so + `e.headers["retry-after"]` works whatever the engine and the server's spelling. Before, CIO kept the server's + spelling while the Js engine lowercased. Values of names that differ only in case are merged. +- **Breaking, for positional calls:** `jevApiException` takes `headers` before `retryAfter`, so Java can pass + headers, and reads a rate-limit hint from the headers when `retryAfter` isn't given. Calls that name their + arguments are unaffected. +- A number or boolean state is rejected with a `JevValidationException` before anything is sent; the API takes a + string, an object or an array. Instructions must be non-blank text or a non-empty object or array, so a number, a + boolean, `{}` and `[]` are rejected like a blank string. +- `enumChoice(id)` requires E to have a constant for every option the question declared, and throws + `IllegalArgumentException` if it doesn't. Before, reading with the wrong enum failed as a + `JevResponseValidationException`, blaming the server, or could return another enum's constant. +- A string-keyed Choice read returns an option the question didn't declare as the server sent it, as both official + SDKs do. That was already the behavior; it is now documented and tested. +- Redirects are no longer followed: a 3xx response is a `JevApiException` with its status. Ktor stripped only + `Authorization` on a cross-host redirect, so other configured headers went to the new host, and on Node.js fetch + re-sent the request body too. +- A response whose declared `Content-Length` is over 16 MiB is refused before its body is read: a 2xx as a + `JevResponseValidationException`, any other status as its usual exception with a null `body`. A body sent without + a length isn't capped; the per-attempt timeout bounds it. +- New subtypes of the sealed `Answer` and `Question` types may be added in a minor release, when TypeSafe adds a kind + of question. A `when` over either that must keep compiling across upgrades should end in an `else` branch. +- **Breaking: Maven coordinates.** The group is now `com.pambrose.jev4k`, so every artifact sits under one group, as + common-utils' do. `com.pambrose.jev4k:jev4k` is the multiplatform root module: Gradle builds depend on it and get + the right artifact for each target, and Maven builds depend on `com.pambrose.jev4k:jev4k-jvm`. 0.1.0 stays at + `com.pambrose:jev4k`, and nothing newer is published there. +- The `jev4k-jvm` POM lists five dependencies, two fewer than 0.1.0's: `ktor-client-content-negotiation` and + `ktor-serialization-kotlinx-json` are gone, because jev4k now encodes its one request body itself. The request is + byte for byte the same. The published metadata still carries `org.gradle.jvm.version = 17`. +- `JevClient.blocking` has its methods on the JVM only. On the other platforms `BlockingJev` has no members: + Kotlin/JS and Kotlin/Wasm can't block a thread, and Kotlin/Native callers can wrap the suspend calls in + `runBlocking` themselves. +- The `User-Agent` version is compiled in rather than read from the jar manifest, so it is correct on every + platform and in tests. The JVM jar still carries `Implementation-Version` and `Automatic-Module-Name`. +- Sources moved to `src/commonMain`, `src/jvmMain` and the per-platform source sets. Tests moved to + `src/commonTest`, which runs on every platform, and `src/jvmTest`, which keeps the MockK, blocking, live-smoke and + real-CIO tests, the runnable example, and the documentation examples. +- `make build` compiles every target without running tests; `make tests` re-runs every test task the host supports; + `jvm-tests`, `js-tests`, `native-tests`, `platform-tests` (every platform's tests, Docker Linux included), + `abi-check` and `abi-update` are new. The Maven Central publishing targets require macOS, the only host that + builds the Apple targets. +- CI runs the jvm, js, wasmJs and linuxX64 tests on Linux, and linuxArm64's under QEMU, and adds a macOS job + (macOS and the iOS, tvOS and watchOS simulators) and a Windows job (`mingwX64`). A `ci-ok` job, required by branch protection, passes only when + every other job did, so the JDK matrix and the native jobs gate a merge. +- `check` no longer builds test binaries the host can't run (iosX64's off an Intel Mac, mingwX64's off Windows). + javac compiles the Java example with `--release 17`, as kotlinc already had `-Xjdk-release=17`, and every JDK in the + test matrix gets `-XX:+EnableDynamicAgentLoading`. +- The documentation site is built, in strict mode, on every pull request and `master` push, and deployed only when a + release is published, so it never shows a version that isn't on Maven Central yet. + +### Fixed + +- `JevConfigBuilder.build()` rejects what Ktor would otherwise refuse on every request, with an exception that + wasn't a `JevException` and whose message quoted the value: an API key with a control character in it (which + leaked the whole key), an invalid header name or value, and a `baseUrl` Ktor can't parse. String settings are + trimmed first, so a value read from a file with a trailing newline just works. A `baseUrl` with credentials, a + query or a fragment is rejected too, and plain `http://` is accepted only for a loopback host unless + `allowInsecureHttp` is set. No message quotes the key, a header value, or a URL that could hold credentials. +- A blank per-call `model` falls back to the default instead of being sent as `""`. +- A `RetryPolicy` built from a mutable set of statuses no longer changes a built client when the set changes. + +- Failures that escaped as raw Ktor or JDK exceptions are now `JevException`s: + - a malformed `Content-Type` header, which the body is no longer decoded by; + - a response body that isn't valid UTF-8, which is now decoded leniently on every platform; + - on the JVM, a server certificate the JDK doesn't trust, and a response CIO can't parse (both + `JevConnectionException`); + - a response body cut short of its `Content-Length` (`JevConnectionException`, retried). +- A call on a closed `JevClient` fails with `IllegalStateException("JevClient is closed")` instead of a bare + `CancellationException` that looked like the caller's own cancellation. +- A call cancelled because a sibling coroutine failed ends with its `CancellationException`, not a + `JevConnectionException` made from the sibling's exception, and is no longer retried. +- JSON nested more than 128 levels deep, in a state, a question entry or a response, is rejected with a + `JevValidationException` or `JevResponseValidationException` instead of overflowing the stack. +- With a caller-supplied engine, a `JevTimeoutException` from that engine's own connect or socket timeout names it + instead of quoting `timeout`, which jev4k sets only as the request timeout for such an engine. +- A `Retry-After` given as an HTTP-date is honored, measured from now and capped by `maxRetryAfter` like a number + of seconds, as the official Python SDK does. It was ignored, so retries came too early and `retryAfter` was null. +- A configured `Accept` header is the only one sent; content negotiation added `application/json` beside it. +- `jevResult(String)` checks the body as the client does, so text that isn't a JSON object raises + `JevResponseValidationException` rather than a raw kotlinx exception, and a garbled response can be simulated. +- Reading a result with a handle from another `QuestionSet` whose id matches one in the request says so, and how to + fix it, instead of only that the question isn't part of the request. That is what a mock returning a result built + for a fixed set hits when the code under test builds its questions per call; the testing docs now show + `answers { jevResult(body, secondArg()) }` for it. +- A live run (`make live-tests`) without `TYPESAFE_API_KEY` fails, each smoke test naming the missing key, instead of + passing without making a real call. +- A `null` answer is treated as absent, failing only when it is read, instead of failing the whole response. +- A response number that isn't finite (an unquoted `NaN`, or `1e999`), or a Score level key outside the question's + levels, is a `JevResponseValidationException` with its field path, instead of surfacing later from `band()`, + `isTrue()` or `nearestLevel`. +- A `JevResponseValidationException` for a malformed 2xx body carries the body as received, its status and its + headers, not the parsed JSON written out again with a status of 200 and no headers. +- A question entry holding NaN or an infinity is reported by validation, naming the question and the entry, so + `QuestionSet.toJson()` can't fail with a raw kotlinx exception. +- `jsonOf` and `entry` convert primitive arrays (`IntArray`, `DoubleArray` and the rest), as their docs promised. +- A `JevValidationException` thrown while a `JevQuery` question is built, from `entry()` in a builder lambda or in an + enum option's `JevOption.entry` say, no longer escapes the object's initializer as an + `ExceptionInInitializerError`; it is reported when the questions are first used. +- A `JevQuery` whose `questions` is read during initialization no longer loses the questions declared after that + read. +- `QuestionSet.ids` reads the set's own copy of its questions, not the caller's list. +- `QueryBuilder.question(id, question)` copies the question's options or levels, so changing the caller's map or list + later changes nothing, and its handle checks the answer's type as every other handle does. +- `JevApiException` and `JevRateLimitException` are Java-serializable, as a `Throwable` is expected to be: serializing + one with a JSON body, or a rate-limit error with a hint, threw `NotSerializableException`. `bodyJson` is now parsed + on each read rather than cached. ## [0.1.0] - 2026-09-20 @@ -116,5 +267,5 @@ First release: a Kotlin DSL and client for [TypeSafe](https://docs.typesafe.ai)' - Dokka KDocs for the public API at . - `llms.txt` at , indexing the site for coding agents. -[Unreleased]: https://github.com/pambrose/jev4k/compare/0.1.0...HEAD +[0.2.0]: https://github.com/pambrose/jev4k/compare/0.1.0...0.2.0 [0.1.0]: https://github.com/pambrose/jev4k/releases/tag/0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md index c7e3b9e..0f5bed4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,10 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project -`jev4k` is a Kotlin DSL and client for TypeSafe's Jev "System One" model, built on the Ktor client (CIO engine) and +`jev4k` is a Kotlin Multiplatform DSL and client for TypeSafe's Jev "System One" model, built on the Ktor client and kotlinx.serialization. You describe a state and typed questions (Noul, Choice, Score), and it returns typed answers. The -library is `com.pambrose.jev4k` under `src/main/kotlin`. A runnable example is -`src/test/kotlin/com/pambrose/jev4k/examples/TriageExample.kt`. +library is `com.pambrose.jev4k`, almost all of it common code under `src/commonMain/kotlin`, for the JVM (the primary +target), Apple platforms, Linux, Windows, and Node.js (js and wasmJs). A runnable example is +`src/jvmTest/kotlin/com/pambrose/jev4k/examples/TriageExample.kt`. ## TypeSafe / Jev API docs @@ -20,24 +21,38 @@ the legacy preview API shape (`/preview/evaluation`, `document`, `prompts`); `je ## Architecture -Type names follow TypeSafe's JS SDK. A question definition is a `Question`, one of `NoulQuestion`, `ChoiceQuestion` or -`ScoreQuestion`, which hold only what is asked: instructions and criteria. A `QuestionRef` is the handle code holds. -It pairs an id with a `Question` (`ref.question`) and a decoder for the typed answer. +The question types' names follow TypeSafe's JS SDK; the answer types and the `nouls`/`choices`/`scores` views follow the +Python SDK, and `ModelInfo`/`ModelList` are jev4k's own. A question definition is a `Question`, one of `NoulQuestion`, +`ChoiceQuestion` or `ScoreQuestion`, which hold only what is asked: instructions and criteria. A `QuestionRef` is the +handle code holds. It pairs an id with a `Question` (`ref.question`) and a decoder for the typed answer. There are two DSL layers over one core model. Both produce a validated `QuestionSet`, and `JevApi.evaluate(state, questionSet, model)` is the only call that sends questions to the network. `JevApi.models()` -(`GET v1/models`) is the other network call. +(`GET v1/models`, returning a `ModelList`: a `List` carrying the request id) is the other network call. +Each has an overload taking `JevCallOptions`, with a default implementation that ignores the options, so fakes written +without them keep compiling. - **Inline layer** (`Builders.kt`). `jev.query(state) { noul("id", "...") ... }` uses string ids. `QueryBuilder` functions return `QuestionRef` handles, and `include(query)` merges a `JevQuery` into the same request. - **Typed layer** (`JevQuery.kt`). `object X : JevQuery() { val urgent by noul("...") }`. A `PropertyDelegateProvider` takes the id from the property name (or `id =`) and registers questions in declaration order. `JevQuery.questions` is - built lazily, so an invalid definition fails on first use. `choice()` builds options from an enum. The option key - is the constant's name unless `JevOption.optionKey` overrides it, and `JevOption.entry` becomes the description. + built on first read, so an invalid definition fails on first use, and rebuilt if more questions have registered + since (a read from an `init` block or a base class would otherwise freeze a partial set). Definition errors are + carried on the `QuestionRef` rather than thrown, so they can't escape an `object`'s initializer: duplicate options, + and a `JevValidationException` thrown while a question is built (`entry()` or `jsonOf()` in a builder lambda, or + an enum option's `JevOption.entry`), which `QuestionProvider.provideDelegate` catches, registering a + `failedQuestionRef` stand-in whose question isn't checked. An inline `questions {}` builder throws at once, since it + runs in the caller's own code. An argument such as `noul(entry(...))` is evaluated before the builder runs, so that + one still throws from the initializer. `choice()` builds options from an enum. The + option key is the constant's name unless `JevOption.optionKey` overrides it, and `JevOption.entry` becomes the + description. - **Answers** (`JevResult.kt`, `Answers.kt`). `result[handle]` decodes through the handle's `decode` function, and also checks that the handle belongs to the request. By-id accessors (`noul`/`choice`/`score`/`enumChoice`) check that the question type matches. Raw answers are stored string-keyed. Enum choices are mapped when read, and an unknown option - becomes `JevResponseValidationException`. + becomes `JevResponseValidationException`; `enumChoice(id)` first requires E to cover every declared option, + since a mismatch is the caller's mistake (`IllegalArgumentException`). A string-keyed read returns an undeclared + option as sent, as both official SDKs do. `QueryBuilder.question(id, q)` copies the question's map or list and + picks the type-checking decoder for its subtype. - **Wire** (`internal/Wire.kt`). - Requests are `@Serializable` DTOs, and a sealed `WireQuestion` writes the `"type"` discriminator. - `JevJson` sets `encodeDefaults = false`, so unset optional fields (Noul criteria) are omitted. Explicit JSON @@ -48,19 +63,113 @@ There are two DSL layers over one core model. Both produce a validated `Question - Responses are parsed by hand from `JsonObject`, so every error has a field path such as `answers..noul`. - An answer with no `type` is read as the type of question that was asked; an unknown type becomes `UnknownAnswer`. - Choice probabilities are reordered to the order the options were declared. Score keys `"0".."n"` become `Int`. - - Absent answers fail when they are read, not when the response is parsed. + - Absent answers fail when they are read, not when the response is parsed, and a `null` answer counts as absent. + - Numbers must be finite (an unquoted `NaN` and `1e999` both parse), and a Score level key must lie within the + question's levels, or be non-negative for an answer nobody asked for. + - Every `JevResponseValidationException` from a 2xx body is thrown by `ResponseInfo.fail`, which carries the + body's text as received, the status and the headers. `send` builds the `ResponseInfo`, and `JevResult` keeps + it for failures at read time. + - JSON nested more than `MAX_JSON_DEPTH` (128, `internal/JsonDepth.kt`) levels is refused before + kotlinx.serialization recurses into it: a state or question entry with `JevValidationException`, a response + body (scanned as text before parsing) with `JevResponseValidationException`, and an error body's `bodyJson` + is null. Both checks are iterative, so they can't overflow themselves. Windows sets the limit: its 1 MB + main-thread stack crashed the mingwX64 test binary encoding 512 levels, so the limit is 128. - **Client** (`JevClient.kt`, `internal/HttpClientFactory.kt`, `internal/Retry.kt`). - `HttpRequestRetry` reproduces the official SDKs' retry rules. `RetryPolicy` sets them: 408/429/5xx, connection errors, timeouts, 0.5 s doubling to 5 s with 25% jitter, and `retry-after-ms`/`Retry-After` hints up to 60 s. + A `Retry-After` HTTP-date, which the Python SDK also reads, counts from the config's internal `now` clock. + - `followRedirects = false`: a 3xx becomes a `JevApiException`, since Ktor strips only `Authorization` on a + cross-host redirect and fetch would re-send the POST body. + - No `ContentNegotiation` plugin: `evaluate` encodes the request with `JevJson` into a `TextContent` (merging + `extraBody`), inside the request block so an encoding failure still becomes `JevValidationException`, and + responses are read raw. So the only `Accept` is `setHeaders`' (or the caller's), and `jev4k-jvm` needs only + CIO at runtime. `ClientTest` pins the request bytes the plugin used to produce. + - `HttpClientFactory.limitBodySize` inserts a receive-pipeline phase ahead of `Before`, where Ktor's `SaveBody` + reads the whole body into memory, and refuses a declared `Content-Length` over `MAX_RESPONSE_BYTES` (16 MiB) + with an internal `OversizedResponseException`, which `execute` maps by status. A chunked body isn't capped; + the timeout bounds it. An `HttpSend` interceptor would be too late: the receive pipeline runs inside the send. - `HttpRequestRetry` must be installed **before** `HttpTimeout`, otherwise one timeout cancels every retry. - `expectSuccess = false`: non-2xx responses map to `JevApiException` subclasses (`apiException` in `Errors.kt`) - after retries run out, keeping the raw body and the `x-typesafe-request-id` header. - - `BlockingJev` (`jev.blocking`) wraps the suspend API in `runBlocking`. -- **Config** (`JevConfig.kt`). Each setting resolves as explicit value, then env var, then default; blank env values are - ignored. The env vars are `TYPESAFE_API_KEY` (required), `TYPESAFE_BASE_URL` and `TYPESAFE_DEFAULT_MODEL`. Internal - hooks (`env`, `retryDelay`, `random`) make tests deterministic. + after retries run out, keeping the raw body and the `x-typesafe-request-id` header. `JevApiException` lowercases + header names in its constructor (CIO keeps the server's spelling, fetch lowercases) and holds only + `Serializable` state: `bodyJson` is a plain getter, and `JevRateLimitException` stores its hint as nanoseconds. + - `BlockingJev` (`jev.blocking`, or `api.blocking()` for any `JevApi`) wraps the suspend API in `runBlocking`, on + the JVM only (see Platforms). Every method is `@Throws(InterruptedException::class)`. + - Headers are set on each request (`JevClient.setHeaders`: built-in, then the client's, then the call's), not + through `DefaultRequest`, which only sets the URL. `DefaultRequest` merges its headers with a request's own + (KTOR-6946), so a header named in both would be sent twice. + - `JevCallOptions` (`JevCallOptions.kt`) overrides a call's timeout and retry policy through Ktor's per-request + `timeout {}` and `retry {}`, which `HttpClientFactory`'s `limitTo` and `follow` fill in exactly as the plugins + are configured; a per-request retry config replaces the plugin's except for its `delay`. `extraBody` is merged + into the encoded request inside the call, so an unencodable state still becomes `JevValidationException`, and + can't set `state`, `model` or `questions`. `JevApi.withOptions` wraps an API so `query`/`ask` use the options. + - `JevClient.execute` classifies a failed call in one place. `SerializationException` becomes + `JevValidationException`. A `CancellationException` is caught next (first among the rest, because on + Kotlin/Native it is also an `IllegalStateException`): a cancelled caller gets its own cancellation through + `ensureActive()`, and one that isn't cancelled has lost a client closed as the call began, so it gets an + `IllegalStateException`. Any other `Throwable` first goes through `ensureActive()` too, because Ktor unwraps a + cancellation to its cause, and a caller cancelled when a sibling coroutine failed would otherwise get the + sibling's exception as a Jev error. It then becomes `JevTimeoutException` or `JevConnectionException` if it is + one, or is rethrown. `Throwable`, because the Js engine reports a failed fetch as a `kotlin.Error`. + `isConnectionError` in `Retry.kt` uses the same predicate, so what is reported as a connection error is also + what gets retried; `retriesOn` retries a cancellation only when it wraps a timeout, as Ktor's own rule does. + - `send` checks the client isn't closed before anything else, and reads the body as raw bytes decoded with + `decodeToString()`, not `bodyAsText()`: that parses the `Content-Type` (a malformed one throws) and, off the + JVM, throws on bytes that aren't UTF-8. + - A `JevTimeoutException` quotes `timeout` only when it is the limit that fired. A supplied engine gets only the + request timeout, so its own connect or socket timeout is named instead. +- **Config** (`JevConfig.kt`). Each setting resolves as explicit value, then env var, then default. String settings + are trimmed, and one that is blank after trimming counts as unset. The env vars are `TYPESAFE_API_KEY` (required), + `TYPESAFE_BASE_URL` and `TYPESAFE_DEFAULT_MODEL`. Internal hooks (`env`, `retryDelay`, `random`, `now`) make tests + deterministic; `env` defaults to `platformGetenv`. + - `build()` checks what Ktor would otherwise reject on every request, with an exception that isn't a + `JevException` and whose message quotes the value. `baseUrl` is parsed once: http(s), a host, no userinfo, + query or fragment, and plain `http://` only for a loopback host unless `allowInsecureHttp`. The API key may + not contain control characters. Header names and values follow Ktor's `checkHeaderName`/`checkHeaderValue`. + Every problem goes into one `JevConfigException`, whose messages never quote the key, a header value, or a + URL that could hold credentials. + - The retry policy is stored with a copy of its status set, since the caller's set may be mutable and the + client re-reads it on every response. A blank per-call model falls back to the default, as a blank + `defaultModel` does. +- **Platforms** (`internal/Platform.kt` and its actuals). Everything that differs between platforms is an `internal` + expect: `platformGetenv`, `defaultEngine` (the engine factory; `JevConfig.toString` reports its class name), + `isPlatformConnectionError`, and `Enum<*>.enumTypeName()` (which keeps `enumChoiceRef`'s `@PublishedApi` signature + unchanged). Actual files carry a platform suffix (`Platform.jvm.kt`, `Engine.linux.kt`) so JVM facade names never + clash. No engine gets a timeout of its own: `HttpTimeout` sets one on every request, and CIO, for one, ignores its + `requestTimeout` whenever a request carries that capability. + - On the JVM, `defaultEngine` is a getter in its own `Engine.jvm.kt`. As a stored value next to `platformGetenv` + it would load CIO in the class initializer that every environment lookup runs, and a consumer who supplies an + engine and excludes `ktor-client-cio` (as the README suggests) couldn't build a client. `CioExclusionTest` + runs jev4k in a class loader that hides CIO to pin this. + - Default engines: CIO on the JVM (`jvmMain`), Darwin (`appleMain`), Curl (`linuxMain`), WinHttp (`mingwMain`), + and the Js engine bundled in `ktor-client-core` (`webMain`, shared by js and wasmJs). CIO can't be used + natively: Ktor's native TLS fails with "TLS sessions are not supported on Native platform". + - How each engine reports a refused connection: CIO and Darwin throw an `IOException`; Curl and WinHttp a bare + `IllegalStateException` (matched by exact class, so a native `CancellationException` never counts); the Js + engine `Error("Fail to fetch")`. `PlatformEngineTest` dials a dead loopback port on every platform to pin this. + The bare-ISE rule is deliberately broad: Curl and WinHttp also throw one for local setup failures (a failed + handle or proxy setup), which are then retried and reported as connection errors with the cause kept, and the + rule follows the host's default engine rather than the engine in use. `ClientJvmTest` pins that the JVM + treats a bare ISE as an ordinary failure. + - Two more failures count as connection errors. On every platform, Ktor's own "Content-Length mismatch" check + (a bare ISE from `SavedCall` when a body is cut short) is matched by exact class and wording. On the JVM, CIO + reports an untrusted server certificate as a raw `CertificateException` and a response it can't parse as a + `ParserException` (in ktor-http-cio, which ktor-client-core needs anyway, so excluding CIO still works); the + JVM actual counts any `GeneralSecurityException` and `ParserException`. `ClientJvmTest` drives a real CIO + engine against `RawServer` for all three, which also pins Ktor's wording. + - `BlockingJev` is an `expect class`. The `jvmMain` actual is the real one; the `nativeMain` and `webMain` actuals + are empty. `JevClient` keeps `val blocking = BlockingJev(this)` in common code, so the JVM class file, and + Java's `jev.getBlocking()`, are exactly as before. `-Xexpect-actual-classes` silences the Beta warning. + - `platformGetenv` on js/wasmJs is a `js()` call that must be the whole body of a top-level function (a + Kotlin/Wasm rule) and needs `@OptIn(ExperimentalWasmJsInterop::class)`. It reads `process.env`, so it only + works on Node.js, the only JS runtime targeted. + - Common code can't use JVM-only APIs such as `Map.putIfAbsent`, and needs explicit `kotlin.jvm.JvmOverloads` / + `kotlin.jvm.JvmSynthetic` imports (only the JVM imports `kotlin.jvm.*` by default). - **Validation** (`Questions.kt`). Every problem is collected into one `JevValidationException` before anything is sent: - at least one question, unique non-blank ids, non-empty instructions, 1..255 Choice options, 2..10 Score levels. + at least one question, unique non-blank ids, instructions that are non-blank text or a non-empty object or array, + 1..255 Choice options, 2..10 Score levels, and no entry nested too deeply or holding NaN or an infinity (which + kotlinx.serialization refuses to encode). `evaluate` also rejects a null, number or boolean state; the API takes a + string, an object or an array. ## Documentation site @@ -69,11 +178,13 @@ There are two DSL layers over one core model. Both produce a validated `Question `slate` palette) is the default. `docs/stylesheets/extra.css` widens the page grid from 61rem to 90rem so 120-column examples fit without horizontal scrolling. Emoji and icons come from Zensical's own extension, so `mkdocs-material` isn't needed. -- **Publishing.** `.github/workflows/docs.yml` publishes the site to GitHub Pages on every push to `master`, or on - manual dispatch. It runs the same steps as `make site-build` (Zensical build, Dokka, KDocs copied to `/kdocs`) in a - build job, and a separate deploy job can be re-run on its own. `zensical.toml` sets `site_url`, `repo_url` and - `edit_uri`, and Dokka's `sourceLink`/`homepageLink` point at `github.com/pambrose/jev4k` on `master`. The - repository's Pages source must be set to "GitHub Actions". +- **Publishing.** `.github/workflows/docs.yml` runs the same steps as `make site-build` (Zensical build with + `--strict`, Dokka, KDocs copied to `/kdocs`) in its `docs` job on every PR and `master` push, as a required check. + It deploys to GitHub Pages only when a release is published, or on manual dispatch, so jev4k.com never shows a + version that isn't on Maven Central yet; a separate deploy job can be re-run on its own. A release runs on its + tag, so the `github-pages` environment allows tags matching `[0-9]*.[0-9]*.[0-9]*` as well as `master`. + `zensical.toml` sets `site_url`, `repo_url` and `edit_uri`, and Dokka's `sourceLink`/`homepageLink` point at + `github.com/pambrose/jev4k` on `master`. The repository's Pages source must be set to "GitHub Actions". - **Custom domain.** The site is served at , not `pambrose.github.io/jev4k/`, so page URLs carry no path prefix. `docs/CNAME` holds the bare domain and Zensical copies it to `site/CNAME`; GitHub Pages reads the domain from that file, and without it an Actions-published site can lose the custom domain set under Settings → Pages on a @@ -89,21 +200,26 @@ There are two DSL layers over one core model. Both produce a validated `Question breaks into a one-item list, a stray rule and two loose paragraphs, each landing in its own grid cell. The page still builds cleanly, so only the rendered HTML (or a look at the page) catches it: one `