diff --git a/CHANGELOG.md b/CHANGELOG.md index 067f485..8ed0dc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 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). +- 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 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..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 @@ -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; @@ -14,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; @@ -129,6 +132,32 @@ public TypeSafeResponse systemOne(TypeSafeRequest request, RequestOptions option throw new TypeSafeException("TypeSafe response has no answers"); } + // 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; } @@ -222,9 +251,25 @@ 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); + } 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..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 @@ -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()); } @@ -250,6 +250,80 @@ 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 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 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");