Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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<String, Object>`, 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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Double> probabilities,
double confidence,
Map<String, String> legend
Map<String, Object> legend
) implements TypeSafeAnswer {

/** Jackson entry point: a score answer missing its score, probabilities, or confidence is malformed. */
Expand All @@ -26,7 +26,7 @@ public record ScoreAnswer(
@JsonProperty("score") Double score,
@JsonProperty("probabilities") Map<String, Double> probabilities,
@JsonProperty("confidence") Double confidence,
@JsonProperty("legend") Map<String, String> legend,
@JsonProperty("legend") Map<String, Object> legend,
@JsonProperty("type") String ignoredType
) {
this(TypeSafeAnswer.required(score, "score", "score").doubleValue(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@
@JsonIgnoreProperties(ignoreUnknown = true)
public record TypeSafeResponse(String model, Map<String, TypeSafeAnswer> 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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> 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, """
Expand Down Expand Up @@ -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");
Expand Down
Loading