Skip to content

Commit e2dce30

Browse files
committed
feat: add structured JSON output support for Anthropic provider
Use Anthropic's output_config.format API field to guarantee valid JSON when shouldOutputJson=true and config.format="json_schema", matching the existing OpenAI structured output behavior.
1 parent 8f7539b commit e2dce30

2 files changed

Lines changed: 215 additions & 0 deletions

File tree

ee/backend/app/src/main/kotlin/io/tolgee/ee/component/llm/AnthropicApiService.kt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.tolgee.ee.component.llm
22

3+
import com.fasterxml.jackson.annotation.JsonInclude
34
import io.tolgee.configuration.tolgee.machineTranslation.LlmProviderInterface
45
import io.tolgee.dtos.LlmParams
56
import io.tolgee.dtos.PromptResult
@@ -34,6 +35,12 @@ class AnthropicApiService :
3435
messages = messages,
3536
model = config.model,
3637
max_tokens = config.maxTokens,
38+
output_config =
39+
if (params.shouldOutputJson && config.format == "json_schema") {
40+
OutputConfig(format = OutputFormat())
41+
} else {
42+
null
43+
},
3744
)
3845

3946
val request = HttpEntity(requestBody, headers)
@@ -120,6 +127,27 @@ class AnthropicApiService :
120127
val messages: List<RequestMessage>,
121128
val model: String?,
122129
val temperature: Long? = 0,
130+
@JsonInclude(JsonInclude.Include.NON_NULL)
131+
val output_config: OutputConfig? = null,
132+
)
133+
134+
class OutputConfig(
135+
val format: OutputFormat,
136+
)
137+
138+
class OutputFormat(
139+
val type: String = "json_schema",
140+
val schema: Map<String, Any> =
141+
mapOf(
142+
"type" to "object",
143+
"properties" to
144+
mapOf(
145+
"output" to mapOf("type" to "string"),
146+
"contextDescription" to mapOf("type" to "string"),
147+
),
148+
"required" to listOf("output", "contextDescription"),
149+
"additionalProperties" to false,
150+
),
123151
)
124152

125153
class RequestMessage(
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package io.tolgee.ee.unit
2+
3+
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
4+
import com.fasterxml.jackson.module.kotlin.readValue
5+
import io.tolgee.configuration.tolgee.machineTranslation.LlmProviderInterface
6+
import io.tolgee.dtos.LlmParams
7+
import io.tolgee.ee.component.llm.AnthropicApiService
8+
import io.tolgee.model.enums.LlmProviderPriority
9+
import io.tolgee.model.enums.LlmProviderType
10+
import org.assertj.core.api.Assertions.assertThat
11+
import org.junit.jupiter.api.BeforeEach
12+
import org.junit.jupiter.api.Test
13+
import org.springframework.http.HttpMethod
14+
import org.springframework.http.HttpStatus
15+
import org.springframework.http.MediaType
16+
import org.springframework.http.client.ClientHttpRequest
17+
import org.springframework.http.client.ClientHttpRequestFactory
18+
import org.springframework.http.client.ClientHttpResponse
19+
import org.springframework.web.client.RestTemplate
20+
import java.io.ByteArrayInputStream
21+
import java.io.ByteArrayOutputStream
22+
import java.io.InputStream
23+
import java.io.OutputStream
24+
import java.net.URI
25+
26+
class AnthropicApiServiceTest {
27+
private lateinit var service: AnthropicApiService
28+
private val objectMapper = jacksonObjectMapper()
29+
private var capturedRequestBody: String? = null
30+
31+
@BeforeEach
32+
fun setUp() {
33+
service = AnthropicApiService()
34+
capturedRequestBody = null
35+
}
36+
37+
@Test
38+
fun `includes output_config when shouldOutputJson and format is json_schema`() {
39+
val config = createConfig(format = "json_schema")
40+
val params = createParams(shouldOutputJson = true)
41+
val restTemplate = createCapturingRestTemplate()
42+
43+
service.translate(params, config, restTemplate)
44+
45+
val bodyMap = objectMapper.readValue<Map<String, Any>>(capturedRequestBody!!)
46+
47+
assertThat(bodyMap).containsKey("output_config")
48+
@Suppress("UNCHECKED_CAST")
49+
val outputConfig = bodyMap["output_config"] as Map<String, Any>
50+
51+
@Suppress("UNCHECKED_CAST")
52+
val format = outputConfig["format"] as Map<String, Any>
53+
assertThat(format["type"]).isEqualTo("json_schema")
54+
@Suppress("UNCHECKED_CAST")
55+
val schema = format["schema"] as Map<String, Any>
56+
assertThat(schema["type"]).isEqualTo("object")
57+
assertThat(schema).containsKey("properties")
58+
@Suppress("UNCHECKED_CAST")
59+
val properties = schema["properties"] as Map<String, Any>
60+
assertThat(properties).containsKey("output")
61+
assertThat(properties).containsKey("contextDescription")
62+
assertThat(schema["required"]).isEqualTo(listOf("output", "contextDescription"))
63+
assertThat(schema["additionalProperties"]).isEqualTo(false)
64+
}
65+
66+
@Test
67+
fun `omits output_config when format is not json_schema`() {
68+
val config = createConfig(format = null)
69+
val params = createParams(shouldOutputJson = true)
70+
val restTemplate = createCapturingRestTemplate()
71+
72+
service.translate(params, config, restTemplate)
73+
74+
val bodyMap = objectMapper.readValue<Map<String, Any>>(capturedRequestBody!!)
75+
76+
assertThat(bodyMap).doesNotContainKey("output_config")
77+
}
78+
79+
@Test
80+
fun `omits output_config when shouldOutputJson is false`() {
81+
val config = createConfig(format = "json_schema")
82+
val params = createParams(shouldOutputJson = false)
83+
val restTemplate = createCapturingRestTemplate()
84+
85+
service.translate(params, config, restTemplate)
86+
87+
val bodyMap = objectMapper.readValue<Map<String, Any>>(capturedRequestBody!!)
88+
89+
assertThat(bodyMap).doesNotContainKey("output_config")
90+
}
91+
92+
private fun createConfig(format: String? = null): LlmProviderInterface {
93+
return object : LlmProviderInterface {
94+
override var name = "test-anthropic"
95+
override var type = LlmProviderType.ANTHROPIC
96+
override var priority: LlmProviderPriority? = LlmProviderPriority.HIGH
97+
override var apiKey: String? = "test-key"
98+
override var apiUrl: String? = "https://api.anthropic.com"
99+
override var model: String? = "claude-sonnet-4-5-20250929"
100+
override var format: String? = format
101+
override var deployment: String? = null
102+
override var reasoningEffort: String? = null
103+
override var maxTokens: Long = 1000
104+
override var tokenPriceInCreditsInput: Double? = null
105+
override var tokenPriceInCreditsOutput: Double? = null
106+
override var attempts: List<Int>? = null
107+
}
108+
}
109+
110+
private fun createParams(shouldOutputJson: Boolean): LlmParams {
111+
return LlmParams(
112+
messages =
113+
listOf(
114+
LlmParams.Companion.LlmMessage(
115+
type = LlmParams.Companion.LlmMessageType.TEXT,
116+
text = "Translate 'hello' to Czech",
117+
),
118+
),
119+
shouldOutputJson = shouldOutputJson,
120+
priority = LlmProviderPriority.HIGH,
121+
)
122+
}
123+
124+
private fun createCapturingRestTemplate(): RestTemplate {
125+
val responseJson =
126+
"""
127+
{"content":[{"text":"result"}],"usage":{"input_tokens":10,"output_tokens":5}}
128+
""".trimIndent()
129+
130+
val factory =
131+
ClientHttpRequestFactory { uri, httpMethod ->
132+
CapturingClientHttpRequest(uri, httpMethod, responseJson) { body ->
133+
capturedRequestBody = body
134+
}
135+
}
136+
137+
return RestTemplate(factory)
138+
}
139+
140+
private class CapturingClientHttpRequest(
141+
private val uri: URI,
142+
private val httpMethod: HttpMethod,
143+
private val responseJson: String,
144+
private val onBody: (String) -> Unit,
145+
) : ClientHttpRequest {
146+
private val outputStream = ByteArrayOutputStream()
147+
private val headers = org.springframework.http.HttpHeaders()
148+
149+
override fun getMethod() = httpMethod
150+
151+
override fun getURI() = uri
152+
153+
override fun getHeaders() = headers
154+
155+
override fun getBody(): OutputStream = outputStream
156+
157+
override fun getAttributes(): MutableMap<String, Any> = mutableMapOf()
158+
159+
override fun execute(): ClientHttpResponse {
160+
onBody(outputStream.toString(Charsets.UTF_8))
161+
return StubClientHttpResponse(responseJson)
162+
}
163+
}
164+
165+
private class StubClientHttpResponse(
166+
private val body: String,
167+
) : ClientHttpResponse {
168+
private val headers =
169+
org.springframework.http.HttpHeaders().apply {
170+
contentType = MediaType.APPLICATION_JSON
171+
}
172+
173+
override fun getStatusCode() = HttpStatus.OK
174+
175+
override fun getHeaders() = headers
176+
177+
override fun getBody(): InputStream = ByteArrayInputStream(body.toByteArray())
178+
179+
override fun close() {}
180+
181+
@Deprecated("Deprecated in Java")
182+
override fun getRawStatusCode() = 200
183+
184+
@Deprecated("Deprecated in Java")
185+
override fun getStatusText() = "OK"
186+
}
187+
}

0 commit comments

Comments
 (0)