# jev_api

A sans-io [Gleam](https://gleam.run) client for [TypeSafe AI](https://typesafe.ai)'s
System One API and its **Jev** decision model.

Jev does not generate text. You send it one piece of *state* and any number of
typed *questions*, and it answers all of them in a single call with calibrated
probabilities, a chosen option, or a score on a scale you define. That makes it
a good fit for classification, routing, rubric scoring and automated checks
inside ordinary code.

[![Package Version](https://img.shields.io/hexpm/v/jev_api)](https://hex.pm/packages/jev_api)
[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/jev_api/)

```sh
gleam add jev_api
```

## Sans-io

This library never touches the network. It builds a
`gleam/http/request.Request(String)` for you to send with whatever HTTP client
suits your target, and decodes the `gleam/http/response.Response(String)` you
get back. That keeps it free of target-specific code and trivial to test: the
core depends only on `gleam_stdlib`, `gleam_http` and `gleam_json`.

On Erlang, [`gleam_httpc`](https://hex.pm/packages/gleam_httpc) sends the
request:

```gleam
import gleam/httpc
import gleam/json
import jev_api
import jev_api/question

pub fn triage(api_key: String, ticket: String) {
  let client = jev_api.new(api_key)

  let req =
    jev_api.evaluate_request(
      client,
      state: json.string(ticket),
      questions: [
        #("urgent", question.noul("Does this message express urgency?")),
        #(
          "team",
          question.choice("Which team should handle this?", [
            "billing",
            "technical",
            "other",
          ]),
        ),
        #(
          "frustration",
          question.score("How frustrated is the customer?", [
            "Calm",
            "Frustrated",
            "Very angry",
          ]),
        ),
      ],
    )

  let assert Ok(res) = httpc.send(req)
  jev_api.evaluate_response(res)
}
```

On JavaScript, send the same request with
[`gleam_fetch`](https://hex.pm/packages/gleam_fetch) instead. If you are not
using `gleam_http` at all, the request's `body`, `headers`, `method` and URL
fields are plain values, and `evaluate_response` only needs a
`response.Response(status:, headers:, body:)` built from what your client
returned.

## Reading answers

`evaluate_response` returns an `Evaluation`: the concrete model that answered,
token usage, TypeSafe's request id, and one `Answer` per question under the key
you asked it with.

```gleam
import gleam/dict
import jev_api

let assert Ok(evaluation) = jev_api.evaluate_response(res)

case jev_api.answer(evaluation, "urgent") {
  Ok(jev_api.NoulAnswer(probability:)) if probability >. 0.8 -> escalate()
  _ -> queue()
}

case jev_api.answer(evaluation, "team") {
  Ok(jev_api.ChoiceAnswer(choice:, confidence:, ..)) if confidence >. 0.7 ->
    route_to(choice)
  _ -> route_to_human()
}

case jev_api.answer(evaluation, "frustration") {
  // `score` can fall between levels: 1.68 is "Frustrated" leaning "Very angry".
  Ok(jev_api.ScoreAnswer(score:, levels:, ..)) -> record(score, levels)
  _ -> Nil
}
```

| Question | Answer                                         |
| -------- | ---------------------------------------------- |
| `Noul`   | `NoulAnswer(probability)`, 0.0 to 1.0 for "yes" |
| `Choice` | `ChoiceAnswer(choice, probabilities, confidence)` |
| `Score`  | `ScoreAnswer(score, levels, confidence)`, where each `Level` has its `index`, the `label` you sent, and its `probability` |

Confidence measures how peaked a distribution is, not how likely the answer is
to be correct. TypeSafe's [confidence guide](https://docs.typesafe.ai/confidence)
covers choosing thresholds.

## Questions

`jev_api/question` has constructors for the common plain-string case:

```gleam
question.noul("Is the customer asking for a human agent?")

question.noul_with_criteria(
  "Has the customer contacted support about this before?",
  when_true: "Mentions a prior attempt, ticket, or that they asked before",
  when_false: "No sign of any previous contact",
)

question.choice("Which team should handle this?", ["billing", "technical", "other"])

question.described_choice("Which team should handle this?", [
  #("billing", "Charges, invoices, refunds, subscriptions"),
  #("technical", "Bugs, outages, integrations"),
  #("other", "None of the above"),
])

question.score("How frustrated is the customer?", ["Calm", "Frustrated", "Very angry"])
```

Instructions and criteria are JSON on the wire, so a structured rubric is just
a `gleam/json` value in the record:

```gleam
question.Score(
  instructions: json.string("How large is this pull request?"),
  levels: [
    json.object([
      #("summary", json.string("One change, clearly stated")),
      #("signals", json.array(["A single fix or feature"], json.string)),
    ]),
    json.object([
      #("summary", json.string("Several independent changes bundled together")),
      #("signals", json.array(["Two or more unrelated fixes"], json.string)),
    ]),
  ],
)
```

Structured levels come back verbatim in `Level.label` as a `Dynamic`.

## State

`state` is any JSON. TypeSafe recommends an object so each part of the context
has a descriptive name, a string for a single piece of text, or an array for a
sequence of messages or records. A request is budgeted at roughly 32,000
tokens. `null` is rejected with a `ValidationError`.

```gleam
json.object([
  #("ticket", json.object([
    #("subject", json.string("Duplicate charge")),
    #("messages", json.array(messages, message_to_json)),
  ])),
  #("refund_policy", json.string("Duplicate charges are eligible for a refund.")),
])
```

## Errors

`evaluate_response` and `models_response` classify failures by the API's own
error envelope first and the HTTP status second:

| Error                                  | When                                                             |
| -------------------------------------- | ---------------------------------------------------------------- |
| `AuthenticationError(message)`         | missing, invalid or unauthorised key (401/403)                   |
| `ValidationError(problems)`            | the request body failed validation (422), one problem per field |
| `RateLimited(message, retry_after)`    | 429; `retry_after` is the `retry-after` header in seconds        |
| `Overloaded(message)`                  | 529; retry after a delay                                         |
| `ApiError(status, error_type, message)` | any other `error_type` envelope, e.g. `api_usage_error` for an unknown model |
| `UnexpectedResponse(status, body)`     | a non-success status this library does not recognise            |
| `MalformedResponse(status, body, error)` | a success status whose body did not decode                     |

`jev_api.request_id(res)` reads TypeSafe's request id from any response, including
errors, for support requests.

## Models and configuration

```gleam
// Ask a different model. `models_request`/`models_response` list the ones
// your account can use (currently `jev-latest` and `jev-preview`).
let client = jev_api.new(api_key) |> jev_api.with_model("jev-preview")

// Go through a proxy or a mock server. A path prefix is kept.
let assert Ok(client) = jev_api.with_base_url(client, "https://proxy.internal/typesafe")
```

Not modelled yet: the undocumented `bounding_box` question type the API
advertises in its validation errors.

## Development

The toolchain is pinned in `mise.toml` (Gleam 1.18.1, Erlang/OTP 29).

```sh
gleam test                 # unit tests against captured API responses
TYPESAFE_API_KEY=... gleam dev   # live smoke test: lists models, evaluates a ticket
gleam format src test dev
```

### Releasing

Releases are automated with [`version_bump`](https://hex.pm/packages/version_bump),
a Gleam port of semantic-release, installed as a dev dependency and configured
under `[tools.version_bump]` in `gleam.toml`. Write
[Conventional Commits](https://www.conventionalcommits.org): `fix:` cuts a
patch, `feat:` a minor, and a `BREAKING CHANGE:` footer or `!` a major. Commits
of other types release nothing.

The package stays in 0.x while the API surface settles. `initial_development =
true` gives SemVer's initial-development semantics: the first release is 0.1.0,
and a breaking change bumps the minor rather than the major, so `0.3.1` becomes
`0.4.0`. To leave 0.x, cut 1.0.0 by hand (set `version` in `gleam.toml`,
commit, tag it `v1.0.0` and `gleam publish`); version_bump then bumps from that
tag and the flag has no further effect.

Preview the next version and release notes locally:

```sh
gleam run -m version_bump -- --dry-run
```

On every push to `main`, `.github/workflows/release.yml` runs the tests, then
`gleam run -m version_bump`, which writes the version into `gleam.toml`,
commits and tags it, publishes to Hex and creates a GitHub Release. It needs
one secret, `HEX_API_KEY` (a hex.pm key with publish permission), and the
workflow's `contents: write` permission covers the push and the release.
