baikai-claude 0.7.2.0 → 0.7.3.0
raw patch · 11 files changed
+451/−16 lines, 11 filesdep ~baikaiPVP ok
version bump matches the API change (PVP)
Dependency ranges changed: baikai
API changes (from Hackage documentation)
+ Baikai.Provider.Claude.Shape: injectSystemCacheControl :: AnthropicMessagesCompat -> Options -> Value -> Value
Files
- CHANGELOG.md +107/−0
- baikai-claude.cabal +3/−3
- src/Baikai/Provider/Claude/Agent.hs +4/−3
- src/Baikai/Provider/Claude/Cli.hs +3/−2
- src/Baikai/Provider/Claude/Interactive.hs +3/−1
- src/Baikai/Provider/Claude/Internal/Request.hs +49/−0
- src/Baikai/Provider/Claude/Shape.hs +43/−1
- test/EvidenceSpec.hs +11/−1
- test/Main.hs +7/−3
- test/ShapeSpec.hs +97/−2
- test/ThinkingSpec.hs +124/−0
CHANGELOG.md view
@@ -7,6 +7,113 @@ ## [Unreleased] +## [baikai 0.10.0.0] - 2026-10-25++### Added++- `ThinkingNone` (`none`) explicitly requests reasoning off; `Nothing` still+ leaves provider defaults untouched. Anthropic compatibility records declare+ `supportsThinkingDisabled`; legacy records conservatively default to False.+- Endpoint-specific `NativeCachePolicy` records distinguish modern, legacy and+ unsupported native OpenAI cache controls. Policies survive legacy JSON and+ catalog refresh; Luna Responses Long has verified 12h base and tier rates.+- Evidence schema 2.8 includes the `none` preference and content-free native+ cache controls and input structure. Digest rules and the separation of+ requested, translated and observed reasoning are unchanged.++### Changed (breaking)++- `ThinkingLevel` gains `ThinkingNone` as its least constructor. Update exhaustive+ matches. Anthropic and OpenAI compatibility records gain fields; use the+ default records or update explicit record construction.+- All seven dependent packages now require `baikai ^>=0.10.0` and release+ independently below. The unchanged adapters take dependency-only patch bumps.++## [baikai-claude 0.7.3.0] - 2026-10-25++### Added++- Export `injectSystemCacheControl` for shared system-prefix cache markers.+- `ThinkingNone` sends catalog-gated `thinking: {"type": "disabled"}` without+ effort, display or a budget. Unsupported off requests remain visible+ adjustments and fail strict evidence before dispatch. A host with no thinking+ controls is not assumed to be a non-reasoning model.++### Fixed++- Short and long `cacheRetention` mark non-empty system prompts so fresh+ conversations with different first questions can reuse the tools-and-system+ prefix. System, last-tool and automatic markers share the effective TTL,+ including host downgrades, and use at most three breakpoints. Empty or absent+ system prompts and disabled caching keep their existing shape. Regression+ coverage includes tool compatibility and an isolated live shared-prefix case.+ Implements issue #2 and the approach in PR #3.++### Changed++- Require `baikai ^>=0.10.0`.++## [baikai-openai 0.7.3.0] - 2026-10-25++### Added++- Export `applyNativeCache` for validated native cache plans.+- OpenAI-compatible Chat (including Ollama) and Responses carry `none` where+ supported. Z.ai and Qwen emit `enable_thinking: false`; unsupported off+ requests remain visible adjustments and fail strict evidence before dispatch.++### Fixed++- Native OpenAI Short caching marks shared non-empty system prefixes on+ catalog-supported Responses and Chat bindings. Unsupported native requests+ fail locally. Effective duration reaches terminal pricing, partial usage+ and evidence.+- Funded live prefix checks pass for Luna Responses Short/Long and GPT-5.6 Chat+ Short. Luna Long uses verified 12h base and tier rates; unsupported Chat Long+ and unverified modern bindings remain locally refused.++### Changed++- Require `baikai ^>=0.10.0`. Catalog effort policies distinguish GPT-5/o-series+ models that cannot disable reasoning, Sol/Luna that accept none, and+ Astra/GPT-6.1 Sol that do not.++## [baikai-typesafe 0.1.0.1] - 2026-10-25++### Changed++- Require `baikai ^>=0.10.0`. Dependency-only update; no package API change.++## [baikai-trace-otel 0.4.1.1] - 2026-10-25++### Changed++- Require `baikai ^>=0.10.0`. Dependency-only update; no package API change.++## [baikai-effectful 0.4.0.5] - 2026-10-25++### Changed++- Require `baikai ^>=0.10.0`. Dependency-only update; no package API change.++## [baikai-kit 0.4.0.3] - 2026-10-25++### Changed++- Require `baikai ^>=0.10.0`. Dependency-only update; no package API change.++## [baikai-agent 0.2.0.4] - 2026-10-25++### Fixed++- Layered job configuration accepts `thinking "none"` through the shared+ canonical parser, keeping explicit off distinct from an absent preference.++### Changed++- Require `baikai ^>=0.10.0`, `baikai-claude ^>=0.7.3` and+ `baikai-openai ^>=0.7.3` for explicit-off handling in both vendor renderers.+ ## [baikai 0.9.0.0] - 2026-10-25 ### Added
baikai-claude.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.4 name: baikai-claude-version: 0.7.2.0+version: 0.7.3.0 synopsis: Anthropic Claude providers for the baikai abstraction description: Anthropic backends for baikai: the Messages API over SSE, the claude -p batch@@ -72,7 +72,7 @@ autogen-modules: Paths_baikai_claude build-depends: aeson ^>=2.2,- baikai ^>=0.9.0,+ baikai ^>=0.10.0, base >=4.20 && <5, base16-bytestring ^>=1.0, base64-bytestring ^>=1.2,@@ -126,7 +126,7 @@ build-depends: aeson,- baikai ^>=0.9.0,+ baikai ^>=0.10.0, baikai-claude, base >=4.20 && <5, bytestring,
src/Baikai/Provider/Claude/Agent.hs view
@@ -176,15 +176,16 @@ Just mid -> ["--model", Text.unpack mid] -- | Claude's @--effort@ accepts @low|medium|high|xhigh|max@, but not--- @minimal@, so the lowest Baikai level maps up to @low@. This matches--- "Baikai.Provider.Claude.Interactive" exactly so the two surfaces--- cannot drift.+-- @minimal@ or @none@, so the two lowest Baikai levels map up to @low@.+-- This matches "Baikai.Provider.Claude.Interactive" exactly so the two+-- surfaces cannot drift. effortArgs :: AgentRunRequest -> [String] effortArgs req = case req ^. #effort of Nothing -> [] Just lvl -> ["--effort", Text.unpack (claudeEffortValue lvl)] claudeEffortValue :: ThinkingLevel -> Text+claudeEffortValue ThinkingNone = "low" claudeEffortValue ThinkingMinimal = "low" claudeEffortValue lvl = renderThinkingLevel lvl
src/Baikai/Provider/Claude/Cli.hs view
@@ -68,7 +68,7 @@ import Baikai.ResponseFormat (ResponseFormat (..), declaredStructuredOutput) import Baikai.StopReason (StopReason (..)) import Baikai.Stream (liftCompleteToStream)-import Baikai.ThinkingLevel (ThinkingLevel (ThinkingMinimal), renderThinkingLevel)+import Baikai.ThinkingLevel (ThinkingLevel (ThinkingMinimal, ThinkingNone), renderThinkingLevel) import Baikai.Usage (Usage, zeroUsage) import Control.Exception (SomeException, displayException, fromException) import Control.Exception qualified@@ -173,7 +173,7 @@ | otherwise -> Right (Internal.decodeUtf8Lenient (LBS.toStrict (Aeson.encode v))) -- | Render @--effort@ from 'Options.thinking'. Claude's @--effort@ has--- no @minimal@, so the lowest Baikai level collapses to @low@; when+-- no @minimal@ or @none@ effort value, so those levels map to @low@; when -- 'thinking' is unset, no effort flag is emitted. effortArgs :: Options -> [String] effortArgs opts = case opts ^. #thinking of@@ -181,6 +181,7 @@ Just lvl -> ["--effort", Text.unpack (claudeEffortValue lvl)] claudeEffortValue :: ThinkingLevel -> Text+claudeEffortValue ThinkingNone = "low" claudeEffortValue ThinkingMinimal = "low" claudeEffortValue lvl = renderThinkingLevel lvl
src/Baikai/Provider/Claude/Interactive.hs view
@@ -103,13 +103,15 @@ Just mid -> ["--model", Text.unpack mid] -- | Claude's @--effort@ accepts @low|medium|high|xhigh|max@, but--- not @minimal@, so the lowest Baikai level maps up to @low@.+-- not @minimal@ or @none@, so the two lowest Baikai levels map up to+-- @low@. effortArgs :: InteractiveLaunchRequest -> [String] effortArgs req = case req ^. #effort of Nothing -> [] Just lvl -> ["--effort", Text.unpack (claudeEffortValue lvl)] claudeEffortValue :: ThinkingLevel -> Text+claudeEffortValue ThinkingNone = "low" claudeEffortValue ThinkingMinimal = "low" claudeEffortValue lvl = renderThinkingLevel lvl
src/Baikai/Provider/Claude/Internal/Request.hs view
@@ -347,6 +347,15 @@ -- Display changes visibility only; it does not establish reasoning depth. -- Returning only the first is what this provider used to do, and it is -- why a caller could never tell an honoured request from a dropped one.+--+-- 'ThinkingNone' sends @thinking: {"type": "disabled"}@ when the+-- generation accepts it ('supportsThinkingDisabled'), on either style:+-- some adaptive generations otherwise think by default, and on a+-- budget one the explicit value is documented as identical to omitting+-- the field. It carries no effort and no display, because Anthropic+-- rejects @display@ beside @disabled@. A generation that rejects+-- @disabled@ falls through to the adaptive branch below, where 'adaptiveEffort'+-- clamps @none@ to @low@ and the clamp is recorded. computeThinking :: AnthropicMessagesCompat -> Model ->@@ -366,6 +375,41 @@ adjustments = [ThinkingDroppedUnsupportedModel lvl] } )+ | lvl == ThinkingNone && compat ^. #supportsThinkingDisabled =+ -- Opus 5 and Haiku 5.5 accept @disabled@ only at effort @high@ or+ -- below. That is safe here: this branch sends no effort, so+ -- @output_config@ effort is omitted.+ ( ThinkingPlan+ { field = Just Messages.ThinkingDisabled,+ effort = Nothing,+ budget = Nothing+ },+ ThinkingTranslation+ { requested = Just lvl,+ mode = ThinkingModeToggle,+ effortText = Nothing,+ budgetTokens = Nothing,+ wireField = Just "thinking",+ displayText = Nothing,+ adjustments = []+ }+ )+ | lvl == ThinkingNone && thinkingStyle compat /= AnthropicThinkingAdaptive =+ -- Legacy or hand-rolled compat records can reach this: a budget-style+ -- host that rejects @disabled@. Omitting the field is the provider+ -- default, which a budget-era generation documents as no thinking,+ -- but nothing on the wire says so.+ ( emptyThinkingPlan,+ ThinkingTranslation+ { requested = Just lvl,+ mode = ThinkingModeUnsupported,+ effortText = Nothing,+ budgetTokens = Nothing,+ wireField = Nothing,+ displayText = Nothing,+ adjustments = [EffortOmitted lvl]+ }+ ) | thinkingStyle compat == AnthropicThinkingAdaptive = let e = adaptiveEffort lvl in ( ThinkingPlan@@ -411,8 +455,13 @@ -- <https://platform.claude.com/docs/en/build-with-claude/effort>), so an -- omitted field does not mean @high@. Sending a model's default -- explicitly is documented as identical to omitting it.+--+-- 'ThinkingNone' reaches this when disable support is absent from the+-- compatibility record. The positive @low@ fallback is recorded, so it+-- cannot pass a strict caller's explicit off requirement. adaptiveEffort :: ThinkingLevel -> Text adaptiveEffort = \case+ ThinkingNone -> "low" ThinkingMinimal -> "low" ThinkingLow -> "low" ThinkingMedium -> "medium"
src/Baikai/Provider/Claude/Shape.hs view
@@ -12,6 +12,7 @@ replaceToolSchemas, injectToolChoiceNone, injectToolCacheControl,+ injectSystemCacheControl, ) where @@ -36,7 +37,8 @@ Aeson.Value -> Aeson.Value shapeRequestBody compat ctx opts =- injectToolCacheControl compat opts+ injectSystemCacheControl compat opts+ . injectToolCacheControl compat opts . injectToolChoiceNone opts . replaceToolSchemas ctx @@ -72,6 +74,46 @@ | Just marker <- cacheControlMarker compat retention -> mapObject (adjustKey (key "tools") (markLastTool marker)) body _ -> body++-- | Put a @cache_control@ breakpoint on the system prompt when the+-- caller asked for cache retention.+--+-- The top-level marker that+-- 'Baikai.Provider.Claude.Internal.Request.computeCacheControl' sends is+-- Anthropic's automatic caching: it places its breakpoint on the last+-- cacheable block. Without a breakpoint on the shared prefix, changing+-- the first user message prevents reuse of an earlier end-of-request+-- entry. The system-block breakpoint caches that shared prefix+-- (tools, then system) on its own.+--+-- A plain-string @system@ becomes a one-element array of text blocks+-- carrying the same marker, with the same TTL as the top-level one, so+-- Anthropic's "longer TTL first" ordering rule holds. Together with the+-- automatic and the last-tool breakpoints this uses three of the four+-- slots Anthropic allows. An absent or empty system prompt is left+-- alone: Anthropic rejects empty text blocks.+injectSystemCacheControl :: AnthropicMessagesCompat -> Options -> Aeson.Value -> Aeson.Value+injectSystemCacheControl compat opts body =+ case cacheRetention opts of+ Just retention+ | Just marker <- cacheControlMarker compat retention ->+ mapObject (adjustKey (key "system") (markSystem marker)) body+ _ -> body++markSystem :: Aeson.Value -> Aeson.Value -> Aeson.Value+markSystem marker = \case+ String txt+ | txt /= "" ->+ Array+ ( Vector.singleton+ ( Aeson.object+ [ "type" .= ("text" :: Text),+ "text" .= txt,+ "cache_control" .= marker+ ]+ )+ )+ other -> other mapTools :: [Tool.Tool] -> Aeson.Value -> Aeson.Value mapTools toolDefs = \case
test/EvidenceSpec.hs view
@@ -50,7 +50,17 @@ -- @--test-options='--pattern Evidence'@ actually selects it. A -- pattern that matches nothing reports "All 0 tests passed". "EvidenceSpec: Anthropic model-call evidence"- [ successEvidenceTest,+ [ testCase "off remains requested and translated without inventing observed thinking" $ do+ ev <- oneEvidence =<< replay 200 successHeaders successBody (baseOptions & #thinking .~ Just ThinkingNone)+ field "observed_thinking" ev @?= Just (String "unobserved")+ case field "thinking" ev of+ Just (Object t) -> do+ KeyMap.lookup "requested" t @?= Just (String "none")+ KeyMap.lookup "mode" t @?= Just (String "toggle")+ KeyMap.lookup "wire_field" t @?= Just (String "thinking")+ KeyMap.lookup "adjustments" t @?= Just (Array Vector.empty)+ other -> assertFailure ("missing thinking translation: " <> show other),+ successEvidenceTest, rateLimitEvidenceTest, thinkingEvidenceTest, samplingEvidenceTest,
test/Main.hs view
@@ -204,7 +204,8 @@ claudeInteractiveCommand defaultClaudeInteractiveConfig req @?= Right ("claude", ["--effort", expected, "--", "prompt"]) | (name, level, expected) <-- [ ("minimal as low", ThinkingMinimal, "low"),+ [ ("none as low", ThinkingNone, "low"),+ ("minimal as low", ThinkingMinimal, "low"), ("low", ThinkingLow, "low"), ("medium", ThinkingMedium, "medium"), ("high", ThinkingHigh, "high"),@@ -374,7 +375,8 @@ "plan" ] | (name, level, expected) <-- [ ("minimal as low", ThinkingMinimal, "low"),+ [ ("none as low", ThinkingNone, "low"),+ ("minimal as low", ThinkingMinimal, "low"), ("low", ThinkingLow, "low"), ("medium", ThinkingMedium, "medium"), ("high", ThinkingHigh, "high"),@@ -473,6 +475,7 @@ [ -- claude's --effort has no minimal, so the lowest level -- collapses and a run at minimal is wire-identical to a -- run at low.+ (ThinkingNone, "low", [EffortClamped ThinkingNone "low"]), (ThinkingMinimal, "low", [EffortClamped ThinkingMinimal "low"]), (ThinkingLow, "low", []), (ThinkingMedium, "medium", []),@@ -680,7 +683,8 @@ ] ) | (name, level, expected) <-- [ ("minimal as low", ThinkingMinimal, "low"),+ [ ("none as low", ThinkingNone, "low"),+ ("minimal as low", ThinkingMinimal, "low"), ("max", ThinkingMax, "max") ] ]
test/ShapeSpec.hs view
@@ -3,7 +3,7 @@ import Baikai import Baikai.Models.Generated qualified as Models import Baikai.Provider.Claude.Internal.Request (describeThinkingFor, mapRequest)-import Baikai.Provider.Claude.Shape (streamRequestBody)+import Baikai.Provider.Claude.Shape (injectSystemCacheControl, streamRequestBody) import Baikai.Provider.Claude.Transport qualified as Transport import Control.Lens ((&), (.~), (^.)) import Control.Monad (forM_)@@ -29,7 +29,8 @@ verbatimToolSchemaTest, toolChoiceNoneTest, toolCacheControlTest,- toolCacheControlCompatGateTest+ toolCacheControlCompatGateTest,+ systemCacheControlTests ] verbatimToolSchemaTest :: TestTree@@ -107,6 +108,100 @@ opts = emptyOptions & #cacheRetention .~ Just CacheRetentionShort value <- shapedBody model ctx opts lookupPath ["tools", "0", "cache_control"] value @?= Nothing++systemCacheControlTests :: TestTree+systemCacheControlTests =+ testGroup+ "system prompt cache breakpoint"+ [ testCase "without retention the system prompt stays a plain string" $ do+ value <- shapedBody Models.anthropic_claude_haiku_4_5 systemCtx emptyOptions+ lookupPath ["system"] value @?= Just (String systemText)+ lookupPath ["cache_control"] value @?= Nothing,+ testCase "CacheRetentionNone keeps the system prompt a plain string" $ do+ value <- shapedBody Models.anthropic_claude_haiku_4_5 systemCtx (retention CacheRetentionNone)+ lookupPath ["system"] value @?= Just (String systemText)+ lookupPath ["cache_control"] value @?= Nothing,+ testCase "short retention sends the system prompt as one ephemeral block" $ do+ value <- shapedBody Models.anthropic_claude_haiku_4_5 systemCtx (retention CacheRetentionShort)+ lookupPath ["system"] value @?= Just (systemBlocks ephemeral)+ lookupPath ["cache_control"] value @?= Just ephemeral,+ testCase "long retention gives the system block the 1h ttl" $ do+ value <- shapedBody Models.anthropic_claude_haiku_4_5 systemCtx (retention CacheRetentionLong)+ lookupPath ["system"] value @?= Just (systemBlocks oneHour)+ lookupPath ["cache_control"] value @?= Just oneHour,+ testCase "long retention downgrades with the host" $ do+ let model =+ Models.anthropic_claude_haiku_4_5+ & #compat .~ CompatAnthropicMessages (defaultAnthropicMessagesCompat & #supportsLongCacheRetention .~ False)+ value <- shapedBody model systemCtx (retention CacheRetentionLong)+ lookupPath ["system"] value @?= Just (systemBlocks ephemeral)+ lookupPath ["cache_control"] value @?= Just ephemeral,+ testCase "without a system prompt no system field appears" $ do+ value <- shapedBody Models.anthropic_claude_haiku_4_5 emptyContext (retention CacheRetentionShort)+ lookupPath ["system"] value @?= Nothing+ lookupPath ["cache_control"] value @?= Just ephemeral,+ testCase "tools, system and automatic markers stay within four breakpoints" $ do+ let ctx =+ systemCtx+ & #tools+ .~ Vector.singleton+ (emptyTool & #name .~ "lookup" & #description .~ "Lookup" & #parameters .~ objectSchema)+ value <- shapedBody Models.anthropic_claude_haiku_4_5 ctx (retention CacheRetentionShort)+ lookupPath ["tools", "0", "cache_control"] value @?= Just ephemeral+ lookupPath ["system", "0", "cache_control"] value @?= Just ephemeral+ lookupPath ["cache_control"] value @?= Just ephemeral,+ testCase "empty system text never becomes an empty cacheable block" $+ forM_ [CacheRetentionShort, CacheRetentionLong] $ \r -> do+ value <- shapedBody Models.anthropic_claude_haiku_4_5 (emptyContext & #systemPrompt .~ Just "") (retention r)+ lookupPath ["system"] value @?= Just (String ""),+ testCase "system caching is independent of tool-marker support" $ do+ let model =+ Models.anthropic_claude_haiku_4_5+ & #compat .~ CompatAnthropicMessages (defaultAnthropicMessagesCompat & #supportsCacheControlOnTools .~ False)+ ctx = systemCtx & #tools .~ Vector.singleton (emptyTool & #name .~ "lookup" & #parameters .~ objectSchema)+ value <- shapedBody model ctx (retention CacheRetentionLong)+ lookupPath ["tools", "0", "cache_control"] value @?= Nothing+ lookupPath ["system"] value @?= Just (systemBlocks oneHour)+ lookupPath ["cache_control"] value @?= Just oneHour,+ testCase "all markers share the effective TTL with multiple tools" $+ forM_ [True, False] $ \supportsLong -> do+ let model =+ Models.anthropic_claude_haiku_4_5+ & #compat .~ CompatAnthropicMessages (defaultAnthropicMessagesCompat & #supportsLongCacheRetention .~ supportsLong)+ ctx = systemCtx & #tools .~ Vector.fromList [emptyTool & #name .~ "first", emptyTool & #name .~ "last"]+ marker = if supportsLong then oneHour else ephemeral+ value <- shapedBody model ctx (retention CacheRetentionLong)+ lookupPath ["tools", "0", "cache_control"] value @?= Nothing+ lookupPath ["tools", "1", "cache_control"] value @?= Just marker+ lookupPath ["system"] value @?= Just (systemBlocks marker)+ lookupPath ["cache_control"] value @?= Just marker,+ testCase "different first questions retain the same cacheable system prefix" $ do+ let ctxFor question = systemCtx & #messages .~ Vector.singleton (user question)+ first <- shapedBody Models.anthropic_claude_haiku_4_5 (ctxFor "First question") (retention CacheRetentionShort)+ second <- shapedBody Models.anthropic_claude_haiku_4_5 (ctxFor "Different question") (retention CacheRetentionShort)+ lookupPath ["system"] first @?= lookupPath ["system"] second+ assertBool "user messages remain distinct" (lookupPath ["messages"] first /= lookupPath ["messages"] second),+ testCase "system shaping preserves other fields and existing block arrays" $ do+ let body = Aeson.object ["system" .= systemText, "unrelated" .= objectSchema]+ expected = Aeson.object ["system" .= systemBlocks ephemeral, "unrelated" .= objectSchema]+ inject = injectSystemCacheControl defaultAnthropicMessagesCompat (retention CacheRetentionShort)+ inject body @?= expected+ inject expected @?= expected+ ]+ where+ systemText = "You are a careful assistant." :: Text.Text+ systemCtx = emptyContext & #systemPrompt .~ Just systemText+ retention r = emptyOptions & #cacheRetention .~ Just r+ ephemeral = Aeson.object ["type" .= ("ephemeral" :: Text.Text)]+ oneHour = Aeson.object ["type" .= ("ephemeral" :: Text.Text), "ttl" .= ("1h" :: Text.Text)]+ systemBlocks marker =+ Aeson.toJSON+ [ Aeson.object+ [ "type" .= ("text" :: Text.Text),+ "text" .= systemText,+ "cache_control" .= marker+ ]+ ] shapedBody :: Model -> Context -> Options -> IO Value shapedBody model ctx opts = do
test/ThinkingSpec.hs view
@@ -14,6 +14,7 @@ import Claude.V1.Messages qualified as Messages import Control.Lens ((&), (.~), (^.)) import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KeyMap import Data.ByteString.Lazy qualified as BSL import Data.Char qualified as Char import Data.Generics.Labels ()@@ -23,6 +24,7 @@ import Data.Time.Clock (UTCTime) import Data.Vector qualified as Vector import Numeric.Natural (Natural)+import Streamly.Data.Stream qualified as Stream import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=)) @@ -48,6 +50,7 @@ tooSmallCapDropsThinkingTest, mergedOutputConfigTest, explicitCompatOverridesDefaultTest,+ thinkingNoneTests, anthropicModelsCoverCatalogTest, testGroup "fast-mode catalog facts" [testCase name ((anthropicMessagesCompatFor m ^. #supportsFastMode) @?= fast) | (name, m, _, _, _, fast) <- anthropicModels], testGroup "forced-tool catalog facts" [testCase name ((anthropicMessagesCompatFor m ^. #supportsForcedToolChoice) @?= forced) | (name, m, _, _, forced, _) <- anthropicModels],@@ -355,6 +358,126 @@ requestThinking req @?= Just (Messages.ThinkingAdaptiveWithDisplay Messages.ThinkingSummarized) (req ^. #output_config >>= Messages.effort) @?= Just "low" +-- | 'ThinkingNone' against every catalog model, and the wire bytes it+-- and the settings it sits beside produce.+--+-- Whether a model accepts @thinking: {"type": "disabled"}@ is pinned+-- by hand in @CatalogSpec@; here it is read off the record, because+-- what is under test is the request built from it.+thinkingNoneTests :: TestTree+thinkingNoneTests =+ testGroup+ "ThinkingNone"+ ( [ testCase (name <> " none") $ do+ let opts = emptyOptions & #thinking .~ Just ThinkingNone+ (req, t) <- mappedFor model emptyContext opts+ t ^. #requested @?= Just ThinkingNone+ if anthropicMessagesCompatFor model ^. #supportsThinkingDisabled+ then do+ requestThinking req @?= Just Messages.ThinkingDisabled+ (req ^. #output_config >>= Messages.effort) @?= Nothing+ req ^. #max_tokens @?= model ^. #maxOutputTokens+ t ^. #mode @?= ThinkingModeToggle+ t ^. #wireField @?= Just "thinking"+ t ^. #effortText @?= Nothing+ t ^. #displayText @?= Nothing+ t ^. #adjustments @?= []+ else do+ -- Anthropic rejects "disabled" here, so the least thinking+ -- the model can be asked for is adaptive at low effort.+ requestThinking req @?= Just (Messages.ThinkingAdaptiveWithDisplay Messages.ThinkingSummarized)+ (req ^. #output_config >>= Messages.effort) @?= Just "low"+ t ^. #mode @?= ThinkingModeAdaptive+ t ^. #adjustments @?= [EffortClamped ThinkingNone "low"]+ | (name, model, _, _, _, _) <- anthropicModels+ ]+ <> [ testCase "Haiku 5.5 none sends exactly {\"type\":\"disabled\"} and no output_config" $ do+ body <- requestBody anthropic_claude_haiku_5_5 (emptyOptions & #thinking .~ Just ThinkingNone)+ KeyMap.lookup "thinking" body @?= Just (Aeson.object ["type" Aeson..= ("disabled" :: Text.Text)])+ KeyMap.member "output_config" body @?= False,+ testCase "Haiku 5.5 with no level still omits thinking and output_config" $ do+ -- Nothing leaves the model's own default in force; on an+ -- adaptive generation that is to think. Unchanged by the+ -- arrival of ThinkingNone.+ body <- requestBody anthropic_claude_haiku_5_5 emptyOptions+ KeyMap.member "thinking" body @?= False+ KeyMap.member "output_config" body @?= False,+ testCase "Haiku 5.5 low still sends adaptive with summarized display" $ do+ body <- requestBody anthropic_claude_haiku_5_5 (emptyOptions & #thinking .~ Just ThinkingLow)+ KeyMap.lookup "thinking" body+ @?= Just+ ( Aeson.object+ [ "type" Aeson..= ("adaptive" :: Text.Text),+ "display" Aeson..= ("summarized" :: Text.Text)+ ]+ )+ KeyMap.lookup "output_config" body+ @?= Just (Aeson.object ["effort" Aeson..= ("low" :: Text.Text)]),+ testCase "none keeps a JSON-schema output_config without an effort" $ do+ let schema = Aeson.object ["type" Aeson..= ("object" :: Text.Text)]+ opts =+ emptyOptions+ & #thinking .~ Just ThinkingNone+ & #responseFormat .~ Just (JsonSchema (jsonSchemaFormat "answer" schema))+ req <- requestFor anthropic_claude_haiku_5_5 opts+ requestThinking req @?= Just Messages.ThinkingDisabled+ req ^. #output_config @?= Just (Messages.jsonSchemaConfig schema),+ testCase "a budget-style host that rejects disabled omits the field and says so" $ do+ let model =+ anthropic_claude_haiku_4_5+ & #compat .~ CompatAnthropicMessages (defaultAnthropicMessagesCompat {supportsThinkingDisabled = False})+ opts = emptyOptions & #thinking .~ Just ThinkingNone+ (req, t) <- mappedFor model emptyContext opts+ requestThinking req @?= Nothing+ req ^. #max_tokens @?= model ^. #maxOutputTokens+ t ^. #mode @?= ThinkingModeUnsupported+ t ^. #wireField @?= Nothing+ t ^. #adjustments @?= [EffortOmitted ThinkingNone],+ testCase "strict complete and stream refuse an always-on model before dispatch" $ do+ let opts = emptyOptions & #thinking .~ Just ThinkingNone & #evidence .~ Just (evidenceRequest "off" & #strictness .~ EvidenceRequired EvidenceRequestedOnly)+ provider = apiProviderWith AnthropicMessages (\_ _ _ -> error "stream dispatched") (\_ _ _ -> error "complete dispatched") & #describeThinking .~ describeThinkingFor & #strengthCeiling .~ EvidenceModelObserved+ reg <- newProviderRegistry+ registerApiProviderWith reg provider+ response <- completeRequestWith reg anthropic_claude_opus_5_5 emptyContext opts+ case responseError response of+ Just err -> err ^. #category @?= InvalidRequest+ Nothing -> assertFailure "strict off unexpectedly succeeded"+ events <- Stream.toList (streamRequestWith reg anthropic_claude_opus_5_5 emptyContext opts)+ case events of+ [EventStart _, EventError payload] -> (payload ^. #errorInfo) /= Nothing @?= True+ other -> assertFailure ("expected pre-dispatch stream refusal: " <> show other),+ testCase "strict evidence accepts none where disabled is sent" $+ checkEvidenceRequirements+ (EvidenceRequired EvidenceRequestedOnly)+ (declaredStrength AnthropicMessages)+ (describeThinkingFor anthropic_claude_haiku_5_5 (emptyOptions & #thinking .~ Just ThinkingNone))+ @?= [],+ testCase "strict evidence refuses none where it is clamped to low" $+ assertBool "Opus 5.5 cannot turn thinking off, so a strict caller is refused" $+ not . null $+ checkEvidenceRequirements+ (EvidenceRequired EvidenceRequestedOnly)+ (declaredStrength AnthropicMessages)+ (describeThinkingFor anthropic_claude_opus_5_5 (emptyOptions & #thinking .~ Just ThinkingNone)),+ testCase "strict evidence accepts none on a model that cannot reason" $ do+ let model = anthropic_claude_haiku_4_5 & #reasoning .~ False+ opts = emptyOptions & #thinking .~ Just ThinkingNone+ t <- translationFor model opts+ t ^. #adjustments @?= [ThinkingDroppedUnsupportedModel ThinkingNone]+ checkEvidenceRequirements+ (EvidenceRequired EvidenceRequestedOnly)+ (declaredStrength AnthropicMessages)+ t+ @?= []+ ]+ )+ where+ requestBody model opts = do+ req <- requestFor model opts+ case Aeson.toJSON req of+ Aeson.Object o -> pure o+ other -> assertFailure ("request did not encode as an object: " <> show other)+ requestFor :: Model -> Options -> IO Messages.CreateMessage requestFor model opts = fst <$> mappedFor model emptyContext opts @@ -372,6 +495,7 @@ adaptiveEffort :: ThinkingLevel -> Maybe Text.Text adaptiveEffort = \case+ ThinkingNone -> Just "low" ThinkingMinimal -> Just "low" ThinkingLow -> Just "low" ThinkingMedium -> Just "medium"