diff --git a/CHANGELOG.md b/CHANGELOG.md index 22642bb..311cdd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 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 - 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/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 b706c4b..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 @@ -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, """ @@ -361,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");