From aec8bbfafe7015151544b05f66648144023635a0 Mon Sep 17 00:00:00 2001 From: Alex Johnson Date: Sat, 19 Sep 2026 11:25:00 -0400 Subject: [PATCH 1/3] Reject answers with missing or null required fields instead of reading them as zero A noul answer with no value deserialized as 0.0, a choice with no choice as null, and so on, because the record components are primitives and Jackson defaults a missing primitive. In a fraud check that reads as "not fraud". Each answer record now has a JsonCreator taking boxed values and rejecting a missing or null required field, so the existing parse error path turns it into a TypeSafeException from systemOne. Parse errors also carry the JSON path so the message names the question (answers -> is_fraud). Fixes #1 (first half; the accessor exception type is left as is). --- CHANGELOG.md | 5 ++ .../premocloud/typesafe/ChoiceAnswer.java | 15 ++++++ .../premocloud/typesafe/NoulAnswer.java | 8 ++++ .../premocloud/typesafe/ScoreAnswer.java | 17 +++++++ .../premocloud/typesafe/TypeSafeAnswer.java | 9 ++++ .../premocloud/typesafe/TypeSafeClient.java | 4 ++ .../typesafe/TypeSafeClientTest.java | 47 +++++++++++++++++++ 7 files changed, 105 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 067f485..545d2d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- A response whose answer is missing a required field (`noul`, `choice`, `score`, `probabilities`, `confidence`), or has it as `null`, now fails `systemOne` with a `TypeSafeException` naming the question, instead of reading as `0.0` or `null` (#1). +- Response parse errors name the JSON path of the offending element. + ## 0.1.1 - 2026-09-18 First published release. A `0.1.0` tag was cut earlier the same day but never published to Maven Central; its contents are listed here. diff --git a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/ChoiceAnswer.java b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/ChoiceAnswer.java index 23503ec..c018207 100644 --- a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/ChoiceAnswer.java +++ b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/ChoiceAnswer.java @@ -1,6 +1,8 @@ package io.github.premocloud.typesafe; +import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; import java.util.Map; @@ -11,4 +13,17 @@ */ @JsonIgnoreProperties(ignoreUnknown = true) public record ChoiceAnswer(String choice, Map probabilities, double confidence) implements TypeSafeAnswer { + + /** Jackson entry point: a choice answer missing any of its fields is malformed. */ + @JsonCreator + ChoiceAnswer( + @JsonProperty("choice") String choice, + @JsonProperty("probabilities") Map probabilities, + @JsonProperty("confidence") Double confidence, + @JsonProperty("type") String ignoredType + ) { + this(TypeSafeAnswer.required(choice, "choice", "choice"), + TypeSafeAnswer.required(probabilities, "choice", "probabilities"), + TypeSafeAnswer.required(confidence, "choice", "confidence").doubleValue()); + } } diff --git a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/NoulAnswer.java b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/NoulAnswer.java index f7ffd89..ea23322 100644 --- a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/NoulAnswer.java +++ b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/NoulAnswer.java @@ -1,8 +1,16 @@ package io.github.premocloud.typesafe; +import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; /** @param noul probability that the answer is yes, 0 to 1. There is no separate confidence. */ @JsonIgnoreProperties(ignoreUnknown = true) public record NoulAnswer(double noul) implements TypeSafeAnswer { + + /** Jackson entry point: a noul answer whose value is missing or null is malformed, not 0. */ + @JsonCreator + NoulAnswer(@JsonProperty("noul") Double noul) { + this(TypeSafeAnswer.required(noul, "noul", "noul").doubleValue()); + } } 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 db7eb74..64a031c 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 @@ -1,6 +1,8 @@ package io.github.premocloud.typesafe; +import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; import java.util.Map; @@ -17,4 +19,19 @@ public record ScoreAnswer( double confidence, Map legend ) implements TypeSafeAnswer { + + /** Jackson entry point: a score answer missing its score, probabilities, or confidence is malformed. */ + @JsonCreator + ScoreAnswer( + @JsonProperty("score") Double score, + @JsonProperty("probabilities") Map probabilities, + @JsonProperty("confidence") Double confidence, + @JsonProperty("legend") Map legend, + @JsonProperty("type") String ignoredType + ) { + this(TypeSafeAnswer.required(score, "score", "score").doubleValue(), + TypeSafeAnswer.required(probabilities, "score", "probabilities"), + TypeSafeAnswer.required(confidence, "score", "confidence").doubleValue(), + legend); + } } diff --git a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeAnswer.java b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeAnswer.java index acdfc1f..5b51481 100644 --- a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeAnswer.java +++ b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeAnswer.java @@ -11,4 +11,13 @@ @JsonSubTypes.Type(value = ScoreAnswer.class, name = "score") }) public sealed interface TypeSafeAnswer permits NoulAnswer, ChoiceAnswer, ScoreAnswer { + + /** Rejects a missing or null answer field so a malformed response cannot read as a real value (0, null). */ + static T required(T value, String answerType, String field) { + if (value == null) { + throw new IllegalArgumentException("%s answer is missing '%s'".formatted(answerType, field)); + } + + return value; + } } diff --git a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeClient.java b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeClient.java index 8ef9dbd..e9c3c31 100644 --- a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeClient.java +++ b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeClient.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.ObjectMapper; import org.jspecify.annotations.Nullable; @@ -225,6 +226,9 @@ private static void pause(Duration delay) { private T deserialize(String body, Class type) { try { return objectMapper.readValue(body, type); + } catch (JsonMappingException e) { + // The path names the offending question, e.g. answers -> is_fraud, which the message alone does not. + throw new TypeSafeException("Could not read response at %s: %s".formatted(e.getPathReference(), e.getOriginalMessage()), e); } catch (JsonProcessingException e) { throw new TypeSafeException("Could not read response: " + e.getOriginalMessage(), e); } 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 55e1c9f..ba5ab21 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 @@ -250,6 +250,53 @@ void perCallOptionsOverrideTimeoutRetryAndHeaders() { assertThrows(IllegalArgumentException.class, () -> RequestOptions.of(o -> o.timeout(Duration.ZERO))); } + @Test + void systemOneRejectsNoulAnswerMissingItsValue() { + server.reply(200, """ + {"model": "jev-1.13.0", "answers": {"is_fraud": {"type": "noul"}}, "usage": {"input_tokens": 1, "output_tokens": 1}} + """); + + TypeSafeException exception = assertThrows(TypeSafeException.class, () -> client.systemOne(spamRequest())); + + assertTrue(exception.getMessage().contains("noul"), exception.getMessage()); + assertTrue(exception.getMessage().contains("is_fraud"), exception.getMessage()); + } + + @Test + void systemOneRejectsNoulAnswerWithExplicitNullValue() { + server.reply(200, """ + {"model": "jev-1.13.0", "answers": {"is_fraud": {"type": "noul", "noul": null}}, "usage": {"input_tokens": 1, "output_tokens": 1}} + """); + + TypeSafeException exception = assertThrows(TypeSafeException.class, () -> client.systemOne(spamRequest())); + + assertTrue(exception.getMessage().contains("noul"), exception.getMessage()); + } + + @Test + void systemOneRejectsChoiceAnswerMissingItsChoice() { + server.reply(200, """ + {"model": "jev-1.13.0", "answers": {"category": {"type": "choice", "probabilities": {"a": 1.0}, "confidence": 1.0}}, + "usage": {"input_tokens": 1, "output_tokens": 1}} + """); + + TypeSafeException exception = assertThrows(TypeSafeException.class, () -> client.systemOne(spamRequest())); + + assertTrue(exception.getMessage().contains("choice"), exception.getMessage()); + } + + @Test + void systemOneRejectsScoreAnswerMissingItsScore() { + server.reply(200, """ + {"model": "jev-1.13.0", "answers": {"urgency": {"type": "score", "probabilities": {"0": 1.0}, "confidence": 1.0, "legend": {"0": "calm"}}}, + "usage": {"input_tokens": 1, "output_tokens": 1}} + """); + + TypeSafeException exception = assertThrows(TypeSafeException.class, () -> client.systemOne(spamRequest())); + + assertTrue(exception.getMessage().contains("score"), exception.getMessage()); + } + @Test void systemOneRejectsUnreadableBody() { server.reply(200, "not json"); From c9b21e887a7abbf85b8966ea7f4c8b577cd04b19 Mon Sep 17 00:00:00 2001 From: Alex Johnson Date: Sat, 19 Sep 2026 12:43:11 -0400 Subject: [PATCH 2/3] Fail systemOne when the response omits an answer that was asked for A question with no answer in the response only surfaced when the caller read that key, as an IllegalArgumentException, so systemOne returned normally and a catch of TypeSafeException never saw it. systemOne now diffs the question ids it sent against the answers it got back and throws TypeSafeException naming the unanswered ones, next to the existing null-answers check. One existing test asked question 'q' while the canned stub answered is_phishing/spam_category/urgency; its subject is the request's model field, so the question id now matches the fixture. --- CHANGELOG.md | 1 + .../github/premocloud/typesafe/TypeSafeClient.java | 12 ++++++++++++ .../premocloud/typesafe/TypeSafeClientTest.java | 14 +++++++++++++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 545d2d1..3e5ed05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - A response whose answer is missing a required field (`noul`, `choice`, `score`, `probabilities`, `confidence`), or has it as `null`, now fails `systemOne` with a `TypeSafeException` naming the question, instead of reading as `0.0` or `null` (#1). +- A response that omits an answer for a question that was asked now fails `systemOne` with a `TypeSafeException` naming the unanswered questions, instead of surfacing later as an `IllegalArgumentException` when that key is read (#1). - Response parse errors name the JSON path of the offending element. ## 0.1.1 - 2026-09-18 diff --git a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeClient.java b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeClient.java index e9c3c31..e0a4db3 100644 --- a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeClient.java +++ b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeClient.java @@ -15,9 +15,11 @@ import java.net.http.HttpTimeoutException; import java.time.Duration; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.concurrent.ThreadLocalRandom; import java.util.function.Consumer; @@ -130,6 +132,16 @@ public TypeSafeResponse systemOne(TypeSafeRequest request, RequestOptions option throw new TypeSafeException("TypeSafe response has no answers"); } + // Every question asked must come back answered; otherwise the gap only surfaces later, when the + // caller reads that key, as an IllegalArgumentException no catch of TypeSafeException would see. + Set unanswered = new LinkedHashSet<>(resolved.questions().keySet()); + unanswered.removeAll(response.answers().keySet()); + + if (!unanswered.isEmpty()) { + throw new TypeSafeException("TypeSafe response is missing answers for %s; answered: %s" + .formatted(unanswered, response.answers().keySet())); + } + return response; } 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 ba5ab21..c6ee5ad 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 @@ -95,7 +95,7 @@ void systemOnePostsBearerAuthenticatedJsonAndReturnsTypedAnswers() throws Except void systemOneKeepsAnExplicitModel() throws Exception { server.reply(200, RESPONSE_JSON); - client.systemOne(r -> r.state("text").model("jev-1.12.0").noul("q", n -> n.instructions("Yes?"))); + client.systemOne(r -> r.state("text").model("jev-1.12.0").noul("is_phishing", n -> n.instructions("Yes?"))); assertEquals("jev-1.12.0", objectMapper.readTree(server.recorded().get(0).body()).at("/model").asText()); } @@ -297,6 +297,18 @@ void systemOneRejectsScoreAnswerMissingItsScore() { assertTrue(exception.getMessage().contains("score"), exception.getMessage()); } + @Test + void systemOneRejectsAResponseMissingAnAnswerForAQuestionThatWasAsked() { + server.reply(200, """ + {"model": "jev-1.13.0", "answers": {"is_phishing": {"type": "noul", "noul": 0.93}}, + "usage": {"input_tokens": 1, "output_tokens": 1}} + """); + + TypeSafeException exception = assertThrows(TypeSafeException.class, () -> client.systemOne(spamRequest())); + + assertTrue(exception.getMessage().contains("spam_category"), exception.getMessage()); + } + @Test void systemOneRejectsUnreadableBody() { server.reply(200, "not json"); From 5b54a368648a29cf2be07e12cc278efcb8c6ed7e Mon Sep 17 00:00:00 2001 From: Alex Johnson Date: Sat, 19 Sep 2026 12:44:28 -0400 Subject: [PATCH 3/3] Fail systemOne when an answer's type differs from the question asked A noul question answered with a choice parsed fine and only failed when the caller read it, as an IllegalArgumentException indistinguishable from using the wrong accessor by mistake. The request knows what each question was, so the mismatch is detectable where the response is checked. systemOne now compares each question's type against its answer's and throws TypeSafeException naming the mismatched ids. Reading a good answer with the wrong accessor is still IllegalArgumentException: that is a caller bug, not a bad response. --- CHANGELOG.md | 1 + .../premocloud/typesafe/TypeSafeClient.java | 37 +++++++++++++++++-- .../typesafe/TypeSafeClientTest.java | 15 ++++++++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e5ed05..8ed0dc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - A response whose answer is missing a required field (`noul`, `choice`, `score`, `probabilities`, `confidence`), or has it as `null`, now fails `systemOne` with a `TypeSafeException` naming the question, instead of reading as `0.0` or `null` (#1). - A response that omits an answer for a question that was asked now fails `systemOne` with a `TypeSafeException` naming the unanswered questions, instead of surfacing later as an `IllegalArgumentException` when that key is read (#1). +- A response answering a question with a different type than was asked now fails `systemOne` with a `TypeSafeException` naming that question (#1). - Response parse errors name the JSON path of the offending element. ## 0.1.1 - 2026-09-18 diff --git a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeClient.java b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeClient.java index e0a4db3..2dfb546 100644 --- a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeClient.java +++ b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeClient.java @@ -132,16 +132,32 @@ public TypeSafeResponse systemOne(TypeSafeRequest request, RequestOptions option throw new TypeSafeException("TypeSafe response has no answers"); } - // Every question asked must come back answered; otherwise the gap only surfaces later, when the - // caller reads that key, as an IllegalArgumentException no catch of TypeSafeException would see. - Set unanswered = new LinkedHashSet<>(resolved.questions().keySet()); - unanswered.removeAll(response.answers().keySet()); + // Every question asked must come back answered, and answered as its own type. Either gap otherwise + // surfaces later, when the caller reads that key, as an IllegalArgumentException no catch of + // TypeSafeException would see. + Set unanswered = new LinkedHashSet<>(); + Set mistyped = new LinkedHashSet<>(); + + resolved.questions().forEach((id, question) -> { + TypeSafeAnswer answer = response.answers().get(id); + + if (Objects.isNull(answer)) { + unanswered.add(id); + } else if (!expectedAnswer(question).isInstance(answer)) { + mistyped.add(id); + } + }); if (!unanswered.isEmpty()) { throw new TypeSafeException("TypeSafe response is missing answers for %s; answered: %s" .formatted(unanswered, response.answers().keySet())); } + if (!mistyped.isEmpty()) { + throw new TypeSafeException("TypeSafe response answered %s with a different type than was asked" + .formatted(mistyped)); + } + return response; } @@ -235,6 +251,19 @@ private static void pause(Duration delay) { } } + /** The answer type the API must return for a question of this type. */ + private static Class expectedAnswer(TypeSafeQuestion question) { + if (question instanceof Noul) { + return NoulAnswer.class; + } + + if (question instanceof Choice) { + return ChoiceAnswer.class; + } + + return ScoreAnswer.class; + } + private T deserialize(String body, Class type) { try { return objectMapper.readValue(body, type); 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 c6ee5ad..a9f0ffa 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 @@ -309,6 +309,21 @@ void systemOneRejectsAResponseMissingAnAnswerForAQuestionThatWasAsked() { assertTrue(exception.getMessage().contains("spam_category"), exception.getMessage()); } + @Test + void systemOneRejectsAnAnswerOfADifferentTypeThanTheQuestionAsked() { + server.reply(200, """ + {"model": "jev-1.13.0", "answers": { + "is_phishing": {"type": "choice", "choice": "PHISHING", "probabilities": {"PHISHING": 1.0}, "confidence": 1.0}, + "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": {"input_tokens": 1, "output_tokens": 1}} + """); + + TypeSafeException exception = assertThrows(TypeSafeException.class, () -> client.systemOne(spamRequest())); + + assertTrue(exception.getMessage().contains("is_phishing"), exception.getMessage()); + } + @Test void systemOneRejectsUnreadableBody() { server.reply(200, "not json");