packages feed

agentic-openai (empty) → 0.2.0.0

raw patch · 6 files changed

+545/−0 lines, 6 filesdep +aesondep +agenticdep +agentic-aeson

Dependencies added: aeson, agentic, agentic-aeson, agentic-io, agentic-openai, base, bytestring, hspec, http-client, http-client-tls, http-types, text

Files

+ CHANGELOG.md view
@@ -0,0 +1,5 @@+# Changelog for agentic-openai++## 0.2.0.0 - 2026-10-01++First release of the v2 design.
+ LICENSE view
@@ -0,0 +1,25 @@+Copyright (c) 2026, Tom Wells++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are+met:++1. Redistributions of source code must retain the above copyright+   notice, this list of conditions and the following disclaimer.++2. Redistributions in binary form must reproduce the above copyright+   notice, this list of conditions and the following disclaimer in the+   documentation and/or other materials provided with the+   distribution.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ agentic-openai.cabal view
@@ -0,0 +1,75 @@+cabal-version:      3.0+name:               agentic-openai+version:            0.2.0.0+synopsis:           OpenAI as the System Two provider for agentic+description:+  Runs agentic's draft steps on OpenAI models through the Responses API, with strict structured outputs and tools, and can stand in as System One.+license:            BSD-2-Clause+license-file:       LICENSE+author:             Tom Wells+maintainer:         drshade@gmail.com+copyright:          2026 Tom Wells+category:           AI+homepage:           https://github.com/drshade/haskell-agentic+bug-reports:        https://github.com/drshade/haskell-agentic/issues+build-type:         Simple+extra-doc-files:    CHANGELOG.md+tested-with:        GHC ==9.6.7 || ==9.8.4 || ==9.10.3 || ==9.12.2 || ==9.14.1++source-repository head+  type:     git+  location: https://github.com/drshade/haskell-agentic.git+  subdir:   agentic-openai++common shared+  default-language: GHC2021+  default-extensions:+    DeriveAnyClass+    DerivingVia+    DuplicateRecordFields+    LambdaCase+    NoFieldSelectors+    OverloadedRecordDot+    OverloadedStrings+  ghc-options:      -Wall++library+  import:           shared+  hs-source-dirs:   src+  exposed-modules:  Agentic.OpenAI+  build-depends:+    , agentic          ==0.2.*+    , agentic-aeson    ==0.2.*+    , aeson            >=2.1 && <2.3+    , base             >=4.18 && <5+    , bytestring       >=0.11 && <0.13+    , http-client      >=0.7 && <0.8+    , http-client-tls  >=0.3 && <0.4+    , http-types       >=0.12 && <0.13+    , text             >=2.0 && <2.2+test-suite agentic-openai-test+  import:           shared+  type:             exitcode-stdio-1.0+  hs-source-dirs:   test+  main-is:          Spec.hs+  build-depends:+    , agentic          ==0.2.*+    , agentic-aeson    ==0.2.*+    , agentic-openai   ==0.2.*+    , aeson            >=2.1 && <2.3+    , base             >=4.18 && <5+    , hspec            >=2.10 && <3+    , text             >=2.0 && <2.2+-- Calls the real API. Pending unless OPENAI_API_KEY is set (in the environment or .env).+test-suite agentic-openai-live+  import:           shared+  type:             exitcode-stdio-1.0+  hs-source-dirs:   test-live+  main-is:          Live.hs+  build-depends:+    , agentic          ==0.2.*+    , agentic-openai   ==0.2.*+    , agentic-io       ==0.2.*+    , base             >=4.18 && <5+    , hspec            >=2.10 && <3+    , text             >=2.0 && <2.2
+ src/Agentic/OpenAI.hs view
@@ -0,0 +1,248 @@+-- | OpenAI as a runtime's System Two (and, through 'viaLLM', System One), over+-- the Responses API.+--+-- > rt <- pure runtime >>= withSystemTwo openai+-- > rt <- pure runtime >>= withSystemTwo (openai & model "gpt-6.1-sol" & effort Low)+module Agentic.OpenAI+  ( OpenAI (..)+  , openai+  , OpenAIError (..)+    -- * Wire format+  , requestBody+  , decodeTurn+  ) where++import Agentic.Aeson (fromAeson)+import Agentic.Core (Instruction (..))+import Agentic.JsonSchema (objectSchema, schemaName, unwrap)+import Agentic.Runtime+import Agentic.Schema (Schema)+import Agentic.Settings+import qualified Agentic.Value as A+import Agentic.ViaLLM (viaLLM)+import Control.Exception (Exception (..), throwIO)+import Data.Aeson ((.:), (.:?))+import qualified Data.Aeson as J+import qualified Data.Aeson.Types as J+import qualified Data.ByteString.Lazy as LBS+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as T+import qualified Data.Text.Lazy as TL+import qualified Data.Text.Lazy.Encoding as TL+import qualified Network.HTTP.Client as Http+import Network.HTTP.Client.TLS (newTlsManager)+import Network.HTTP.Types.Status (statusCode)+import System.Environment (lookupEnv)++-- | OpenAI's settings. Start from 'openai' and change them with the setters+-- from "Agentic.Settings": 'Agentic.Settings.model', 'Agentic.Settings.system',+-- 'Agentic.Settings.effort', 'Agentic.Settings.maxTokens', 'Agentic.Settings.key',+-- 'Agentic.Settings.endpoint' and 'Agentic.Settings.timeout'.+data OpenAI = OpenAI+  { model :: Text+  , system :: Maybe Text+    -- ^ Sent as the request's instructions.+  , maxTokens :: Maybe Int+  , effort :: Maybe Effort+    -- ^ The model's default if unset.+  , key :: Maybe Text+    -- ^ Defaults to the @OPENAI_API_KEY@ environment variable.+  , endpoint :: String+  , timeout :: Int+    -- ^ Seconds.+  }++openai :: OpenAI+openai =+  OpenAI+    { model = "gpt-6-astra"+    , system = Nothing+    , maxTokens = Nothing+    , effort = Nothing+    , key = Nothing+    , endpoint = "https://api.openai.com/v1/responses"+    , timeout = 600+    }++instance HasModel OpenAI where model m c = c {model = m}+instance HasSystem OpenAI where system t c = c {system = Just t}+instance HasMaxTokens OpenAI where maxTokens n c = c {maxTokens = Just n}+instance HasEffort OpenAI where effort e c = c {effort = Just e}+instance HasKey OpenAI where key k c = c {key = Just k}+instance HasEndpoint OpenAI where endpoint e c = c {endpoint = e}+instance HasTimeout OpenAI where timeout t c = c {timeout = t}++effortName :: Effort -> Text+effortName = \case+  Low -> "low"+  Medium -> "medium"+  High -> "high"+  XHigh -> "xhigh"+  Max -> "max"++-- | What can go wrong talking to the Responses API. Thrown in IO.+data OpenAIError+  = MissingKey+  | HttpError Int Text+    -- ^ A non-200 status, and the API's error body.+  | Refused Text+    -- ^ The model refused, with its explanation.+  | Truncated+    -- ^ The reply hit the token limit before it was complete.+  | Incomplete Text+    -- ^ The response stopped early for another reason.+  | UnexpectedResponse Text+  deriving (Show)++instance Exception OpenAIError where+  displayException = \case+    MissingKey -> "OpenAI: no API key. Set OPENAI_API_KEY, or use (openai & key ...)."+    HttpError status body -> "OpenAI rejected the request (HTTP " <> show status <> "): " <> T.unpack body+    Refused why -> "The model refused: " <> T.unpack why+    Truncated -> "The reply hit the token limit; raise it with (openai & maxTokens ...)"+    Incomplete reason -> "The response stopped early: " <> T.unpack reason+    UnexpectedResponse problem -> "OpenAI sent a response agentic can't read: " <> T.unpack problem++instance ProvidesSystemTwo OpenAI where+  toSystemTwo cfg = do+    key' <- maybe (fmap T.pack <$> lookupEnv "OPENAI_API_KEY") (pure . Just) cfg.key >>= maybe (throwIO MissingKey) pure+    manager <- newTlsManager+    base <- Http.parseRequest cfg.endpoint+    pure $ SystemTwo $ \conversation -> do+      let http =+            base+              { Http.method = "POST"+              , Http.requestHeaders =+                  [ ("Authorization", "Bearer " <> T.encodeUtf8 key')+                  , ("Content-Type", "application/json")+                  ]+              , Http.requestBody = Http.RequestBodyLBS (TL.encodeUtf8 (TL.fromStrict (A.renderJson (requestBody cfg conversation))))+              , Http.responseTimeout = Http.responseTimeoutMicro (cfg.timeout * 1000000)+              }+      response <- Http.httpLbs http manager+      let status = statusCode (Http.responseStatus response)+          body = Http.responseBody response+      if status /= 200+        then throwIO (HttpError status (T.decodeUtf8Lenient (LBS.toStrict body)))+        else case J.eitherDecode body of+          Left problem -> throwIO (UnexpectedResponse (T.pack problem))+          Right value -> either throwIO pure (decodeTurn conversation value)++-- | The model answers judgements too, with uncalibrated probabilities.+instance ProvidesSystemOne OpenAI where+  toSystemOne cfg = viaLLM <$> toSystemTwo cfg++-- | The Responses API request for one turn of a step. Nothing is stored on+-- OpenAI's side: each turn sends the whole step so far, and reasoning comes+-- back encrypted so it can be sent back unchanged.+requestBody :: OpenAI -> Conversation -> A.Value+requestBody cfg c =+  A.Object $+    [("model", A.String cfg.model)]+      <> maybe [] (\s -> [("instructions", A.String s)]) cfg.system+      <> [("tools", A.Array (map tool (tools c))) | not (null (tools c))]+      <> [ ("input", A.Array (task : concatMap exchange (history c)))+         , ( "text"+           , A.Object+               [ ( "format"+                 , A.Object+                     [ ("type", A.String "json_schema")+                     , ("name", A.String (schemaName (output c)))+                     , ("schema", objectSchema (output c))+                     , ("strict", A.Bool True)+                     ]+                 )+               ]+           )+         , ("store", A.Bool False)+         , ("include", A.Array [A.String "reasoning.encrypted_content"])+         ]+      <> maybe [] (\e -> [("reasoning", A.Object [("effort", A.String (effortName e))])]) cfg.effort+      <> maybe [] (\n -> [("max_output_tokens", A.Integer (toInteger n))]) cfg.maxTokens+  where+    task = message "user" (instructionText (instruction c) <> input)+    input = case state c of+      A.Null -> ""+      s -> "\n\nInput:\n" <> A.renderJson s+    tool spec =+      A.Object+        [ ("type", A.String "function")+        , ("name", A.String (specName spec))+        , ("description", A.String (specDescription spec))+        , ("parameters", objectSchema (specInput spec))+        , ("strict", A.Bool True)+        ]+    -- A turn's raw value is its list of output items, which go back as input.+    items (Raw raw) = case raw of+      A.Array xs -> xs+      other -> [other]+    exchange = \case+      Called raw results -> items raw <> map result results+      Rejected raw problem -> items raw <> [message "user" ("That answer was rejected: " <> problem <> ". Please answer again.")]+    result (callId', r) =+      A.Object+        [ ("type", A.String "function_call_output")+        , ("call_id", A.String callId')+        , ("output", A.String (case r of ToolOk v -> asText v; ToolFailed problem -> "Error: " <> problem))+        ]+    asText = \case+      A.String t -> t+      v -> A.renderJson v+    message :: Text -> Text -> A.Value+    message role content = A.Object [("role", A.String role), ("content", A.String content)]++-- | Read one turn from a Responses API response.+decodeTurn :: Conversation -> J.Value -> Either OpenAIError Turn+decodeTurn c = either (Left . UnexpectedResponse . T.pack) id . J.parseEither parse+  where+    parse = J.withObject "response" $ \r -> do+      status <- r .: "status"+      details <- r .:? "incomplete_details"+      reason <- maybe (pure Nothing) (J.withObject "incomplete_details" (.:? "reason")) details+      outputs <- r .: "output" :: J.Parser [J.Value]+      -- Message parts are flattened into the item list.+      parsed <- concatMap (\case Message parts -> parts; x -> [x]) <$> traverse item outputs+      let raw = Raw (fromAeson (J.toJSON outputs))+          calls = [ToolCall i n (unwrapInput n v) | Call i n v <- parsed]+          text = T.concat [t | Text t <- parsed]+          refusals = [t | Refusal t <- parsed]+      pure $ case (status :: Text, reason :: Maybe Text) of+        (_, _) | why : _ <- refusals -> Left (Refused why)+        ("incomplete", Just "max_output_tokens") -> Left Truncated+        ("incomplete", Just "content_filter") -> Left (Refused "content filter")+        ("incomplete", other) -> Left (Incomplete (maybe "unknown" id other))+        ("completed", _)+          | not (null calls) -> Right (Turn raw (CallTools calls))+          | otherwise -> Right (Turn raw (Respond (final text)))+        (other, _) -> Left (Incomplete other)+    item = J.withObject "item" $ \o -> do+      kind <- o .: "type"+      case kind :: Text of+        "function_call" -> do+          arguments <- o .: "arguments"+          Call <$> o .: "call_id" <*> o .: "name" <*> pure (json arguments)+        "message" -> do+          content <- o .: "content"+          Message <$> traverse part content+        _ -> pure Other+    part = J.withObject "part" $ \p -> do+      kind <- p .: "type"+      case kind :: Text of+        "output_text" -> Text <$> p .: "text"+        "refusal" -> Refusal <$> p .: "refusal"+        _ -> pure Other+    -- A reply that isn't JSON goes back to the core as text; the output+    -- contract then rejects it and the model gets another go.+    final text = case decodeJson text of+      Just v -> unwrap (output c) v+      Nothing -> A.String text+    json t = maybe (A.String t) id (decodeJson t)+    decodeJson t = fromAeson <$> J.decode (TL.encodeUtf8 (TL.fromStrict t))+    unwrapInput name v = maybe v (`unwrap` v) (inputSchema name)+    inputSchema :: Text -> Maybe Schema+    inputSchema name = case [specInput s | s <- tools c, specName s == name] of+      s : _ -> Just s+      [] -> Nothing++data Item = Call Text Text A.Value | Message [Item] | Text Text | Refusal Text | Other
+ test-live/Live.hs view
@@ -0,0 +1,89 @@+module Main (main) where++import Agentic+import Agentic.OpenAI (openai)+import Agentic.IO.DotEnv (loadDotEnv)+import Data.IORef+import Data.Text (Text)+import qualified Data.Text as T+import GHC.Generics (Generic)+import System.Environment (lookupEnv)+import Test.Hspec hiding (describe)+import qualified Test.Hspec++data Joke = Joke {genre :: Text, setup :: Text, punchline :: Text}+  deriving (Generic, Show, Contract)++data BetterJoke+  = DadJoke {setup' :: Text, punchline' :: Text}+  | OneLiner {line :: Text}+  | KnockKnock {whosThere :: Text, punchline' :: Text}+  deriving (Generic, Show, Contract)++data Groan = Mild | Solid | Unbearable+  deriving (Generic, Show, Eq)++instance Options Groan where+  options =+    described+      "How much the audience groans"+      [option Mild "A polite smile", option Solid "An audible groan", option Unbearable "People get up and leave"]++deriving via Enumeration Groan instance Contract Groan++newtype Rating = Rating Int+  deriving (Show, Eq)++instance Contract Rating where+  contract = mapCodec Rating (\(Rating n) -> n) (between 1 10 contract)++main :: IO ()+main = do+  _ <- loadDotEnv+  apiKey <- lookupEnv "OPENAI_API_KEY"+  hspec $ Test.Hspec.describe "OpenAI, live" $ case apiKey of+    Nothing -> it "needs OPENAI_API_KEY" (pendingWith "set OPENAI_API_KEY in .env to run the live tests")+    Just _ -> do+      let fast = openai & effort Low+          withModel = pure runtime >>= withSystemTwo fast++      it "drafts a record" $ do+        rt <- withModel+        j <- interpret rt (draft @Joke "a joke please") ()+        T.null j.punchline `shouldBe` False++      it "drafts a list, which has to be wrapped for structured outputs" $ do+        rt <- withModel+        names <- interpret rt (draft @[Text] "Name exactly 3 dinosaurs") ()+        length names `shouldBe` 3++      it "drafts a sum type from typed input" $ do+        rt <- withModel+        j <- interpret rt (draft @BetterJoke "Convert this knock-knock joke") (Joke "knock-knock" "Knock knock. Who's there? Boo." "Don't cry, it's only a joke!")+        case j of+          KnockKnock {} -> pure ()+          other -> expectationFailure ("expected a KnockKnock, got " <> show other)++      it "runs a tool loop" $ do+        calls <- newIORef (0 :: Int)+        let lookupFossil = tool @Text @Text "fossil_count" "How many fossils the museum holds of a dinosaur" $+              act (\_ -> modifyIORef calls (+ 1) >> pure "The museum holds 17 fossils of it.")+        rt <- withModel+        n <- interpret rt (draftWith @Int [lookupFossil] "How many Stegosaurus fossils does the museum hold? Use the tool.") ()+        n `shouldBe` 17+        readIORef calls >>= (`shouldSatisfy` (>= 1))++      it "keeps within a contract's checks" $ do+        rt <- withModel+        Rating n <- interpret rt (draft @Rating "Rate this joke") (Joke "pun" "Why was the scarecrow promoted?" "He was outstanding in his field.")+        n `shouldSatisfy` (\x -> x >= 1 && x <= 10)++      it "drafts an Options type, whose schema is described constants" $ do+        rt <- withModel+        g <- interpret rt (draft @Groan "How much will the audience groan at this pun?") ("I'm reading a book about anti-gravity. It's impossible to put down." :: Text)+        g `shouldSatisfy` (`elem` [Mild, Solid, Unbearable])++      it "stands in as System One" $ do+        rt <- pure runtime >>= withSystemOne fast+        kept <- interpret rt (keep 0.5 (yesNo "Is this text a joke?")) ["Why was the scarecrow promoted? He was outstanding in his field.", "The meeting is at 3pm in room 4." :: Text]+        length kept `shouldBe` 1
+ test/Spec.hs view
@@ -0,0 +1,103 @@+module Main (main) where++import Agentic+import Agentic.Runtime (Conversation (..))+import Agentic.Aeson (toAeson)+import Agentic.OpenAI+import qualified Data.Aeson as J+import qualified Data.Aeson.KeyMap as KeyMap+import Data.Maybe (fromJust)+import Data.Text (Text)+import GHC.Generics (Generic)+import Test.Hspec hiding (describe)+import qualified Test.Hspec++data Square = Blank | X | O+  deriving (Generic, Show, Contract)++data Row = Row {left :: Square, centre :: Square, right :: Square}+  deriving (Generic, Show, Contract)++data Board = Board {top :: Row, middle :: Row, bottom :: Row}+  deriving (Generic, Show, Contract)++conversation :: Conversation+conversation =+  Conversation+    { path = []+    , instruction = "Suggest 3 dinosaurs"+    , state = Null+    , stateSchema = schemaOf SNull+    , tools = [ToolSpec "search" "Search the fossil database" (codecSchema (contract @Text))]+    , output = codecSchema (contract @[Text])+    , history = []+    }++at :: J.Key -> J.Value -> J.Value+at k = \case+  J.Object o -> fromJust (KeyMap.lookup k o)+  other -> error ("not an object: " <> show other)++body :: Conversation -> J.Value+body = toAeson . requestBody openai++decoded :: J.Value -> Either String Turn+decoded = either (Left . show) Right . decodeTurn conversation++main :: IO ()+main = hspec $ Test.Hspec.describe "Agentic.OpenAI" $ do+  it "asks for strict structured output, wrapped when it isn't an object" $+    at "format" (at "text" (body conversation))+      `shouldBe` fromJust+        ( J.decode+            "{\"type\":\"json_schema\",\"name\":\"output\",\"strict\":true,\"schema\":{\"type\":\"object\",\+            \\"properties\":{\"value\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}},\+            \\"required\":[\"value\"],\"additionalProperties\":false}}"+        )++  it "names the schema after its type, and shares repeated types through $defs" $ do+    let format = at "format" (at "text" (body conversation {output = codecSchema (contract @Board)}))+        schema = at "schema" format+    at "name" format `shouldBe` J.String "Board"+    at "top" (at "properties" schema) `shouldBe` fromJust (J.decode "{\"$ref\":\"#/$defs/Row\"}")+    KeyMap.keys (case at "$defs" schema of J.Object o -> o; _ -> mempty) `shouldMatchList` ["Row", "Square"]++  it "stores nothing, and asks for reasoning it can send back" $ do+    at "store" (body conversation) `shouldBe` J.Bool False+    at "include" (body conversation) `shouldBe` J.toJSON ["reasoning.encrypted_content" :: Text]++  it "sends tools as strict functions" $+    at "tools" (body conversation)+      `shouldBe` fromJust+        ( J.decode+            "[{\"type\":\"function\",\"name\":\"search\",\"description\":\"Search the fossil database\",\"strict\":true,\+            \\"parameters\":{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"string\"}},\+            \\"required\":[\"value\"],\"additionalProperties\":false}}]"+        )++  it "sends earlier output items back as input, followed by tool outputs" $ do+    let reasoning = Object [("type", String "reasoning"), ("encrypted_content", String "abc")]+        call = Object [("type", String "function_call"), ("call_id", String "c1"), ("name", String "search"), ("arguments", String "{\"value\":\"rex\"}")]+        c = conversation {history = [Called (Raw (Array [reasoning, call])) [("c1", ToolOk (String "T. rex"))]]}+    at "input" (body c)+      `shouldBe` fromJust+        ( J.decode+            "[{\"role\":\"user\",\"content\":\"Suggest 3 dinosaurs\"},\+            \{\"type\":\"reasoning\",\"encrypted_content\":\"abc\"},\+            \{\"type\":\"function_call\",\"call_id\":\"c1\",\"name\":\"search\",\"arguments\":\"{\\\"value\\\":\\\"rex\\\"}\"},\+            \{\"type\":\"function_call_output\",\"call_id\":\"c1\",\"output\":\"T. rex\"}]"+        )++  it "reads function calls, parsing and unwrapping their arguments" $+    fmap action (decoded (fromJust (J.decode "{\"status\":\"completed\",\"output\":[{\"type\":\"reasoning\"},{\"type\":\"function_call\",\"call_id\":\"c1\",\"name\":\"search\",\"arguments\":\"{\\\"value\\\":\\\"rex\\\"}\"}]}")))+      `shouldBe` Right (CallTools [ToolCall "c1" "search" (String "rex")])++  it "reads a final answer from the message text" $+    fmap action (decoded (fromJust (J.decode "{\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"text\":\"{\\\"value\\\":[\\\"Stegosaurus\\\"]}\"}]}]}")))+      `shouldBe` Right (Respond (Array [String "Stegosaurus"]))++  it "reports refusals and truncation as errors" $ do+    let refusal = decodeTurn conversation (fromJust (J.decode "{\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"content\":[{\"type\":\"refusal\",\"refusal\":\"No.\"}]}]}"))+        truncated = decodeTurn conversation (fromJust (J.decode "{\"status\":\"incomplete\",\"incomplete_details\":{\"reason\":\"max_output_tokens\"},\"output\":[]}"))+    either (\case Refused "No." -> True; _ -> False) (const False) refusal `shouldBe` True+    either (\case Truncated -> True; _ -> False) (const False) truncated `shouldBe` True