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
10 changes: 4 additions & 6 deletions claude/src/main/scala/sttp/ai/claude/ClaudeClient.scala
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,18 @@ class ClaudeClientImpl(config: ClaudeConfig) extends ClaudeClient with ResponseH

private val claudeUris = new ClaudeUris(config.baseUrl)

/** Beta header for structured outputs feature */
private val StructuredOutputsBetaHeader = "structured-outputs-2025-11-13"

private def claudeAuthRequest =
basicRequest
.header("x-api-key", config.apiKey)
.header("anthropic-version", config.anthropicVersion)
.header("content-type", "application/json")

private def claudeAuthRequestForMessage(request: MessageRequest): PartialRequest[Either[String, String]] =
private def claudeAuthRequestForMessage(request: MessageRequest): PartialRequest[Either[String, String]] = {
if (request.usesStructuredOutput) {
validateModelForStructuredOutput(request.model)
claudeAuthRequest.header("anthropic-beta", StructuredOutputsBetaHeader)
} else claudeAuthRequest
}
claudeAuthRequest
}

private def validateModelForStructuredOutput(modelId: String): Unit =
if (!ClaudeModel.modelSupportsStructuredOutput(modelId)) {
Expand Down
31 changes: 31 additions & 0 deletions claude/src/main/scala/sttp/ai/claude/models/Effort.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package sttp.ai.claude.models

import sttp.ai.core.json.SnakePickle

sealed abstract class Effort(val value: String)

object Effort {

private val LowValue = "low"
private val MediumValue = "medium"
private val HighValue = "high"
private val MaxValue = "max"

case object Low extends Effort(LowValue)
case object Medium extends Effort(MediumValue)
case object High extends Effort(HighValue)
case object Max extends Effort(MaxValue)

implicit val effortRW: SnakePickle.ReadWriter[Effort] = SnakePickle
.readwriter[ujson.Value]
.bimap[Effort](
effort => ujson.Str(effort.value),
{
case ujson.Str(LowValue) => Low
case ujson.Str(MediumValue) => Medium
case ujson.Str(HighValue) => High
case ujson.Str(MaxValue) => Max
case other => throw new Exception(s"Unknown Effort: $other")
}
)
}
12 changes: 12 additions & 0 deletions claude/src/main/scala/sttp/ai/claude/models/OutputConfig.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package sttp.ai.claude.models

import sttp.ai.core.json.SnakePickle.{macroRW, ReadWriter}

case class OutputConfig(
format: Option[OutputFormat] = None,
effort: Option[Effort] = None
)

object OutputConfig {
implicit val rw: ReadWriter[OutputConfig] = macroRW
}
26 changes: 16 additions & 10 deletions claude/src/main/scala/sttp/ai/claude/requests/MessageRequest.scala
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package sttp.ai.claude.requests

import sttp.ai.claude.models.{Message, Tool}
import sttp.ai.claude.models.OutputFormat
import sttp.ai.claude.models.{Effort, Message, OutputConfig, OutputFormat, Tool}
import sttp.ai.core.json.SnakePickle.{macroRW, ReadWriter}

case class MessageRequest(
Expand All @@ -15,39 +14,46 @@ case class MessageRequest(
stopSequences: Option[List[String]] = None,
stream: Option[Boolean] = None,
tools: Option[List[Tool]] = None,
outputFormat: Option[OutputFormat] = None
outputConfig: Option[OutputConfig] = None
) {
def usesStructuredOutput: Boolean = outputFormat.exists(_.isInstanceOf[OutputFormat.JsonSchema])
def usesStructuredOutput: Boolean = outputConfig.exists(_.format.exists(_.isInstanceOf[OutputFormat.JsonSchema]))

def withStructuredOutput(format: OutputFormat): MessageRequest =
this.copy(outputFormat = Some(format))
def withStructuredOutput(format: OutputFormat): MessageRequest = {
val updated = outputConfig.getOrElse(OutputConfig()).copy(format = Some(format))
this.copy(outputConfig = Some(updated))
}

def withEffort(effort: Effort): MessageRequest = {
val updated = outputConfig.getOrElse(OutputConfig()).copy(effort = Some(effort))
this.copy(outputConfig = Some(updated))
}
}

object MessageRequest {
def simple(
model: String,
messages: List[Message],
maxTokens: Int,
outputFormat: Option[OutputFormat] = None
outputConfig: Option[OutputConfig] = None
): MessageRequest = MessageRequest(
model = model,
messages = messages,
maxTokens = maxTokens,
outputFormat = outputFormat
outputConfig = outputConfig
)

def withSystem(
model: String,
system: String,
messages: List[Message],
maxTokens: Int,
outputFormat: Option[OutputFormat] = None
outputConfig: Option[OutputConfig] = None
): MessageRequest = MessageRequest(
model = model,
messages = messages,
system = Some(system),
maxTokens = maxTokens,
outputFormat = outputFormat
outputConfig = outputConfig
)

def withTools(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class ClaudeAgentIntegrationSpec extends AgentIntegrationSpecBase {
val allTools = agentConfig.userTools ++ AgentConfig.systemTools
val agentBackend = new ClaudeAgentBackend[Identity](
client,
"claude-3-haiku-20240307",
"claude-haiku-4-5-20251001",
allTools,
agentConfig.systemPrompt
)(IdentityMonad)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ object Person {
*/
class ClaudeIntegrationSpec extends AnyFlatSpec with Matchers with BeforeAndAfterAll with Eventually {

private val testModel = "claude-haiku-4-5-20251001"

implicit override val patienceConfig: PatienceConfig =
PatienceConfig(timeout = Span(30, Seconds), interval = Span(500, Millis))

Expand Down Expand Up @@ -93,7 +95,7 @@ class ClaudeIntegrationSpec extends AnyFlatSpec with Matchers with BeforeAndAfte
withClient { client =>
// given
val request = MessageRequest.simple(
model = "claude-3-haiku-20240307", // Using the cheapest Claude model
model = testModel, // Using the cheapest Claude model
messages = List(Message.user("Hi")), // Minimal message to reduce cost
maxTokens = 5 // Limit tokens to minimize cost
)
Expand All @@ -119,7 +121,7 @@ class ClaudeIntegrationSpec extends AnyFlatSpec with Matchers with BeforeAndAfte
withClient { client =>
// given
val request = MessageRequest.withSystem(
model = "claude-3-haiku-20240307",
model = testModel,
system = "Be concise.", // Short system prompt
messages = List(Message.user("What is 2+2?")), // Simple question
maxTokens = 10
Expand Down Expand Up @@ -157,7 +159,7 @@ class ClaudeIntegrationSpec extends AnyFlatSpec with Matchers with BeforeAndAfte
)

val request = MessageRequest.simple(
model = "claude-3-haiku-20240307",
model = testModel,
messages = List(imageMessage),
maxTokens = 20
)
Expand Down Expand Up @@ -193,7 +195,7 @@ class ClaudeIntegrationSpec extends AnyFlatSpec with Matchers with BeforeAndAfte
)

val request = MessageRequest.withTools(
model = "claude-3-haiku-20240307",
model = testModel,
messages = List(Message.user("What's the weather in Paris?")),
maxTokens = 50,
tools = List(weatherTool)
Expand Down Expand Up @@ -240,7 +242,7 @@ class ClaudeIntegrationSpec extends AnyFlatSpec with Matchers with BeforeAndAfte
withClient { client =>
// given
val request = MessageRequest.simple(
model = "claude-3-haiku-20240307",
model = testModel,
messages = List(Message.user("Hi")),
maxTokens = 1 // Minimal to reduce cost
)
Expand Down Expand Up @@ -284,7 +286,7 @@ class ClaudeIntegrationSpec extends AnyFlatSpec with Matchers with BeforeAndAfte
// This tests basic message structure rather than complex tool flows

val request = MessageRequest.simple(
model = "claude-3-haiku-20240307",
model = testModel,
messages = List(Message.user("Say 'Hello world' in exactly two words.")),
maxTokens = 10
)
Expand All @@ -311,7 +313,7 @@ class ClaudeIntegrationSpec extends AnyFlatSpec with Matchers with BeforeAndAfte
val outputFormat = OutputFormat.JsonSchema.withTapirSchema[Person]

val request = MessageRequest
.simple("claude-haiku-4-5-20251001", List(Message.user("Generate a person named Alice who is 30 years old.")), 100)
.simple(testModel, List(Message.user("Generate a person named Alice who is 30 years old.")), 100)
.withStructuredOutput(outputFormat)

// when
Expand Down Expand Up @@ -343,14 +345,14 @@ class ClaudeIntegrationSpec extends AnyFlatSpec with Matchers with BeforeAndAfte
val client = ClaudeSyncClient.fromEnv
try {
val request = MessageRequest
.simple("claude-3-haiku-20240307", List(Message.user("Test")), 10)
.simple("claude-3-5-sonnet-20241022", List(Message.user("Test")), 10)
.withStructuredOutput(outputFormat)

client.createMessage(request)
} finally client.close()
}

exception.getMessage should include("claude-3-haiku-20240307")
exception.getMessage should include("claude-3-5-sonnet-20241022")
exception.getMessage should include("does not support structured output")
()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@ package sttp.ai.claude.unit.models

import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import sttp.ai.claude.models.OutputFormat
import sttp.ai.claude.models.{ContentBlock, Effort, Message, OutputConfig, OutputFormat}
import sttp.ai.claude.requests.MessageRequest
import sttp.ai.core.json.SnakePickle
import sttp.tapir.{Schema => TSchema}

class OutputFormatSpec extends AnyFlatSpec with Matchers {
class OutputConfigSpec extends AnyFlatSpec with Matchers {

case class Person(name: String, age: Int)
implicit val personSchema: TSchema[Person] = TSchema.derived[Person]

val sampleMessages: List[Message] = List(Message.user(List(ContentBlock.TextContent("Hello"))))

"OutputFormat.Text" should "serialize to text format" in {
val format: OutputFormat = OutputFormat.Text
val json = SnakePickle.write(format)
Expand Down Expand Up @@ -83,34 +86,74 @@ class OutputFormatSpec extends AnyFlatSpec with Matchers {
deserialized shouldBe original
}

"usesStructuredOutput detection" should "return false for Text and JsonObject" in {
import sttp.ai.claude.requests.MessageRequest
import sttp.ai.claude.models.{ContentBlock, Message}
"Effort" should "serialize to string values" in {
SnakePickle.write(Effort.Low: Effort) shouldBe "\"low\""
SnakePickle.write(Effort.Medium: Effort) shouldBe "\"medium\""
SnakePickle.write(Effort.High: Effort) shouldBe "\"high\""
SnakePickle.write(Effort.Max: Effort) shouldBe "\"max\""
}

it should "round-trip correctly for all values" in
List(Effort.Low, Effort.Medium, Effort.High, Effort.Max).foreach { effort =>
val json = SnakePickle.write(effort: Effort)
val deserialized = SnakePickle.read[Effort](json)
deserialized shouldBe effort
}

"OutputConfig" should "serialize with format only" in {
val config = OutputConfig(format = Some(OutputFormat.Text))
val json = SnakePickle.write(config)
val parsed = ujson.read(json)

parsed("format")("type").str shouldBe "text"
parsed.obj.contains("effort") shouldBe false
}

it should "serialize with effort only" in {
val config = OutputConfig(effort = Some(Effort.High))
val json = SnakePickle.write(config)
val parsed = ujson.read(json)

parsed("effort").str shouldBe "high"
parsed.obj.contains("format") shouldBe false
}

val sampleMessages = List(Message.user(List(ContentBlock.TextContent("Hello"))))
it should "serialize with both format and effort" in {
val config = OutputConfig(format = Some(OutputFormat.JsonObject), effort = Some(Effort.Max))
val json = SnakePickle.write(config)
val parsed = ujson.read(json)

parsed("format")("type").str shouldBe "json_object"
parsed("effort").str shouldBe "max"
}

it should "round-trip correctly" in {
val original = OutputConfig(format = Some(OutputFormat.Text), effort = Some(Effort.Medium))
val json = SnakePickle.write(original)
val deserialized = SnakePickle.read[OutputConfig](json)

deserialized shouldBe original
}

"usesStructuredOutput detection" should "return false for Text and JsonObject" in {
val textRequest = MessageRequest(
model = "claude-sonnet-4-5-20250514",
messages = sampleMessages,
maxTokens = 1024,
outputFormat = Some(OutputFormat.Text)
outputConfig = Some(OutputConfig(format = Some(OutputFormat.Text)))
)
textRequest.usesStructuredOutput shouldBe false

val jsonObjectRequest = MessageRequest(
model = "claude-sonnet-4-5-20250514",
messages = sampleMessages,
maxTokens = 1024,
outputFormat = Some(OutputFormat.JsonObject)
outputConfig = Some(OutputConfig(format = Some(OutputFormat.JsonObject)))
)
jsonObjectRequest.usesStructuredOutput shouldBe false
}

it should "return true for JsonSchema" in {
import sttp.ai.claude.requests.MessageRequest
import sttp.ai.claude.models.{ContentBlock, Message}

val sampleMessages = List(Message.user(List(ContentBlock.TextContent("Hello"))))
val schema = sttp.apispec.Schema(`type` = Some(List(sttp.apispec.SchemaType.Object)))
val jsonSchemaFormat = OutputFormat.JsonSchema(schema)

Expand All @@ -120,12 +163,7 @@ class OutputFormatSpec extends AnyFlatSpec with Matchers {
request.usesStructuredOutput shouldBe true
}

it should "return false when no outputFormat is provided" in {
import sttp.ai.claude.requests.MessageRequest
import sttp.ai.claude.models.{ContentBlock, Message}

val sampleMessages = List(Message.user(List(ContentBlock.TextContent("Hello"))))

it should "return false when no outputConfig is provided" in {
val request = MessageRequest.simple("claude-sonnet-4-5-20250514", sampleMessages, 1024)
request.usesStructuredOutput shouldBe false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class MessageRequestSpec extends AnyFlatSpec with Matchers {
Message.user(List(ContentBlock.TextContent("Hello")))
)

"MessageRequest serialization" should "include output_format with schema" in {
"MessageRequest serialization" should "include output_config with format and schema" in {
val outputFormat = OutputFormat.JsonSchema.withTapirSchema[UserProfile]
val request = MessageRequest
.simple("claude-sonnet-4-5-20250514", sampleMessages, 1024)
Expand All @@ -35,9 +35,9 @@ class MessageRequestSpec extends AnyFlatSpec with Matchers {

parsed("model").str shouldBe "claude-sonnet-4-5-20250514"
parsed("max_tokens").num shouldBe 1024
parsed("output_format")("type").str shouldBe "json_schema"
parsed("output_config")("format")("type").str shouldBe "json_schema"

val schema = parsed("output_format")("schema")
val schema = parsed("output_config")("format")("schema")
schema("type").str shouldBe "object"
schema("properties").obj.contains("name") shouldBe true
schema("properties").obj.contains("age") shouldBe true
Expand All @@ -46,13 +46,13 @@ class MessageRequestSpec extends AnyFlatSpec with Matchers {
schema("properties").obj.contains("tags") shouldBe true
}

it should "not include output_format when absent" in {
it should "not include output_config when absent" in {
val request = MessageRequest.simple("claude-sonnet-4-5-20250514", sampleMessages, 1024)

val json = SnakePickle.write(request)
val parsed = ujson.read(json)

parsed.obj.contains("output_format") shouldBe false
parsed.obj.contains("output_config") shouldBe false
}

it should "round-trip with structured output" in {
Expand All @@ -66,7 +66,8 @@ class MessageRequestSpec extends AnyFlatSpec with Matchers {

deserialized.model shouldBe request.model
deserialized.maxTokens shouldBe request.maxTokens
deserialized.outputFormat shouldBe defined
deserialized.outputFormat.get shouldBe a[OutputFormat.JsonSchema]
deserialized.outputConfig shouldBe defined
deserialized.outputConfig.get.format shouldBe defined
deserialized.outputConfig.get.format.get shouldBe a[OutputFormat.JsonSchema]
}
}