packages feed

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 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"