packages feed

baikai-openai 0.2.0.0 → 0.3.0.0

raw patch · 16 files changed

+2481/−627 lines, 16 filesdep +directorydep +filepathdep ~baikaiPVP ok

version bump matches the API change (PVP)

Dependencies added: directory, filepath

Dependency ranges changed: baikai

API changes (from Hackage documentation)

- Baikai.Provider.OpenAI.Api: mapRequest :: Model -> Context -> Options -> Either Text CreateChatCompletion
- Baikai.Provider.OpenAI.Cli: CodexCliConfig :: !FilePath -> !Vector Text -> !Maybe FilePath -> !Bool -> !Bool -> CodexCliConfig
- Baikai.Provider.OpenAI.Cli: [ephemeral] :: CodexCliConfig -> !Bool
- Baikai.Provider.OpenAI.Cli: [executable] :: CodexCliConfig -> !FilePath
- Baikai.Provider.OpenAI.Cli: [extraArgs] :: CodexCliConfig -> !Vector Text
- Baikai.Provider.OpenAI.Cli: [skipGitRepoCheck] :: CodexCliConfig -> !Bool
- Baikai.Provider.OpenAI.Cli: [workingDir] :: CodexCliConfig -> !Maybe FilePath
- Baikai.Provider.OpenAI.ErrorClass: classifyErrorText :: Text -> Maybe BaikaiError
- Baikai.Provider.OpenAI.ErrorClass: classifyException :: SomeException -> BaikaiError
- Baikai.Provider.OpenAI.ErrorClass: responseToError :: ResponseF ByteString -> BaikaiError
- Baikai.Provider.OpenAI.Interactive: CodexInteractiveConfig :: !FilePath -> !Vector Text -> CodexInteractiveConfig
- Baikai.Provider.OpenAI.Interactive: [executable] :: CodexInteractiveConfig -> !FilePath
- Baikai.Provider.OpenAI.Interactive: [extraArgs] :: CodexInteractiveConfig -> !Vector Text
+ Baikai.Provider.OpenAI.Api: Assembler :: !Model -> !UTCTime -> !Maybe Int -> !Text -> !Bool -> !Maybe Int -> !Text -> !TagScanState -> !IntMap Int -> !Map Text Int -> !Maybe Int -> !IntMap (Text, Text) -> !IntMap Text -> !IntMap AssistantContent -> !Int -> !Usage -> !StopReason -> !Bool -> !Maybe BaikaiError -> !Maybe Text -> Assembler
+ Baikai.Provider.OpenAI.Api: RawChunk :: !Maybe Text -> !Maybe Text -> !Maybe Text -> ![RawToolDelta] -> !Maybe RawUsage -> RawChunk
+ Baikai.Provider.OpenAI.Api: RawToolDelta :: !Maybe Int -> !Maybe Text -> !Maybe Text -> !Maybe Text -> RawToolDelta
+ Baikai.Provider.OpenAI.Api: RawUsage :: !Natural -> !Natural -> !Natural -> !Maybe Natural -> RawUsage
+ Baikai.Provider.OpenAI.Api: TagScanState :: !TagMode -> !Text -> TagScanState
+ Baikai.Provider.OpenAI.Api: [args] :: RawToolDelta -> !Maybe Text
+ Baikai.Provider.OpenAI.Api: [cacheReadTokens] :: RawUsage -> !Natural
+ Baikai.Provider.OpenAI.Api: [closed] :: Assembler -> !IntMap AssistantContent
+ Baikai.Provider.OpenAI.Api: [contentDelta] :: RawChunk -> !Maybe Text
+ Baikai.Provider.OpenAI.Api: [finishNote] :: Assembler -> !Maybe Text
+ Baikai.Provider.OpenAI.Api: [finishReason] :: RawChunk -> !Maybe Text
+ Baikai.Provider.OpenAI.Api: [finishSeen] :: Assembler -> !Bool
+ Baikai.Provider.OpenAI.Api: [id_] :: RawToolDelta -> !Maybe Text
+ Baikai.Provider.OpenAI.Api: [index] :: RawToolDelta -> !Maybe Int
+ Baikai.Provider.OpenAI.Api: [inputTokens] :: RawUsage -> !Natural
+ Baikai.Provider.OpenAI.Api: [lastToolIdx] :: Assembler -> !Maybe Int
+ Baikai.Provider.OpenAI.Api: [model] :: Assembler -> !Model
+ Baikai.Provider.OpenAI.Api: [name] :: RawToolDelta -> !Maybe Text
+ Baikai.Provider.OpenAI.Api: [nextContentIndex] :: Assembler -> !Int
+ Baikai.Provider.OpenAI.Api: [outputTokens] :: RawUsage -> !Natural
+ Baikai.Provider.OpenAI.Api: [pendingError] :: Assembler -> !Maybe BaikaiError
+ Baikai.Provider.OpenAI.Api: [reasoningAccum] :: Assembler -> !Text
+ Baikai.Provider.OpenAI.Api: [reasoningDelta] :: RawChunk -> !Maybe Text
+ Baikai.Provider.OpenAI.Api: [reasoningOpen] :: Assembler -> !Maybe Int
+ Baikai.Provider.OpenAI.Api: [reasoningTokens] :: RawUsage -> !Maybe Natural
+ Baikai.Provider.OpenAI.Api: [start] :: Assembler -> !UTCTime
+ Baikai.Provider.OpenAI.Api: [stopReason] :: Assembler -> !StopReason
+ Baikai.Provider.OpenAI.Api: [tagMode] :: TagScanState -> !TagMode
+ Baikai.Provider.OpenAI.Api: [tagPending] :: TagScanState -> !Text
+ Baikai.Provider.OpenAI.Api: [tagScanState] :: Assembler -> !TagScanState
+ Baikai.Provider.OpenAI.Api: [textAccum] :: Assembler -> !Text
+ Baikai.Provider.OpenAI.Api: [textEverOpened] :: Assembler -> !Bool
+ Baikai.Provider.OpenAI.Api: [textOpen] :: Assembler -> !Maybe Int
+ Baikai.Provider.OpenAI.Api: [toolArgs] :: Assembler -> !IntMap Text
+ Baikai.Provider.OpenAI.Api: [toolDeltas] :: RawChunk -> ![RawToolDelta]
+ Baikai.Provider.OpenAI.Api: [toolIdMap] :: Assembler -> !Map Text Int
+ Baikai.Provider.OpenAI.Api: [toolIndexMap] :: Assembler -> !IntMap Int
+ Baikai.Provider.OpenAI.Api: [toolMeta] :: Assembler -> !IntMap (Text, Text)
+ Baikai.Provider.OpenAI.Api: [usage] :: Assembler -> !Usage
+ Baikai.Provider.OpenAI.Api: _TagScanState :: TagScanState
+ Baikai.Provider.OpenAI.Api: closeOpenStream :: UTCTime -> Maybe BaikaiError -> Assembler -> ([AssistantMessageEvent], Assembler)
+ Baikai.Provider.OpenAI.Api: data Assembler
+ Baikai.Provider.OpenAI.Api: data RawChunk
+ Baikai.Provider.OpenAI.Api: data RawToolDelta
+ Baikai.Provider.OpenAI.Api: data RawUsage
+ Baikai.Provider.OpenAI.Api: data TagScanState
+ Baikai.Provider.OpenAI.Api: emptyAssembler :: Model -> UTCTime -> Assembler
+ Baikai.Provider.OpenAI.Api: instance GHC.Classes.Eq Baikai.Provider.OpenAI.Api.TagMode
+ Baikai.Provider.OpenAI.Api: instance GHC.Classes.Eq Baikai.Provider.OpenAI.Api.TagScanState
+ Baikai.Provider.OpenAI.Api: instance GHC.Internal.Generics.Generic Baikai.Provider.OpenAI.Api.TagMode
+ Baikai.Provider.OpenAI.Api: instance GHC.Internal.Generics.Generic Baikai.Provider.OpenAI.Api.TagScanState
+ Baikai.Provider.OpenAI.Api: instance GHC.Internal.Show.Show Baikai.Provider.OpenAI.Api.TagMode
+ Baikai.Provider.OpenAI.Api: instance GHC.Internal.Show.Show Baikai.Provider.OpenAI.Api.TagScanState
+ Baikai.Provider.OpenAI.Api: openaiChatProvider :: ApiProvider
+ Baikai.Provider.OpenAI.Api: parseChunk :: Value -> Either String RawChunk
+ Baikai.Provider.OpenAI.Api: parseUsage :: Object -> Maybe RawUsage
+ Baikai.Provider.OpenAI.Api: rawUsageToUsage :: RawUsage -> Usage
+ Baikai.Provider.OpenAI.Api: scanThinkTags :: TagScanState -> Text -> (TagScanState, [Either Text Text])
+ Baikai.Provider.OpenAI.Api: translate :: Either BaikaiError RawChunk -> Assembler -> UTCTime -> ([AssistantMessageEvent], Assembler)
+ Baikai.Provider.OpenAI.Cli: codexCliCommand :: CodexCliConfig -> Model -> Context -> (FilePath, [String])
+ Baikai.Provider.OpenAI.Cli: codexCliPrompt :: Context -> Text
+ Baikai.Provider.OpenAI.Cli: codexCliProvider :: CodexCliConfig -> ApiProvider
+ Baikai.Provider.OpenAI.Cli: ephemeral :: CodexCliConfig -> Bool
+ Baikai.Provider.OpenAI.Cli: executable :: CodexCliConfig -> FilePath
+ Baikai.Provider.OpenAI.Cli: extraArgs :: CodexCliConfig -> [Text]
+ Baikai.Provider.OpenAI.Cli: skipGitRepoCheck :: CodexCliConfig -> Bool
+ Baikai.Provider.OpenAI.Cli: workingDir :: CodexCliConfig -> Maybe FilePath
+ Baikai.Provider.OpenAI.Interactive: executable :: CodexInteractiveConfig -> FilePath
+ Baikai.Provider.OpenAI.Interactive: extraArgs :: CodexInteractiveConfig -> [Text]
+ Baikai.Provider.OpenAI.Internal.ErrorClass: classifyErrorText :: Text -> Maybe BaikaiError
+ Baikai.Provider.OpenAI.Internal.ErrorClass: classifyException :: SomeException -> BaikaiError
+ Baikai.Provider.OpenAI.Internal.ErrorClass: responseToError :: ResponseF ByteString -> BaikaiError
+ Baikai.Provider.OpenAI.Internal.Request: mapRequest :: Model -> Context -> Options -> Either Text CreateChatCompletion
+ Baikai.Provider.OpenAI.Shape: dropUnsupportedStrict :: OpenAICompletionsCompat -> Value -> Value
+ Baikai.Provider.OpenAI.Shape: injectCacheControl :: OpenAICompletionsCompat -> Options -> Value -> Value
+ Baikai.Provider.OpenAI.Shape: injectThinkingShape :: OpenAICompletionsCompat -> Options -> Value -> Value
+ Baikai.Provider.OpenAI.Shape: renameMaxTokens :: OpenAICompletionsCompat -> Value -> Value
+ Baikai.Provider.OpenAI.Shape: shapeRequestBody :: OpenAICompletionsCompat -> Options -> Value -> Value
+ Baikai.Provider.OpenAI.Shape: streamRequestBody :: OpenAICompletionsCompat -> Options -> CreateChatCompletion -> Value
+ Baikai.Provider.OpenAI.Sse: openaiSseStream :: ClientEnv -> Text -> CreateChatCompletion -> (Either BaikaiError Value -> IO ()) -> IO ()
+ Baikai.Provider.OpenAI.Sse: openaiSseStreamValue :: ClientEnv -> Text -> Value -> (Either BaikaiError Value -> IO ()) -> IO ()
+ Baikai.Provider.OpenAI.Sse: openaiSseStreamValueWithHeaders :: ClientEnv -> RequestHeaders -> Value -> (Either BaikaiError Value -> IO ()) -> IO ()
+ Baikai.Provider.OpenAI.Sse: sseFromResponse :: Response BodyReader -> (Either BaikaiError Value -> IO ()) -> IO ()
+ Baikai.Provider.OpenAI.Transport: cachedClientEnvCount :: IO Int
+ Baikai.Provider.OpenAI.Transport: getClientEnvCached :: Text -> IO ClientEnv
+ Baikai.Provider.OpenAI.Transport: requestHeaders :: Text -> Model -> Options -> RequestHeaders
+ Baikai.Provider.OpenAI.Transport: resolveKey :: Text -> Options -> IO Text
+ Baikai.Provider.OpenAI.Transport: runWithTimeout :: Maybe Int -> IO () -> IO (Maybe BaikaiError)

Files

baikai-openai.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.4 name:          baikai-openai-version:       0.2.0.0+version:       0.3.0.0 synopsis:      OpenAI providers for the baikai abstraction description:   Wraps the openai Haskell package as a Baikai Provider for OpenAI's Chat Completions API.@@ -33,12 +33,16 @@   exposed-modules:     Baikai.Provider.OpenAI.Api     Baikai.Provider.OpenAI.Cli-    Baikai.Provider.OpenAI.ErrorClass     Baikai.Provider.OpenAI.Interactive+    Baikai.Provider.OpenAI.Internal.ErrorClass+    Baikai.Provider.OpenAI.Internal.Request+    Baikai.Provider.OpenAI.Shape+    Baikai.Provider.OpenAI.Sse+    Baikai.Provider.OpenAI.Transport    build-depends:     , aeson-    , baikai             ^>=0.2.0+    , baikai             ^>=0.3.0     , base               >=4.20   && <5     , base64-bytestring     , bytestring@@ -63,16 +67,25 @@   type:           exitcode-stdio-1.0   hs-source-dirs: test   main-is:        Main.hs-  other-modules:  ErrorClassSpec+  other-modules:+    ErrorClassSpec+    ReasoningSpec+    ShapeSpec+    SseSpec+    TransportSpec+   build-depends:     , aeson-    , baikai            ^>=0.2.0+    , baikai            ^>=0.3.0     , baikai-openai     , base              >=4.20   && <5     , bytestring     , case-insensitive     , containers+    , directory+    , filepath     , generic-lens+    , http-client     , http-types     , lens              ^>=5.3     , openai@@ -81,4 +94,5 @@     , tasty     , tasty-hunit     , text              ^>=2.1+    , time     , vector
src/Baikai/Provider/OpenAI/Api.hs view
@@ -9,19 +9,20 @@ -- through this handler. -- -- The handler resolves 'Baikai.Options.apiKey' when present, falling--- back to the @OPENAI_API_KEY@ env var via--- 'Baikai.Auth.resolveApiKey'.+-- back to the host-specific env var from+-- 'Baikai.Auth.defaultApiKeyEnvForBaseUrl'. Unknown hosts require an+-- explicit key source. -- -- EP-3 promotes streaming to the primary entry point. The handler -- exposes a 'streamly' 'Stream' of 'AssistantMessageEvent' values--- bridged from the upstream SDK's raw--- 'createChatCompletionStream' callback. We deliberately bypass--- the typed variant because the typed @ChatCompletionChunk@--- requires @id@ + @function.name@ on every tool-call delta — fields--- that OpenAI omits on partial-argument continuation chunks — so a--- tool-using stream fails to parse end-to-end. Parsing the raw--- 'Aeson.Value' chunk manually lets us tolerate missing fields the--- way the upstream wire protocol intends.+-- bridged from a local SSE transport. Requests start as the SDK's+-- typed 'OpenAI.V1.Chat.Completions.CreateChatCompletion' value, then+-- 'Baikai.Provider.OpenAI.Shape.streamRequestBody' rewrites the raw+-- JSON body for OpenAI-compatible host quirks before+-- 'Baikai.Provider.OpenAI.Sse.openaiSseStreamValueWithHeaders' sends+-- it with cached transport settings and caller headers. Streaming+-- responses are parsed from raw 'Aeson.Value' chunks so partial+-- tool-call deltas may omit fields such as @id@ and @function.name@. -- -- The synchronous 'complete' field is derived via -- 'streamingComplete', so callers that drain the stream get the@@ -29,33 +30,49 @@ module Baikai.Provider.OpenAI.Api   ( register,     registerWithRegistry,+    openaiChatProvider,     openaiChatStream,-    mapRequest,+    RawChunk (..),+    RawToolDelta (..),+    parseChunk,+    TagScanState (..),+    _TagScanState,+    scanThinkTags,+    Assembler (..),+    emptyAssembler,+    translate,+    closeOpenStream,++    -- * Usage mapping++    -- Exposed for tests; may move behind an .Internal namespace in a later plan.+    RawUsage (..),+    parseUsage,+    rawUsageToUsage,   ) where  import Baikai.Api (Api (..))-import Baikai.Auth qualified as Auth-import Baikai.Compat-  ( OpenAICompletionsCompat (..),-    ThinkingFormat (..),-  )+import Baikai.Compat (OpenAICompletionsCompat (requiresThinkingAsText)) import Baikai.Content qualified as Content import Baikai.Context (Context (..))-import Baikai.Cost (_Cost)+import Baikai.Cost (zeroCost) import Baikai.Cost.Pricing qualified as Pricing-import Baikai.Error (BaikaiError, invalidRequest)+import Baikai.Error (BaikaiError, invalidRequest, providerError) import Baikai.Message qualified as Msg import Baikai.Model (Model, openaiCompletionsCompatFor) import Baikai.Options (Options (..))-import Baikai.Provider.OpenAI.ErrorClass (classifyErrorText, classifyException)+import Baikai.Provider.OpenAI.Internal.ErrorClass (classifyException)+import Baikai.Provider.OpenAI.Internal.Request (mapRequest)+import Baikai.Provider.OpenAI.Shape (streamRequestBody)+import Baikai.Provider.OpenAI.Sse (openaiSseStreamValueWithHeaders)+import Baikai.Provider.OpenAI.Transport qualified as Transport import Baikai.Provider.Registry   ( ApiProvider (..),     ProviderRegistry,-    globalProviderRegistry,+    registerApiProvider,     registerApiProviderWith,   )-import Baikai.ResponseFormat (ResponseFormat (..)) import Baikai.StopReason qualified as Stop import Baikai.Stream (streamingComplete) import Baikai.Stream.Event@@ -64,30 +81,28 @@     DeltaPayload (..),     IndexPayload (..),     StartPayload (..),+    ThinkingEndPayload (..),     ToolCallEndPayload (..),     doneTerminal,     errorTerminal,   )-import Baikai.ThinkingLevel-  ( ThinkingLevel (..),-  )-import Baikai.Tool qualified as Tool import Baikai.Usage qualified as Usage+import Control.Applicative ((<|>)) import Control.Concurrent (forkIO) import Control.Concurrent.Chan (Chan, newChan, readChan, writeChan)-import Control.Exception (SomeException, try)+import Control.Exception (SomeAsyncException (..), SomeException, fromException, throwIO, try) import Control.Lens ((%~), (&), (.~), (^.)) import Data.Aeson (Value (..), (.:?)) import Data.Aeson qualified as Aeson import Data.Aeson.Key qualified as AesonKey import Data.Aeson.KeyMap qualified as KeyMap import Data.Aeson.Types qualified as Aeson-import Data.ByteString.Base64 qualified as Base64-import Data.ByteString.Lazy qualified as BSL import Data.Generics.Labels () import Data.IORef (IORef, newIORef, readIORef, writeIORef) import Data.IntMap.Strict (IntMap) import Data.IntMap.Strict qualified as IntMap+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map import Data.Maybe (fromMaybe) import Data.Text (Text) import Data.Text qualified as Text@@ -96,30 +111,34 @@ import Data.Vector (Vector) import Data.Vector qualified as Vector import GHC.Generics (Generic)+import Network.HTTP.Types.Header (RequestHeaders) import Numeric.Natural (Natural)-import OpenAI.V1 qualified as OpenAI-import OpenAI.V1.Chat.Completions qualified as Chat-import OpenAI.V1.Models qualified as OpenAIModels-import OpenAI.V1.ResponseFormat qualified as RF-import OpenAI.V1.Tool qualified as OpenAITool-import OpenAI.V1.ToolCall qualified as ToolCall+import Servant.Client qualified as Client import Streamly.Data.Stream (Stream) import Streamly.Data.Stream qualified as Stream  -- | Install the OpenAI Chat Completions handler into the registry. register :: IO ()-register = registerWithRegistry globalProviderRegistry+register = registerApiProvider openaiChatProvider +-- | First-class OpenAI Chat Completions provider value. Use with+-- 'registerApiProviderWith' or 'newProviderRegistryFrom' for explicit+-- registries.+openaiChatProvider :: ApiProvider+openaiChatProvider =+  ApiProvider+    { apiTag = OpenAIChatCompletions,+      stream = openaiChatStream,+      complete = streamingComplete openaiChatStream+    }+ -- | Install the OpenAI Chat Completions handler into an explicit registry. registerWithRegistry :: ProviderRegistry -> IO () registerWithRegistry reg =   registerApiProviderWith     reg-    ApiProvider-      { apiTag = OpenAIChatCompletions,-        stream = openaiChatStream,-        complete = streamingComplete openaiChatStream-      }+    openaiChatProvider+{-# DEPRECATED registerWithRegistry "use registerApiProviderWith reg openaiChatProvider" #-}  -- | Streaming producer for the OpenAI Chat Completions API. --@@ -134,23 +153,22 @@   Model -> Context -> Options -> Stream IO AssistantMessageEvent openaiChatStream m ctx opts =   Stream.concatEffect $ do-    setup <- prepareCall m ctx opts+    setupResult <- trySync (prepareCall m ctx opts)+    let setup = either (Left . exceptionToError) id setupResult     case setup of-      Left err -> pure (Stream.fromEffect (immediateError err))+      Left err -> Stream.fromList <$> immediateError err       Right call -> do-        ch <- newChan :: IO (Chan (Maybe RawChunk))+        ch <- newChan :: IO (Chan (Maybe (Either BaikaiError RawChunk)))         tref <- newIORef False-        eref <- newIORef Nothing-        _ <- forkIO (worker call ch eref)+        _ <- forkIO (worker call ch)         startTime <- getCurrentTime         let initialState =               ProducerState                 { chan = ch,-                  pending = [EventStart StartPayload {partial = skeletonStart m startTime}],+                  pending = [EventStart StartPayload {partial = skeletonStart m startTime, responseId = Nothing}],                   assembler = emptyAssembler m startTime,                   finished = False,-                  terminalRef = tref,-                  errInfoRef = eref+                  terminalRef = tref                 }         pure (Stream.unfoldrM step initialState) @@ -159,44 +177,42 @@   Msg.AssistantMessage     Msg.AssistantPayload       { Msg.content = Vector.empty,-        Msg.usage = Usage._Usage,+        Msg.usage = Usage.zeroUsage,         Msg.stopReason = Stop.Stop,         Msg.errorMessage = Nothing,-        Msg.timestamp = start+        Msg.timestamp = Just start       }  -- | Per-call prepared values. data OpenAICall = OpenAICall-  { methods :: !OpenAI.Methods,-    request :: !Chat.CreateChatCompletion+  { clientEnv :: !Client.ClientEnv,+    requestHeaders :: !RequestHeaders,+    timeoutMs :: !(Maybe Int),+    requestBody :: !Aeson.Value   }   deriving stock (Generic) -prepareCall :: Model -> Context -> Options -> IO (Either Text OpenAICall)+prepareCall :: Model -> Context -> Options -> IO (Either BaikaiError OpenAICall) prepareCall m ctx opts = case mapRequest m ctx opts of-  Left e -> pure (Left e)+  Left e -> pure (Left (invalidRequest e))   Right req -> do-    key <- resolveKey opts     let url = case m ^. #baseUrl of           "" -> "https://api.openai.com"           u -> u-    env <- OpenAI.getClientEnv url-    let mtds = OpenAI.makeMethods env key Nothing Nothing-        req' =-          req-            { Chat.stream = Just True,-              Chat.stream_options =-                Just-                  Chat._ChatCompletionStreamOptions-                    { Chat.include_usage = Just True-                    }+    key <- Transport.resolveKey url opts+    env <- Transport.getClientEnvCached url+    let compat = openaiCompletionsCompatFor m+        body = streamRequestBody compat opts req+        headers = Transport.requestHeaders key m opts+    pure+      ( Right+          OpenAICall+            { clientEnv = env,+              requestHeaders = headers,+              timeoutMs = opts ^. #timeoutMs,+              requestBody = body             }-    pure (Right OpenAICall {methods = mtds, request = req'})--resolveKey :: Options -> IO Text-resolveKey opts = case opts ^. #apiKey of-  Just source -> Auth.resolveApiKey source-  Nothing -> Auth.resolveApiKey (Auth.ApiKeyEnv "OPENAI_API_KEY")+      )  -- | A loose summary of one streamed chunk. The raw 'Aeson.Value' is -- pre-parsed into the fields we care about; unknown fields are@@ -204,15 +220,15 @@ -- tool-call deltas). data RawChunk = RawChunk   { contentDelta :: !(Maybe Text),+    reasoningDelta :: !(Maybe Text),     finishReason :: !(Maybe Text),     toolDeltas :: ![RawToolDelta],-    usage :: !(Maybe RawUsage),-    error :: !(Maybe Text)+    usage :: !(Maybe RawUsage)   }   deriving stock (Show, Generic)  data RawToolDelta = RawToolDelta-  { index :: !Int,+  { index :: !(Maybe Int),     id_ :: !(Maybe Text),     name :: !(Maybe Text),     args :: !(Maybe Text)@@ -228,34 +244,22 @@   deriving stock (Show, Generic)  worker ::-  OpenAICall -> Chan (Maybe RawChunk) -> IORef (Maybe BaikaiError) -> IO ()-worker call ch errInfoRef = do-  let OpenAI.Methods {OpenAI.createChatCompletionStream = stream'} = call ^. #methods+  OpenAICall -> Chan (Maybe (Either BaikaiError RawChunk)) -> IO ()+worker call ch = do   r <--    try @SomeException $-      stream' (call ^. #request) $ \case-        Left errText -> writeChan ch (Just (errorChunk errText))-        Right val -> case parseChunk val of-          Left err -> writeChan ch (Just (errorChunk (Text.pack err)))-          Right chunk -> writeChan ch (Just chunk)+    trySync $+      Transport.runWithTimeout (call ^. #timeoutMs) $+        openaiSseStreamValueWithHeaders (call ^. #clientEnv) (call ^. #requestHeaders) (call ^. #requestBody) $ \case+          Left be -> writeChan ch (Just (Left be))+          Right val -> case parseChunk val of+            Left err -> writeChan ch (Just (Left (providerError (Text.pack err))))+            Right chunk -> writeChan ch (Just (Right chunk))   case r of-    Right () -> pure ()-    -- An HTTP-level exception carries an HTTP status; classify it and-    -- stash the structured error for the end-of-stream recovery path-    -- (see 'step' / 'closeOpenStream'). No terminal chunk is written.-    Left e -> writeIORef errInfoRef (Just (classifyException e))+    Right Nothing -> pure ()+    Right (Just be) -> writeChan ch (Just (Left be))+    Left e -> writeChan ch (Just (Left (exceptionToError e)))   writeChan ch Nothing -errorChunk :: Text -> RawChunk-errorChunk t =-  RawChunk-    { contentDelta = Nothing,-      finishReason = Nothing,-      toolDeltas = [],-      usage = Nothing,-      error = Just t-    }- -- | Aeson parser tolerant of partial tool-call fields. parseChunk :: Value -> Either String RawChunk parseChunk = Aeson.parseEither $ Aeson.withObject "ChatCompletionChunk" $ \o -> do@@ -268,19 +272,20 @@                 Aeson.Object obj -> Just obj                 _ -> Nothing         _ -> Nothing-  (contentDelta, finishR, toolDeltas) <- case firstChoice of-    Nothing -> pure (Nothing, Nothing, [])+  (contentDelta, reasoningDelta, finishR, toolDeltas) <- case firstChoice of+    Nothing -> pure (Nothing, Nothing, Nothing, [])     Just ch -> do       finish <- ch .:? "finish_reason"       delta <- ch .:? "delta"       case delta of-        Nothing -> pure (Nothing, finish, [])+        Nothing -> parseMessageObject ch finish         Just (Aeson.Object dObj) -> do           cd <- dObj .:? "content"+          let rd = reasoningText dObj           tc <- dObj .:? "tool_calls"           let tds = parseToolCallDeltas tc-          pure (cd, finish, tds)-        _ -> pure (Nothing, finish, [])+          pure (cd, rd, finish, tds)+        _ -> parseMessageObject ch finish   usageM <- o .:? "usage"   let ru = case usageM of         Just (Aeson.Object uObj) -> parseUsage uObj@@ -288,12 +293,28 @@   pure     RawChunk       { contentDelta = contentDelta,+        reasoningDelta = reasoningDelta,         finishReason = finishR,         toolDeltas = toolDeltas,-        usage = ru,-        error = Nothing+        usage = ru       } +parseMessageObject ::+  Aeson.Object ->+  Maybe Text ->+  Aeson.Parser (Maybe Text, Maybe Text, Maybe Text, [RawToolDelta])+parseMessageObject ch finish = do+  msg <- ch .:? "message"+  case msg of+    Just (Aeson.Object mObj) -> do+      cd <- mObj .:? "content"+      pure (cd, reasoningText mObj, finish, [])+    _ -> pure (Nothing, Nothing, finish, [])++reasoningText :: Aeson.Object -> Maybe Text+reasoningText obj =+  lookupText "reasoning_content" obj <|> lookupText "reasoning" obj+ parseToolCallDeltas :: Maybe Value -> [RawToolDelta] parseToolCallDeltas = \case   Just (Aeson.Array v) -> Vector.toList (Vector.mapMaybe oneDelta v)@@ -310,7 +331,7 @@             getArgs = funcObj >>= lookupText "arguments"          in Just               RawToolDelta-                { index = maybe 0 fromInt (lookupField "index" o),+                { index = fromInt <$> lookupField "index" o,                   id_ = lookupText "id" o,                   name = getName,                   args = getArgs@@ -366,14 +387,11 @@ -- ============================================================  data ProducerState = ProducerState-  { chan :: !(Chan (Maybe RawChunk)),+  { chan :: !(Chan (Maybe (Either BaikaiError RawChunk))),     pending :: ![AssistantMessageEvent],     assembler :: !Assembler,     finished :: !Bool,-    terminalRef :: !(IORef Bool),-    -- | Set by the worker when an HTTP-level exception is caught, so the-    -- end-of-stream recovery path can surface a categorised error.-    errInfoRef :: !(IORef (Maybe BaikaiError))+    terminalRef :: !(IORef Bool)   }   deriving stock (Generic) @@ -399,8 +417,7 @@             then pure Nothing             else do               now <- getCurrentTime-              mErr <- readIORef (s ^. #errInfoRef)-              let (events, ass') = closeOpenStream now mErr (s ^. #assembler)+              let (events, ass') = closeOpenStream now Nothing (s ^. #assembler)               case events of                 [] -> pure Nothing                 (e : rest) -> do@@ -446,6 +463,99 @@ -- Translation -- ============================================================ +data TagMode+  = TagVisible+  | TagReasoning+  deriving stock (Eq, Show, Generic)++-- | Incremental scanner state for hosts that stream reasoning in+-- assistant text using @<think>@ or @<thinking>@ tags.+data TagScanState = TagScanState+  { tagMode :: !TagMode,+    tagPending :: !Text+  }+  deriving stock (Eq, Show, Generic)++_TagScanState :: TagScanState+_TagScanState =+  TagScanState+    { tagMode = TagVisible,+      tagPending = Text.empty+    }++-- | Split one text delta into reasoning fragments ('Left') and+-- visible text fragments ('Right'), preserving partial tag prefixes+-- across chunk boundaries.+scanThinkTags :: TagScanState -> Text -> (TagScanState, [Either Text Text])+scanThinkTags st input =+  let (mode', pending', parts) = go (tagMode st) (tagPending st <> input) []+   in (TagScanState {tagMode = mode', tagPending = pending'}, parts)+  where+    go mode txt acc =+      case findTag mode txt of+        Just (before, after, nextMode) ->+          go nextMode after (appendPart mode before acc)+        Nothing ->+          let (emitNow, pending) = splitPending mode txt+           in (mode, pending, appendPart mode emitNow acc)++    appendPart _ "" acc = acc+    appendPart TagVisible t acc = acc <> [Right t]+    appendPart TagReasoning t acc = acc <> [Left t]++findTag :: TagMode -> Text -> Maybe (Text, Text, TagMode)+findTag mode txt =+  case earliest markers of+    Nothing -> Nothing+    Just (idx, marker) ->+      Just+        ( Text.take idx txt,+          Text.drop (idx + Text.length marker) txt,+          nextMode+        )+  where+    (markers, nextMode) = case mode of+      TagVisible -> (openingTags, TagReasoning)+      TagReasoning -> (closingTags, TagVisible)+    earliest =+      foldr+        ( \marker best ->+            case Text.breakOn marker txt of+              (_, "") -> best+              (before, _) ->+                let candidate = (Text.length before, marker)+                 in case best of+                      Nothing -> Just candidate+                      Just (oldIdx, _) | Text.length before < oldIdx -> Just candidate+                      _ -> best+        )+        Nothing++splitPending :: TagMode -> Text -> (Text, Text)+splitPending mode txt =+  let suffix = longestTagPrefix (case mode of TagVisible -> openingTags; TagReasoning -> closingTags) txt+   in (Text.dropEnd (Text.length suffix) txt, suffix)++longestTagPrefix :: [Text] -> Text -> Text+longestTagPrefix markers txt =+  foldr longer Text.empty candidates+  where+    candidates =+      [ suffix+      | n <- [1 .. Text.length txt],+        let suffix = Text.takeEnd n txt,+        any (suffix `Text.isPrefixOf`) markers+      ]+    longer a b+      | Text.length a > Text.length b = a+      | otherwise = b++openingTags :: [Text]+openingTags = ["<think>", "<thinking>"]++closingTags :: [Text]+closingTags = ["</think>", "</thinking>"]+ -- | Translation state across one streaming call. data Assembler = Assembler   { model :: !Model,@@ -455,9 +565,14 @@     textOpen :: !(Maybe Int),     textAccum :: !Text,     textEverOpened :: !Bool,+    reasoningOpen :: !(Maybe Int),+    reasoningAccum :: !Text,+    tagScanState :: !TagScanState,     -- | Maps OpenAI's per-call tool-call index to baikai's     -- 'contentIndex'.     toolIndexMap :: !(IntMap Int),+    toolIdMap :: !(Map Text Int),+    lastToolIdx :: !(Maybe Int),     -- | baikai contentIndex → (id, name).     toolMeta :: !(IntMap (Text, Text)),     -- | baikai contentIndex → accumulated arguments JSON.@@ -471,7 +586,8 @@     -- the post-@finish_reason@ usage chunk (when @include_usage@ is     -- enabled) has a chance to land.     finishSeen :: !Bool,-    errorMsg :: !(Maybe Text)+    pendingError :: !(Maybe BaikaiError),+    finishNote :: !(Maybe Text)   }   deriving stock (Generic) @@ -483,58 +599,108 @@       textOpen = Nothing,       textAccum = Text.empty,       textEverOpened = False,+      reasoningOpen = Nothing,+      reasoningAccum = Text.empty,+      tagScanState = _TagScanState,       toolIndexMap = IntMap.empty,+      toolIdMap = Map.empty,+      lastToolIdx = Nothing,       toolMeta = IntMap.empty,       toolArgs = IntMap.empty,       closed = IntMap.empty,       nextContentIndex = 0,-      usage = Usage._Usage,+      usage = Usage.zeroUsage,       stopReason = Stop.Stop,       finishSeen = False,-      errorMsg = Nothing+      pendingError = Nothing,+      finishNote = Nothing     }  translate ::-  RawChunk ->+  Either BaikaiError RawChunk ->   Assembler ->   UTCTime ->   ([AssistantMessageEvent], Assembler) translate chunk ass now-  | Just errMsg <- chunk ^. #error =-      let msg = finalMessage ass now (Just errMsg) Stop.ErrorReason-       in ( [EventError (errorTerminal Stop.ErrorReason msg (classifyErrorText errMsg))],-            ass & #errorMsg .~ Just errMsg-          )-  | otherwise =-      let -- 1. Apply content delta (open text block if needed).-          (textEvents, ass1) = applyContentDelta (chunk ^. #contentDelta) ass-          -- 2. Apply tool-call deltas.-          (toolEvents, ass2) = applyToolDeltas (chunk ^. #toolDeltas) ass1-          -- 3. Apply usage chunk if present.-          ass3 = applyUsage (chunk ^. #usage) ass2-          -- 4. If finish_reason is set, close any open text/tool+  | Left be <- chunk =+      let msg = finalMessage ass now (Just (be ^. #message)) Stop.ErrorReason+       in ([EventError (errorTerminal Nothing Stop.ErrorReason msg be)], ass)+  | Right raw <- chunk =+      let -- 1. Apply field-based reasoning delta.+          (reasoningEvents, ass1) = applyReasoningDelta (raw ^. #reasoningDelta) ass+          -- 2. Apply content delta (open text block if needed).+          (textEvents, ass2) = applyContentDelta (raw ^. #contentDelta) ass1+          -- 3. Apply tool-call deltas.+          (toolEvents, ass3) = applyToolDeltas (raw ^. #toolDeltas) ass2+          -- 4. Apply usage chunk if present.+          ass4 = applyUsage (raw ^. #usage) ass3+          -- 5. If finish_reason is set, close any open text/tool           --    blocks and stash the reason. EventDone is deferred           --    to channel close so the post-finish_reason usage           --    chunk has a chance to land.-          (closeEvents, ass4) = case chunk ^. #finishReason of-            Just fr -> closeOnFinish fr ass3-            Nothing -> ([], ass3)-       in (textEvents <> toolEvents <> closeEvents, ass4)+          (closeEvents, ass5) = case raw ^. #finishReason of+            Just fr -> closeOnFinish fr ass4+            Nothing -> ([], ass4)+       in (reasoningEvents <> textEvents <> toolEvents <> closeEvents, ass5) +applyReasoningDelta ::+  Maybe Text -> Assembler -> ([AssistantMessageEvent], Assembler)+applyReasoningDelta Nothing ass = ([], ass)+applyReasoningDelta (Just "") ass = ([], ass)+applyReasoningDelta (Just d) ass =+  case ass ^. #reasoningOpen of+    Just i ->+      ( [ThinkingDelta DeltaPayload {contentIndex = i, delta = d}],+        ass & #reasoningAccum %~ (<> d)+      )+    Nothing ->+      let i = ass ^. #nextContentIndex+       in ( [ThinkingStart IndexPayload {contentIndex = i}, ThinkingDelta DeltaPayload {contentIndex = i, delta = d}],+            ass+              & #reasoningOpen .~ Just i+              & #reasoningAccum .~ d+              & #nextContentIndex .~ (i + 1)+          )+ applyContentDelta ::   Maybe Text -> Assembler -> ([AssistantMessageEvent], Assembler) applyContentDelta Nothing ass = ([], ass) applyContentDelta (Just "") ass = ([], ass) applyContentDelta (Just d) ass =+  if requiresThinkingAsText (openaiCompletionsCompatFor (ass ^. #model))+    then+      let (tagState', parts) = scanThinkTags (ass ^. #tagScanState) d+          (events, ass') = foldl' applyTaggedPart ([], ass & #tagScanState .~ tagState') parts+       in (events, ass')+    else applyVisibleTextDelta d ass++applyTaggedPart ::+  ([AssistantMessageEvent], Assembler) ->+  Either Text Text ->+  ([AssistantMessageEvent], Assembler)+applyTaggedPart (acc, ass) = \case+  Left reasoning ->+    let (events, ass') = applyReasoningDelta (Just reasoning) ass+     in (acc <> events, ass')+  Right visible ->+    let (events, ass') = applyVisibleTextDelta visible ass+     in (acc <> events, ass')++applyVisibleTextDelta ::+  Text -> Assembler -> ([AssistantMessageEvent], Assembler)+applyVisibleTextDelta "" ass = ([], ass)+applyVisibleTextDelta d ass =   case ass ^. #textOpen of     Just i ->-      ( [TextDelta DeltaPayload {contentIndex = i, delta = d}],-        ass & #textAccum %~ (<> d)-      )+      let (reasoningEvents, ass1) = closeOpenReasoning ass+       in ( reasoningEvents <> [TextDelta DeltaPayload {contentIndex = i, delta = d}],+            ass1 & #textAccum %~ (<> d)+          )     Nothing ->-      let i = ass ^. #nextContentIndex-       in ( [TextStart IndexPayload {contentIndex = i}, TextDelta DeltaPayload {contentIndex = i, delta = d}],-            ass+      let (reasoningEvents, ass1) = closeOpenReasoning ass+          i = ass1 ^. #nextContentIndex+       in ( reasoningEvents <> [TextStart IndexPayload {contentIndex = i}, TextDelta DeltaPayload {contentIndex = i, delta = d}],+            ass1               & #textOpen .~ Just i               & #textAccum .~ d               & #textEverOpened .~ True@@ -543,7 +709,11 @@  applyToolDeltas ::   [RawToolDelta] -> Assembler -> ([AssistantMessageEvent], Assembler)-applyToolDeltas deltas ass = foldl' apply ([], ass) deltas+applyToolDeltas [] ass = ([], ass)+applyToolDeltas deltas ass =+  let (reasoningEvents, ass0) = closeOpenReasoning ass+      (toolEvents, ass') = foldl' apply ([], ass0) deltas+   in (reasoningEvents <> toolEvents, ass')   where     apply (acc, a) d =       let (events, a') = applyOneToolDelta d a@@ -552,14 +722,29 @@ applyOneToolDelta ::   RawToolDelta -> Assembler -> ([AssistantMessageEvent], Assembler) applyOneToolDelta d ass =-  let openaiIdx = d ^. #index-      (baikaiIdx, ass1, opened) = case IntMap.lookup openaiIdx (ass ^. #toolIndexMap) of-        Just i -> (i, ass, False)+  let mOpenaiIdx = d ^. #index+      mToolId = d ^. #id_+      byIndex = mOpenaiIdx >>= \idx -> IntMap.lookup idx (ass ^. #toolIndexMap)+      byId = mToolId >>= \tid -> Map.lookup tid (ass ^. #toolIdMap)+      byLast =+        case (mOpenaiIdx, mToolId) of+          (Nothing, Nothing) -> ass ^. #lastToolIdx+          _ -> Nothing+      (baikaiIdx, ass1, opened) = case byIndex <|> byId <|> byLast of+        Just i ->+          ( i,+            ass+              & #toolIdMap %~ maybe id (`Map.insert` i) mToolId+              & #lastToolIdx .~ Just i,+            False+          )         Nothing ->           let i = ass ^. #nextContentIndex               ass' =                 ass-                  & #toolIndexMap %~ IntMap.insert openaiIdx i+                  & #toolIndexMap %~ maybe id (`IntMap.insert` i) mOpenaiIdx+                  & #toolIdMap %~ maybe id (`Map.insert` i) mToolId+                  & #lastToolIdx .~ Just i                   & #toolMeta %~ IntMap.insert i ("", "")                   & #toolArgs %~ IntMap.insert i Text.empty                   & #nextContentIndex .~ (i + 1)@@ -585,32 +770,87 @@           else events0 <> [ToolCallDelta DeltaPayload {contentIndex = baikaiIdx, delta = argsDelta}]    in (events1, ass3) +-- | Normalize OpenAI's inclusive usage counters into baikai's+-- disjoint 'Usage.Usage' convention. OpenAI's @prompt_tokens@+-- includes @prompt_tokens_details.cached_tokens@, so the cached count+-- is subtracted out of 'Usage.inputTokens'. The subtraction is clamped+-- at zero because 'Natural' subtraction throws on underflow and because+-- OpenAI-compatible hosts can report inconsistent counters.+-- 'Usage.totalTokens' is recomputed from the normalized parts;+-- 'Usage.reasoningTokens' is a subset of 'Usage.outputTokens' and is+-- not added to the total. OpenAI does not bill cache writes, so+-- 'Usage.cacheWriteTokens' is always zero.+rawUsageToUsage :: RawUsage -> Usage.Usage+rawUsageToUsage u =+  let prompt = u ^. #inputTokens+      cached = u ^. #cacheReadTokens+      out = u ^. #outputTokens+      nonCached = if cached >= prompt then 0 else prompt - cached+   in Usage.Usage+        { Usage.inputTokens = nonCached,+          Usage.outputTokens = out,+          Usage.cacheReadTokens = cached,+          Usage.cacheWriteTokens = 0,+          Usage.reasoningTokens = u ^. #reasoningTokens,+          Usage.totalTokens = nonCached + out + cached,+          Usage.cost = zeroCost+        }+ applyUsage :: Maybe RawUsage -> Assembler -> Assembler applyUsage Nothing ass = ass-applyUsage (Just u) ass =-  let usage' =-        Usage.Usage-          { Usage.inputTokens = u ^. #inputTokens,-            Usage.outputTokens = u ^. #outputTokens,-            Usage.cacheReadTokens = u ^. #cacheReadTokens,-            Usage.cacheWriteTokens = 0,-            Usage.reasoningTokens = u ^. #reasoningTokens,-            Usage.totalTokens = (u ^. #inputTokens) + (u ^. #outputTokens) + (u ^. #cacheReadTokens),-            Usage.cost = _Cost-          }-   in ass & #usage .~ usage'+applyUsage (Just u) ass = ass & #usage .~ rawUsageToUsage u  -- | Close all open content blocks and stash the resolved stop -- reason; defer 'EventDone' to channel close. closeOnFinish ::   Text -> Assembler -> ([AssistantMessageEvent], Assembler) closeOnFinish finishReason ass =-  let (closeText, ass1) = closeOpenText ass-      (closeTools, ass2) = closeOpenTools ass1-      reason = mapFinishReason finishReason-      ass3 = ass2 & #stopReason .~ reason & #finishSeen .~ True-   in (closeText <> closeTools, ass3)+  let (tagEvents, ass0) = flushTagScanPending ass+      (closeReasoning, ass1) = closeOpenReasoning ass0+      (closeText, ass2) = closeOpenText ass1+      (closeTools, ass3) = closeOpenTools ass2+      (reason, note) = mapFinishReason finishReason+      pending =+        if reason == Stop.ErrorReason+          then Just (providerError ("provider stopped the response: finish_reason=" <> finishReason))+          else Nothing+      ass4 =+        ass3+          & #stopReason .~ reason+          & #finishSeen .~ True+          & #pendingError .~ pending+          & #finishNote .~ note+   in (tagEvents <> closeReasoning <> closeText <> closeTools, ass4) +flushTagScanPending :: Assembler -> ([AssistantMessageEvent], Assembler)+flushTagScanPending ass =+  let st = ass ^. #tagScanState+      pending = tagPending st+      ass0 = ass & #tagScanState .~ st {tagPending = Text.empty}+   in case (tagMode st, pending) of+        (_, "") -> ([], ass0)+        (TagVisible, t) -> applyVisibleTextDelta t ass0+        (TagReasoning, t) -> applyReasoningDelta (Just t) ass0++closeOpenReasoning :: Assembler -> ([AssistantMessageEvent], Assembler)+closeOpenReasoning ass = case ass ^. #reasoningOpen of+  Nothing -> ([], ass)+  Just i ->+    let body = ass ^. #reasoningAccum+        thinkingContent =+          Content.ThinkingContent+            { Content.thinking = body,+              Content.signature = Nothing,+              Content.redacted = False+            }+        block = Content.AssistantThinking thinkingContent+     in ( [ThinkingEnd ThinkingEndPayload {contentIndex = i, content = thinkingContent}],+          ass+            & #reasoningOpen .~ Nothing+            & #reasoningAccum .~ Text.empty+            & #closed %~ IntMap.insert i block+        )+ -- | Close the open text block, if any, by emitting a 'TextEnd' and -- storing the assembled content in 'closed'. closeOpenText :: Assembler -> ([AssistantMessageEvent], Assembler)@@ -652,31 +892,43 @@               & #closed %~ IntMap.insert i block               & #toolArgs %~ IntMap.delete i               & #toolMeta %~ IntMap.delete i+              & #toolIdMap %~ (if Text.null tid then id else Map.delete tid)+              & #lastToolIdx .~ Nothing           )  closeOpenStream ::   UTCTime -> Maybe BaikaiError -> Assembler -> ([AssistantMessageEvent], Assembler) closeOpenStream now mErr ass   | ass ^. #finishSeen =-      -- Channel closed cleanly after finish_reason. Emit-      -- EventDone with the accumulated content + usage.+      -- Channel closed cleanly after finish_reason.       let reason = ass ^. #stopReason-          msg = finalMessage ass now Nothing reason-       in ([EventDone (doneTerminal reason msg)], ass)+          terminalErr =+            (ass ^. #pendingError)+              <|> if reason == Stop.ErrorReason+                then Just (providerError "provider stopped the response with an error finish_reason")+                else Nothing+          msg = finalMessage ass now (fmap (^. #message) terminalErr) reason+          terminalEvent = case terminalErr of+            Just be -> EventError (errorTerminal Nothing reason msg be)+            Nothing -> EventDone (doneTerminal Nothing reason msg)+       in ([terminalEvent], ass)   | otherwise =       -- Channel closed without a finish_reason. Force-close any       -- still-open blocks and emit EventError. When the worker stored a       -- classified HTTP error ('Just be'), surface it structurally;       -- otherwise report the unexpected end of stream.-      let (closeText, ass1) = closeOpenText ass-          (closeTools, ass2) = closeOpenTools ass1+      let (tagEvents, ass0) = flushTagScanPending ass+          (closeReasoning, ass1) = closeOpenReasoning ass0+          (closeText, ass2) = closeOpenText ass1+          (closeTools, ass3) = closeOpenTools ass2           reason = Stop.ErrorReason           errText = case mErr of             Just be -> be ^. #message             Nothing -> "openai stream ended without finish_reason"-          msg = finalMessage ass2 now (Just errText) reason-          errEv = EventError (errorTerminal reason msg mErr)-       in (closeText <> closeTools <> [errEv], ass2)+          msg = finalMessage ass3 now (Just errText) reason+          errInfo = fromMaybe (providerError errText) mErr+          errEv = EventError (errorTerminal Nothing reason msg errInfo)+       in (tagEvents <> closeReasoning <> closeText <> closeTools <> [errEv], ass3)  finalMessage ::   Assembler -> UTCTime -> Maybe Text -> Stop.StopReason -> Msg.Message@@ -691,257 +943,51 @@           { Msg.content = blocks,             Msg.usage = usage',             Msg.stopReason = sr,-            Msg.errorMessage = errMsg,-            Msg.timestamp = now+            Msg.errorMessage = errMsg <|> (ass ^. #finishNote),+            Msg.timestamp = Just now           }  blocksInOrder :: Assembler -> Vector Content.AssistantContent blocksInOrder ass = Vector.fromList (IntMap.elems (ass ^. #closed)) --- | Immediate single-error stream emitted when the request itself--- could not be built (e.g. message mapping failed).-immediateError :: Text -> IO AssistantMessageEvent-immediateError errText = do+-- | Immediate error stream emitted when the request itself could not+-- be built (e.g. message mapping failed).+immediateError :: BaikaiError -> IO [AssistantMessageEvent]+immediateError err = do   now <- getCurrentTime+  let errText = err ^. #message   let msg =         Msg.AssistantMessage           Msg.AssistantPayload             { Msg.content = Vector.empty,-              Msg.usage = Usage._Usage,+              Msg.usage = Usage.zeroUsage,               Msg.stopReason = Stop.ErrorReason,               Msg.errorMessage = Just errText,-              Msg.timestamp = now+              Msg.timestamp = Just now             }-  pure (EventError (errorTerminal Stop.ErrorReason msg (Just (invalidRequest errText))))---- ============================================================--- Request mapping (preserved from EP-2 with minor refactoring)--- ============================================================--mapRequest ::-  Model -> Context -> Options -> Either Text Chat.CreateChatCompletion-mapRequest m ctx opts = do-  body <- traverse mapMessage (Vector.toList (ctx ^. #messages))-  let compat = openaiCompletionsCompatFor m-      prefix = case ctx ^. #systemPrompt of-        Nothing -> []-        Just sp ->-          [ Chat.System-              { Chat.content = Vector.singleton Chat.Text {Chat.text = sp},-                Chat.name = Nothing-              }-          ]-      mt = fromMaybe (m ^. #maxOutputTokens) (opts ^. #maxTokens)-      toolsField =-        if Vector.null (ctx ^. #tools)-          then Nothing-          else Just (Vector.map (mkOpenAITool compat) (ctx ^. #tools))-      toolChoiceField = fmap mkOpenAIToolChoice (opts ^. #toolChoice)-      reasoningEffortField =-        applyThinkingFormat compat (opts ^. #thinking)-      responseFormatField =-        fmap mkOpenAIResponseFormat (opts ^. #responseFormat)   pure-    Chat._CreateChatCompletion-      { Chat.messages = Vector.fromList (prefix <> body),-        Chat.model = OpenAIModels.Model (m ^. #modelId),-        Chat.max_completion_tokens = Just mt,-        Chat.temperature = opts ^. #temperature,-        Chat.tools = toolsField,-        Chat.tool_choice = toolChoiceField,-        Chat.reasoning_effort = reasoningEffortField,-        Chat.response_format = responseFormatField-      }---- | Map a baikai 'ResponseFormat' onto the upstream OpenAI--- 'RF.ResponseFormat'. 'JsonObject' becomes plain-JSON mode;--- 'JsonSchema' becomes a named, optionally-strict schema. The--- schema 'Value' is forwarded verbatim.-mkOpenAIResponseFormat :: ResponseFormat -> RF.ResponseFormat-mkOpenAIResponseFormat = \case-  JsonObject -> RF.JSON_Object-  JsonSchema {name = n, schema = s, strict = st} ->-    RF.JSON_Schema-      { RF.json_schema =-          RF.JSONSchema-            { RF.description = Nothing,-              RF.name = n,-              RF.schema = Just s,-              RF.strict = Just st-            }-      }---- | Map a 'Baikai.ThinkingLevel.ThinkingLevel' onto the OpenAI SDK's--- 'Chat.ReasoningEffort' enum. Returns 'Nothing' when the caller did--- not request a level, when the host's 'thinkingFormat' is--- 'ThinkingFormatNone', or when the host expects a non-OpenAI shape--- the SDK does not support natively.------ The non-OpenAI thinking formats (DeepSeek, OpenRouter, Together,--- Z.ai, Qwen) require additional top-level JSON keys the upstream--- @openai@ Haskell SDK does not expose. They are silently dropped on--- this revision; see the EP-5 Decision Log for the rationale and--- pointers to the workaround when one is needed.-applyThinkingFormat ::-  OpenAICompletionsCompat ->-  Maybe ThinkingLevel ->-  Maybe Chat.ReasoningEffort-applyThinkingFormat _ Nothing = Nothing-applyThinkingFormat compat (Just lvl) = case thinkingFormat compat of-  ThinkingFormatOpenAI -> Just (toReasoningEffort lvl)-  _ -> Nothing--toReasoningEffort :: ThinkingLevel -> Chat.ReasoningEffort-toReasoningEffort = \case-  ThinkingMinimal -> Chat.ReasoningEffort_Minimal-  ThinkingLow -> Chat.ReasoningEffort_Low-  ThinkingMedium -> Chat.ReasoningEffort_Medium-  ThinkingHigh -> Chat.ReasoningEffort_High---- | Map a baikai 'Tool.Tool' into the upstream OpenAI 'Tool_Function'--- shape. The compat record's 'supportsStrictMode' flag controls--- whether the @strict@ field is sent ('True') or dropped ('False');--- some OpenAI-compatible hosts reject strict-mode tools entirely.------ The default ('defaultOpenAICompletionsCompat') leaves strict--- unset, which OpenAI treats as the default-permissive behaviour;--- callers that want @strict: true@ on every tool can flip the field--- on their compat record (a future enhancement; currently we pass--- 'Nothing' even when 'supportsStrictMode' is 'True', matching the--- pre-EP-5 behaviour).-mkOpenAITool :: OpenAICompletionsCompat -> Tool.Tool -> OpenAITool.Tool-mkOpenAITool _compat t =-  OpenAITool.Tool_Function-    { OpenAITool.function =-        OpenAITool.Function-          { OpenAITool.name = Tool.name t,-            OpenAITool.description = Just (Tool.description t),-            OpenAITool.parameters = Just (Tool.parameters t),-            OpenAITool.strict = Nothing-          }-    }---- | Map a baikai 'Tool.ToolChoice' into the upstream OpenAI--- 'ToolChoice'. OpenAI accepts @none@, @auto@, @required@, and a--- specific function reference; the SDK's 'ToolChoiceTool' takes the--- whole 'OpenAITool.Tool' value so we synthesise a stub function--- tool carrying just the name (OpenAI ignores the schema in this--- position).-mkOpenAIToolChoice :: Tool.ToolChoice -> OpenAITool.ToolChoice-mkOpenAIToolChoice = \case-  Tool.ToolChoiceAuto -> OpenAITool.ToolChoiceAuto-  Tool.ToolChoiceNone -> OpenAITool.ToolChoiceNone-  Tool.ToolChoiceRequired -> OpenAITool.ToolChoiceRequired-  Tool.ToolChoiceSpecific n ->-    OpenAITool.ToolChoiceTool-      ( OpenAITool.Tool_Function-          { OpenAITool.function =-              OpenAITool.Function-                { OpenAITool.name = n,-                  OpenAITool.description = Nothing,-                  OpenAITool.parameters = Nothing,-                  OpenAITool.strict = Nothing-                }-          }-      )--mapMessage :: Msg.Message -> Either Text (Chat.Message (Vector Chat.Content))-mapMessage = \case-  Msg.UserMessage Msg.UserPayload {Msg.content = uc} ->-    Right-      Chat.User-        { Chat.content = Vector.map userContentToPart uc,-          Chat.name = Nothing-        }-  Msg.AssistantMessage Msg.AssistantPayload {Msg.content = ac} ->-    let textBody = collectAssistantText ac-        toolCalls = collectToolCalls ac-        content-          | Text.null textBody = Nothing-          | otherwise = Just (Vector.singleton Chat.Text {Chat.text = textBody})-        toolCallVec-          | Vector.null toolCalls = Nothing-          | otherwise = Just toolCalls-     in Right-          Chat.Assistant-            { Chat.assistant_content = content,-              Chat.refusal = Nothing,-              Chat.name = Nothing,-              Chat.assistant_audio = Nothing,-              Chat.tool_calls = toolCallVec-            }-  Msg.ToolResultMessage-    Msg.ToolResultPayload-      { Msg.toolCallId = tid,-        Msg.content = trc,-        Msg.isError = err-      } ->-      case collectToolResultText trc of-        Left unsupported -> Left unsupported-        Right body ->-          let decorated = if err then "[error] " <> body else body-           in Right-                Chat.Tool-                  { Chat.content = Vector.singleton Chat.Text {Chat.text = decorated},-                    Chat.tool_call_id = tid-                  }--userContentToPart :: Content.UserContent -> Chat.Content-userContentToPart = \case-  Content.UserText (Content.TextContent t) -> Chat.Text {Chat.text = t}-  Content.UserImage img ->-    let encoded = Text.decodeUtf8 (Base64.encode (Content.imageData img))-        uri = "data:" <> Content.mimeType img <> ";base64," <> encoded-     in Chat.Image_URL-          { Chat.image_url = Chat.ImageURL {Chat.url = uri, Chat.detail = Nothing}-          }--collectAssistantText :: Vector Content.AssistantContent -> Text-collectAssistantText =-  Text.concat-    . Vector.toList-    . Vector.mapMaybe-      ( \case-          Content.AssistantText (Content.TextContent t) -> Just t-          Content.AssistantThinking th ->-            if Content.redacted th-              then Nothing-              else Just ("<thinking>" <> Content.thinking th <> "</thinking>")-          Content.AssistantToolCall _ -> Nothing-      )+    [ EventStart StartPayload {partial = msg, responseId = Nothing},+      EventError (errorTerminal Nothing Stop.ErrorReason msg err)+    ] -collectToolCalls :: Vector Content.AssistantContent -> Vector ToolCall.ToolCall-collectToolCalls =-  Vector.mapMaybe-    ( \case-        Content.AssistantToolCall tc ->-          Just-            ToolCall.ToolCall_Function-              { ToolCall.id = Content.id_ tc,-                ToolCall.function =-                  ToolCall.Function-                    { ToolCall.name = Content.name tc,-                      ToolCall.arguments =-                        Text.decodeUtf8 (BSL.toStrict (Aeson.encode (Content.arguments tc)))-                    }-              }-        _ -> Nothing-    )+mapFinishReason :: Text -> (Stop.StopReason, Maybe Text)+mapFinishReason r = case r of+  "stop" -> (Stop.Stop, Nothing)+  "length" -> (Stop.Length, Nothing)+  "tool_calls" -> (Stop.ToolUse, Nothing)+  "function_call" -> (Stop.ToolUse, Nothing)+  "content_filter" -> (Stop.ErrorReason, Nothing)+  _ -> (Stop.Stop, Just ("unrecognized finish_reason: " <> r)) -collectToolResultText :: Vector Content.ToolResultContent -> Either Text Text-collectToolResultText =-  fmap (Text.concat . Vector.toList) . traverse oneBlock-  where-    oneBlock = \case-      Content.ToolResultText (Content.TextContent t) -> Right t-      Content.ToolResultImage _ ->-        Left "OpenAI Chat Completions cannot encode ToolResultImage blocks in tool-result messages"+trySync :: IO a -> IO (Either SomeException a)+trySync action = do+  r <- try action+  case r of+    Left e+      | Just (SomeAsyncException _) <- (fromException e :: Maybe SomeAsyncException) ->+          throwIO e+      | otherwise -> pure (Left e)+    Right a -> pure (Right a) -mapFinishReason :: Text -> Stop.StopReason-mapFinishReason r = case r of-  "stop" -> Stop.Stop-  "length" -> Stop.Length-  "tool_calls" -> Stop.ToolUse-  "function_call" -> Stop.ToolUse-  "content_filter" -> Stop.ErrorReason-  _ -> Stop.Stop+exceptionToError :: SomeException -> BaikaiError+exceptionToError e = fromMaybe (classifyException e) (fromException e)
src/Baikai/Provider/OpenAI/Cli.hs view
@@ -5,8 +5,16 @@ -- 'Baikai.Api.OpenAICompletionsCli' handler with default config. -- 'registerWith' accepts a caller-supplied 'CodexCliConfig'. module Baikai.Provider.OpenAI.Cli-  ( CodexCliConfig (..),+  ( CodexCliConfig,+    executable,+    extraArgs,+    workingDir,+    skipGitRepoCheck,+    ephemeral,+    codexCliCommand,+    codexCliPrompt,     defaultCodexCliConfig,+    codexCliProvider,     register,     registerWith,     registerWithRegistry,@@ -17,7 +25,7 @@ import Baikai.Api (Api (..)) import Baikai.Content (AssistantContent (..), TextContent (..)) import Baikai.Context (Context)-import Baikai.Error (processError, providerError)+import Baikai.Error (BaikaiError, processError, providerError) import Baikai.Message (AssistantPayload (..)) import Baikai.Model (Model) import Baikai.Options (Options)@@ -25,33 +33,35 @@ import Baikai.Provider.Registry   ( ApiProvider (..),     ProviderRegistry,-    globalProviderRegistry,+    registerApiProvider,     registerApiProviderWith,   ) import Baikai.Response qualified as Resp import Baikai.StopReason (StopReason (..)) import Baikai.Stream (liftCompleteToStream)-import Baikai.Usage (_Usage)-import Control.Exception (bracket, throwIO)+import Baikai.Usage (zeroUsage)+import Control.Concurrent (forkIO)+import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)+import Control.Exception (SomeAsyncException (..), SomeException, displayException, fromException, throwIO, try) import Control.Lens ((^.)) import Data.ByteString qualified as BS import Data.Generics.Labels ()+import Data.Maybe (fromMaybe) import Data.Text (Text) import Data.Text qualified as Text import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime)-import Data.Vector (Vector) import Data.Vector qualified as Vector import GHC.Generics (Generic) import Streamly.Data.Stream (Stream) import Streamly.Data.Stream qualified as Stream import System.Exit (ExitCode (..))-import System.IO (Handle, hClose)+import System.IO (Handle) import System.Process qualified as P  -- | Configuration for the @codex exec --json@ subprocess. data CodexCliConfig = CodexCliConfig   { executable :: !FilePath,-    extraArgs :: !(Vector Text),+    extraArgs :: ![Text],     workingDir :: !(Maybe FilePath),     skipGitRepoCheck :: !Bool,     ephemeral :: !Bool@@ -70,8 +80,17 @@  -- | Install the Codex CLI handler with 'defaultCodexCliConfig'. register :: IO ()-register = registerWith defaultCodexCliConfig+register = registerApiProvider (codexCliProvider defaultCodexCliConfig) +-- | First-class Codex CLI provider value for a caller-supplied config.+codexCliProvider :: CodexCliConfig -> ApiProvider+codexCliProvider cfg =+  ApiProvider+    { apiTag = OpenAICompletionsCli,+      stream = liftCompleteToStream (runCodexCli cfg),+      complete = runCodexCli cfg+    }+ -- | Install the Codex CLI handler with a caller-supplied config. -- -- The Codex binary runs in batch mode. 'stream' wraps the batch@@ -83,12 +102,14 @@ -- Log records the deviation from "complete = streamingComplete . -- stream". registerWith :: CodexCliConfig -> IO ()-registerWith = registerWithRegistryAndConfig globalProviderRegistry+registerWith cfg = registerApiProvider (codexCliProvider cfg)+{-# DEPRECATED registerWith "use registerApiProvider (codexCliProvider cfg)" #-}  -- | Install the Codex CLI handler with 'defaultCodexCliConfig' into an explicit -- registry. registerWithRegistry :: ProviderRegistry -> IO () registerWithRegistry reg = registerWithRegistryAndConfig reg defaultCodexCliConfig+{-# DEPRECATED registerWithRegistry "use registerApiProviderWith reg (codexCliProvider defaultCodexCliConfig)" #-}  -- | Install the Codex CLI handler with a caller-supplied config into an -- explicit registry.@@ -96,11 +117,8 @@ registerWithRegistryAndConfig reg cfg =   registerApiProviderWith     reg-    ApiProvider-      { apiTag = OpenAICompletionsCli,-        stream = liftCompleteToStream (runCodexCli cfg),-        complete = runCodexCli cfg-      }+    (codexCliProvider cfg)+{-# DEPRECATED registerWithRegistryAndConfig "use registerApiProviderWith reg (codexCliProvider cfg)" #-}  modelArgs :: Model -> [String] modelArgs m = case Text.strip (m ^. #modelId) of@@ -116,69 +134,107 @@         then pure Nothing         else pure (Just (chunk, ())) +-- | The full prompt text passed to @codex exec@: the flattened+-- conversation, wrapped with the system prompt when one is set.+-- @codex exec --help@ exposes no system-prompt flag, so the system+-- prompt travels in prompt text.+codexCliPrompt :: Context -> Text+codexCliPrompt ctx =+  Internal.wrapSystemPrompt (ctx ^. #systemPrompt) (Internal.renderPrompt ctx)++-- | Render the executable and arguments for a @codex exec --json@+-- batch call. The prompt is preceded by @--@ so dash-leading prompts+-- cannot be parsed as options.+codexCliCommand :: CodexCliConfig -> Model -> Context -> (FilePath, [String])+codexCliCommand cfg m ctx =+  ( cfg ^. #executable,+    ["exec"]+      <> modelArgs m+      <> ["--json"]+      <> ["--skip-git-repo-check" | cfg ^. #skipGitRepoCheck]+      <> ["--ephemeral" | cfg ^. #ephemeral]+      <> fmap Text.unpack (cfg ^. #extraArgs)+      <> ["--", Text.unpack (codexCliPrompt ctx)]+  )+ runCodexCli :: CodexCliConfig -> Model -> Context -> Options -> IO Resp.Response runCodexCli cfg m ctx _opts = do-  let prompt = Internal.renderPrompt ctx-      baseArgs =-        ["exec"]-          <> modelArgs m-          <> ["--json"]-          <> ["--skip-git-repo-check" | cfg ^. #skipGitRepoCheck]-          <> ["--ephemeral" | cfg ^. #ephemeral]-          <> fmap Text.unpack (Vector.toList (cfg ^. #extraArgs))-          <> [Text.unpack prompt]+  let (exe, args) = codexCliCommand cfg m ctx       procSpec =-        (P.proc (cfg ^. #executable) baseArgs)+        (P.proc exe args)           { P.std_in = P.NoStream,             P.std_out = P.CreatePipe,             P.std_err = P.CreatePipe,             P.cwd = cfg ^. #workingDir           }   start <- getCurrentTime-  bracket-    (P.createProcess procSpec)-    cleanup-    (consume start m)--cleanup :: (Maybe Handle, Maybe Handle, Maybe Handle, P.ProcessHandle) -> IO ()-cleanup (_, mOut, mErr, ph) = do-  maybe (pure ()) hClose mOut-  maybe (pure ()) hClose mErr-  P.terminateProcess ph+  result <- trySync (P.withCreateProcess procSpec (consume start m))+  case result of+    Right resp -> pure resp+    Left ex -> do+      end <- getCurrentTime+      pure (Resp.errorResponse m end (millisBetween start end) (exceptionToError ex))  consume ::   UTCTime ->   Model ->-  (Maybe Handle, Maybe Handle, Maybe Handle, P.ProcessHandle) ->+  Maybe Handle ->+  Maybe Handle ->+  Maybe Handle ->+  P.ProcessHandle ->   IO Resp.Response-consume start m (_, mOut, mErr, ph) = do-  hOut <- maybe (throwIO (providerError "codex: stdout handle missing")) pure mOut-  hErr <- maybe (throwIO (providerError "codex: stderr handle missing")) pure mErr-  body <- Internal.parseCodexJsonlStream (handleStream hOut)-  errBytes <- BS.hGetContents hErr-  exitCode <- P.waitForProcess ph-  end <- getCurrentTime-  case exitCode of-    ExitFailure n -> throwIO (processError n (Internal.decodeUtf8Lenient errBytes))-    ExitSuccess ->-      pure-        Resp.Response-          { Resp.message =-              AssistantPayload-                { content =-                    Vector.singleton (AssistantText (TextContent (Text.strip body))),-                  usage = _Usage,-                  stopReason = Stop,-                  errorMessage = Nothing,-                  timestamp = end-                },-            Resp.model = m,-            Resp.api = OpenAICompletionsCli,-            Resp.provider = m ^. #provider,-            Resp.responseId = Nothing,-            Resp.latencyMs = millisBetween start end,-            Resp.errorInfo = Nothing-          }+consume start m _ mOut mErr ph = do+  case (mOut, mErr) of+    (Nothing, _) -> errorNow (providerError "codex: stdout handle missing")+    (_, Nothing) -> errorNow (providerError "codex: stderr handle missing")+    (Just hOut, Just hErr) -> do+      errVar <- newEmptyMVar+      _ <-+        forkIO $ do+          result <- try (BS.hGetContents hErr) :: IO (Either SomeException BS.ByteString)+          putMVar errVar (either (const BS.empty) id result)+      body <- Internal.parseCodexJsonlStream (handleStream hOut)+      errBytes <- takeMVar errVar+      exitCode <- P.waitForProcess ph+      end <- getCurrentTime+      case exitCode of+        ExitFailure n -> pure (Resp.errorResponse m end (millisBetween start end) (processError n (Internal.decodeUtf8Lenient errBytes)))+        ExitSuccess ->+          pure+            Resp.Response+              { Resp.message =+                  AssistantPayload+                    { content =+                        Vector.singleton (AssistantText (TextContent (Text.strip body))),+                      usage = zeroUsage,+                      stopReason = Stop,+                      errorMessage = Nothing,+                      timestamp = Just end+                    },+                Resp.model = m,+                Resp.api = OpenAICompletionsCli,+                Resp.provider = m ^. #provider,+                Resp.responseId = Nothing,+                Resp.latencyMs = millisBetween start end,+                Resp.errorInfo = Nothing+              }+  where+    errorNow err = do+      end <- getCurrentTime+      pure (Resp.errorResponse m end (millisBetween start end) err) -millisBetween :: UTCTime -> UTCTime -> Integer+millisBetween :: UTCTime -> UTCTime -> Int millisBetween a b = round (realToFrac (diffUTCTime b a) * (1000 :: Double))++trySync :: IO a -> IO (Either SomeException a)+trySync action = do+  r <- try action+  case r of+    Left e+      | Just (SomeAsyncException _) <- (fromException e :: Maybe SomeAsyncException) ->+          throwIO e+      | otherwise -> pure (Left e)+    Right a -> pure (Right a)++exceptionToError :: SomeException -> BaikaiError+exceptionToError e = fromMaybe (providerError (Text.pack (displayException e))) (fromException e)
− src/Baikai/Provider/OpenAI/ErrorClass.hs
@@ -1,104 +0,0 @@--- | Map failures from the OpenAI SDK onto baikai's typed 'BaikaiError'.--- 'classifyException' handles an exception thrown by the--- @servant-client@ HTTP layer (carrying an HTTP status);--- 'classifyErrorText' handles an error that arrives mid-stream as a--- plain text message (the OpenAI streaming chunk's @error@ field).-module Baikai.Provider.OpenAI.ErrorClass-  ( classifyException,-    classifyErrorText,-    -- | Exposed for testing the HTTP-status mapping without a live call.-    responseToError,-  )-where--import Baikai.Error-  ( BaikaiError (..),-    ErrorCategory (..),-    bodyIndicatesOverflow,-    classifyHttpStatusWithBody,-    decodeError,-    providerError,-  )-import Control.Exception (SomeException, displayException, fromException)-import Data.ByteString (ByteString)-import Data.ByteString.Lazy qualified as LBS-import Data.CaseInsensitive qualified as CI-import Data.Foldable (toList)-import Data.Sequence (Seq)-import Data.Text (Text)-import Data.Text qualified as Text-import Data.Text.Encoding qualified as Text-import Data.Text.Encoding.Error qualified as Text-import Network.HTTP.Types.Status (statusCode)-import Servant.Client (ClientError, ResponseF (..))-import Servant.Client qualified as Servant-import Text.Read (readMaybe)---- | Convert any exception caught from the OpenAI SDK into a categorised--- 'BaikaiError'. Recognises @servant-client@ 'ClientError'; anything--- else degrades to a generic provider error carrying the displayed--- exception text.-classifyException :: SomeException -> BaikaiError-classifyException ex = case fromException ex of-  Just clientErr -> fromClientError clientErr-  Nothing -> providerError (Text.pack (displayException ex))--fromClientError :: ClientError -> BaikaiError-fromClientError clientErr = case clientErr of-  Servant.FailureResponse _req resp -> responseToError resp-  Servant.DecodeFailure detail _ -> decodeError detail-  Servant.UnsupportedContentType _ _ -> decodeError "unsupported content type in OpenAI response"-  Servant.InvalidContentTypeHeader _ -> decodeError "invalid content-type header in OpenAI response"-  Servant.ConnectionError exc ->-    (providerError ("connection error: " <> Text.pack (displayException exc)))-      { category = TransientError-      }--responseToError :: ResponseF LBS.ByteString -> BaikaiError-responseToError resp =-  BaikaiError-    { category = classifyHttpStatusWithBody status retryAfter body,-      message = msg,-      httpStatus = Just status,-      retryAfterSeconds = retryAfter,-      exitCode = Nothing-    }-  where-    status = statusCode (responseStatusCode resp)-    body = decodeLenient (LBS.toStrict (responseBody resp))-    retryAfter = parseRetryAfter (responseHeaders resp)-    snippet = Text.take 300 (Text.strip body)-    msg =-      "HTTP "-        <> Text.pack (show status)-        <> (if Text.null snippet then "" else ": " <> snippet)--parseRetryAfter :: Seq (CI.CI ByteString, ByteString) -> Maybe Int-parseRetryAfter headers = do-  raw <- lookup (CI.mk "Retry-After") (toList headers)-  readMaybe (Text.unpack (Text.strip (decodeLenient raw)))--decodeLenient :: ByteString -> Text-decodeLenient = Text.decodeUtf8With Text.lenientDecode---- | Best-effort classification of an OpenAI streamed error message,--- which arrives as plain text without an HTTP status. Returns 'Nothing'--- for empty text (the caller keeps the raw message and 'errorInfo'--- stays absent).-classifyErrorText :: Text -> Maybe BaikaiError-classifyErrorText t-  | Text.null (Text.strip t) = Nothing-  | otherwise = Just (providerError t) {category = cat}-  where-    lower = Text.toLower t-    has needle = needle `Text.isInfixOf` lower-    cat-      | bodyIndicatesOverflow t = ContextOverflow-      | has "rate limit" || has "rate_limit" = RateLimited-      | has "overloaded" || has "server_error" || has "service unavailable" = TransientError-      | has "insufficient_quota"-          || has "invalid api key"-          || has "incorrect api key"-          || has "invalid_api_key" =-          AuthError-      | otherwise = OtherError
src/Baikai/Provider/OpenAI/Interactive.hs view
@@ -6,7 +6,9 @@ -- batch completion provider, while this module starts the interactive -- terminal UI and returns only after the CLI exits. module Baikai.Provider.OpenAI.Interactive-  ( CodexInteractiveConfig (..),+  ( CodexInteractiveConfig,+    executable,+    extraArgs,     defaultCodexInteractiveConfig,     codexInteractiveCommand,     codexInteractivePrompt,@@ -17,24 +19,24 @@ import Baikai.Interactive   ( CodexApprovalPolicy,     CodexSandboxMode,-    InteractiveLaunchRequest (..),+    InteractiveLaunchRequest,     InteractiveLaunchResult,     InteractiveProvider (..),     InteractiveSafety (..),+    interactiveLaunchResult,     renderCodexApprovalPolicy,     renderCodexSandboxMode,-    _InteractiveLaunchResult,   ) import Baikai.Prelude+import Baikai.Provider.Cli.Internal qualified as Internal import Data.Generics.Labels () import Data.Text qualified as Text-import Data.Vector qualified as Vector import System.Process qualified as P  -- | Configuration for the interactive @codex@ process. data CodexInteractiveConfig = CodexInteractiveConfig   { executable :: !FilePath,-    extraArgs :: !(Vector Text)+    extraArgs :: ![Text]   }   deriving stock (Eq, Show, Generic) @@ -55,25 +57,17 @@       <> workingDirArgs req       <> extraDirArgs req       <> safetyArgs req-      <> fmap Text.unpack (Vector.toList (cfg ^. #extraArgs))+      <> fmap Text.unpack (cfg ^. #extraArgs)       <> fmap Text.unpack (req ^. #extraArgs)-      <> [Text.unpack (codexInteractivePrompt req)]+      <> ["--", Text.unpack (codexInteractivePrompt req)]   )  -- | Codex does not currently expose a top-level interactive -- system-prompt flag. Preserve Baikai's request shape by placing the -- system prompt before the user prompt in the initial prompt text. codexInteractivePrompt :: InteractiveLaunchRequest -> Text-codexInteractivePrompt req = case Text.strip <$> req ^. #systemPrompt of-  Nothing -> req ^. #userPrompt-  Just "" -> req ^. #userPrompt-  Just prompt ->-    Text.concat-      [ "System instructions:\n",-        prompt,-        "\n\nUser request:\n",-        req ^. #userPrompt-      ]+codexInteractivePrompt req =+  Internal.wrapSystemPrompt (req ^. #systemPrompt) (req ^. #userPrompt)  -- | Launch Codex with inherited stdin, stdout, and stderr so the -- local CLI owns the interactive terminal experience.@@ -88,12 +82,11 @@             P.std_err = P.Inherit,             P.cwd = req ^. #workingDir           }-  (_, _, _, ph) <- P.createProcess spec-  code <- P.waitForProcess ph-  pure (_InteractiveLaunchResult InteractiveCodex code)+  code <- P.withCreateProcess spec (\_ _ _ ph -> P.waitForProcess ph)+  pure (interactiveLaunchResult InteractiveCodex code)  modelArgs :: InteractiveLaunchRequest -> [String]-modelArgs req = case Text.strip <$> req ^. #model of+modelArgs req = case Text.strip <$> req ^. #modelId of   Nothing -> []   Just "" -> []   Just mid -> ["--model", Text.unpack mid]
+ src/Baikai/Provider/OpenAI/Internal/ErrorClass.hs view
@@ -0,0 +1,140 @@+-- | Internal failure classification for the OpenAI provider.+--+-- This module is exposed for provider tests and debugging, but it is+-- not part of baikai's PVP-stable application surface. Names, types,+-- and semantics here may change in minor releases.+--+-- 'classifyException' handles an exception thrown by the+-- @servant-client@ HTTP layer; 'classifyErrorText' handles an error+-- that arrives mid-stream as a plain text message.+module Baikai.Provider.OpenAI.Internal.ErrorClass+  ( classifyException,+    classifyErrorText,+    -- | Exposed for testing the HTTP-status mapping without a live call.+    responseToError,+  )+where++import Baikai.Error+  ( BaikaiError (..),+    ErrorCategory (..),+    bodyIndicatesOverflow,+    decodeError,+    httpError,+    invalidRequest,+    parseRetryAfterSeconds,+    providerError,+  )+import Control.Exception (SomeException, displayException, fromException)+import Data.ByteString (ByteString)+import Data.ByteString.Lazy qualified as LBS+import Data.CaseInsensitive qualified as CI+import Data.Foldable (toList)+import Data.Sequence (Seq)+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import Data.Text.Encoding.Error qualified as Text+import Network.HTTP.Client qualified as HTTP+import Network.HTTP.Types.Status (statusCode)+import Servant.Client (ClientError, ResponseF (..))+import Servant.Client qualified as Servant+import Text.Read (readMaybe)++-- | Convert any exception caught from the OpenAI SDK into a categorised+-- 'BaikaiError'. Recognises @servant-client@ 'ClientError' and raw+-- @http-client@ 'HttpException'; anything else degrades to a generic+-- provider error carrying the displayed exception text.+classifyException :: SomeException -> BaikaiError+classifyException ex+  | Just clientErr <- fromException ex = fromClientError clientErr+  | Just httpEx <- fromException ex = fromHttpException httpEx+  | otherwise = providerError (Text.pack (displayException ex))++fromClientError :: ClientError -> BaikaiError+fromClientError clientErr = case clientErr of+  Servant.FailureResponse _req resp -> responseToError resp+  Servant.DecodeFailure detail _ -> decodeError detail+  Servant.UnsupportedContentType _ _ -> decodeError "unsupported content type in OpenAI response"+  Servant.InvalidContentTypeHeader _ -> decodeError "invalid content-type header in OpenAI response"+  Servant.ConnectionError exc ->+    (providerError ("connection error: " <> Text.pack (displayException exc)))+      { category = TransientError+      }++responseToError :: ResponseF LBS.ByteString -> BaikaiError+responseToError resp = httpError status retryAfter body+  where+    status = statusCode (responseStatusCode resp)+    body = decodeLenient (LBS.toStrict (responseBody resp))+    retryAfter = parseRetryAfter (responseHeaders resp)++parseRetryAfter :: Seq (CI.CI ByteString, ByteString) -> Maybe Int+parseRetryAfter headers = do+  raw <- lookup (CI.mk "Retry-After") (toList headers)+  parseRetryAfterSeconds (decodeLenient raw)++fromHttpException :: HTTP.HttpException -> BaikaiError+fromHttpException = \case+  HTTP.InvalidUrlException url reason ->+    invalidRequest (Text.pack (url <> ": " <> reason))+  HTTP.HttpExceptionRequest _ content -> fromHttpExceptionContent content++fromHttpExceptionContent :: HTTP.HttpExceptionContent -> BaikaiError+fromHttpExceptionContent = \case+  HTTP.StatusCodeException resp body ->+    httpError+      (statusCode (HTTP.responseStatus resp))+      (parseRetryAfterHttp (HTTP.responseHeaders resp))+      (decodeLenient body)+  HTTP.ConnectionFailure e -> transient (Text.pack (displayException e))+  HTTP.ConnectionTimeout -> transient "connection timeout"+  HTTP.ResponseTimeout -> transient "response timeout"+  HTTP.ConnectionClosed -> transient "connection closed"+  HTTP.NoResponseDataReceived -> transient "no response data received"+  HTTP.IncompleteHeaders -> transient "incomplete response headers"+  other -> providerError (Text.pack (show other))+  where+    transient t = (providerError ("connection error: " <> t)) {category = TransientError}++parseRetryAfterHttp :: [(CI.CI ByteString, ByteString)] -> Maybe Int+parseRetryAfterHttp headers = do+  raw <- lookup (CI.mk "Retry-After") headers+  parseRetryAfterSeconds (decodeLenient raw)++decodeLenient :: ByteString -> Text+decodeLenient = Text.decodeUtf8With Text.lenientDecode++-- | Best-effort classification of an OpenAI streamed error message,+-- which arrives as plain text without an HTTP status. Returns 'Nothing'+-- for empty text (the caller keeps the raw message and 'errorInfo'+-- stays absent).+classifyErrorText :: Text -> Maybe BaikaiError+classifyErrorText t+  | Just e <- classifySdkHttpText t = Just e+  | Text.null (Text.strip t) = Nothing+  | otherwise = Just (providerError t) {category = cat}+  where+    lower = Text.toLower t+    has needle = needle `Text.isInfixOf` lower+    cat+      | bodyIndicatesOverflow t = ContextOverflow+      | has "rate limit" || has "rate_limit" = RateLimited+      | has "overloaded" || has "server_error" || has "service unavailable" = TransientError+      | has "insufficient_quota"+          || has "invalid api key"+          || has "incorrect api key"+          || has "invalid_api_key" =+          AuthError+      | otherwise = OtherError++classifySdkHttpText :: Text -> Maybe BaikaiError+classifySdkHttpText raw = do+  rest <- Text.stripPrefix "HTTP error " raw+  let (codeText, afterCode) = Text.breakOn " " rest+  code <- readMaybe (Text.unpack codeText)+  let body = case Text.breakOn ": " afterCode of+        (_, sepBody)+          | not (Text.null sepBody) -> Text.drop 2 sepBody+        _ -> ""+  pure (httpError code Nothing body)
+ src/Baikai/Provider/OpenAI/Internal/Request.hs view
@@ -0,0 +1,265 @@+{-# LANGUAGE LambdaCase #-}++-- | Internal request mapping for the OpenAI Chat Completions provider.+--+-- This module is exposed for provider tests and debugging but is not covered by PVP+-- compatibility guarantees. Import the public provider module for stable application code.+module Baikai.Provider.OpenAI.Internal.Request+  ( mapRequest,+  )+where++import Baikai.Compat+  ( OpenAICompletionsCompat (..),+    ThinkingFormat (..),+  )+import Baikai.Content qualified as Content+import Baikai.Context (Context (..))+import Baikai.Message qualified as Msg+import Baikai.Model (Model, openaiCompletionsCompatFor)+import Baikai.Options (Options (..))+import Baikai.ResponseFormat (ResponseFormat (..))+import Baikai.ThinkingLevel (ThinkingLevel (..))+import Baikai.Tool qualified as Tool+import Control.Lens ((^.))+import Data.Aeson qualified as Aeson+import Data.ByteString.Base64 qualified as Base64+import Data.ByteString.Lazy qualified as BSL+import Data.Generics.Labels ()+import Data.Maybe (fromMaybe)+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import Data.Vector (Vector)+import Data.Vector qualified as Vector+import OpenAI.V1.Chat.Completions qualified as Chat+import OpenAI.V1.Models qualified as OpenAIModels+import OpenAI.V1.ResponseFormat qualified as RF+import OpenAI.V1.Tool qualified as OpenAITool+import OpenAI.V1.ToolCall qualified as ToolCall++-- ============================================================+-- Request mapping (preserved from EP-2 with minor refactoring)+-- ============================================================++mapRequest ::+  Model -> Context -> Options -> Either Text Chat.CreateChatCompletion+mapRequest m ctx opts = do+  body <- traverse mapMessage (Vector.toList (ctx ^. #messages))+  let compat = openaiCompletionsCompatFor m+      prefix = case ctx ^. #systemPrompt of+        Nothing -> []+        Just sp ->+          [ Chat.System+              { Chat.content = Vector.singleton Chat.Text {Chat.text = sp},+                Chat.name = Nothing+              }+          ]+      mt = fromMaybe (m ^. #maxOutputTokens) (opts ^. #maxTokens)+      maxTokensField =+        if mt == 0 then Nothing else Just mt+      toolsField =+        if Vector.null (ctx ^. #tools)+          then Nothing+          else Just (Vector.map (mkOpenAITool compat) (ctx ^. #tools))+      toolChoiceField = fmap mkOpenAIToolChoice (opts ^. #toolChoice)+      reasoningEffortField =+        applyThinkingFormat compat (opts ^. #thinking)+      responseFormatField =+        fmap (mkOpenAIResponseFormat compat) (opts ^. #responseFormat)+  pure+    Chat._CreateChatCompletion+      { Chat.messages = Vector.fromList (prefix <> body),+        Chat.model = OpenAIModels.Model (m ^. #modelId),+        Chat.max_completion_tokens = maxTokensField,+        Chat.temperature = opts ^. #temperature,+        Chat.top_p = opts ^. #topP,+        Chat.stop = opts ^. #stopSequences,+        Chat.seed = opts ^. #seed,+        Chat.frequency_penalty = opts ^. #frequencyPenalty,+        Chat.presence_penalty = opts ^. #presencePenalty,+        Chat.tools = toolsField,+        Chat.tool_choice = toolChoiceField,+        Chat.reasoning_effort = reasoningEffortField,+        Chat.response_format = responseFormatField+      }++-- | Map a baikai 'ResponseFormat' onto the upstream OpenAI+-- 'RF.ResponseFormat'. 'JsonObject' becomes plain-JSON mode;+-- 'JsonSchema' becomes a named, optionally-strict schema. The+-- schema 'Value' is forwarded verbatim.+mkOpenAIResponseFormat :: OpenAICompletionsCompat -> ResponseFormat -> RF.ResponseFormat+mkOpenAIResponseFormat _ JsonObject = RF.JSON_Object+mkOpenAIResponseFormat compat JsonSchema {name = n, schema = s, strict = st} =+  RF.JSON_Schema+    { RF.json_schema =+        RF.JSONSchema+          { RF.description = Nothing,+            RF.name = n,+            RF.schema = Just s,+            RF.strict = if supportsStrictMode compat then Just st else Nothing+          }+    }++-- | Map a 'Baikai.ThinkingLevel.ThinkingLevel' onto the OpenAI SDK's+-- 'Chat.ReasoningEffort' enum. Returns 'Nothing' when the caller did+-- not request a level, when the host's 'thinkingFormat' is+-- 'ThinkingFormatNone', or when the host expects a non-OpenAI shape+-- the SDK does not support natively.+--+-- The non-OpenAI thinking formats (DeepSeek, OpenRouter, Together,+-- Z.ai, Qwen) require additional top-level JSON keys the upstream+-- @openai@ Haskell SDK does not expose. They are silently dropped on+-- this revision; see the EP-5 Decision Log for the rationale and+-- pointers to the workaround when one is needed.+applyThinkingFormat ::+  OpenAICompletionsCompat ->+  Maybe ThinkingLevel ->+  Maybe Chat.ReasoningEffort+applyThinkingFormat _ Nothing = Nothing+applyThinkingFormat compat (Just lvl) = case thinkingFormat compat of+  ThinkingFormatOpenAI -> Just (toReasoningEffort lvl)+  _ -> Nothing++toReasoningEffort :: ThinkingLevel -> Chat.ReasoningEffort+toReasoningEffort = \case+  ThinkingMinimal -> Chat.ReasoningEffort_Minimal+  ThinkingLow -> Chat.ReasoningEffort_Low+  ThinkingMedium -> Chat.ReasoningEffort_Medium+  ThinkingHigh -> Chat.ReasoningEffort_High++-- | Map a baikai 'Tool.Tool' into the upstream OpenAI 'Tool_Function'+-- shape. The compat record's 'supportsStrictMode' flag controls+-- whether the @strict@ field is sent ('True') or dropped ('False');+-- some OpenAI-compatible hosts reject strict-mode tools entirely.+--+-- The default ('defaultOpenAICompletionsCompat') leaves strict+-- unset, which OpenAI treats as the default-permissive behaviour;+-- callers that want @strict: true@ on every tool can flip the field+-- on their compat record (a future enhancement; currently we pass+-- 'Nothing' even when 'supportsStrictMode' is 'True', matching the+-- pre-EP-5 behaviour).+mkOpenAITool :: OpenAICompletionsCompat -> Tool.Tool -> OpenAITool.Tool+mkOpenAITool _compat t =+  OpenAITool.Tool_Function+    { OpenAITool.function =+        OpenAITool.Function+          { OpenAITool.name = Tool.name t,+            OpenAITool.description = Just (Tool.description t),+            OpenAITool.parameters = Just (Tool.parameters t),+            OpenAITool.strict = Nothing+          }+    }++-- | Map a baikai 'Tool.ToolChoice' into the upstream OpenAI+-- 'ToolChoice'. OpenAI accepts @none@, @auto@, @required@, and a+-- specific function reference; the SDK's 'ToolChoiceTool' takes the+-- whole 'OpenAITool.Tool' value so we synthesise a stub function+-- tool carrying just the name (OpenAI ignores the schema in this+-- position).+mkOpenAIToolChoice :: Tool.ToolChoice -> OpenAITool.ToolChoice+mkOpenAIToolChoice = \case+  Tool.ToolChoiceAuto -> OpenAITool.ToolChoiceAuto+  Tool.ToolChoiceNone -> OpenAITool.ToolChoiceNone+  Tool.ToolChoiceRequired -> OpenAITool.ToolChoiceRequired+  Tool.ToolChoiceSpecific n ->+    OpenAITool.ToolChoiceTool+      ( OpenAITool.Tool_Function+          { OpenAITool.function =+              OpenAITool.Function+                { OpenAITool.name = n,+                  OpenAITool.description = Nothing,+                  OpenAITool.parameters = Nothing,+                  OpenAITool.strict = Nothing+                }+          }+      )++mapMessage :: Msg.Message -> Either Text (Chat.Message (Vector Chat.Content))+mapMessage = \case+  Msg.UserMessage Msg.UserPayload {Msg.content = uc} ->+    Right+      Chat.User+        { Chat.content = Vector.map userContentToPart uc,+          Chat.name = Nothing+        }+  Msg.AssistantMessage Msg.AssistantPayload {Msg.content = ac} ->+    let textBody = collectAssistantText ac+        toolCalls = collectToolCalls ac+        content+          | Text.null textBody = Nothing+          | otherwise = Just (Vector.singleton Chat.Text {Chat.text = textBody})+        toolCallVec+          | Vector.null toolCalls = Nothing+          | otherwise = Just toolCalls+     in Right+          Chat.Assistant+            { Chat.assistant_content = content,+              Chat.refusal = Nothing,+              Chat.name = Nothing,+              Chat.assistant_audio = Nothing,+              Chat.tool_calls = toolCallVec+            }+  Msg.ToolResultMessage+    Msg.ToolResultPayload+      { Msg.toolCallId = tid,+        Msg.content = trc,+        Msg.isError = err+      } ->+      case collectToolResultText trc of+        Left unsupported -> Left unsupported+        Right body ->+          let decorated = if err then "[error] " <> body else body+           in Right+                Chat.Tool+                  { Chat.content = Vector.singleton Chat.Text {Chat.text = decorated},+                    Chat.tool_call_id = tid+                  }++userContentToPart :: Content.UserContent -> Chat.Content+userContentToPart = \case+  Content.UserText (Content.TextContent t) -> Chat.Text {Chat.text = t}+  Content.UserImage img ->+    let encoded = Text.decodeUtf8 (Base64.encode (Content.imageData img))+        uri = "data:" <> Content.mimeType img <> ";base64," <> encoded+     in Chat.Image_URL+          { Chat.image_url = Chat.ImageURL {Chat.url = uri, Chat.detail = Nothing}+          }++collectAssistantText :: Vector Content.AssistantContent -> Text+collectAssistantText =+  Text.concat+    . Vector.toList+    . Vector.mapMaybe+      ( \case+          Content.AssistantText (Content.TextContent t) -> Just t+          Content.AssistantThinking _ -> Nothing+          Content.AssistantToolCall _ -> Nothing+      )++collectToolCalls :: Vector Content.AssistantContent -> Vector ToolCall.ToolCall+collectToolCalls =+  Vector.mapMaybe+    ( \case+        Content.AssistantToolCall tc ->+          Just+            ToolCall.ToolCall_Function+              { ToolCall.id = Content.id_ tc,+                ToolCall.function =+                  ToolCall.Function+                    { ToolCall.name = Content.name tc,+                      ToolCall.arguments =+                        Text.decodeUtf8 (BSL.toStrict (Aeson.encode (Content.arguments tc)))+                    }+              }+        _ -> Nothing+    )++collectToolResultText :: Vector Content.ToolResultContent -> Either Text Text+collectToolResultText =+  fmap (Text.concat . Vector.toList) . traverse oneBlock+  where+    oneBlock = \case+      Content.ToolResultText (Content.TextContent t) -> Right t+      Content.ToolResultImage _ ->+        Left "OpenAI Chat Completions cannot encode ToolResultImage blocks in tool-result messages"
+ src/Baikai/Provider/OpenAI/Shape.hs view
@@ -0,0 +1,216 @@+{-# LANGUAGE LambdaCase #-}++-- | Pure request-body shaping for OpenAI-compatible Chat Completions hosts.+module Baikai.Provider.OpenAI.Shape+  ( shapeRequestBody,+    streamRequestBody,+    renameMaxTokens,+    dropUnsupportedStrict,+    injectThinkingShape,+    injectCacheControl,+  )+where++import Baikai.CacheRetention (CacheRetention (..))+import Baikai.Compat+  ( CacheControlFormat (..),+    MaxTokensField (..),+    OpenAICompletionsCompat+      ( cacheControlFormat,+        maxTokensField,+        supportsLongCacheRetention,+        supportsStrictMode,+        supportsUsageInStreaming,+        thinkingFormat+      ),+    ThinkingFormat (..),+  )+import Baikai.Options (Options, cacheRetention, thinking)+import Baikai.ThinkingLevel (ThinkingLevel (..))+import Data.Aeson (Value (..), (.=))+import Data.Aeson qualified as Aeson+import Data.Aeson.Key qualified as AesonKey+import Data.Aeson.KeyMap (KeyMap)+import Data.Aeson.KeyMap qualified as KeyMap+import Data.Text (Text)+import Data.Vector (Vector)+import Data.Vector qualified as Vector+import OpenAI.V1.Chat.Completions qualified as Chat++shapeRequestBody ::+  OpenAICompletionsCompat -> Options -> Aeson.Value -> Aeson.Value+shapeRequestBody compat opts =+  injectCacheControl compat opts+    . injectThinkingShape compat opts+    . dropUnsupportedStrict compat+    . renameMaxTokens compat++streamRequestBody ::+  OpenAICompletionsCompat ->+  Options ->+  Chat.CreateChatCompletion ->+  Aeson.Value+streamRequestBody compat opts req =+  shapeRequestBody compat opts (Aeson.toJSON req')+  where+    req' =+      req+        { Chat.stream = Just True,+          Chat.stream_options =+            if supportsUsageInStreaming compat+              then+                Just+                  Chat._ChatCompletionStreamOptions+                    { Chat.include_usage = Just True+                    }+              else Nothing+        }++renameMaxTokens :: OpenAICompletionsCompat -> Aeson.Value -> Aeson.Value+renameMaxTokens compat+  | maxTokensField compat /= MaxTokensField = id+  | otherwise =+      mapObject $ \obj ->+        case KeyMap.lookup (key "max_completion_tokens") obj of+          Nothing -> obj+          Just v ->+            KeyMap.insert (key "max_tokens") v $+              KeyMap.delete (key "max_completion_tokens") obj++dropUnsupportedStrict :: OpenAICompletionsCompat -> Aeson.Value -> Aeson.Value+dropUnsupportedStrict compat+  | supportsStrictMode compat = id+  | otherwise =+      mapObject $+        adjustKey (key "response_format") $+          mapObject $+            adjustKey (key "json_schema") $+              mapObject (KeyMap.delete (key "strict"))++injectThinkingShape :: OpenAICompletionsCompat -> Options -> Aeson.Value -> Aeson.Value+injectThinkingShape compat opts body =+  case thinking opts of+    Nothing -> body+    Just lvl -> case thinkingFormat compat of+      ThinkingFormatOpenAI -> body+      ThinkingFormatNone -> body+      ThinkingFormatOpenRouter ->+        insertTop "reasoning" (Aeson.object ["effort" .= effort lvl]) body+      ThinkingFormatDeepseek ->+        insertTop "reasoning_effort" (String (effort lvl)) $+          insertTop "thinking" (Aeson.object ["type" .= ("enabled" :: Text)]) body+      ThinkingFormatTogether ->+        insertTop "reasoning_effort" (String (effort lvl)) $+          insertTop "reasoning" (Aeson.object ["enabled" .= True]) body+      ThinkingFormatZai ->+        insertTop "enable_thinking" (Bool True) body+      ThinkingFormatQwen ->+        insertTop "enable_thinking" (Bool True) body++injectCacheControl :: OpenAICompletionsCompat -> Options -> Aeson.Value -> Aeson.Value+injectCacheControl compat opts body =+  case (cacheControlFormat compat, cacheRetention opts) of+    (Just CacheControlFormatAnthropic, Just retention)+      | Just marker <- cacheControlMarker compat retention ->+          mapObject+            (adjustKey (key "messages") (shapeMessages marker))+            body+    _ -> body++cacheControlMarker :: OpenAICompletionsCompat -> CacheRetention -> Maybe Aeson.Value+cacheControlMarker compat = \case+  CacheRetentionNone -> Nothing+  CacheRetentionShort -> Just (Aeson.object ["type" .= ("ephemeral" :: Text)])+  CacheRetentionLong ->+    if supportsLongCacheRetention compat+      then+        Just+          ( Aeson.object+              [ "type" .= ("ephemeral" :: Text),+                "ttl" .= ("1h" :: Text)+              ]+          )+      else Just (Aeson.object ["type" .= ("ephemeral" :: Text)])++shapeMessages :: Aeson.Value -> Aeson.Value -> Aeson.Value+shapeMessages marker = \case+  Array messages ->+    let target =+          findLastIndex isSystemMessage messages+            `orElse` findLastIndex isUserMessage messages+     in case target of+          Nothing -> Array messages+          Just i ->+            Array (messages Vector.// [(i, shapeMessage marker (messages Vector.! i))])+  other -> other++shapeMessage :: Aeson.Value -> Aeson.Value -> Aeson.Value+shapeMessage marker =+  mapObject (adjustKey (key "content") (shapeContent marker))++shapeContent :: Aeson.Value -> Aeson.Value -> Aeson.Value+shapeContent marker = \case+  Array parts+    | not (Vector.null parts) ->+        let i = Vector.length parts - 1+         in Array (parts Vector.// [(i, shapeContentPart marker (parts Vector.! i))])+  other -> other++shapeContentPart :: Aeson.Value -> Aeson.Value -> Aeson.Value+shapeContentPart marker =+  mapObject (KeyMap.insert (key "cache_control") marker)++isSystemMessage :: Aeson.Value -> Bool+isSystemMessage = hasRole "system"++isUserMessage :: Aeson.Value -> Bool+isUserMessage = hasRole "user"++hasRole :: Text -> Aeson.Value -> Bool+hasRole role = \case+  Object obj -> KeyMap.lookup (key "role") obj == Just (String role)+  _ -> False++findLastIndex :: (Aeson.Value -> Bool) -> Vector Aeson.Value -> Maybe Int+findLastIndex p =+  fmap fst+    . lastMay+    . filter (p . snd)+    . zip [0 ..]+    . Vector.toList++lastMay :: [a] -> Maybe a+lastMay [] = Nothing+lastMay xs = Just (last xs)++orElse :: Maybe a -> Maybe a -> Maybe a+orElse (Just x) _ = Just x+orElse Nothing y = y++insertTop :: Text -> Aeson.Value -> Aeson.Value -> Aeson.Value+insertTop k v = mapObject (KeyMap.insert (key k) v)++mapObject :: (KeyMap Aeson.Value -> KeyMap Aeson.Value) -> Aeson.Value -> Aeson.Value+mapObject f = \case+  Object obj -> Object (f obj)+  other -> other++adjustKey ::+  AesonKey.Key ->+  (Aeson.Value -> Aeson.Value) ->+  KeyMap Aeson.Value ->+  KeyMap Aeson.Value+adjustKey k f obj =+  case KeyMap.lookup k obj of+    Nothing -> obj+    Just old -> KeyMap.insert k (f old) obj++key :: Text -> AesonKey.Key+key = AesonKey.fromText++effort :: ThinkingLevel -> Text+effort = \case+  ThinkingMinimal -> "low"+  ThinkingLow -> "low"+  ThinkingMedium -> "medium"+  ThinkingHigh -> "high"
+ src/Baikai/Provider/OpenAI/Sse.hs view
@@ -0,0 +1,159 @@+{-# LANGUAGE LambdaCase #-}++-- | Local SSE transport for OpenAI Chat Completions streams.+module Baikai.Provider.OpenAI.Sse+  ( openaiSseStream,+    openaiSseStreamValue,+    openaiSseStreamValueWithHeaders,+    sseFromResponse,+  )+where++import Baikai.Error (BaikaiError, decodeError, httpError, parseRetryAfterSeconds)+import Control.Monad (foldM, when)+import Data.Aeson qualified as Aeson+import Data.ByteString qualified as SBS+import Data.ByteString.Char8 qualified as S8+import Data.CaseInsensitive qualified as CI+import Data.IORef qualified as IORef+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import Data.Text.Encoding.Error qualified as Text+import Network.HTTP.Client qualified as HTTP+import Network.HTTP.Types.Header (RequestHeaders)+import Network.HTTP.Types.Status qualified as Status+import OpenAI.V1.Chat.Completions qualified as Chat+import Servant.Client qualified as Client++-- | POST the request to @/v1/chat/completions@ and feed decoded SSE+-- JSON payloads to the callback. A @data: [DONE]@ frame ends the+-- stream without producing a callback value.+openaiSseStream ::+  Client.ClientEnv ->+  Text ->+  Chat.CreateChatCompletion ->+  (Either BaikaiError Aeson.Value -> IO ()) ->+  IO ()+openaiSseStream env apiKey req =+  openaiSseStreamValue env apiKey (Aeson.toJSON req)++openaiSseStreamValue ::+  Client.ClientEnv ->+  Text ->+  Aeson.Value ->+  (Either BaikaiError Aeson.Value -> IO ()) ->+  IO ()+openaiSseStreamValue env apiKey =+  openaiSseStreamValueWithHeaders+    env+    [ ("Authorization", Text.encodeUtf8 ("Bearer " <> apiKey)),+      ("Accept", "text/event-stream"),+      ("Content-Type", "application/json")+    ]++openaiSseStreamValueWithHeaders ::+  Client.ClientEnv ->+  RequestHeaders ->+  Aeson.Value ->+  (Either BaikaiError Aeson.Value -> IO ()) ->+  IO ()+openaiSseStreamValueWithHeaders env requestHeaders requestBody onEvent = do+  let base = Client.baseUrl env+      secure = case Client.baseUrlScheme base of+        Client.Http -> False+        Client.Https -> True+      request =+        HTTP.defaultRequest+          { HTTP.secure = secure,+            HTTP.host = S8.pack (Client.baseUrlHost base),+            HTTP.port = Client.baseUrlPort base,+            HTTP.method = "POST",+            HTTP.path = S8.pack (normalizePath (Client.baseUrlPath base) <> "/v1/chat/completions"),+            HTTP.requestHeaders = requestHeaders,+            HTTP.requestBody = HTTP.RequestBodyLBS (Aeson.encode requestBody),+            -- EP-8 wires Options.timeoutMs through this local transport.+            HTTP.responseTimeout = HTTP.responseTimeoutNone+          }+  HTTP.withResponse request (Client.manager env) (`sseFromResponse` onEvent)++sseFromResponse ::+  HTTP.Response HTTP.BodyReader ->+  (Either BaikaiError Aeson.Value -> IO ()) ->+  IO ()+sseFromResponse response onEvent = do+  let st = HTTP.responseStatus response+  if not (Status.statusIsSuccessful st)+    then do+      bodyChunks <- HTTP.brConsume (HTTP.responseBody response)+      let bodyText = decodeLenient (SBS.concat bodyChunks)+          retryAfter =+            parseRetryAfterSeconds . decodeLenient+              =<< lookup (CI.mk "Retry-After") (HTTP.responseHeaders response)+      onEvent (Left (httpError (Status.statusCode st) retryAfter bodyText))+    else do+      lineBufRef <- IORef.newIORef SBS.empty+      eventBufRef <- IORef.newIORef ([] :: [SBS.ByteString])+      let flushEvent = do+            es <- IORef.atomicModifyIORef' eventBufRef (\buf -> ([], reverse buf))+            case es of+              [] -> pure False+              _ -> do+                let payload = S8.concat es+                if payload == "[DONE]"+                  then pure True+                  else case Aeson.eitherDecodeStrict payload of+                    Left err -> onEvent (Left (decodeError (Text.pack err))) >> pure False+                    Right val -> onEvent (Right val) >> pure False++          handleLine line =+            let l = stripCR line+             in if S8.null l+                  then flushEvent+                  else+                    if "data:" `S8.isPrefixOf` l+                      then do+                        let d = S8.dropWhile (== ' ') (S8.drop 5 l)+                        IORef.modifyIORef' eventBufRef (d :)+                        pure False+                      else pure False++          loop = do+            chunk <- HTTP.brRead (HTTP.responseBody response)+            if SBS.null chunk+              then do+                pendingLine <- IORef.readIORef lineBufRef+                when (not (SBS.null pendingLine)) $ do+                  _ <- handleLine pendingLine+                  IORef.writeIORef lineBufRef SBS.empty+                _ <- flushEvent+                pure ()+              else do+                prev <- IORef.readIORef lineBufRef+                let combined = prev <> chunk+                    ls = S8.split '\n' combined+                case unsnoc ls of+                  Nothing -> loop+                  Just (completeLines, lastLine) -> do+                    IORef.writeIORef lineBufRef lastLine+                    stop <- foldM (\acc ln -> if acc then pure True else handleLine ln) False completeLines+                    if stop then pure () else loop+      loop++normalizePath :: String -> String+normalizePath = \case+  "" -> ""+  p@('/' : _) -> p+  p -> '/' : p++stripCR :: SBS.ByteString -> SBS.ByteString+stripCR bs = case S8.unsnoc bs of+  Just (initBs, '\r') -> initBs+  _ -> bs++unsnoc :: [a] -> Maybe ([a], a)+unsnoc [] = Nothing+unsnoc xs = Just (init xs, last xs)++decodeLenient :: SBS.ByteString -> Text+decodeLenient = Text.decodeUtf8With Text.lenientDecode
+ src/Baikai/Provider/OpenAI/Transport.hs view
@@ -0,0 +1,104 @@+module Baikai.Provider.OpenAI.Transport+  ( getClientEnvCached,+    cachedClientEnvCount,+    requestHeaders,+    resolveKey,+    runWithTimeout,+  )+where++import Baikai.Auth qualified as Auth+import Baikai.Error (BaikaiError (..), ErrorCategory (..), authError)+import Baikai.Model (Model (..))+import Baikai.Options (Options (..))+import Control.Concurrent.MVar (MVar, modifyMVar, newMVar)+import Control.Exception (throwIO)+import Control.Lens ((^.))+import Data.CaseInsensitive qualified as CI+import Data.Generics.Labels ()+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import Network.HTTP.Client qualified as HTTP+import Network.HTTP.Client.TLS qualified as TLS+import Network.HTTP.Types.Header (RequestHeaders)+import Servant.Client qualified as Client+import System.IO.Unsafe (unsafePerformIO)+import System.Timeout qualified as Timeout++getClientEnvCached :: Text -> IO Client.ClientEnv+getClientEnvCached baseUrl =+  modifyMVar clientEnvCache $ \cache ->+    case Map.lookup baseUrl cache of+      Just env -> pure (cache, env)+      Nothing -> do+        env <- newClientEnv baseUrl+        pure (Map.insert baseUrl env cache, env)++cachedClientEnvCount :: IO Int+cachedClientEnvCount =+  modifyMVar clientEnvCache $ \cache -> pure (cache, Map.size cache)++requestHeaders :: Text -> Model -> Options -> RequestHeaders+requestHeaders apiKey m opts =+  applyHeaderOverrides+    [ ("Authorization", Text.encodeUtf8 ("Bearer " <> apiKey)),+      ("Accept", "text/event-stream"),+      ("Content-Type", "application/json")+    ]+    (Map.toList (m ^. #headers) <> Map.toList (opts ^. #headers))++resolveKey :: Text -> Options -> IO Text+resolveKey baseUrl opts = case opts ^. #apiKey of+  Just source -> Auth.resolveApiKey source+  Nothing -> case Auth.defaultApiKeyEnvForBaseUrl baseUrl of+    Just name -> Auth.resolveApiKey (Auth.ApiKeyEnv name)+    Nothing ->+      throwIO $+        authError $+          "no default API key env is known for " <> baseUrl <> "; set Options.apiKey explicitly"++runWithTimeout :: Maybe Int -> IO () -> IO (Maybe BaikaiError)+runWithTimeout Nothing action = action >> pure Nothing+runWithTimeout (Just ms) action = do+  result <- Timeout.timeout (max 0 ms * 1000) action+  pure $ case result of+    Just () -> Nothing+    Nothing -> Just (timeoutError ms)++timeoutError :: Int -> BaikaiError+timeoutError ms =+  BaikaiError+    { category = TransientError,+      message = "provider stream exceeded timeoutMs=" <> Text.pack (show ms),+      httpStatus = Nothing,+      retryAfterSeconds = Nothing,+      exitCode = Nothing+    }++newClientEnv :: Text -> IO Client.ClientEnv+newClientEnv baseUrl = do+  parsed <- Client.parseBaseUrl (Text.unpack baseUrl)+  manager <-+    TLS.newTlsManagerWith+      TLS.tlsManagerSettings+        { HTTP.managerResponseTimeout = HTTP.responseTimeoutNone+        }+  pure (Client.mkClientEnv manager parsed)++applyHeaderOverrides ::+  RequestHeaders ->+  [(Text, Text)] ->+  RequestHeaders+applyHeaderOverrides =+  foldl addHeader+  where+    addHeader headers (name, value) =+      let nameBytes = Text.encodeUtf8 name+          ciName = CI.mk nameBytes+       in (ciName, Text.encodeUtf8 value) : filter ((/= ciName) . fst) headers++{-# NOINLINE clientEnvCache #-}+clientEnvCache :: MVar (Map.Map Text Client.ClientEnv)+clientEnvCache = unsafePerformIO (newMVar Map.empty)
test/ErrorClassSpec.hs view
@@ -1,7 +1,7 @@ module ErrorClassSpec (tests) where -import Baikai.Error (BaikaiError (..), ErrorCategory (..))-import Baikai.Provider.OpenAI.ErrorClass+import Baikai.Error (BaikaiError (..), ErrorCategory (..), isRetryable)+import Baikai.Provider.OpenAI.Internal.ErrorClass   ( classifyErrorText,     classifyException,     responseToError,@@ -12,6 +12,7 @@ import Data.CaseInsensitive qualified as CI import Data.Sequence qualified as Seq import Data.Text qualified as Text+import Network.HTTP.Client qualified as HTTP import Network.HTTP.Types.Status (mkStatus) import Network.HTTP.Types.Version (http11) import Servant.Client (ResponseF (..))@@ -21,8 +22,9 @@ tests :: TestTree tests =   testGroup-    "Baikai.Provider.OpenAI.ErrorClass"+    "Baikai.Provider.OpenAI.Internal.ErrorClass"     [ httpStatusTests,+      sdkTextTests,       streamedErrorTests,       fallbackTests     ]@@ -77,9 +79,42 @@ fallbackTests =   testGroup     "classifyException fallback"-    [ testCase "non-ClientError exception -> OtherError, text preserved" $ do+    [ testCase "http-client connection failures are transient" $ do+        let e =+              classifyException $+                toException $+                  HTTP.HttpExceptionRequest+                    HTTP.defaultRequest+                    (HTTP.ConnectionFailure (toException (userError "reset")))+        category e @?= TransientError+        assertBool "connection failure is retryable" (isRetryable e),+      testCase "http-client response timeouts are transient" $ do+        let e =+              classifyException $+                toException $+                  HTTP.HttpExceptionRequest+                    HTTP.defaultRequest+                    HTTP.ResponseTimeout+        category e @?= TransientError+        assertBool "response timeout is retryable" (isRetryable e),+      testCase "non-ClientError exception -> OtherError, text preserved" $ do         let e = classifyException (toException (userError "weird failure"))         category e @?= OtherError         assertBool "message keeps the original text" $           "weird failure" `Text.isInfixOf` message e+    ]++sdkTextTests :: TestTree+sdkTextTests =+  testGroup+    "classifyErrorText (SDK HTTP text)"+    [ testCase "429 SDK text -> RateLimited with status" $ do+        let parsed =+              classifyErrorText+                "HTTP error 429 Too Many Requests: {\"error\":{\"message\":\"Rate limit reached...\",\"type\":\"tokens\"}}"+        fmap category parsed @?= Just RateLimited+        fmap httpStatus parsed @?= Just (Just 429),+      testCase "401 SDK text -> AuthError" $+        fmap category (classifyErrorText "HTTP error 401 Unauthorized: {\"error\":{\"message\":\"bad key\"}}")+          @?= Just AuthError     ]
test/Main.hs view
@@ -1,10 +1,24 @@ module Main (main) where  import Baikai+import Baikai.Cost qualified as Cost+import Baikai.Cost.Pricing (computeCost) import Baikai.Provider.OpenAI.Api+  ( RawChunk (..),+    closeOpenStream,+    emptyAssembler,+    openaiChatStream,+    parseUsage,+    rawUsageToUsage,+    translate,+  )+import Baikai.Provider.OpenAI.Cli qualified as CodexCli import Baikai.Provider.OpenAI.Interactive+import Baikai.Provider.OpenAI.Internal.Request (mapRequest)+import Control.Exception (bracket) import Control.Lens ((&), (.~), (^.)) import Data.Aeson qualified as Aeson+import Data.Aeson.Types qualified as AesonTypes import Data.ByteString.Char8 qualified as BS8 import Data.Generics.Labels () import Data.Text qualified as Text@@ -12,9 +26,17 @@ import ErrorClassSpec qualified import OpenAI.V1.Chat.Completions qualified as Chat import OpenAI.V1.ResponseFormat qualified as RF+import ReasoningSpec qualified+import ShapeSpec qualified+import SseSpec qualified import Streamly.Data.Stream qualified as Stream+import System.Directory (getPermissions, getTemporaryDirectory, setOwnerExecutable, setPermissions)+import System.Environment (lookupEnv, setEnv, unsetEnv)+import System.FilePath ((</>))+import System.Timeout (timeout) import Test.Tasty (TestTree, defaultMain, testGroup)-import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))+import Test.Tasty.HUnit (Assertion, assertBool, assertFailure, testCase, (@?=))+import TransportSpec qualified  main :: IO () main =@@ -22,11 +44,23 @@     testGroup       "Baikai.Provider.OpenAI"       [ commandRenderingTest,+        batchCommandRenderingTest,+        batchSystemPromptTest,+        stderrFloodTest,+        usageMappingTests,         promptRenderingTest,         compatDetectionTest,         rejectsImageToolResultsTest,+        noKeyStreamTest,+        codexMissingBinaryTest,+        finishReasonTests,         responseFormatMappingTest,-        ErrorClassSpec.tests+        optionsMappingTest,+        ErrorClassSpec.tests,+        ReasoningSpec.tests,+        ShapeSpec.tests,+        SseSpec.tests,+        TransportSpec.tests       ]  -- | A 'JsonSchema' on 'Options.responseFormat' maps onto the@@ -37,7 +71,7 @@ responseFormatMappingTest =   testCase "responseFormat JsonSchema maps onto OpenAI response_format" $ do     let model =-          _Model+          emptyModel             & #modelId .~ "gpt-4o-mini"             & #api .~ OpenAIChatCompletions             & #provider .~ "openai"@@ -52,9 +86,9 @@               "required" Aeson..= (["name", "age"] :: [Text.Text]),               "additionalProperties" Aeson..= False             ]-        ctx = _Context+        ctx = emptyContext         opts =-          _Options+          emptyOptions             & #responseFormat               .~ Just (JsonSchema {name = "person", schema = personSchema, strict = True})     case mapRequest model ctx opts of@@ -67,18 +101,130 @@           RF.description js @?= Nothing         other -> assertFailure ("expected JSON_Schema, got: " <> show other) +optionsMappingTest :: TestTree+optionsMappingTest =+  testCase "sampling Options map onto OpenAI request fields" $ do+    let model =+          emptyModel+            & #modelId .~ "gpt-4o-mini"+            & #api .~ OpenAIChatCompletions+            & #provider .~ "openai"+        opts =+          emptyOptions+            & #topP .~ Just 0.9+            & #stopSequences .~ Just (Vector.fromList ["END", "STOP"])+            & #seed .~ Just 7+            & #frequencyPenalty .~ Just 0.2+            & #presencePenalty .~ Just 0.3+    case mapRequest model emptyContext opts of+      Left e -> assertFailure ("mapRequest failed: " <> Text.unpack e)+      Right req -> do+        Chat.top_p req @?= Just 0.9+        Chat.stop req @?= Just (Vector.fromList ["END", "STOP"])+        Chat.seed req @?= Just 7+        Chat.frequency_penalty req @?= Just 0.2+        Chat.presence_penalty req @?= Just 0.3++usageMappingTests :: TestTree+usageMappingTests =+  testGroup+    "usage mapping"+    [ testCase "cached prompt tokens map to disjoint fields" $ do+        u <- normalizedUsage cachedUsagePayload+        inputTokens u @?= 20+        cacheReadTokens u @?= 80+        outputTokens u @?= 50+        reasoningTokens u @?= Just 20+        cacheWriteTokens u @?= 0+        totalTokens u @?= 150,+      testCase "computeCost bills each token class exactly once" $ do+        u <- normalizedUsage cachedUsagePayload+        let c = computeCost usageCostModel u+        -- The double-billing bug produced 358 / 1000000 by charging+        -- cached tokens at both the input and cache-read rates.+        Cost.usd c @?= (139 / 500000 :: Rational)+        Cost.inputUsd (Cost.breakdown c) @?= (20 / 1000000 :: Rational)+        Cost.cachedInputUsd (Cost.breakdown c) @?= (8 / 1000000 :: Rational),+      testCase "clamps when a compatible host over-reports cached tokens" $ do+        u <- normalizedUsage overCachedUsagePayload+        inputTokens u @?= 0+        cacheReadTokens u @?= 120+        outputTokens u @?= 50+        totalTokens u @?= 170,+      testCase "no cache details means no cache tokens" $ do+        u <- normalizedUsage uncachedUsagePayload+        inputTokens u @?= 100+        cacheReadTokens u @?= 0+        outputTokens u @?= 50+        reasoningTokens u @?= Nothing+        totalTokens u @?= 150+    ]++cachedUsagePayload :: Aeson.Object+cachedUsagePayload =+  usageObject+    [ "prompt_tokens" Aeson..= (100 :: Int),+      "completion_tokens" Aeson..= (50 :: Int),+      "total_tokens" Aeson..= (150 :: Int),+      "prompt_tokens_details" Aeson..= Aeson.object ["cached_tokens" Aeson..= (80 :: Int)],+      "completion_tokens_details" Aeson..= Aeson.object ["reasoning_tokens" Aeson..= (20 :: Int)]+    ]++overCachedUsagePayload :: Aeson.Object+overCachedUsagePayload =+  usageObject+    [ "prompt_tokens" Aeson..= (100 :: Int),+      "completion_tokens" Aeson..= (50 :: Int),+      "total_tokens" Aeson..= (150 :: Int),+      "prompt_tokens_details" Aeson..= Aeson.object ["cached_tokens" Aeson..= (120 :: Int)]+    ]++uncachedUsagePayload :: Aeson.Object+uncachedUsagePayload =+  usageObject+    [ "prompt_tokens" Aeson..= (100 :: Int),+      "completion_tokens" Aeson..= (50 :: Int),+      "total_tokens" Aeson..= (150 :: Int)+    ]++usageObject :: [AesonTypes.Pair] -> Aeson.Object+usageObject pairs =+  case Aeson.object pairs of+    Aeson.Object o -> o+    _ -> error "unreachable: Aeson.object builds an Object"++normalizedUsage :: Aeson.Object -> IO Usage+normalizedUsage payload =+  case parseUsage payload of+    Just raw -> pure (rawUsageToUsage raw)+    Nothing -> assertFailure "expected usage payload to parse"++usageCostModel :: Model+usageCostModel =+  emptyModel+    & #modelId .~ "gpt-test"+    & #api .~ OpenAIChatCompletions+    & #provider .~ "openai"+    & #cost+      .~ ModelCost+        { inputCost = 1,+          outputCost = 5,+          cacheReadCost = 1 / 10,+          cacheWriteCost = 5 / 4+        }+ commandRenderingTest :: TestTree commandRenderingTest =   testCase "renders model, working directory, extra dirs, sandbox, approval, and extra args" $ do     let cfg =           defaultCodexInteractiveConfig             { executable = "/bin/codex",-              extraArgs = Vector.fromList ["--no-alt-screen"]+              extraArgs = ["--no-alt-screen"]             }         req =-          (_InteractiveLaunchRequest "inspect the repo")+          (interactiveLaunchRequest "inspect the repo")             & #systemPrompt .~ Just "Be precise."-            & #model .~ Just "gpt-5-codex"+            & #modelId .~ Just "gpt-5-codex"             & #workingDir .~ Just "/work/project"             & #extraDirs .~ ["/work/shared", "/work/docs"]             & #safety .~ CodexSandbox CodexWorkspaceWrite CodexApprovalOnRequest@@ -99,39 +245,108 @@               "on-request",               "--no-alt-screen",               "--search",+              "--",               "System instructions:\nBe precise.\n\nUser request:\ninspect the repo"             ]           ) +batchCommandRenderingTest :: TestTree+batchCommandRenderingTest =+  testCase "codex exec argv terminates options before a dash-leading prompt" $ do+    let model =+          emptyModel+            & #modelId .~ ""+            & #api .~ OpenAICompletionsCli+            & #provider .~ "openai"+        ctx = emptyContext & #messages .~ Vector.singleton (user "-begin with a dash")+    CodexCli.codexCliCommand CodexCli.defaultCodexCliConfig model ctx+      @?= ( "codex",+            [ "exec",+              "--json",+              "--skip-git-repo-check",+              "--ephemeral",+              "--",+              "-begin with a dash"+            ]+          )++batchSystemPromptTest :: TestTree+batchSystemPromptTest =+  testCase "codex exec argv carries system prompt in the prompt text" $ do+    let model =+          emptyModel+            & #modelId .~ ""+            & #api .~ OpenAICompletionsCli+            & #provider .~ "openai"+        ctx =+          emptyContext+            & #systemPrompt .~ Just "Be terse."+            & #messages .~ Vector.singleton (user "ping")+    CodexCli.codexCliCommand CodexCli.defaultCodexCliConfig model ctx+      @?= ( "codex",+            [ "exec",+              "--json",+              "--skip-git-repo-check",+              "--ephemeral",+              "--",+              "System instructions:\nBe terse.\n\nUser request:\nping"+            ]+          )++stderrFloodTest :: TestTree+stderrFloodTest =+  testCase "codex batch provider survives a 1MiB stderr flood without deadlock" $ do+    dir <- getTemporaryDirectory+    let script = dir </> "baikai-codex-stderr-flood.sh"+    writeFile script $+      unlines+        [ "#!/bin/sh",+          "head -c 1048576 /dev/zero | tr '\\0' 'e' >&2",+          "printf '{\"type\":\"agent_message\",\"message\":\"pong\"}\\n'"+        ]+    perms <- getPermissions script+    setPermissions script (setOwnerExecutable True perms)+    reg <- newProviderRegistry+    registerApiProviderWith reg (CodexCli.codexCliProvider CodexCli.defaultCodexCliConfig {CodexCli.executable = script})+    let model =+          emptyModel+            & #modelId .~ ""+            & #api .~ OpenAICompletionsCli+            & #provider .~ "openai"+        ctx = emptyContext & #messages .~ Vector.singleton (user "ping")+    mResp <- timeout 30000000 (completeRequestWith reg model ctx emptyOptions)+    case mResp of+      Nothing -> assertFailure "deadlock: stderr was not drained concurrently"+      Just resp -> assistantText resp @?= "pong"+ promptRenderingTest :: TestTree promptRenderingTest =   testCase "omits the system-instruction wrapper when no system prompt is present" $ do-    codexInteractivePrompt (_InteractiveLaunchRequest "hello") @?= "hello"+    codexInteractivePrompt (interactiveLaunchRequest "hello") @?= "hello"  compatDetectionTest :: TestTree compatDetectionTest =   testCase "OpenAI-compatible hosts auto-detect request-shaping compat flags" $ do     let model =-          _Model+          emptyModel             & #api .~ OpenAIChatCompletions             & #baseUrl .~ "https://api.deepseek.com"         compat = openaiCompletionsCompatFor model     compat ^. #thinkingFormat @?= ThinkingFormatDeepseek     compat ^. #maxTokensField @?= MaxTokensField     compat ^. #supportsStrictMode @?= False-    compat ^. #supportsDeveloperRole @?= False  rejectsImageToolResultsTest :: TestTree rejectsImageToolResultsTest =   testCase "OpenAI API mapping rejects image tool-result blocks instead of dropping them" $ do     let model =-          _Model+          emptyModel             & #modelId .~ "gpt-test"             & #api .~ OpenAIChatCompletions             & #provider .~ "openai"         image = ImageContent {imageData = BS8.pack "png-bytes", mimeType = "image/png"}         ctx =-          _Context+          emptyContext             & #messages               .~ Vector.singleton                 ( ToolResultMessage@@ -140,13 +355,118 @@                         toolName = "render",                         content = Vector.singleton (ToolResultImage image),                         isError = False,-                        timestamp = read "2026-06-05 00:00:00 UTC"+                        timestamp = Just (read "2026-06-05 00:00:00 UTC")                       }                 )-    events <- Stream.toList (openaiChatStream model ctx _Options)+    events <- Stream.toList (openaiChatStream model ctx emptyOptions)+    assertErrorContract events     case events of-      [EventError TerminalPayload {message = AssistantMessage AssistantPayload {errorMessage = Just msg}}] ->-        assertBool-          ("expected ToolResultImage error, got: " <> Text.unpack msg)-          ("ToolResultImage" `Text.isInfixOf` msg)-      other -> error ("expected one EventError; got: " <> show other)+      [ EventStart StartPayload {},+        EventError TerminalPayload {message = AssistantMessage AssistantPayload {errorMessage = Just msg}}+        ] ->+          assertBool+            ("expected ToolResultImage error, got: " <> Text.unpack msg)+            ("ToolResultImage" `Text.isInfixOf` msg)+      other -> error ("expected EventStart then EventError; got: " <> show other)++noKeyStreamTest :: TestTree+noKeyStreamTest =+  testCase "missing OPENAI_API_KEY yields one terminal EventError" $+    withUnsetEnv "OPENAI_API_KEY" $ do+      let model =+            emptyModel+              & #modelId .~ "gpt-test"+              & #api .~ OpenAIChatCompletions+              & #provider .~ "openai"+      events <- Stream.toList (openaiChatStream model emptyContext emptyOptions)+      assertErrorContract events+      case last events of+        EventError TerminalPayload {errorInfo = Just be} ->+          be ^. #category @?= AuthError+        other -> assertFailure ("expected terminal EventError with AuthError, got: " <> show other)++codexMissingBinaryTest :: TestTree+codexMissingBinaryTest =+  testCase "codex CLI missing binary returns an error-shaped Response" $ do+    reg <- newProviderRegistry+    registerApiProviderWith+      reg+      (CodexCli.codexCliProvider CodexCli.defaultCodexCliConfig {CodexCli.executable = "/nonexistent/codex-binary"})+    let model =+          emptyModel+            & #modelId .~ ""+            & #api .~ OpenAICompletionsCli+            & #provider .~ "openai"+        ctx = emptyContext & #messages .~ Vector.singleton (user "ping")+    resp <- completeRequestWith reg model ctx emptyOptions+    case responseError resp of+      Just be -> be ^. #category @?= OtherError+      Nothing -> assertFailure "expected missing binary to be returned in-band"++finishReasonTests :: TestTree+finishReasonTests =+  testGroup+    "finish_reason handling"+    [ testCase "content_filter terminates as EventError" $ do+        let (_events1, ass1) =+              translate+                (Right RawChunk {contentDelta = Just "partial", reasoningDelta = Nothing, finishReason = Nothing, toolDeltas = [], usage = Nothing})+                (emptyAssembler openaiTestModel (read "2026-06-05 00:00:00 UTC"))+                (read "2026-06-05 00:00:01 UTC")+            (events2, ass2) =+              translate+                (Right RawChunk {contentDelta = Nothing, reasoningDelta = Nothing, finishReason = Just "content_filter", toolDeltas = [], usage = Nothing})+                ass1+                (read "2026-06-05 00:00:02 UTC")+            (events3, _) = closeOpenStream (read "2026-06-05 00:00:03 UTC") Nothing ass2+        let terminalEvents = events2 <> events3+        assertErrorContract terminalEvents+        case last terminalEvents of+          EventError TerminalPayload {errorInfo = Just be} -> do+            be ^. #category @?= OtherError+            assertBool "message mentions content_filter" ("content_filter" `Text.isInfixOf` (be ^. #message))+          other -> assertFailure ("expected EventError for content_filter, got: " <> show other),+      testCase "unknown finish_reason is a successful diagnostic" $ do+        let (_events, ass1) =+              translate+                (Right RawChunk {contentDelta = Nothing, reasoningDelta = Nothing, finishReason = Just "mystery", toolDeltas = [], usage = Nothing})+                (emptyAssembler openaiTestModel (read "2026-06-05 00:00:00 UTC"))+                (read "2026-06-05 00:00:01 UTC")+            (terminalEvents, _) = closeOpenStream (read "2026-06-05 00:00:02 UTC") Nothing ass1+        case terminalEvents of+          [EventDone TerminalPayload {message = AssistantMessage AssistantPayload {stopReason = Stop, errorMessage = Just msg}}] ->+            msg @?= "unrecognized finish_reason: mystery"+          other -> assertFailure ("expected successful diagnostic EventDone, got: " <> show other)+    ]++openaiTestModel :: Model+openaiTestModel =+  emptyModel+    & #modelId .~ "gpt-test"+    & #api .~ OpenAIChatCompletions+    & #provider .~ "openai"++withUnsetEnv :: String -> IO a -> IO a+withUnsetEnv name action =+  bracket+    (lookupEnv name)+    restore+    (const (unsetEnv name >> action))+  where+    restore = maybe (unsetEnv name) (setEnv name)++assertErrorContract :: [AssistantMessageEvent] -> Assertion+assertErrorContract events = do+  let terminals = filter isTerminal events+  length terminals @?= 1+  case terminals of+    [EventError TerminalPayload {errorInfo = Nothing}] ->+      assertFailure "terminal EventError omitted errorInfo"+    _ -> pure ()++assistantText :: Response -> Text.Text+assistantText resp =+  Text.concat+    [ t+    | AssistantText (TextContent t) <- Vector.toList (resp ^. #message ^. #content)+    ]
+ test/ReasoningSpec.hs view
@@ -0,0 +1,255 @@+{-# LANGUAGE LambdaCase #-}++module ReasoningSpec (tests) where++import Baikai+import Baikai.Models.Generated+import Baikai.Provider.OpenAI.Api+  ( RawChunk (..),+    closeOpenStream,+    emptyAssembler,+    parseChunk,+    scanThinkTags,+    translate,+    _TagScanState,+  )+import Baikai.Provider.OpenAI.Internal.Request (mapRequest)+import Control.Lens ((&), (.~))+import Data.Aeson qualified as Aeson+import Data.Generics.Labels ()+import Data.Text qualified as Text+import Data.Time.Clock (UTCTime)+import Data.Vector qualified as Vector+import OpenAI.V1.Chat.Completions qualified as Chat+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertFailure, testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "ReasoningSpec"+    [ parseReasoningTests,+      assemblyTests,+      tagScannerTests,+      taggedTextCompatTest,+      replayDropsThinkingTest+    ]++parseReasoningTests :: TestTree+parseReasoningTests =+  testGroup+    "parseChunk reasoning fields"+    [ testCase "DeepSeek reasoning_content delta" $+        case parseChunk (chunkValue "reasoning_content" "because") of+          Right RawChunk {reasoningDelta = Just r} -> r @?= "because"+          other -> assertFailure ("unexpected parse result: " <> show other),+      testCase "OpenRouter reasoning delta" $+        case parseChunk (chunkValue "reasoning" "because") of+          Right RawChunk {reasoningDelta = Just r} -> r @?= "because"+          other -> assertFailure ("unexpected parse result: " <> show other)+    ]++assemblyTests :: TestTree+assemblyTests =+  testGroup+    "reasoning assembly"+    [ testCase "reasoning deltas close before visible content" $ do+        let chunks =+              [ emptyChunk {reasoningDelta = Just "because "},+                emptyChunk {reasoningDelta = Just "therefore"},+                emptyChunk {contentDelta = Just "answer "},+                emptyChunk {contentDelta = Just "done"},+                emptyChunk {finishReason = Just "stop"}+              ]+            events = runChunks deepseek_deepseek_reasoner chunks+        eventShape events+          @?= [ "ThinkingStart:0",+                "ThinkingDelta:0:because ",+                "ThinkingDelta:0:therefore",+                "ThinkingEnd:0:because therefore",+                "TextStart:1",+                "TextDelta:1:answer ",+                "TextDelta:1:done",+                "TextEnd:1:answer done",+                "EventDone"+              ]+        terminalContent events+          @?= Vector.fromList+            [ AssistantThinking+                ThinkingContent+                  { thinking = "because therefore",+                    signature = Nothing,+                    redacted = False+                  },+              AssistantText (TextContent "answer done")+            ],+      testCase "whole message shape yields reasoning then text" $ do+        let raw =+              Aeson.object+                [ "choices"+                    Aeson..= [ Aeson.object+                                 [ "message"+                                     Aeson..= Aeson.object+                                       [ "reasoning_content" Aeson..= ("because" :: Text.Text),+                                         "content" Aeson..= ("answer" :: Text.Text)+                                       ],+                                   "finish_reason" Aeson..= ("stop" :: Text.Text)+                                 ]+                             ]+                ]+        chunk <- either (assertFailure . ("parse failed: " <>)) pure (parseChunk raw)+        terminalContent (runChunks deepseek_deepseek_reasoner [chunk])+          @?= Vector.fromList+            [ AssistantThinking+                ThinkingContent+                  { thinking = "because",+                    signature = Nothing,+                    redacted = False+                  },+              AssistantText (TextContent "answer")+            ]+    ]++tagScannerTests :: TestTree+tagScannerTests =+  testGroup+    "scanThinkTags"+    [ testCase "split tags across deltas" $ do+        let (st1, p1) = scanThinkTags _TagScanState "<th"+            (st2, p2) = scanThinkTags st1 "ink>reasoning</thi"+            (_st3, p3) = scanThinkTags st2 "nk>answer"+        p1 <> p2 <> p3 @?= [Left "reasoning", Right "answer"],+      testCase "literal less-than text passes through" $ do+        let (_st, parts) = scanThinkTags _TagScanState "2 < 3"+        parts @?= [Right "2 < 3"]+    ]++taggedTextCompatTest :: TestTree+taggedTextCompatTest =+  testCase "requiresThinkingAsText gates tag extraction" $ do+    let tagged = "<think>reasoning</think>answer"+        deepseekEvents =+          runChunks+            deepseek_deepseek_reasoner+            [ emptyChunk {contentDelta = Just tagged},+              emptyChunk {finishReason = Just "stop"}+            ]+        openaiEvents =+          runChunks+            openai_gpt_4o_mini+            [ emptyChunk {contentDelta = Just tagged},+              emptyChunk {finishReason = Just "stop"}+            ]+    terminalContent deepseekEvents+      @?= Vector.fromList+        [ AssistantThinking+            ThinkingContent+              { thinking = "reasoning",+                signature = Nothing,+                redacted = False+              },+          AssistantText (TextContent "answer")+        ]+    terminalContent openaiEvents+      @?= Vector.singleton (AssistantText (TextContent tagged))++replayDropsThinkingTest :: TestTree+replayDropsThinkingTest =+  testCase "OpenAI-compatible replay drops AssistantThinking blocks" $ do+    let msg =+          AssistantMessage+            AssistantPayload+              { content =+                  Vector.fromList+                    [ AssistantThinking+                        ThinkingContent+                          { thinking = "internal",+                            signature = Nothing,+                            redacted = False+                          },+                      AssistantText (TextContent "visible")+                    ],+                usage = zeroUsage,+                stopReason = Stop,+                errorMessage = Nothing,+                timestamp = Just testTime+              }+        ctx = emptyContext & #messages .~ Vector.singleton msg+    case mapRequest deepseek_deepseek_reasoner ctx emptyOptions of+      Left e -> assertFailure ("mapRequest failed: " <> Text.unpack e)+      Right req -> case Vector.toList (requestMessages req) of+        [Chat.Assistant {Chat.assistant_content = Just parts}] ->+          case Vector.toList parts of+            [Chat.Text {Chat.text = body}] -> body @?= "visible"+            other -> assertFailure ("unexpected assistant content parts: " <> show other)+        other -> assertFailure ("unexpected mapped messages: " <> show other)++chunkValue :: Text.Text -> Text.Text -> Aeson.Value+chunkValue key value =+  Aeson.object+    [ "choices"+        Aeson..= [ Aeson.object+                     [ "delta"+                         Aeson..= case key of+                           "reasoning_content" ->+                             Aeson.object ["reasoning_content" Aeson..= value]+                           "reasoning" ->+                             Aeson.object ["reasoning" Aeson..= value]+                           _ ->+                             Aeson.object []+                     ]+                 ]+    ]++emptyChunk :: RawChunk+emptyChunk =+  RawChunk+    { contentDelta = Nothing,+      reasoningDelta = Nothing,+      finishReason = Nothing,+      toolDeltas = [],+      usage = Nothing+    }++runChunks :: Model -> [RawChunk] -> [AssistantMessageEvent]+runChunks model chunks =+  let (events, ass) =+        foldl+          ( \(acc, st) chunk ->+              let (newEvents, st') = translate (Right chunk) st testTime+               in (acc <> newEvents, st')+          )+          ([], emptyAssembler model testTime)+          chunks+      (terminalEvents, _) = closeOpenStream testTime Nothing ass+   in events <> terminalEvents++eventShape :: [AssistantMessageEvent] -> [Text.Text]+eventShape =+  fmap $ \case+    ThinkingStart IndexPayload {contentIndex = i} -> "ThinkingStart:" <> tshow i+    ThinkingDelta DeltaPayload {contentIndex = i, delta = d} -> "ThinkingDelta:" <> tshow i <> ":" <> d+    ThinkingEnd ThinkingEndPayload {contentIndex = i, content = ThinkingContent {thinking = body}} ->+      "ThinkingEnd:" <> tshow i <> ":" <> body+    TextStart IndexPayload {contentIndex = i} -> "TextStart:" <> tshow i+    TextDelta DeltaPayload {contentIndex = i, delta = d} -> "TextDelta:" <> tshow i <> ":" <> d+    TextEnd BlockEndPayload {contentIndex = i, content = body} -> "TextEnd:" <> tshow i <> ":" <> body+    EventDone {} -> "EventDone"+    EventError {} -> "EventError"+    _ -> "other"++terminalContent :: [AssistantMessageEvent] -> Vector.Vector AssistantContent+terminalContent events =+  case last events of+    EventDone TerminalPayload {message = AssistantMessage AssistantPayload {content = blocks}} -> blocks+    EventError TerminalPayload {message = AssistantMessage AssistantPayload {content = blocks}} -> blocks+    _ -> Vector.empty++requestMessages :: Chat.CreateChatCompletion -> Vector.Vector (Chat.Message (Vector.Vector Chat.Content))+requestMessages Chat.CreateChatCompletion {Chat.messages = msgs} = msgs++tshow :: (Show a) => a -> Text.Text+tshow = Text.pack . show++testTime :: UTCTime+testTime = read "2026-07-03 12:00:00 UTC"
+ test/ShapeSpec.hs view
@@ -0,0 +1,216 @@+{-# LANGUAGE LambdaCase #-}++module ShapeSpec (tests) where++import Baikai+import Baikai.Content qualified as Content+import Baikai.Models.Generated qualified as Models+import Baikai.Provider.OpenAI.Api+  ( RawChunk (..),+    RawToolDelta (..),+    closeOpenStream,+    emptyAssembler,+    translate,+  )+import Baikai.Provider.OpenAI.Internal.Request (mapRequest)+import Baikai.Provider.OpenAI.Shape (streamRequestBody)+import Control.Lens ((&), (.~))+import Data.Aeson (Value (..), (.=))+import Data.Aeson qualified as Aeson+import Data.Aeson.Key qualified as AesonKey+import Data.Aeson.KeyMap qualified as KeyMap+import Data.Generics.Labels ()+import Data.Text qualified as Text+import Data.Time.Clock (UTCTime)+import Data.Vector qualified as Vector+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertFailure, testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "ShapeSpec"+    [ deepseekShapeTest,+      openRouterCacheControlTest,+      strictModeGateTest,+      usageStreamingGateTest,+      zeroCapOmissionTest,+      indexlessToolDeltaTest+    ]++deepseekShapeTest :: TestTree+deepseekShapeTest =+  testCase "DeepSeek request body uses max_tokens and reasoning shape" $ do+    value <-+      shapedBody+        Models.deepseek_deepseek_chat+        (emptyOptions & #thinking .~ Just ThinkingHigh)+        emptyContext+    lookupTop "max_completion_tokens" value @?= Nothing+    lookupTop "max_tokens" value @?= Just (Number 8192)+    lookupTop "thinking" value+      @?= Just (Aeson.object ["type" .= ("enabled" :: Text.Text)])+    lookupTop "reasoning_effort" value @?= Just (String "high")++openRouterCacheControlTest :: TestTree+openRouterCacheControlTest =+  testCase "OpenRouter cache marker lands on the system content part with ttl" $ do+    let ctx =+          emptyContext+            & #systemPrompt .~ Just "cache this prefix"+            & #messages .~ Vector.singleton (user "answer")+        opts = emptyOptions & #cacheRetention .~ Just CacheRetentionLong+    value <- shapedBody Models.openrouter_openai_gpt_4o_mini opts ctx+    systemCacheControl value+      @?= Just+        ( Aeson.object+            [ "type" .= ("ephemeral" :: Text.Text),+              "ttl" .= ("1h" :: Text.Text)+            ]+        )++strictModeGateTest :: TestTree+strictModeGateTest =+  testCase "supportsStrictMode gates response_format json_schema strict" $ do+    let schema = Aeson.object ["type" .= ("object" :: Text.Text)]+        opts =+          emptyOptions+            & #responseFormat+              .~ Just (JsonSchema {name = "shape", schema = schema, strict = True})+    value <- shapedBody Models.deepseek_deepseek_chat opts emptyContext+    lookupPath ["response_format", "json_schema", "strict"] value+      @?= Nothing++usageStreamingGateTest :: TestTree+usageStreamingGateTest =+  testCase "supportsUsageInStreaming gates stream_options" $ do+    let compat = defaultOpenAICompletionsCompat {supportsUsageInStreaming = False}+        model =+          Models.openai_gpt_4o_mini+            & #compat .~ CompatOpenAICompletions compat+    value <- shapedBody model emptyOptions emptyContext+    lookupTop "stream" value @?= Just (Bool True)+    lookupTop "stream_options" value @?= Nothing++zeroCapOmissionTest :: TestTree+zeroCapOmissionTest =+  testCase "unknown zero maxOutputTokens omits max_completion_tokens" $ do+    let model =+          emptyModel+            & #modelId .~ "custom"+            & #api .~ OpenAIChatCompletions+            & #provider .~ "custom"+            & #maxOutputTokens .~ 0+    req <- either (assertFailure . Text.unpack) pure (mapRequest model emptyContext emptyOptions)+    lookupTop "max_completion_tokens" (Aeson.toJSON req) @?= Nothing++indexlessToolDeltaTest :: TestTree+indexlessToolDeltaTest =+  testCase "id-bearing index-less tool deltas remain separate" $ do+    let chunks =+          [ emptyChunk+              { toolDeltas =+                  [ RawToolDelta+                      { index = Nothing,+                        id_ = Just "call_a",+                        name = Just "first",+                        args = Just "{\"a\":"+                      },+                    RawToolDelta+                      { index = Nothing,+                        id_ = Just "call_b",+                        name = Just "second",+                        args = Just "{\"b\":"+                      }+                  ]+              },+            emptyChunk+              { toolDeltas =+                  [ RawToolDelta+                      { index = Nothing,+                        id_ = Just "call_a",+                        name = Nothing,+                        args = Just "1}"+                      },+                    RawToolDelta+                      { index = Nothing,+                        id_ = Just "call_b",+                        name = Nothing,+                        args = Just "2}"+                      }+                  ]+              },+            emptyChunk {finishReason = Just "tool_calls"}+          ]+        events = runChunks chunks+        toolCalls =+          [ toolCall+          | ToolCallEnd ToolCallEndPayload {toolCall = toolCall} <- events+          ]+    fmap Content.id_ toolCalls @?= ["call_a", "call_b"]+    fmap Content.name toolCalls @?= ["first", "second"]+    fmap Content.arguments toolCalls+      @?= [ Aeson.object ["a" .= (1 :: Int)],+            Aeson.object ["b" .= (2 :: Int)]+          ]++shapedBody :: Model -> Options -> Context -> IO Value+shapedBody model opts ctx = do+  req <- either (assertFailure . Text.unpack) pure (mapRequest model ctx opts)+  pure (streamRequestBody (openaiCompletionsCompatFor model) opts req)++lookupTop :: Text.Text -> Value -> Maybe Value+lookupTop field = lookupPath [field]++lookupPath :: [Text.Text] -> Value -> Maybe Value+lookupPath [] value = Just value+lookupPath (field : rest) (Object obj) =+  KeyMap.lookup (AesonKey.fromText field) obj >>= lookupPath rest+lookupPath _ _ = Nothing++systemCacheControl :: Value -> Maybe Value+systemCacheControl value = do+  Array messages <- lookupTop "messages" value+  systemMessage <-+    firstMay+      [ msg+      | msg@(Object _) <- Vector.toList messages,+        lookupPath ["role"] msg == Just (String "system")+      ]+  Array content <- lookupPath ["content"] systemMessage+  contentPart <- lastMay (Vector.toList content)+  lookupPath ["cache_control"] contentPart++firstMay :: [a] -> Maybe a+firstMay [] = Nothing+firstMay (x : _) = Just x++lastMay :: [a] -> Maybe a+lastMay [] = Nothing+lastMay xs = Just (last xs)++emptyChunk :: RawChunk+emptyChunk =+  RawChunk+    { contentDelta = Nothing,+      reasoningDelta = Nothing,+      finishReason = Nothing,+      toolDeltas = [],+      usage = Nothing+    }++runChunks :: [RawChunk] -> [AssistantMessageEvent]+runChunks chunks =+  let (events, ass) =+        foldl+          ( \(acc, st) chunk ->+              let (newEvents, st') = translate (Right chunk) st testTime+               in (acc <> newEvents, st')+          )+          ([], emptyAssembler Models.openai_gpt_4o_mini testTime)+          chunks+      (terminalEvents, _) = closeOpenStream testTime Nothing ass+   in events <> terminalEvents++testTime :: UTCTime+testTime = read "2026-07-03 12:00:00 UTC"
+ test/SseSpec.hs view
@@ -0,0 +1,58 @@+module SseSpec (tests) where++import Baikai.Error (ErrorCategory (..), category, httpStatus, retryAfterSeconds)+import Baikai.Provider.OpenAI.Sse (sseFromResponse)+import Data.Aeson qualified as Aeson+import Data.ByteString (ByteString)+import Data.CaseInsensitive qualified as CI+import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)+import Network.HTTP.Client.Internal qualified as HTTP+import Network.HTTP.Types.Status (mkStatus)+import Network.HTTP.Types.Version (http11)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertFailure, testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "Baikai.Provider.OpenAI.Sse"+    [ testCase "non-2xx response preserves Retry-After and status" $ do+        eventsRef <- newIORef []+        resp <- mkResponse 429 [("Retry-After", "9")] ["{\"error\":{\"message\":\"rate limited\",\"type\":\"tokens\"}}"]+        sseFromResponse resp (\ev -> modifyIORef' eventsRef (<> [ev]))+        events <- readIORef eventsRef+        case events of+          [Left e] -> do+            category e @?= RateLimited+            retryAfterSeconds e @?= Just 9+            httpStatus e @?= Just 429+          other -> assertFailure ("expected one classified error, got: " <> show other),+      testCase "[DONE] terminates without emitting a JSON event" $ do+        eventsRef <- newIORef []+        resp <- mkResponse 200 [] ["data: {\"choices\":[]}\n\n", "data: [DONE]\n\n", "data: {\"ignored\":true}\n\n"]+        sseFromResponse resp (\ev -> modifyIORef' eventsRef (<> [ev]))+        events <- readIORef eventsRef+        case events of+          [Right (Aeson.Object _)] -> pure ()+          other -> assertFailure ("expected one JSON event before [DONE], got: " <> show other)+    ]++mkResponse :: Int -> [(ByteString, ByteString)] -> [ByteString] -> IO (HTTP.Response HTTP.BodyReader)+mkResponse status headers chunks = do+  ref <- newIORef chunks+  let bodyReader = do+        remaining <- readIORef ref+        case remaining of+          [] -> pure ""+          (x : xs) -> writeIORef ref xs >> pure x+  pure+    HTTP.Response+      { HTTP.responseStatus = mkStatus status "",+        HTTP.responseVersion = http11,+        HTTP.responseHeaders = [(CI.mk k, v) | (k, v) <- headers],+        HTTP.responseBody = bodyReader,+        HTTP.responseCookieJar = HTTP.createCookieJar [],+        HTTP.responseClose' = HTTP.ResponseClose (pure ()),+        HTTP.responseOriginalRequest = HTTP.defaultRequest,+        HTTP.responseEarlyHints = []+      }
+ test/TransportSpec.hs view
@@ -0,0 +1,81 @@+module TransportSpec (tests) where++import Baikai+import Baikai.Provider.OpenAI.Transport qualified as Transport+import Control.Concurrent (threadDelay)+import Control.Exception (bracket, try)+import Control.Lens ((&), (.~), (^.))+import Data.CaseInsensitive qualified as CI+import Data.Map.Strict qualified as Map+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import Network.HTTP.Types.Header (RequestHeaders)+import System.Environment (lookupEnv, setEnv, unsetEnv)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertFailure, testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "Baikai.Provider.OpenAI.Transport"+    [ clientEnvCacheTest,+      requestHeadersTest,+      timeoutTest,+      unknownHostKeyTest+    ]++clientEnvCacheTest :: TestTree+clientEnvCacheTest =+  testCase "cached ClientEnv is allocated once for a base URL" $ do+    let url = "https://cache-openai.test"+    before <- Transport.cachedClientEnvCount+    _ <- Transport.getClientEnvCached url+    afterFirst <- Transport.cachedClientEnvCount+    _ <- Transport.getClientEnvCached url+    afterSecond <- Transport.cachedClientEnvCount+    afterFirst @?= before + 1+    afterSecond @?= afterFirst++requestHeadersTest :: TestTree+requestHeadersTest =+  testCase "model and option headers reach the wire, options winning case-insensitively" $ do+    let model =+          emptyModel+            & #headers .~ Map.fromList [("X-Trace", "model"), ("authorization", "model-auth")]+        opts =+          emptyOptions+            & #headers .~ Map.fromList [("x-trace", "option"), ("Authorization", "option-auth")]+        headers = Transport.requestHeaders "secret" model opts+    header "X-Trace" headers @?= Just "option"+    header "authorization" headers @?= Just "option-auth"+    header "Accept" headers @?= Just "text/event-stream"++timeoutTest :: TestTree+timeoutTest =+  testCase "runWithTimeout classifies an elapsed whole-call timeout as transient" $ do+    result <- Transport.runWithTimeout (Just 1) (threadDelay 100000)+    case result of+      Just be -> do+        be ^. #category @?= TransientError+        "timeoutMs=1" `Text.isInfixOf` (be ^. #message) @?= True+      Nothing -> assertFailure "expected timeout error"++unknownHostKeyTest :: TestTree+unknownHostKeyTest =+  testCase "unknown hosts do not fall back to OPENAI_API_KEY" $+    withEnv "OPENAI_API_KEY" "openai-secret" $ do+      result <- try (Transport.resolveKey "https://unknown.example" emptyOptions) :: IO (Either BaikaiError Text.Text)+      case result of+        Left be -> be ^. #category @?= AuthError+        Right _ -> assertFailure "expected AuthError for unknown host"++header :: Text.Text -> RequestHeaders -> Maybe Text.Text+header name headers =+  Text.decodeUtf8 <$> lookup (CI.mk (Text.encodeUtf8 name)) headers++withEnv :: String -> String -> IO a -> IO a+withEnv name value =+  bracket+    (lookupEnv name <* setEnv name value)+    (maybe (unsetEnv name) (setEnv name))+    . const