From 28acb75051793d00a447fb29d988aba8e506ec70 Mon Sep 17 00:00:00 2001 From: Jonathan Ferguson Date: Wed, 12 Aug 2026 08:09:04 +1200 Subject: [PATCH 1/2] Add Scala 2.12 cross-building for all Scala 2 modules Adds 2.12.20 to the build matrix for core, openai, claude, gemini, agent-testkit and the fs2/zio/pekko/akka streaming modules. The Scala 3-only modules (mcp, ox, examples, docs) and Scala Native targets are unchanged. Source adjustments, all cross-compatible with 2.13 and 3: - Replace 2.13-only stdlib calls: String.toLongOption, Either.orElse, Seq.distinctBy. - Provide sttp.ai.core.compat.unused: scala.annotation.unused does not exist on 2.12, so it is aliased on 2.13/3 and an inert annotation on 2.12 (with the warning category silenced on 2.12 rows only). - Materialise immutable Seqs where 2.12's default scala.collection.Seq is not accepted (multipartBody in OpenAI.scala, pekko/akka Source in test specs). - Add type ascriptions where 2.12's weaker inference fails (LoopAgent toolMap, SchemaSupport fold state, and two test specs). - Qualify assistants.Tool in Attachment: 2.12 resolves same-package members ahead of explicit imports (scala/bug#4695, fixed in 2.13), which silently bound the wrong Tool type. Docs updated to state 2.12 support. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 4 ++-- README.md | 6 +++--- .../ai/core/agent/testing/RecordedInteractions.scala | 5 ++++- build.sbt | 7 +++++-- .../sttp/ai/claude/json/ClaudeManualCodecs.scala | 2 +- .../main/scala-2.12/sttp/ai/core/compat/unused.scala | 6 ++++++ .../scala-2.13/sttp/ai/core/compat/package.scala | 6 ++++++ .../main/scala-3/sttp/ai/core/compat/compat.scala | 4 ++++ .../scala/sttp/ai/core/agent/AgentInterceptor.scala | 2 +- .../main/scala/sttp/ai/core/agent/LoopAgent.scala | 2 +- .../scala/sttp/ai/core/http/RetryingBackend.scala | 2 +- .../ai/core/agent/AgentInterceptorComposeSpec.scala | 2 +- .../sttp/ai/core/http/RetryingBackendSpec.scala | 2 +- docs/index.md | 2 +- docs/quickstart.md | 8 ++++---- .../gemini/unit/GeminiClientSerializationSpec.scala | 2 +- openai/src/main/scala/sttp/ai/openai/OpenAI.scala | 12 ++++++------ .../requests/completions/chat/SchemaSupport.scala | 2 +- .../completions/chat/message/Attachment.scala | 4 ++-- .../ai/openai/streaming/akka/AkkaClientSpec.scala | 4 ++-- .../ai/openai/streaming/pekko/PekkoClientSpec.scala | 4 ++-- 21 files changed, 55 insertions(+), 33 deletions(-) create mode 100644 core/src/main/scala-2.12/sttp/ai/core/compat/unused.scala create mode 100644 core/src/main/scala-2.13/sttp/ai/core/compat/package.scala create mode 100644 core/src/main/scala-3/sttp/ai/core/compat/compat.scala diff --git a/CLAUDE.md b/CLAUDE.md index a238c8ac..dd83fa15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 diff --git a/README.md b/README.md index 43b6aa45..83627149 100644 --- a/README.md +++ b/README.md @@ -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) ``` @@ -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. diff --git a/agent-testkit/src/main/scala/sttp/ai/core/agent/testing/RecordedInteractions.scala b/agent-testkit/src/main/scala/sttp/ai/core/agent/testing/RecordedInteractions.scala index 040ffd2c..0450ed8f 100644 --- a/agent-testkit/src/main/scala/sttp/ai/core/agent/testing/RecordedInteractions.scala +++ b/agent-testkit/src/main/scala/sttp/ai/core/agent/testing/RecordedInteractions.scala @@ -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)] = diff --git a/build.sbt b/build.sbt index 52375c5c..baedf94d 100644 --- a/build.sbt +++ b/build.sbt @@ -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] = @@ -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(".")) diff --git a/claude/src/main/scala/sttp/ai/claude/json/ClaudeManualCodecs.scala b/claude/src/main/scala/sttp/ai/claude/json/ClaudeManualCodecs.scala index efe8e73a..55918265 100644 --- a/claude/src/main/scala/sttp/ai/claude/json/ClaudeManualCodecs.scala +++ b/claude/src/main/scala/sttp/ai/claude/json/ClaudeManualCodecs.scala @@ -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 { diff --git a/core/src/main/scala-2.12/sttp/ai/core/compat/unused.scala b/core/src/main/scala-2.12/sttp/ai/core/compat/unused.scala new file mode 100644 index 00000000..b504ce41 --- /dev/null +++ b/core/src/main/scala-2.12/sttp/ai/core/compat/unused.scala @@ -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 diff --git a/core/src/main/scala-2.13/sttp/ai/core/compat/package.scala b/core/src/main/scala-2.13/sttp/ai/core/compat/package.scala new file mode 100644 index 00000000..54c4297d --- /dev/null +++ b/core/src/main/scala-2.13/sttp/ai/core/compat/package.scala @@ -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 +} diff --git a/core/src/main/scala-3/sttp/ai/core/compat/compat.scala b/core/src/main/scala-3/sttp/ai/core/compat/compat.scala new file mode 100644 index 00000000..e72dbe4e --- /dev/null +++ b/core/src/main/scala-3/sttp/ai/core/compat/compat.scala @@ -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 diff --git a/core/src/main/scala/sttp/ai/core/agent/AgentInterceptor.scala b/core/src/main/scala/sttp/ai/core/agent/AgentInterceptor.scala index 58393f79..5a25aba8 100644 --- a/core/src/main/scala/sttp/ai/core/agent/AgentInterceptor.scala +++ b/core/src/main/scala/sttp/ai/core/agent/AgentInterceptor.scala @@ -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]]. diff --git a/core/src/main/scala/sttp/ai/core/agent/LoopAgent.scala b/core/src/main/scala/sttp/ai/core/agent/LoopAgent.scala index 76e4fac5..faf3d876 100644 --- a/core/src/main/scala/sttp/ai/core/agent/LoopAgent.scala +++ b/core/src/main/scala/sttp/ai/core/agent/LoopAgent.scala @@ -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) diff --git a/core/src/main/scala/sttp/ai/core/http/RetryingBackend.scala b/core/src/main/scala/sttp/ai/core/http/RetryingBackend.scala index 2c451d4c..fd0a9741 100644 --- a/core/src/main/scala/sttp/ai/core/http/RetryingBackend.scala +++ b/core/src/main/scala/sttp/ai/core/http/RetryingBackend.scala @@ -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) diff --git a/core/src/test/scala/sttp/ai/core/agent/AgentInterceptorComposeSpec.scala b/core/src/test/scala/sttp/ai/core/agent/AgentInterceptorComposeSpec.scala index e1adff15..2e597177 100644 --- a/core/src/test/scala/sttp/ai/core/agent/AgentInterceptorComposeSpec.scala +++ b/core/src/test/scala/sttp/ai/core/agent/AgentInterceptorComposeSpec.scala @@ -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") } diff --git a/core/src/test/scala/sttp/ai/core/http/RetryingBackendSpec.scala b/core/src/test/scala/sttp/ai/core/http/RetryingBackendSpec.scala index b1afeb73..7f350f4f 100644 --- a/core/src/test/scala/sttp/ai/core/http/RetryingBackendSpec.scala +++ b/core/src/test/scala/sttp/ai/core/http/RetryingBackendSpec.scala @@ -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]) diff --git a/docs/index.md b/docs/index.md index 562e0e25..d722983b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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:: diff --git a/docs/quickstart.md b/docs/quickstart.md index 380e34ad..6fdf13d9 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -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) ``` @@ -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) ``` @@ -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) ``` @@ -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. diff --git a/gemini/src/test/scala/sttp/ai/gemini/unit/GeminiClientSerializationSpec.scala b/gemini/src/test/scala/sttp/ai/gemini/unit/GeminiClientSerializationSpec.scala index 26a27e6d..d0c22393 100644 --- a/gemini/src/test/scala/sttp/ai/gemini/unit/GeminiClientSerializationSpec.scala +++ b/gemini/src/test/scala/sttp/ai/gemini/unit/GeminiClientSerializationSpec.scala @@ -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)) diff --git a/openai/src/main/scala/sttp/ai/openai/OpenAI.scala b/openai/src/main/scala/sttp/ai/openai/OpenAI.scala index eed5ec70..c838b08b 100644 --- a/openai/src/main/scala/sttp/ai/openai/OpenAI.scala +++ b/openai/src/main/scala/sttp/ai/openai/OpenAI.scala @@ -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)), @@ -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]) @@ -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]) @@ -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]) @@ -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]) diff --git a/openai/src/main/scala/sttp/ai/openai/requests/completions/chat/SchemaSupport.scala b/openai/src/main/scala/sttp/ai/openai/requests/completions/chat/SchemaSupport.scala index faf36410..a6b926b2 100644 --- a/openai/src/main/scala/sttp/ai/openai/requests/completions/chat/SchemaSupport.scala +++ b/openai/src/main/scala/sttp/ai/openai/requests/completions/chat/SchemaSupport.scala @@ -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 diff --git a/openai/src/main/scala/sttp/ai/openai/requests/completions/chat/message/Attachment.scala b/openai/src/main/scala/sttp/ai/openai/requests/completions/chat/message/Attachment.scala index 6a4fb334..b4e1c6e0 100644 --- a/openai/src/main/scala/sttp/ai/openai/requests/completions/chat/message/Attachment.scala +++ b/openai/src/main/scala/sttp/ai/openai/requests/completions/chat/message/Attachment.scala @@ -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) diff --git a/streaming/akka/src/test/scala/sttp/ai/openai/streaming/akka/AkkaClientSpec.scala b/streaming/akka/src/test/scala/sttp/ai/openai/streaming/akka/AkkaClientSpec.scala index baf09aaa..a95a0a00 100644 --- a/streaming/akka/src/test/scala/sttp/ai/openai/streaming/akka/AkkaClientSpec.scala +++ b/streaming/akka/src/test/scala/sttp/ai/openai/streaming/akka/AkkaClientSpec.scala @@ -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(_)) @@ -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(_)) diff --git a/streaming/pekko/src/test/scala/sttp/ai/openai/streaming/pekko/PekkoClientSpec.scala b/streaming/pekko/src/test/scala/sttp/ai/openai/streaming/pekko/PekkoClientSpec.scala index 3ac126a0..30bc6cb8 100644 --- a/streaming/pekko/src/test/scala/sttp/ai/openai/streaming/pekko/PekkoClientSpec.scala +++ b/streaming/pekko/src/test/scala/sttp/ai/openai/streaming/pekko/PekkoClientSpec.scala @@ -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(_)) @@ -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(_)) From faba76d55430361a3d518c7870e5da3fd42efb5f Mon Sep 17 00:00:00 2001 From: Jonathan Ferguson Date: Wed, 12 Aug 2026 09:48:11 +1200 Subject: [PATCH 2/2] Add ADR 0004 recording the Scala 2.12 cross-building decision Co-Authored-By: Claude Fable 5 --- docs/adr/0004-scala-2-12-cross-building.md | 50 ++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/adr/0004-scala-2-12-cross-building.md diff --git a/docs/adr/0004-scala-2-12-cross-building.md b/docs/adr/0004-scala-2-12-cross-building.md new file mode 100644 index 00000000..3b46473a --- /dev/null +++ b/docs/adr/0004-scala-2-12-cross-building.md @@ -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.