Skip to content

Commit 87c2297

Browse files
peterbandaclaude
andcommitted
feat(models): reasoning_effort 'max' tier with provider mappings and fixes
OpenAI GPT-5.6 (Sol/Terra/Luna) supports a new top reasoning tier 'max' via the Responses API only (live-verified 2026-07-11; chat completions rejects it, and 'minimal' is rejected by both APIs). - ReasoningEffort: new 'max' member (enumFormat/fromString pick it up via values) - Anthropic: map to OutputEffort.max ungated - every output-effort model accepts max incl. Opus 4.6 / Sonnet 4.6 (live-verified; those two reject xhigh); xhigh gate extracted into xhighOutputEffortModels; OutputEffort scaladocs corrected (Sonnet 5 was missing) - Gemini: max -> ThinkingLevel.HIGH (Gemini 3); thinking-budget mapping max=32768 with a new 24576 ceiling clamp for non-Pro 2.5 models (live-verified: Flash/Flash-Lite return 400 above 24576) - Chat-completions conversions: gpt5_6 now downgrades reasoning_effort max->xhigh and minimal->low instead of letting the remote API 400 - ModelId / GPT-5.6 comments scoped to the verified per-API behavior - Examples: AnthropicOpus48ReasoningEffortMapping covers max + Opus 4.6; new CreateModelResponseGPT56SolMaxReasoning (Responses API smoke test) - .gitignore: TODO.md (local follow-up notes) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FFbEyiUjjhE5jhEBwoTncu
1 parent 3731155 commit 87c2297

10 files changed

Lines changed: 121 additions & 20 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ target
1212
**/build.properties
1313
**/metals.sbt
1414
*.log
15+
/TODO.md

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,18 +93,21 @@ final case class OutputConfig(
9393

9494
sealed trait OutputEffort extends EnumValue
9595

96-
// The effort parameter is supported by Claude Fable 5, Opus 4.8/4.7/4.6, and Sonnet 4.6.
96+
// The effort parameter is supported by Claude Fable 5, Opus 4.8/4.7/4.6, Sonnet 5, and
97+
// Sonnet 4.6.
9798
object OutputEffort {
9899
case object low extends OutputEffort
99100
case object medium extends OutputEffort
100101
case object high extends OutputEffort
101102

102103
// Claude always thinks deeply with extended exploration.
103-
// Fable 5 and Opus 4.7+ only - requests using xhigh on other models will return an error.
104+
// Fable 5, Opus 4.7+, and Sonnet 5 only - requests using xhigh on other models
105+
// (e.g. Opus 4.6, Sonnet 4.6) return an error (live-verified 2026-07-11).
104106
case object xhigh extends OutputEffort
105107

106108
// Absolute maximum capability with no constraints on token spending.
107-
// Available on Fable 5, Opus 4.8/4.7/4.6, and Sonnet 4.6.
109+
// Available on every effort-supporting model, including Opus 4.6 and Sonnet 4.6,
110+
// which reject xhigh (live-verified 2026-07-11).
108111
case object max extends OutputEffort
109112

110113
def values: Seq[OutputEffort] = Seq(low, medium, high, xhigh, max)

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

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,15 @@ package object impl extends AnthropicServiceConsts with HasOpenAIConfig {
275275
NonOpenAIModelId.claude_sonnet_4_6
276276
)
277277

278+
// Models that accept output_config.effort = xhigh - narrower than outputEffortModels:
279+
// Opus 4.6 / Sonnet 4.6 reject xhigh though they do accept max (live-verified 2026-07-11).
280+
private val xhighOutputEffortModels: Set[String] = Set(
281+
NonOpenAIModelId.claude_fable_5,
282+
NonOpenAIModelId.claude_opus_4_8,
283+
NonOpenAIModelId.claude_opus_4_7,
284+
NonOpenAIModelId.claude_sonnet_5
285+
)
286+
278287
// Models where extended thinking with budget_tokens and the sampling params
279288
// (temperature, top_p, top_k) are fully removed - sending them returns a 400.
280289
// Adaptive thinking is the only thinking mode.
@@ -317,19 +326,19 @@ package object impl extends AnthropicServiceConsts with HasOpenAIConfig {
317326
// OutputEffort.xhigh is supported only on Opus 4.7+ (Opus 4.7, Opus 4.8), Fable 5, and
318327
// Sonnet 5; downgrade to high on Opus 4.6 / Sonnet 4.6 to avoid a remote 400 from Anthropic.
319328
val m = model.toLowerCase
320-
if (
321-
m.contains(NonOpenAIModelId.claude_fable_5) ||
322-
m.contains(NonOpenAIModelId.claude_opus_4_8) ||
323-
m.contains(NonOpenAIModelId.claude_opus_4_7) ||
324-
m.contains(NonOpenAIModelId.claude_sonnet_5)
325-
) {
329+
if (xhighOutputEffortModels.exists(m.contains)) {
326330
Some(OutputEffort.xhigh)
327331
} else {
328332
logger.warn(
329333
s"reasoning_effort=xhigh is Opus 4.7+ only; downgrading to 'high' for model '$model'."
330334
)
331335
Some(OutputEffort.high)
332336
}
337+
case ReasoningEffort.max =>
338+
// OutputEffort.max is supported by every output-effort model - including Opus 4.6 and
339+
// Sonnet 4.6, which reject xhigh (live-verified 2026-07-11) - and toOutputEffort is only
340+
// reached when supportsOutputEffort(model) holds, so no model gate is needed here.
341+
Some(OutputEffort.max)
333342
}
334343

335344
def toAnthropicSettings(

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

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -622,9 +622,10 @@ private[service] class OpenAIGeminiChatCompletionService(
622622
val level: ThinkingLevel = effort match {
623623
case ReasoningEffort.none | ReasoningEffort.minimal =>
624624
if (pro) ThinkingLevel.LOW else ThinkingLevel.MINIMAL
625-
case ReasoningEffort.low => ThinkingLevel.LOW
626-
case ReasoningEffort.medium => ThinkingLevel.MEDIUM
627-
case ReasoningEffort.high | ReasoningEffort.xhigh => ThinkingLevel.HIGH
625+
case ReasoningEffort.low => ThinkingLevel.LOW
626+
case ReasoningEffort.medium => ThinkingLevel.MEDIUM
627+
case ReasoningEffort.high | ReasoningEffort.xhigh | ReasoningEffort.max =>
628+
ThinkingLevel.HIGH
628629
}
629630

630631
logger.debug(
@@ -657,9 +658,18 @@ private[service] class OpenAIGeminiChatCompletionService(
657658
)
658659

659660
// budget = 0 is out of range for 2.5 Pro (min 128), so clamp to the minimum.
661+
// Conversely, 2.5 Flash / Flash-Lite cap thinkingBudget at 24576 (Pro allows up to
662+
// 32768), so clamp the 'max' effort mapping (32768) down on non-Pro models.
663+
val nonProMaxBudget = 24576
664+
val isPro = model.startsWith(NonOpenAIModelId.gemini_2_5_pro)
660665
val budgetFinal =
661-
if (budget == 0 && model.startsWith(NonOpenAIModelId.gemini_2_5_pro)) 128
662-
else budget
666+
if (budget == 0 && isPro) 128
667+
else if (!isPro && budget > nonProMaxBudget) {
668+
logger.warn(
669+
s"Thinking budget $budget exceeds the maximum of $nonProMaxBudget for model '$model'. Clamping to $nonProMaxBudget."
670+
)
671+
nonProMaxBudget
672+
} else budget
663673

664674
ThinkingConfig(
665675
includeThoughts = Some(false),

openai-client/src/main/resources/openai-scala-client.conf

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,5 +262,11 @@ openai-scala-client {
262262
gemini = 16384
263263
anthropic = 16384
264264
}
265+
266+
# Highest reasoning tier (GPT-5.6 'max', Responses API only): maximum thinking budget
267+
max {
268+
gemini = 32768 # Gemini 2.5 Pro thinking_budget ceiling
269+
anthropic = 32768
270+
}
265271
}
266272
}

openai-core/src/main/scala/io/cequence/openaiscala/domain/ModelId.scala

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,10 @@ object ModelId {
215215
val o1_pro = "o1-pro"
216216
val o1_pro_2025_03_19 = "o1-pro-2025-03-19"
217217

218-
// GPT-5.6 (preview announced 2026-06-26; Sol/Terra/Luna capability tiers replace pro/mini/nano)
218+
// GPT-5.6 (GA; created 2026-06-23). Sol/Terra/Luna reasoning tiers replace pro/mini/nano.
219+
// Reasoning-first: sampling params rejected. reasoning_effort on chat completions supports
220+
// none/low/medium/high/xhigh; 'max' is Responses-API-only (chat completions rejects it) and
221+
// 'minimal' is rejected by both APIs. Verified against the live API 2026-07-11.
219222
val gpt_5_6_sol = "gpt-5.6-sol"
220223
val gpt_5_6_terra = "gpt-5.6-terra"
221224
val gpt_5_6_luna = "gpt-5.6-luna"

openai-core/src/main/scala/io/cequence/openaiscala/domain/settings/CreateChatCompletionSettings.scala

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,11 @@ object ReasoningEffort {
185185
case object medium extends ReasoningEffort
186186
case object high extends ReasoningEffort
187187
case object xhigh extends ReasoningEffort
188+
// Highest reasoning effort; GPT-5.6 (Sol/Terra/Luna) supports it via the Responses API only
189+
// (Chat Completions rejects it). Reserve for the hardest quality-first workloads.
190+
case object max extends ReasoningEffort
188191

189-
def values: Seq[ReasoningEffort] = Seq(none, minimal, low, medium, high, xhigh)
192+
def values: Seq[ReasoningEffort] = Seq(none, minimal, low, medium, high, xhigh, max)
190193

191194
def fromString(value: String): ReasoningEffort =
192195
values

openai-core/src/main/scala/io/cequence/openaiscala/service/adapter/ChatCompletionSettingsConversions.scala

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,26 @@ object ChatCompletionSettingsConversions {
170170
warning = true
171171
)
172172

173+
// 'max' is Responses-API-only on GPT-5.6; the chat completions API rejects it with a 400,
174+
// so downgrade to the highest chat-completions-supported effort.
175+
val reasoningEffortMaxToXHigh: FieldConversionDef = FieldConversionDef(
176+
settings => settings.reasoning_effort.contains(ReasoningEffort.max),
177+
_.copy(reasoning_effort = Some(ReasoningEffort.xhigh)),
178+
Some(settings =>
179+
s"${settings.model} model doesn't support reasoning_effort 'max' on the chat completions API (Responses API only), downgrading to 'xhigh'."
180+
),
181+
warning = true
182+
)
183+
184+
val reasoningEffortMinimalToLow: FieldConversionDef = FieldConversionDef(
185+
settings => settings.reasoning_effort.contains(ReasoningEffort.minimal),
186+
_.copy(reasoning_effort = Some(ReasoningEffort.low)),
187+
Some(settings =>
188+
s"${settings.model} model doesn't support reasoning_effort 'minimal', converting to 'low'."
189+
),
190+
warning = true
191+
)
192+
173193
val responseFormatTypeMustBeText: FieldConversionDef = FieldConversionDef(
174194
settings =>
175195
settings.response_format_type.isDefined && settings.response_format_type.get != ChatCompletionResponseFormatType.text,
@@ -265,16 +285,20 @@ object ChatCompletionSettingsConversions {
265285
)
266286

267287
// GPT-5.6 (Sol/Terra/Luna) is reasoning-first; restrict all sampling params unconditionally like 5.5.
268-
// NOTE: not confirmed against 5.6-specific docs (limited preview); mirrors the GPT-5 reasoning family + 5.5.
269-
// TODO: confirm on the live API; if a non-reasoning variant accepts temperature, use gpt5_4's *WithReasoning.
288+
// Verified against the live API 2026-07-11: temperature/top_p/presence_penalty/frequency_penalty/logprobs
289+
// all return 400 for every tier, and max_tokens must be sent as max_completion_tokens.
290+
// reasoning_effort on chat completions supports none/low/medium/high/xhigh only - 'max' is
291+
// Responses-API-only and 'minimal' is rejected by both APIs, so downgrade both here.
270292
val gpt5_6: SettingsConversion = generic(
271293
Seq(
272294
maxTokensToMaxCompletionTokens,
273295
temperatureOneOnly,
274296
topPOneOnly,
275297
presencePenaltyZeroOnly,
276298
frequencyPenaltyZeroOnly,
277-
logProbsUnsupported
299+
logProbsUnsupported,
300+
reasoningEffortMaxToXHigh,
301+
reasoningEffortMinimalToLow
278302
)
279303
)
280304

openai-examples/src/main/scala/io/cequence/openaiscala/examples/anthropic/AnthropicOpus48ReasoningEffortMapping.scala

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ object AnthropicOpus48ReasoningEffortMapping extends ExampleBase[OpenAIChatCompl
2929
ReasoningEffort.low,
3030
ReasoningEffort.medium,
3131
ReasoningEffort.high,
32-
ReasoningEffort.xhigh
32+
ReasoningEffort.xhigh,
33+
ReasoningEffort.max
3334
)
3435

3536
private def report(model: String): Unit = {
@@ -54,6 +55,10 @@ object AnthropicOpus48ReasoningEffortMapping extends ExampleBase[OpenAIChatCompl
5455
report(NonOpenAIModelId.claude_opus_4_8)
5556
report(NonOpenAIModelId.bedrock_claude_opus_4_8)
5657

58+
// Opus 4.6 rejects xhigh (downgraded to high) but accepts max (live-verified 2026-07-11),
59+
// so xhigh and max map differently here
60+
report(NonOpenAIModelId.claude_opus_4_6)
61+
5762
// sanity: an older non-output-effort model still uses the legacy thinking-budget path
5863
report(NonOpenAIModelId.claude_3_7_sonnet_latest)
5964
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package io.cequence.openaiscala.examples.responsesapi
2+
3+
import io.cequence.openaiscala.domain.ModelId
4+
import io.cequence.openaiscala.domain.responsesapi.{
5+
CreateModelResponseSettings,
6+
Inputs,
7+
ReasoningConfig
8+
}
9+
import io.cequence.openaiscala.domain.settings.ReasoningEffort
10+
import io.cequence.openaiscala.examples.Example
11+
12+
import scala.concurrent.Future
13+
14+
/**
15+
* Smoke test: GPT-5.6's top reasoning tier 'max' is Responses-API-only - the chat completions
16+
* endpoint rejects reasoning_effort=max (and 'minimal') for gpt-5.6 models, while
17+
* /v1/responses accepts it (live-verified 2026-07-11). On the chat-completion path,
18+
* ChatCompletionSettingsConversions.gpt5_6 downgrades 'max' to 'xhigh' instead.
19+
*/
20+
object CreateModelResponseGPT56SolMaxReasoning extends Example {
21+
22+
override def run: Future[Unit] =
23+
service
24+
.createModelResponse(
25+
Inputs.Text("What is the capital of Norway? One word."),
26+
settings = CreateModelResponseSettings(
27+
model = ModelId.gpt_5_6_sol,
28+
reasoning = Some(ReasoningConfig(effort = Some(ReasoningEffort.max)))
29+
)
30+
)
31+
.map { response =>
32+
println(s"Response: ${response.outputText.getOrElse("N/A")}")
33+
response.usage.foreach { u =>
34+
println(s"Usage: in=${u.inputTokens} out=${u.outputTokens} total=${u.totalTokens}")
35+
}
36+
}
37+
}

0 commit comments

Comments
 (0)