Skip to content
Open
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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ sttp-ai is a Scala library providing a non-official client wrapper for OpenAI, C
- Native Gemini (Google) API support via the Interactions API with dedicated module
- OpenAI-compatible API support (Ollama, Grok, OpenRouter, etc.)
- Streaming support for all major effect systems
- Cross-platform: Scala 2.13.18 and Scala 3.3.8
- Cross-platform: Scala 2.12.20, 2.13.18 and Scala 3.3.8
- Agent loop tools loadable from [MCP](https://modelcontextprotocol.io) servers (`mcp` module, Scala 3 only, via [chimp](https://github.com/softwaremill/chimp)), in addition to manually defined `AgentTool`s

## Development Commands
Expand Down Expand Up @@ -137,7 +137,7 @@ Each streaming module (`streaming/{effect-system}/`) provides extensions for **a
- Claude: Requires `ANTHROPIC_API_KEY`, minimal token usage
- Gemini: Requires `GEMINI_API_KEY`, minimal token usage
- Auto-skip if API key not set
- **Cross-building**: sbt-projectmatrix for Scala 2.13.18 & 3.3.8
- **Cross-building**: sbt-projectmatrix for Scala 2.12.20, 2.13.18 & 3.3.8

## Client Implementation Patterns

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Add the following dependency:
// For streaming support, add one or more (these modules are shared across OpenAI, Claude, and Gemini):
"com.softwaremill.sttp.ai" %% "fs2" % "0.8.0" // cats-effect/fs2
"com.softwaremill.sttp.ai" %% "zio" % "0.8.0" // ZIO
"com.softwaremill.sttp.ai" %% "akka" % "0.8.0" // Akka Streams (Scala 2.13 only)
"com.softwaremill.sttp.ai" %% "akka" % "0.8.0" // Akka Streams (Scala 2.12/2.13 only)
"com.softwaremill.sttp.ai" %% "pekko" % "0.8.0" // Pekko Streams
"com.softwaremill.sttp.ai" %% "ox" % "0.8.0" // Ox direct-style (Scala 3 only)
```
Expand All @@ -55,12 +55,12 @@ Add the following dependency:
// For streaming support, add one or more (these modules are shared across OpenAI, Claude, and Gemini):
"com.softwaremill.sttp.ai" %% "fs2" % "0.8.0" // cats-effect/fs2
"com.softwaremill.sttp.ai" %% "zio" % "0.8.0" // ZIO
"com.softwaremill.sttp.ai" %% "akka" % "0.8.0" // Akka Streams (Scala 2.13 only)
"com.softwaremill.sttp.ai" %% "akka" % "0.8.0" // Akka Streams (Scala 2.12/2.13 only)
"com.softwaremill.sttp.ai" %% "pekko" % "0.8.0" // Pekko Streams
"com.softwaremill.sttp.ai" %% "ox" % "0.8.0" // Ox direct-style (Scala 3 only)
```

sttp-openai is available for Scala 2.13 and Scala 3
sttp-openai is available for Scala 2.12, 2.13 and Scala 3

Then head to the [documentation](https://sttp-ai.softwaremill.com) for usage examples: OpenAI, Claude, and Gemini clients, streaming, structured outputs, tool calling, and the agent loop — plus the `agent-testkit` module for testing agents without calling a paid API.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ trait RecordedInteractions {
/** The tools offered on any request, deduplicated by name. Note that the agent loop withholds tools on the last allowed iteration, so
* with `maxIterations(1)` no tools are ever offered and this is empty.
*/
final def offeredTools: Seq[OfferedTool] = requests.flatMap(_.toolsOffered).distinctBy(_.name)
final def offeredTools: Seq[OfferedTool] = {
val seen = scala.collection.mutable.Set.empty[String]
requests.flatMap(_.toolsOffered).filter(t => seen.add(t.name))
}

/** All (toolName, result) pairs fed back to the model, from the final history, in order. */
final def toolResultsSent: Seq[(String, String)] =
Expand Down
7 changes: 5 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import com.softwaremill.Publish.{ossPublishSettings, updateDocs}
import com.softwaremill.SbtSoftwareMillCommon.commonSmlBuildSettings
import com.softwaremill.UpdateVersionInDocs

val scala2 = List("2.13.18")
val scala2 = List("2.13.18", "2.12.20")
val scala3 = List("3.3.8")

def dependenciesFor(version: String)(deps: (Option[(Long, Long)] => ModuleID)*): Seq[ModuleID] =
Expand All @@ -13,7 +13,10 @@ lazy val commonSettings = commonSmlBuildSettings ++ ossPublishSettings ++ Seq(
organization := "com.softwaremill.sttp.ai",
// Suppress ScalaTest Assertion unused value warnings in tests
Test / scalacOptions += "-Wconf:msg=unused value of type org.scalatest.Assertion:silent",
Test / scalacOptions += "-Wconf:msg=discarded non-Unit value of type org.scalatest.Assertion:silent"
Test / scalacOptions += "-Wconf:msg=discarded non-Unit value of type org.scalatest.Assertion:silent",
// 2.12 has no `scala.annotation.unused` to suppress warnings per-site (see sttp.ai.core.compat.unused), so silence the category there;
// 2.13 keeps full unused checking
scalacOptions ++= (if (scalaVersion.value.startsWith("2.12")) Seq("-Wconf:msg=never used:silent") else Seq.empty)
)

lazy val root = (project in file("."))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ object ClaudeManualCodecs {
// (e.g. a property-less object, since `properties` is a required field; or union types) falls back to `CustomRaw`. Caveat:
// a decode -> re-encode round trip through this codec is therefore lossy whenever the original schema had structure `Custom`
// can't express, even though the decode step itself reports no error.
case _ => c.as[Tool.Custom].orElse(decodeCustomRaw(c))
case _ => c.as[Tool.Custom].left.flatMap(_ => decodeCustomRaw(c))
}
),
Encoder.instance {
Expand Down
6 changes: 6 additions & 0 deletions core/src/main/scala-2.12/sttp/ai/core/compat/unused.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package sttp.ai.core.compat

/** Cross-version stand-in for `scala.annotation.unused`, which does not exist on Scala 2.12: inert here, aliased to the real annotation in
* the scala-2.13 and scala-3 source trees.
*/
class unused extends scala.annotation.StaticAnnotation
6 changes: 6 additions & 0 deletions core/src/main/scala-2.13/sttp/ai/core/compat/package.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package sttp.ai.core

/** See the scala-2.12 `unused` stand-in: on 2.13 the real annotation exists, so alias it. */
package object compat {
type unused = scala.annotation.unused
}
4 changes: 4 additions & 0 deletions core/src/main/scala-3/sttp/ai/core/compat/compat.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package sttp.ai.core.compat

/** See the scala-2.12 `unused` stand-in: on Scala 3 the real annotation exists, so alias it. */
type unused = scala.annotation.unused
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package sttp.ai.core.agent

import scala.annotation.unused
import sttp.ai.core.compat.unused

/** Middleware for the agent loop. Interceptors wrap iterations, LLM calls, and tool executions (onion-style, like sttp backend wrappers),
* and can steer the loop via [[decide]].
Expand Down
2 changes: 1 addition & 1 deletion core/src/main/scala/sttp/ai/core/agent/LoopAgent.scala
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ private[agent] class LoopAgent[F[_], In, Out](
extends Agent[F, In, Out] {
import LoopAgent.{ContinueLoop, Finished, IterationOutcome}

private val toolMap = config.userTools.map(t => t.name -> t).toMap
private val toolMap: Map[String, AgentTool[F, _]] = config.userTools.map(t => (t.name, t: AgentTool[F, _])).toMap

private val interceptor: AgentInterceptor[F] = AgentInterceptor.compose(config.interceptors)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ object RetryingBackend {
private def retryAfter(response: Response[_]): Option[FiniteDuration] =
response
.header(HeaderNames.RetryAfter)
.flatMap(_.trim.toLongOption)
.flatMap(s => scala.util.Try(s.trim.toLong).toOption)
.filter(seconds => seconds >= 0 && seconds <= MaxRetryAfter.toSeconds)
.map(_.seconds)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ class AgentInterceptorComposeSpec extends AnyFlatSpec with Matchers {
val second = new AgentInterceptor[Identity] {
override def decide(s: AgentRunState): LoopDecision = LoopDecision.FinishNow(FinishReason.BudgetExceeded, "second")
}
val composed = AgentInterceptor.compose(Seq(AgentInterceptor.noop[Identity], first, second))
val composed = AgentInterceptor.compose[Identity](Seq(AgentInterceptor.noop[Identity], first, second))
composed.decide(state()) shouldBe LoopDecision.FinishNow(FinishReason.Custom("deadline"), "first")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class RetryingBackendSpec extends AnyFlatSpec with Matchers {
DefaultSyncBackend.stub.whenAnyRequest.thenRespondF { _ =>
val i = attempts.getAndIncrement()
val (code, headers) = responses(math.min(i, responses.size - 1))
ResponseStub.adjust("body", code, headers)
ResponseStub.adjust("body", code, headers.toList)
}

private def noHeaders(codes: StatusCode*): Seq[(StatusCode, Seq[Header])] = codes.map(_ -> Seq.empty[Header])
Expand Down
50 changes: 50 additions & 0 deletions docs/adr/0004-scala-2-12-cross-building.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# 4. Cross-build the Scala 2 modules for Scala 2.12

Date: 2026-08-12

## Status

Proposed.

## Context

Some consumers run large Scala 2.12 codebases where a 2.13 migration is a multi-month project, but
adopting an AI client library is needed now. sttp-ai publishes only 2.13 and 3 artifacts, while every
dependency the build pins (sttp client4, tapir-apispec-docs, circe, circe-generic-extras, sttp-apispec,
pekko, akka 2.6, scalatest) already publishes `_2.12` artifacts at those exact versions — so the gap is
in this build, not the ecosystem. Existing 2.13 and 3 support must not regress, in behaviour or in
compiler-warning hygiene.

## Decision

Add Scala 2.12.20 to the `scala2` build-matrix row, cross-building every module that already builds for
2.13: `core`, `openai`, `claude`, `gemini`, `agent-testkit`, and the fs2/zio/pekko/akka streaming
modules. The Scala 3-only modules (`mcp`, `ox`, `examples`, `docs`) and the Scala Native targets are
unchanged.

Shared sources are kept cross-compatible rather than duplicated:

* 2.13-only stdlib calls are replaced with equivalents available on both (`String.toLongOption`,
`Either.orElse`, `Seq.distinctBy`).
* `sttp.ai.core.compat.unused` papers over `scala.annotation.unused`, which does not exist on 2.12:
an alias to the real annotation on 2.13/3, an inert annotation on 2.12, with the unused-warning
message silenced on 2.12 rows only so 2.13 keeps full checking.
* Immutable collections are materialised where 2.12's default `scala.collection.Seq` is not accepted
(sttp's `multipartBody`, pekko/akka `Source`), and type ascriptions added where 2.12's weaker
inference fails.
* `Attachment` references `assistants.Tool` fully qualified: 2.12 resolves same-package members ahead
of explicit imports ([scala/bug#4695](https://github.com/scala/bug/issues/4695), fixed in 2.13), which
silently bound the wrong `Tool` type there.

## Consequences

* 2.12 users get the full Scala 2 surface, including the akka streaming module that exists for them.
* Shared sources are restricted to the 2.12-compatible subset of the standard library; the 2.12 CI rows
enforce this on every PR, so the constraint is discovered at review time, not by consumers.
* Dependency updates must keep `_2.12` artifacts available. When a pinned dependency drops 2.12, that is
the exit criterion: the 2.12 row is removed in the next breaking release rather than holding the
dependency back.
* The import-precedence difference is a silent-miscompilation hazard unique to 2.12 (code compiles with a
different meaning, not an error); the cross-version test suite is the guard against it.
* Per-site unused-warning suppression is unavailable on 2.12; that warning category is silenced row-wide
there as the least-bad option.
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ sttp-ai uses [sttp client](https://github.com/softwaremill/sttp) to describe req

## Why sttp-ai?

sttp-ai implements the native APIs of all three major providers — OpenAI, Claude, and Gemini — in one library, rather than funnelling everything through an OpenAI-compatibility shim. You bring your own effect system: cats-effect, ZIO, Akka/Pekko Streams, direct-style [Ox](https://github.com/softwaremill/ox), or plain blocking calls. Structured-output schemas and tool definitions are derived from case classes instead of written by hand, and the built-in agent loop gives you typed tools and typed results, with tools loadable from MCP servers. Cross-built for Scala 2.13 and Scala 3, with Scala Native support (Scala 3) for the core and provider modules.
sttp-ai implements the native APIs of all three major providers — OpenAI, Claude, and Gemini — in one library, rather than funnelling everything through an OpenAI-compatibility shim. You bring your own effect system: cats-effect, ZIO, Akka/Pekko Streams, direct-style [Ox](https://github.com/softwaremill/ox), or plain blocking calls. Structured-output schemas and tool definitions are derived from case classes instead of written by hand, and the built-in agent loop gives you typed tools and typed results, with tools loadable from MCP servers. Cross-built for Scala 2.12, 2.13 and Scala 3, with Scala Native support (Scala 3) for the core and provider modules.

```{eval-rst}
.. toctree::
Expand Down
8 changes: 4 additions & 4 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Add the following dependency:
// For streaming support, add one or more (these modules are shared across OpenAI, Claude, and Gemini):
"com.softwaremill.sttp.ai" %% "fs2" % "@VERSION@" // cats-effect/fs2
"com.softwaremill.sttp.ai" %% "zio" % "@VERSION@" // ZIO
"com.softwaremill.sttp.ai" %% "akka" % "@VERSION@" // Akka Streams (Scala 2.13 only)
"com.softwaremill.sttp.ai" %% "akka" % "@VERSION@" // Akka Streams (Scala 2.12/2.13 only)
"com.softwaremill.sttp.ai" %% "pekko" % "@VERSION@" // Pekko Streams
"com.softwaremill.sttp.ai" %% "ox" % "@VERSION@" // Ox direct-style (Scala 3 only)
```
Expand Down Expand Up @@ -48,7 +48,7 @@ Add the following dependency:
// For streaming support, add one or more (these modules are shared across OpenAI, Claude, and Gemini):
"com.softwaremill.sttp.ai" %% "fs2" % "@VERSION@" // cats-effect/fs2
"com.softwaremill.sttp.ai" %% "zio" % "@VERSION@" // ZIO
"com.softwaremill.sttp.ai" %% "akka" % "@VERSION@" // Akka Streams (Scala 2.13 only)
"com.softwaremill.sttp.ai" %% "akka" % "@VERSION@" // Akka Streams (Scala 2.12/2.13 only)
"com.softwaremill.sttp.ai" %% "pekko" % "@VERSION@" // Pekko Streams
"com.softwaremill.sttp.ai" %% "ox" % "@VERSION@" // Ox direct-style (Scala 3 only)
```
Expand Down Expand Up @@ -87,7 +87,7 @@ Add the following dependency:
// For streaming support, add one or more (these modules are shared across OpenAI, Claude, and Gemini):
"com.softwaremill.sttp.ai" %% "fs2" % "@VERSION@" // cats-effect/fs2
"com.softwaremill.sttp.ai" %% "zio" % "@VERSION@" // ZIO
"com.softwaremill.sttp.ai" %% "akka" % "@VERSION@" // Akka Streams (Scala 2.13 only)
"com.softwaremill.sttp.ai" %% "akka" % "@VERSION@" // Akka Streams (Scala 2.12/2.13 only)
"com.softwaremill.sttp.ai" %% "pekko" % "@VERSION@" // Pekko Streams
"com.softwaremill.sttp.ai" %% "ox" % "@VERSION@" // Ox direct-style (Scala 3 only)
```
Expand All @@ -111,4 +111,4 @@ object GeminiHello:

See [Gemini API basics](gemini/basics.md) for more.

sttp-ai is available for Scala 2.13 and Scala 3.
sttp-ai is available for Scala 2.12, 2.13 and Scala 3.
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ class GeminiClientSerializationSpec extends AnyFlatSpec with Matchers with Eithe
it should "preserve replayed function_call arguments and function_result results verbatim" in {
val arguments = parse("""{"city":"Paris","unit":null}""").value
val result = parse("""{"temp":20,"error":null}""").value
val request = InteractionRequest(
val request: InteractionRequest = InteractionRequest(
model = testModel,
input = InteractionInput.StepsInput(
List(Step.FunctionCall("c1", "get_weather", arguments), Step.FunctionResult("c1", "get_weather", result))
Expand Down
12 changes: 6 additions & 6 deletions openai/src/main/scala/sttp/ai/openai/OpenAI.scala
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,8 @@ class OpenAI(
.multipartBody {
import imageEditsConfig._
val imageParts = image match {
case singleImage :: Nil => Seq(multipartFile("image", singleImage))
case _ => image.map(img => multipartFile("image[]", img))
case singleImage :: Nil => List(multipartFile("image", singleImage))
case _ => image.map(img => multipartFile("image[]", img)).toList
}
imageParts ++ Seq(
Some(multipart("prompt", prompt)),
Expand All @@ -212,7 +212,7 @@ class OpenAI(
responseFormat.map(format => multipart("response_format", ResponseFormat.asString(format))),
stream.map(s => multipart("stream", s.toString)),
user.map(u => multipart("user", u))
).flatten
).flatten.toList
}
.response(asJson_parseErrors[ImageResponse])

Expand Down Expand Up @@ -274,7 +274,7 @@ class OpenAI(
size.map(s => multipart("size", s.value)),
responseFormat.map(format => multipart("response_format", ResponseFormat.asString(format))),
user.map(multipart("user", _))
).flatten
).flatten.toList
}
.response(asJson_parseErrors[ImageResponse])

Expand Down Expand Up @@ -739,7 +739,7 @@ class OpenAI(
responseFormat.map(format => multipart("response_format", asJson(format))),
temperature.map(i => multipart("temperature", i.toString)),
language.map(multipart("language", _))
).flatten
).flatten.toList
}
.response(asJson_parseErrors[AudioResponse])

Expand Down Expand Up @@ -814,7 +814,7 @@ class OpenAI(
responseFormat.map(format => multipart("response_format", ResponseFormat.asString(format))),
temperature.map(t => multipart("temperature", t.toString)),
language.map(lang => multipart("language", lang.value))
).flatten
).flatten.toList
}
.response(asJson_parseErrors[AudioResponse])

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ object SchemaSupport {
.map(_.flatMap(_.asString).toSet)
.getOrElse(Set.empty)

val state = value.toList.foldRight(FolderState(Nil, addAdditionalProperties = false, Nil)) { case ((k, v), acc) =>
val state: FolderState = value.toList.foldRight(FolderState(Nil, addAdditionalProperties = false, Nil)) { case ((k, v), acc) =>
if (k == "properties") {
// The container map's own entries are parameter NAMES (which may themselves be "properties", "type", "required", ...), not
// schema keywords: fold each entry's schema VALUE, but never fold the container object itself through `onObject` (F6 - doing so
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
package sttp.ai.openai.requests.completions.chat.message

import sttp.ai.openai.requests.assistants.Tool
import sttp.ai.openai.requests.assistants

case class Attachment(fileId: Option[String] = None, tools: Option[Seq[Tool]] = None)
case class Attachment(fileId: Option[String] = None, tools: Option[Seq[assistants.Tool]] = None)
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ class AkkaClientSpec extends AsyncFlatSpec with Matchers with EitherValues {
val events = (eventsToProcess :+ emptyEvent) :+ DoneEvent

val delimiter = "\n\n"
val streamedResponse = Source(events)
val streamedResponse = Source(events.toList)
.map(_.toString + delimiter)
.map(ByteString(_))

Expand All @@ -126,7 +126,7 @@ class AkkaClientSpec extends AsyncFlatSpec with Matchers with EitherValues {
val events = (eventsToProcess :+ DoneEvent) ++ eventsToProcess

val delimiter = "\n\n"
val streamedResponse = Source(events)
val streamedResponse = Source(events.toList)
.map(_.toString + delimiter)
.map(ByteString(_))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ class PekkoClientSpec extends AsyncFlatSpec with Matchers with EitherValues {
val events = (eventsToProcess :+ emptyEvent) :+ DoneEvent

val delimiter = "\n\n"
val streamedResponse = Source(events)
val streamedResponse = Source(events.toList)
.map(_.toString + delimiter)
.map(ByteString(_))

Expand All @@ -126,7 +126,7 @@ class PekkoClientSpec extends AsyncFlatSpec with Matchers with EitherValues {
val events = (eventsToProcess :+ DoneEvent) ++ eventsToProcess

val delimiter = "\n\n"
val streamedResponse = Source(events)
val streamedResponse = Source(events.toList)
.map(_.toString + delimiter)
.map(ByteString(_))

Expand Down