Skip to content

Commit 839462d

Browse files
committed
Anthropic - structured output / json schema
1 parent 7a46e98 commit 839462d

File tree

16 files changed

+317
-17
lines changed

16 files changed

+317
-17
lines changed

anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/JsonFormats.scala

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import io.cequence.openaiscala.anthropic.domain.response.{
4545
}
4646
import io.cequence.openaiscala.anthropic.domain.settings.{ThinkingSettings, ThinkingType}
4747
import io.cequence.openaiscala.anthropic.domain._
48+
import io.cequence.openaiscala.anthropic.domain.OutputFormat
4849
import io.cequence.openaiscala.anthropic.domain.CodeExecutionToolResultContent.CodeExecutionErrorCode
4950
import io.cequence.openaiscala.anthropic.domain.BashCodeExecutionToolResultContent.BashCodeExecutionErrorCode
5051
import io.cequence.openaiscala.anthropic.domain.WebSearchToolResultContent.WebSearchErrorCode
@@ -82,6 +83,7 @@ import io.cequence.openaiscala.anthropic.domain.tools.{
8283
WebSearchTool
8384
}
8485
import io.cequence.openaiscala.JsonFormats.formatWithType
86+
import io.cequence.openaiscala.domain.JsonSchema
8587
import io.cequence.wsclient.JsonUtil
8688
import play.api.libs.functional.syntax._
8789
import play.api.libs.json.JsonNaming.SnakeCase
@@ -1162,4 +1164,31 @@ trait JsonFormats {
11621164

11631165
formatWithType(OFormat(reads, writes))
11641166
}
1167+
1168+
// Output Format
1169+
implicit lazy val outputFormatFormat: OFormat[OutputFormat] = {
1170+
val jsonSchemaFormatReads: Reads[OutputFormat.JsonSchemaFormat] = (json: JsValue) =>
1171+
(json \ "schema").validate[JsonSchema].map { schema =>
1172+
OutputFormat.JsonSchemaFormat(schema)
1173+
}
1174+
1175+
val jsonSchemaFormatWrites: OWrites[OutputFormat.JsonSchemaFormat] = OWrites { format =>
1176+
Json.obj(
1177+
"schema" -> Json.toJson(format.schema)(jsonSchemaFormat)
1178+
)
1179+
}
1180+
1181+
val reads: Reads[OutputFormat] = Reads { json =>
1182+
(json \ "type").validate[String].flatMap {
1183+
case "json_schema" => jsonSchemaFormatReads.reads(json)
1184+
case other => JsError(s"Unknown output format type: $other")
1185+
}
1186+
}
1187+
1188+
val writes: OWrites[OutputFormat] = OWrites { case format: OutputFormat.JsonSchemaFormat =>
1189+
jsonSchemaFormatWrites.writes(format)
1190+
}
1191+
1192+
formatWithType(OFormat(reads, writes))
1193+
}
11651194
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package io.cequence.openaiscala.anthropic.domain
2+
3+
import io.cequence.openaiscala.domain.{HasType, JsonSchema}
4+
5+
/**
6+
* Defines the format for structured output from Claude.
7+
*
8+
* @see
9+
* [[https://docs.anthropic.com/claude/docs/structured-outputs Anthropic Structured Outputs Documentation]]
10+
*/
11+
sealed trait OutputFormat extends HasType
12+
13+
object OutputFormat {
14+
15+
/**
16+
* JSON Schema output format for structured responses.
17+
*
18+
* @param schema
19+
* The JSON schema that defines the structure of the output
20+
*/
21+
case class JsonSchemaFormat(
22+
schema: JsonSchema
23+
) extends OutputFormat {
24+
override val `type`: String = "json_schema"
25+
}
26+
}

anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/domain/settings/AnthropicCreateMessageSettings.scala

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.cequence.openaiscala.anthropic.domain.settings
22

3+
import io.cequence.openaiscala.anthropic.domain.OutputFormat
34
import io.cequence.openaiscala.anthropic.domain.skills.Container
45
import io.cequence.openaiscala.anthropic.domain.tools.{
56
MCPServerURLDefinition,
@@ -62,7 +63,12 @@ final case class AnthropicCreateMessageSettings(
6263
tool_choice: Option[ToolChoice] = None,
6364

6465
// MCP servers to be utilized in this request. Maximum length: 20
65-
mcp_servers: Seq[MCPServerURLDefinition] = Nil
66+
mcp_servers: Seq[MCPServerURLDefinition] = Nil,
67+
68+
// Defines the format for structured output from Claude.
69+
// When specified, Claude will generate responses that conform to the provided JSON schema.
70+
// Structured outputs are currently available as a public beta feature in the Claude API for Claude Sonnet 4.5 and Claude Opus 4.1.
71+
output_format: Option[OutputFormat] = None
6672
)
6773

6874
final case class ThinkingSettings(

anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/service/AnthropicServiceFactory.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ object AnthropicServiceFactory extends AnthropicServiceConsts with EnvHelper {
3434
}
3535

3636
private val anthropicBetaHeaders = Seq(
37+
"structured-outputs-2025-11-13",
3738
"output-128k-2025-02-19",
3839
"files-api-2025-04-14",
3940
"code-execution-2025-08-25",

anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/service/impl/Anthropic.scala

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ package io.cequence.openaiscala.anthropic.service.impl
22

33
import io.cequence.openaiscala.OpenAIScalaClientException
44
import io.cequence.openaiscala.anthropic.JsonFormats
5-
import io.cequence.openaiscala.anthropic.domain.{ChatRole, Content, Message}
5+
import io.cequence.openaiscala.anthropic.domain.{ChatRole, Content, Message, OutputFormat}
66
import io.cequence.openaiscala.anthropic.domain.Message.{SystemMessage, SystemMessageContent}
77
import io.cequence.openaiscala.anthropic.domain.response.ContentBlockDelta
88
import io.cequence.openaiscala.anthropic.domain.settings.AnthropicCreateMessageSettings
@@ -11,6 +11,8 @@ import io.cequence.wsclient.service.WSClientWithEngineTypes.WSClientWithStreamEn
1111
import org.slf4j.LoggerFactory
1212
import play.api.libs.json.{JsString, JsValue, Json, Writes}
1313
import com.typesafe.scalalogging.Logger
14+
import io.cequence.openaiscala.anthropic.domain.OutputFormat.JsonSchemaFormat
15+
import io.cequence.openaiscala.domain.JsonSchema
1416
import io.cequence.wsclient.JsonUtil.JsonOps
1517

1618
trait Anthropic
@@ -58,6 +60,28 @@ trait Anthropic
5860
Json.toJson(blocks)(Writes.seq(contentBlockBaseWrites))
5961
}
6062

63+
def setAdditionalPropertiesToFalseByDefault(schema: JsonSchema): JsonSchema =
64+
schema match {
65+
case obj: JsonSchema.Object =>
66+
obj.copy(
67+
properties = obj.properties.map { case (key, value) =>
68+
key -> setAdditionalPropertiesToFalseByDefault(value)
69+
},
70+
additionalProperties = obj.additionalProperties.orElse(Some(false))
71+
)
72+
case arr: JsonSchema.Array =>
73+
arr.copy(items = setAdditionalPropertiesToFalseByDefault(arr.items))
74+
case other => other
75+
}
76+
77+
val outputFormat: Option[OutputFormat] = settings.output_format.map {
78+
case format: JsonSchemaFormat =>
79+
// json schema set to additionalProperties to false on objects if None
80+
format.copy(
81+
schema = setAdditionalPropertiesToFalseByDefault(format.schema)
82+
)
83+
}
84+
6185
jsonBodyParams(
6286
Param.messages -> Some(messageJsons),
6387
Param.model -> (if (ignoreModel) None else Some(settings.model)),
@@ -94,7 +118,10 @@ trait Anthropic
94118
Some(Json.toJson(settings.mcp_servers)(Writes.seq(mcpServerURLDefinitionWrites)))
95119
else
96120
None
97-
}
121+
},
122+
Param.output_format -> outputFormat.map(
123+
Json.toJson(_)(outputFormatFormat)
124+
)
98125
)
99126
}
100127

anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/service/impl/EndPoint.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ object Param {
2929
case object tools extends Param
3030
case object tool_choice extends Param
3131
case object mcp_servers extends Param
32+
case object output_format extends Param
3233
// bedrock
3334
case object anthropic_version extends Param
3435
// skills

anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/service/impl/package.scala

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.cequence.openaiscala.anthropic.service
22

3+
import com.typesafe.scalalogging.Logger
34
import io.cequence.openaiscala.OpenAIScalaClientException
45
import io.cequence.openaiscala.anthropic.domain.CacheControl.Ephemeral
56
import io.cequence.openaiscala.anthropic.domain.Content.ContentBlock.TextBlock
@@ -15,7 +16,7 @@ import io.cequence.openaiscala.anthropic.domain.settings.{
1516
AnthropicCreateMessageSettings,
1617
ThinkingSettings
1718
}
18-
import io.cequence.openaiscala.anthropic.domain.{CacheControl, Content, Message}
19+
import io.cequence.openaiscala.anthropic.domain.{CacheControl, Content, Message, OutputFormat}
1920
import io.cequence.openaiscala.domain.response.{
2021
ChatCompletionChoiceChunkInfo,
2122
ChatCompletionChoiceInfo,
@@ -25,7 +26,10 @@ import io.cequence.openaiscala.domain.response.{
2526
PromptTokensDetails,
2627
UsageInfo => OpenAIUsageInfo
2728
}
28-
import io.cequence.openaiscala.domain.settings.CreateChatCompletionSettings
29+
import io.cequence.openaiscala.domain.settings.{
30+
ChatCompletionResponseFormatType,
31+
CreateChatCompletionSettings
32+
}
2933
import io.cequence.openaiscala.domain.settings.CreateChatCompletionSettingsOps.RichCreateChatCompletionSettings
3034
import io.cequence.openaiscala.domain.{
3135
ChatRole,
@@ -39,11 +43,16 @@ import io.cequence.openaiscala.domain.{
3943
UserMessage => OpenAIUserMessage,
4044
UserSeqMessage => OpenAIUserSeqMessage
4145
}
46+
import org.slf4j.LoggerFactory
4247

4348
import java.{util => ju}
4449

4550
package object impl extends AnthropicServiceConsts {
4651

52+
private val logger: Logger = Logger(
53+
LoggerFactory.getLogger("io.cequence.openaiscala.anthropic.service.impl")
54+
)
55+
4756
def toAnthropicSystemMessages(
4857
messages: Seq[OpenAIBaseMessage],
4958
settings: CreateChatCompletionSettings
@@ -164,6 +173,33 @@ package object impl extends AnthropicServiceConsts {
164173
): AnthropicCreateMessageSettings = {
165174
val thinkingBudget = settings.anthropicThinkingBudgetTokens
166175

176+
// handle json schema
177+
val responseFormat =
178+
settings.response_format_type.getOrElse(ChatCompletionResponseFormatType.text)
179+
180+
val jsonSchema =
181+
if (
182+
responseFormat == ChatCompletionResponseFormatType.json_schema && settings.jsonSchema.isDefined
183+
) {
184+
val jsonSchemaDef = settings.jsonSchema.get
185+
186+
jsonSchemaDef.structure match {
187+
case Left(schema) =>
188+
if (jsonSchemaDef.strict)
189+
logger.warn(
190+
"OpenAI's 'strict' mode is not supported by Anthropic. The schema will be used without strict validation, and 'additionalProperties' will be set to false by default on all objects."
191+
)
192+
193+
Some(schema)
194+
case Right(_) =>
195+
logger.warn(
196+
"Map-like legacy JSON schema format is not supported for Anthropic - only structured JsonSchema objects are supported"
197+
)
198+
None
199+
}
200+
} else
201+
None
202+
167203
AnthropicCreateMessageSettings(
168204
model = settings.model,
169205
max_tokens = settings.max_tokens.getOrElse(DefaultSettings.CreateMessage.max_tokens),
@@ -172,7 +208,10 @@ package object impl extends AnthropicServiceConsts {
172208
temperature = settings.temperature,
173209
top_p = settings.top_p,
174210
top_k = None,
175-
thinking = thinkingBudget.map(ThinkingSettings(_))
211+
thinking = thinkingBudget.map(ThinkingSettings(_)),
212+
output_format = jsonSchema.map { schema =>
213+
OutputFormat.JsonSchemaFormat(schema)
214+
}
176215
)
177216
}
178217

google-gemini-client/src/main/scala/io/cequence/openaiscala/gemini/service/impl/OpenAIGeminiChatCompletionService.scala

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,15 @@ private[service] class OpenAIGeminiChatCompletionService(
221221
if (
222222
responseFormat == ChatCompletionResponseFormatType.json_schema && settings.jsonSchema.isDefined
223223
) {
224-
settings.jsonSchema.get.structure match {
224+
val jsonSchemaDef = settings.jsonSchema.get
225+
226+
jsonSchemaDef.structure match {
225227
case Left(schema) =>
228+
if (jsonSchemaDef.strict)
229+
logger.warn(
230+
"OpenAI's 'strict' mode is not supported by Gemini. The schema will be used without strict validation. Note: Gemini does not support 'additionalProperties'."
231+
)
232+
226233
Some(toGeminiJSONSchema(schema))
227234
case Right(_) =>
228235
logger.warn(
@@ -327,7 +334,13 @@ private[service] class OpenAIGeminiChatCompletionService(
327334
`type` = SchemaType.TYPE_UNSPECIFIED
328335
)
329336

330-
case JsonSchema.Object(properties, required) =>
337+
case JsonSchema.Object(properties, required, additionalProperties) =>
338+
// additional properties not supported
339+
if (additionalProperties.nonEmpty && additionalProperties.get)
340+
logger.warn(
341+
"Gemini does not support 'additionalProperties' in JSON schema - this field will be ignored"
342+
)
343+
331344
val propertiesFinal =
332345
if (properties.nonEmpty)
333346
properties

google-vertexai-client/src/main/scala/io/cequence/openaiscala/vertexai/service/impl/package.scala

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import com.google.cloud.vertexai.api.{
1010
Schema,
1111
Type
1212
}
13+
import com.typesafe.scalalogging.Logger
1314
import io.cequence.openaiscala.OpenAIScalaClientException
1415
import io.cequence.openaiscala.domain.{
1516
AssistantMessage,
@@ -33,6 +34,7 @@ import io.cequence.openaiscala.domain.settings.{
3334
ChatCompletionResponseFormatType,
3435
CreateChatCompletionSettings
3536
}
37+
import org.slf4j.LoggerFactory
3638

3739
import java.{util => ju}
3840
import scala.collection.convert.ImplicitConversions.`iterable asJava`
@@ -41,6 +43,10 @@ import scala.collection.convert.ImplicitConversions.`list asScalaBuffer`
4143

4244
package object impl {
4345

46+
private val logger: Logger = Logger(
47+
LoggerFactory.getLogger("io.cequence.openaiscala.vertexai.service.impl")
48+
)
49+
4450
def toNonSystemVertexAI(messages: Seq[BaseMessage]): Seq[Content] =
4551
messages.collect {
4652
case UserMessage(content, _) =>
@@ -186,10 +192,21 @@ package object impl {
186192
if (
187193
responseFormat == ChatCompletionResponseFormatType.json_schema && settings.jsonSchema.isDefined
188194
) {
189-
settings.jsonSchema.get.structure match {
195+
val jsonSchemaDef = settings.jsonSchema.get
196+
197+
jsonSchemaDef.structure match {
190198
case Left(schema) =>
199+
if (jsonSchemaDef.strict)
200+
logger.warn(
201+
"OpenAI's 'strict' mode is not supported by VertexAI. The schema will be used without strict validation. Note: VertexAI does not support 'additionalProperties'."
202+
)
203+
191204
Some(toVertexJSONSchema(schema))
205+
192206
case Right(_) =>
207+
logger.warn(
208+
"Map-like legacy JSON schema format is not supported for VertexAI - only structured JsonSchema objects are supported"
209+
)
193210
None
194211
}
195212
} else
@@ -229,7 +246,13 @@ package object impl {
229246
case JsonSchema.Null() =>
230247
builder.setType(Type.TYPE_UNSPECIFIED)
231248

232-
case JsonSchema.Object(properties, required) =>
249+
case JsonSchema.Object(properties, required, additionalProperties) =>
250+
// additional properties not supported
251+
if (additionalProperties.nonEmpty && additionalProperties.get)
252+
logger.warn(
253+
"VertexAI does not support 'additionalProperties' in JSON schema - this field will be ignored"
254+
)
255+
233256
val b = builder.setType(Type.OBJECT)
234257
if (properties.nonEmpty) {
235258
val propsMap = properties.map { case (key, jsonSchema) =>

openai-core/src/main/scala-2/io/cequence/openaiscala/service/JsonSchemaReflectionHelper.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ trait JsonSchemaReflectionHelper {
107107
val properties = fieldSchemas.map { case (fieldName, schema, _) => (fieldName, schema) }
108108

109109
JsonSchema.Object(
110-
properties.toMap,
110+
properties,
111111
required
112112
)
113113
}

0 commit comments

Comments
 (0)