From ed8c38539206b60e9c7fc9a2913c44bbe85b3fa1 Mon Sep 17 00:00:00 2001 From: Garret Premo Date: Tue, 22 Sep 2026 09:37:00 -0400 Subject: [PATCH 1/2] Widen ScoreAnswer.legend to Map so object and array levels read back Score levels are sent as any JSON value and the API echoes them back as sent, but the legend was typed Map. A Score with an object or array level, including the one in Score's own javadoc example, failed the whole response and every other answer with it (#11). --- CHANGELOG.md | 4 ++++ .../premocloud/typesafe/ScoreAnswer.java | 6 ++--- .../typesafe/TypeSafeClientTest.java | 22 +++++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22642bb..59895b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- `ScoreAnswer.legend()` is now `Map`, since levels are sent as any JSON value and echoed back as sent. A `Score` with an object or array level previously failed the whole response, including its other answers and usage, with `Cannot deserialize value of type java.lang.String` (#11). + ## 0.4.0 - 2026-09-22 - Logging through slf4j on the `io.github.premocloud.typesafe` logger: `DEBUG` for one line per request with status and elapsed, plus retries and connection failures; `TRACE` for the wire in both directions. Nothing at `INFO` or above. Credential headers are masked. Adds `org.slf4j:slf4j-api` (#6, #7). diff --git a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/ScoreAnswer.java b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/ScoreAnswer.java index 64a031c..00983ef 100644 --- a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/ScoreAnswer.java +++ b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/ScoreAnswer.java @@ -10,14 +10,14 @@ * @param score probability-weighted position from 0 to the top level index * @param probabilities probability per level index (keys are the index as a string) * @param confidence 0 to 1, how concentrated the distribution is - * @param legend level index back to the description that was sent + * @param legend level index back to the description that was sent: a String, or the Map or List it was given as */ @JsonIgnoreProperties(ignoreUnknown = true) public record ScoreAnswer( double score, Map probabilities, double confidence, - Map legend + Map legend ) implements TypeSafeAnswer { /** Jackson entry point: a score answer missing its score, probabilities, or confidence is malformed. */ @@ -26,7 +26,7 @@ public record ScoreAnswer( @JsonProperty("score") Double score, @JsonProperty("probabilities") Map probabilities, @JsonProperty("confidence") Double confidence, - @JsonProperty("legend") Map legend, + @JsonProperty("legend") Map legend, @JsonProperty("type") String ignoredType ) { this(TypeSafeAnswer.required(score, "score", "score").doubleValue(), diff --git a/typesafe-sdk/src/test/java/io/github/premocloud/typesafe/TypeSafeClientTest.java b/typesafe-sdk/src/test/java/io/github/premocloud/typesafe/TypeSafeClientTest.java index b706c4b..9fef716 100644 --- a/typesafe-sdk/src/test/java/io/github/premocloud/typesafe/TypeSafeClientTest.java +++ b/typesafe-sdk/src/test/java/io/github/premocloud/typesafe/TypeSafeClientTest.java @@ -334,6 +334,28 @@ void systemOneRejectsScoreAnswerMissingItsScore() { assertTrue(exception.getMessage().contains("score"), exception.getMessage()); } + @Test + void systemOneReadsAScoreLegendWithObjectAndArrayLevels() { + // Levels are sent as any JSON value and echoed back as sent, so the legend holds whatever was asked (#11). + server.reply(200, """ + {"model": "jev-1.13.0", "answers": { + "is_phishing": {"type": "noul", "noul": 0.93}, + "spam_category": {"type": "choice", "choice": "PHISHING", "probabilities": {"PHISHING": 1.0}, "confidence": 1.0}, + "urgency": {"type": "score", "score": 1.0, "probabilities": {"0": 0.2, "1": 0.5, "2": 0.3}, "confidence": 0.5, + "legend": {"0": "none", "1": {"what": "Threatens loss within hours", "examples": ["final notice"]}, "2": ["a", "b"]}}}, + "usage": {"input_tokens": 1, "output_tokens": 1}} + """); + + TypeSafeResponse response = client.systemOne(spamRequest()); + + Map legend = response.score("urgency").legend(); + assertEquals("none", legend.get("0")); + assertEquals(Map.of("what", "Threatens loss within hours", "examples", List.of("final notice")), legend.get("1")); + assertEquals(List.of("a", "b"), legend.get("2")); + assertEquals(0.93, response.noul("is_phishing")); + assertEquals(1, response.usage().inputTokens()); + } + @Test void systemOneRejectsAResponseMissingAnAnswerForAQuestionThatWasAsked() { server.reply(200, """ From e06355d9d7c2747c3b01952ef16cd479d2eff98b Mon Sep 17 00:00:00 2001 From: Garret Premo Date: Tue, 22 Sep 2026 09:38:27 -0400 Subject: [PATCH 2/2] Reject a response missing usage, or a usage missing a token count Both counts were primitive longs with no presence check, so a missing or null count read as 0, and a missing usage block read as null and surfaced as an NPE at the first caller. Same fail-loud treatment the answer records got in 0.2.0, naming the missing field (#12). --- CHANGELOG.md | 1 + .../premocloud/typesafe/TypeSafeResponse.java | 7 ++++ .../premocloud/typesafe/TypeSafeUsage.java | 26 ++++++++++++--- .../typesafe/TypeSafeClientTest.java | 33 +++++++++++++++++++ 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59895b2..311cdd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- A response missing its `usage` block, or a `usage` missing `input_tokens` or `output_tokens`, now fails `systemOne` with a `TypeSafeException` naming the field, instead of reading as `null` or `0` (#12). - `ScoreAnswer.legend()` is now `Map`, since levels are sent as any JSON value and echoed back as sent. A `Score` with an object or array level previously failed the whole response, including its other answers and usage, with `Cannot deserialize value of type java.lang.String` (#11). ## 0.4.0 - 2026-09-22 diff --git a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeResponse.java b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeResponse.java index c50cfac..8a5010e 100644 --- a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeResponse.java +++ b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeResponse.java @@ -14,6 +14,13 @@ @JsonIgnoreProperties(ignoreUnknown = true) public record TypeSafeResponse(String model, Map answers, TypeSafeUsage usage) { + /** A response without its usage block is malformed; reading it later as {@code null} would be the first sign. */ + public TypeSafeResponse { + if (Objects.isNull(usage)) { + throw new IllegalArgumentException("response is missing 'usage'"); + } + } + /** @return probability that the yes/no question answered yes, 0 to 1 */ public double noul(String key) { return answer(key, NoulAnswer.class).noul(); diff --git a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeUsage.java b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeUsage.java index b6514ba..5b1490f 100644 --- a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeUsage.java +++ b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeUsage.java @@ -1,11 +1,29 @@ package io.github.premocloud.typesafe; +import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +/** + * Token counts for one request. + * + * @param inputTokens billable input tokens + * @param outputTokens output tokens used to answer + */ @JsonIgnoreProperties(ignoreUnknown = true) -public record TypeSafeUsage( - @JsonProperty("input_tokens") long inputTokens, - @JsonProperty("output_tokens") long outputTokens -) { +public record TypeSafeUsage(long inputTokens, long outputTokens) { + + /** Jackson entry point: a usage block missing a count, or carrying it as null, is malformed rather than 0. */ + @JsonCreator + TypeSafeUsage(@JsonProperty("input_tokens") Long inputTokens, @JsonProperty("output_tokens") Long outputTokens) { + this(required(inputTokens, "input_tokens").longValue(), required(outputTokens, "output_tokens").longValue()); + } + + private static Long required(Long value, String field) { + if (value == null) { + throw new IllegalArgumentException("usage is missing '%s'".formatted(field)); + } + + return value; + } } diff --git a/typesafe-sdk/src/test/java/io/github/premocloud/typesafe/TypeSafeClientTest.java b/typesafe-sdk/src/test/java/io/github/premocloud/typesafe/TypeSafeClientTest.java index 9fef716..781b070 100644 --- a/typesafe-sdk/src/test/java/io/github/premocloud/typesafe/TypeSafeClientTest.java +++ b/typesafe-sdk/src/test/java/io/github/premocloud/typesafe/TypeSafeClientTest.java @@ -383,6 +383,39 @@ void systemOneRejectsAnAnswerOfADifferentTypeThanTheQuestionAsked() { assertTrue(exception.getMessage().contains("is_phishing"), exception.getMessage()); } + @Test + void systemOneRejectsAResponseMissingItsUsage() { + // Without this the response reads with usage() == null and the first caller to read a count gets an NPE (#12). + server.reply(200, """ + {"model": "jev-1.13.0", "answers": { + "is_phishing": {"type": "noul", "noul": 0.93}, + "spam_category": {"type": "choice", "choice": "PHISHING", "probabilities": {"PHISHING": 1.0}, "confidence": 1.0}, + "urgency": {"type": "score", "score": 1.0, "probabilities": {"1": 1.0}, "confidence": 1.0, "legend": {"1": "x"}}}} + """); + + TypeSafeException exception = assertThrows(TypeSafeException.class, () -> client.systemOne(spamRequest())); + + assertTrue(exception.getMessage().contains("usage"), exception.getMessage()); + } + + @Test + void systemOneRejectsUsageMissingACount() { + // A missing or null count previously read as 0, the same silent default 0.2.0 removed from the answers (#12). + for (String usage : List.of("{\"input_tokens\": 1}", "{\"input_tokens\": 1, \"output_tokens\": null}")) { + server.reply(200, """ + {"model": "jev-1.13.0", "answers": { + "is_phishing": {"type": "noul", "noul": 0.93}, + "spam_category": {"type": "choice", "choice": "PHISHING", "probabilities": {"PHISHING": 1.0}, "confidence": 1.0}, + "urgency": {"type": "score", "score": 1.0, "probabilities": {"1": 1.0}, "confidence": 1.0, "legend": {"1": "x"}}}, + "usage": %s} + """.formatted(usage)); + + TypeSafeException exception = assertThrows(TypeSafeException.class, () -> client.systemOne(spamRequest()), usage); + + assertTrue(exception.getMessage().contains("output_tokens"), exception.getMessage()); + } + } + @Test void systemOneRejectsUnreadableBody() { server.reply(200, "not json");