Skip to content

Commit 64fcaec

Browse files
JanCizmarclaude
andauthored
feat: add structured JSON output support for Anthropic provider (#3475)
## Summary - Add `output_config` with `json_schema` format to Anthropic API requests when `shouldOutputJson=true` and `config.format="json_schema"`, mirroring the existing OpenAI structured output support - The schema enforces `output` and `contextDescription` string fields, matching the OpenAI implementation - The existing prompt-based JSON hint ("Return valid JSON and only JSON!") is retained as a fallback ## Test plan - [x] Unit tests verify `output_config` is included when both `shouldOutputJson` and `format="json_schema"` are set - [x] Unit tests verify `output_config` is omitted when `format` is not `json_schema` - [x] Unit tests verify `output_config` is omitted when `shouldOutputJson` is false - [x] Manual test with Anthropic API to confirm structured JSON responses <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added optional JSON Schema–formatted output for Anthropic translations; schema is included in requests only when JSON output is enabled and the provider is configured for json_schema. * **Tests** * Added unit tests validating when the JSON Schema output is included or omitted based on configuration and parameters. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b56ad5d commit 64fcaec

2 files changed

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

0 commit comments

Comments
 (0)