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
23 changes: 19 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,8 @@ object Main extends IOApp.Simple {
val run: IO[Unit] =
EmberClientBuilder.default[IO].build.use { client =>
for {
config <- JevConfig.fromEnv[IO]
jev = Jev.instance[IO](config, client)
provider <- Provider.typeSafeFromEnv[IO]
jev = Jev.instance[IO](JevConfig(provider), client)
result <- jev.evaluate(
Ticket("Payouts", "Help! My payouts have been failing for 3 days."),
triage,
Expand Down Expand Up @@ -118,18 +118,33 @@ on topic: 92%

## Configuration

`JevConfig.fromEnv` reads the same environment variables as the official SDKs:
A `Provider` knows where the API lives, how to authenticate with it and which model to use by default. `Provider.typeSafeFromEnv` reads the same environment variables as the official SDKs:

| Variable | Required | Default |
| --- | --- | --- |
| `TYPESAFE_API_KEY` | yes | |
| `TYPESAFE_BASE_URL` | no | `https://api.typesafe.ai` |
| `TYPESAFE_DEFAULT_MODEL` | no | `jev-latest` |

You can also construct a `JevConfig` directly. Requests that fail with 408, 429 or 5xx are retried with exponential backoff (2 retries by default, see `JevConfig.RetryConfig`). Timeouts and connection pooling are up to the `Client` you provide.
You can also construct one directly with `Provider.typeSafe[IO](ApiKey(...))`. Requests that fail with 408, 429 or 5xx are retried with exponential backoff (2 retries by default, see `JevConfig.RetryConfig`). Timeouts and connection pooling are up to the `Client` you provide.

To use a different model for some requests, use `jev.withModel(ModelId("jev-1.13.0"))`. `jev.models` lists the available models.

### Cloudflare Workers AI (Clef)

Cloudflare's [Clef models](https://developers.cloudflare.com/workers-ai/models/clef/) speak the same System One format, so the same questions work against them:

```scala
val provider = Provider.workersAI[IO](accountId, ApiKey(apiToken)) // or Provider.workersAIFromEnv[IO]
val jev = Jev.instance[IO](JevConfig(provider), client).withModel(ModelId.clefFlash)
```

`Provider.workersAIFromEnv` reads `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_AUTH_TOKEN` and optionally `CLOUDFLARE_MODEL` (`clef` by default). The token needs the "Workers AI - Read" and "Workers AI - Edit" permissions. `jev.models` isn't supported on Workers AI. `example.ClefTriage` runs the triage example against Clef.

### Other APIs

Anything else that serves the System One format can be plugged in by implementing `Provider`: its endpoints, how it adds credentials to a request, and how to find the answers in its response body if they're wrapped.

## License

Licensed under the [Apache License, Version 2.0](LICENSE).
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ ThisBuild / mergifyStewardConfig ~= (_.map(_.withMergeMinors(true)))

val hearthVersion = "0.4.2"
val kindlingsVersion = "0.3.2"
val http4sVersion = "0.23.37"
val http4sVersion = "0.23.38"
val circeVersion = "0.14.16"

lazy val core = crossProject(JVMPlatform, JSPlatform, NativePlatform)
Expand Down
95 changes: 47 additions & 48 deletions core/src/main/scala/jev4s/Jev.scala
Original file line number Diff line number Diff line change
Expand Up @@ -17,29 +17,24 @@
package jev4s

import cats.effect.Concurrent
import cats.effect.MonadCancelThrow
import cats.effect.Resource
import cats.effect.Temporal
import cats.effect.std.Env
import cats.syntax.all.*
import io.circe.Decoder
import io.circe.Encoder
import io.circe.Json
import jev4s.internal.ModelList
import jev4s.internal.ResponseBody
import org.http4s.EntityDecoder
import org.http4s.Headers
import org.http4s.Method
import org.http4s.Request
import org.http4s.Response
import org.http4s.Status
import org.http4s.Uri
import org.http4s.circe.*
import org.http4s.client.Client
import org.http4s.client.middleware.Retry
import org.http4s.client.middleware.RetryPolicy
import org.http4s.headers.Authorization
import org.http4s.implicits.*
import org.http4s.AuthScheme
import org.http4s.Credentials

import scala.concurrent.duration.*

Expand All @@ -54,14 +49,12 @@ trait Jev[F[_]] {

def models: F[List[ModelCard]]

/** The same client, sending `model` instead of the configured one. */
/** The same client, sending `model` instead of the provider's default. */
def withModel(model: ModelId): Jev[F]
}

final case class JevConfig(
apiKey: ApiKey,
baseUri: Uri = uri"https://api.typesafe.ai",
model: ModelId = ModelId.latest,
final case class JevConfig[F[_]](
provider: Provider[F],
retry: JevConfig.RetryConfig = JevConfig.RetryConfig.default,
)

Expand All @@ -74,26 +67,12 @@ object JevConfig {
val disabled: RetryConfig = RetryConfig(maxRetries = 0, maxBackoff = Duration.Zero)
}

/** Reads `TYPESAFE_API_KEY` (required), `TYPESAFE_BASE_URL` and `TYPESAFE_DEFAULT_MODEL`, like
* the official SDKs.
*/
def fromEnv[F[_]: Env: Concurrent]: F[JevConfig] =
(
Env[F].get("TYPESAFE_API_KEY").flatMap(_.liftTo[F](JevError.MissingApiKey)),
Env[F].get("TYPESAFE_BASE_URL").flatMap(_.traverse(Uri.fromString(_).liftTo[F])),
Env[F].get("TYPESAFE_DEFAULT_MODEL"),
).mapN { (key, baseUri, model) =>
val base = JevConfig(ApiKey(key))
base.copy(
baseUri = baseUri.getOrElse(base.baseUri),
model = model.fold(base.model)(ModelId(_)),
)
}

}

enum JevError(message: String) extends Exception(message) {
case MissingApiKey extends JevError("TYPESAFE_API_KEY is not set")
case MissingEnv(name: String) extends JevError(s"$name is not set")
case Unsupported(operation: String)
extends JevError(s"Not supported by this provider: $operation")
case InvalidQuestion(reason: String) extends JevError(s"Invalid question: $reason")
case Unauthorized(body: String) extends JevError(s"Unauthorized: $body")
case Unprocessable(body: Json) extends JevError(s"Request failed validation: ${body.noSpaces}")
Expand All @@ -109,8 +88,16 @@ object Jev {
/** Timeouts, connection pooling etc. are up to the `Client` you provide; retries are added on top
* of it.
*/
def instance[F[_]: Temporal](config: JevConfig, client: Client[F]): Jev[F] =
JevImpl(config, withRetries(config.retry, client))
def instance[F[_]: Temporal](config: JevConfig[F], client: Client[F]): Jev[F] =
JevImpl(
config.provider,
config.provider.defaultModel,
withRetries(config.retry, authorized(config.provider, client)),
)

// Under the retries, so that every attempt asks the provider for credentials.
private def authorized[F[_]: MonadCancelThrow](provider: Provider[F], client: Client[F])
: Client[F] = Client(request => Resource.eval(provider.authorize(request)).flatMap(client.run))

// Same statuses as the official SDKs: 408, 429, 5xx (529 included). Retry-After is honored by the middleware.
private def withRetries[F[_]: Temporal](config: JevConfig.RetryConfig, client: Client[F])
Expand All @@ -130,17 +117,22 @@ object Jev {
Headers.SensitiveHeaders.contains,
)(client)

private final class JevImpl[F[_]: Concurrent] private[Jev] (config: JevConfig, client: Client[F])
extends Jev[F] {
private final class JevImpl[F[_]: Concurrent] private[Jev] (
provider: Provider[F],
model: ModelId,
client: Client[F],
) extends Jev[F] {

def evaluate[S: Encoder, A](state: S, question: Question[A]): F[Evaluation[A]] =
for {
body <- Question
.requestBody(state, question, config.model)
.requestBody(state, question, model)
.leftMap(JevError.InvalidQuestion(_))
.liftTo[F]
response <- client
.run(authorized(Method.POST, "v1/systemone").withEntity(body))
.run(
Request[F](Method.POST, provider.evaluateUri(model)).withEntity(body)
)
.use(decodeOrFail[ResponseBody])
evaluation <- Question
.decodeResponse(question, response)
Expand All @@ -149,21 +141,29 @@ object Jev {
} yield evaluation

def models: F[List[ModelCard]] =
client
.run(authorized(Method.GET, "v1/models"))
.use(decodeOrFail[ModelList])
.map(_.models)

def withModel(model: ModelId): Jev[F] = JevImpl(config.copy(model = model), client)
provider.modelsUri match {
case Some(uri) =>
client
.run(Request[F](Method.GET, uri))
.use(decodeOrFail[ModelList])
.map(_.models)
case None => JevError.Unsupported("listing models").raiseError
}

private def authorized(method: Method, path: String): Request[F] =
Request[F](method, config.baseUri.addPath(path))
.putHeaders(Authorization(Credentials.Token(AuthScheme.Bearer, config.apiKey.value)))
def withModel(model: ModelId): Jev[F] = JevImpl(provider, model, client)

private def decodeOrFail[A: Decoder](response: Response[F]): F[A] = {
given EntityDecoder[F, A] = jsonOf[F, A]
private def decodeOrFail[A: Decoder](response: Response[F]): F[A] =
response.status match {
case s if s.isSuccess => response.as[A]
case s if s.isSuccess =>
response
.as[Json]
.flatMap(
provider
.unwrap(_)
.flatMap(_.as[A].leftMap(_.getMessage))
.leftMap(JevError.UnexpectedAnswer(_))
.liftTo[F]
)
case Status.Unauthorized => response.as[String].flatMap(JevError.Unauthorized(_).raiseError)
case Status.UnprocessableContent =>
response.as[Json].flatMap(JevError.Unprocessable(_).raiseError)
Expand All @@ -172,7 +172,6 @@ object Jev {
case s if s.code == 529 => response.as[String].flatMap(JevError.Overloaded(_).raiseError)
case s => response.as[String].flatMap(JevError.UnexpectedStatus(s, _).raiseError)
}
}

}

Expand Down
130 changes: 130 additions & 0 deletions core/src/main/scala/jev4s/Provider.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/*
* Copyright 2026 Polyvariant
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package jev4s

import cats.Applicative
import cats.MonadThrow
import cats.effect.std.Env
import cats.syntax.all.*
import io.circe.Json
import org.http4s.AuthScheme
import org.http4s.Credentials
import org.http4s.Request
import org.http4s.Uri
import org.http4s.headers.Authorization
import org.http4s.implicits.*

/** An API that serves the System One format. Requests and answers look the same everywhere, but
* endpoints and credentials differ: implement this to point jev4s at a new one.
*/
trait Provider[F[_]] {

/** The model to use unless `Jev.withModel` says otherwise. */
def defaultModel: ModelId

/** Where evaluations with `model` are POSTed. */
def evaluateUri(model: ModelId): Uri

/** Where the model list is fetched from, if the API has one. */
def modelsUri: Option[Uri]

/** Adds credentials to a request. Runs before every request, so it can fetch or refresh them. */
def authorize(request: Request[F]): F[Request[F]]

/** Finds the System One response in a successful response body, e.g. inside an envelope. */
def unwrap(body: Json): Either[String, Json] = Right(body)
}

object Provider {

private val typeSafeUri = uri"https://api.typesafe.ai"

/** TypeSafe's own API. */
def typeSafe[F[_]: Applicative](
apiKey: ApiKey,
baseUri: Uri = typeSafeUri,
defaultModel: ModelId = ModelId.latest,
): Provider[F] = {
val model = defaultModel
new Provider[F] {
def defaultModel: ModelId = model
def evaluateUri(model: ModelId): Uri = baseUri / "v1" / "systemone"
def modelsUri: Option[Uri] = Some(baseUri / "v1" / "models")
def authorize(request: Request[F]): F[Request[F]] = bearer(request, apiKey).pure[F]
}
}

/** Reads `TYPESAFE_API_KEY` (required), `TYPESAFE_BASE_URL` and `TYPESAFE_DEFAULT_MODEL`, like
* the official SDKs. They're read once, not on every request.
*/
def typeSafeFromEnv[F[_]: Env: MonadThrow]: F[Provider[F]] =
(
required[F]("TYPESAFE_API_KEY"),
Env[F].get("TYPESAFE_BASE_URL").flatMap(_.traverse(Uri.fromString(_).liftTo[F])),
Env[F].get("TYPESAFE_DEFAULT_MODEL"),
).mapN { (key, baseUri, model) =>
typeSafe[F](
ApiKey(key),
baseUri = baseUri.getOrElse(typeSafeUri),
defaultModel = model.fold(ModelId.latest)(ModelId(_)),
)
}

/** Cloudflare Workers AI, serving the Clef models. `apiToken` needs the "Workers AI - Read" and
* "Workers AI - Edit" permissions. There's no model list, and answers come wrapped in
* Cloudflare's `{"result": ..., "success": ...}` envelope.
*/
def workersAI[F[_]: Applicative](
accountId: String,
apiToken: ApiKey,
baseUri: Uri = uri"https://api.cloudflare.com/client/v4",
defaultModel: ModelId = ModelId.clef,
): Provider[F] = {
val model = defaultModel
new Provider[F] {
def defaultModel: ModelId = model

def evaluateUri(model: ModelId): Uri =
baseUri / "accounts" / accountId / "ai" / "run" / "@cf" / "cloudflare" / model.value

def modelsUri: Option[Uri] = None
def authorize(request: Request[F]): F[Request[F]] = bearer(request, apiToken).pure[F]

override def unwrap(body: Json): Either[String, Json] =
body.hcursor.downField("result").focus.toRight("Missing `result` in the response")
}
}

/** Reads `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_AUTH_TOKEN` (both required) and
* `CLOUDFLARE_MODEL` (`clef` by default). They're read once, not on every request.
*/
def workersAIFromEnv[F[_]: Env: MonadThrow]: F[Provider[F]] =
(
required[F]("CLOUDFLARE_ACCOUNT_ID"),
required[F]("CLOUDFLARE_AUTH_TOKEN"),
Env[F].get("CLOUDFLARE_MODEL"),
).mapN { (accountId, token, model) =>
workersAI[F](accountId, ApiKey(token), defaultModel = model.fold(ModelId.clef)(ModelId(_)))
}

private def required[F[_]: Env: MonadThrow](name: String): F[String] =
Env[F].get(name).flatMap(_.liftTo[F](JevError.MissingEnv(name)))

private def bearer[F[_]](request: Request[F], key: ApiKey): Request[F] =
request.putHeaders(Authorization(Credentials.Token(AuthScheme.Bearer, key.value)))

}
10 changes: 7 additions & 3 deletions core/src/main/scala/jev4s/internal/wire.scala
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,13 @@ private[jev4s] enum QuestionSpec {

private[jev4s] object QuestionSpec {

given Encoder[QuestionSpec] = KindlingsEncoder.derived(
using wireConfig
)
// An absent Noul `criteria` is omitted rather than sent as null: Clef rejects the null. Only the
// top level is filtered, since null is a valid Choice option description.
given Encoder[QuestionSpec] = KindlingsEncoder
.derived[QuestionSpec](
using wireConfig
)
.mapJson(_.mapObject(_.filter((_, v) => !v.isNull)))

}

Expand Down
6 changes: 6 additions & 0 deletions core/src/main/scala/jev4s/model.scala
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ object ModelId {
/** The most recent release, official or not. */
val preview: ModelId = "jev-preview"

/** Cloudflare's 27B multimodal Clef model, for [[Provider.workersAI]]. */
val clef: ModelId = "clef"

/** The faster Clef variant, for [[Provider.workersAI]]. */
val clefFlash: ModelId = "clef-flash"

extension (m: ModelId) {
def value: String = m
}
Expand Down
Loading
Loading