packages feed

baikai 0.2.0.0 → 0.3.0.0

raw patch · 47 files changed

+4155/−1711 lines, 47 filesdep +tasty-quickcheckdep ~lensPVP ok

version bump matches the API change (PVP)

Dependencies added: tasty-quickcheck

Dependency ranges changed: lens

API changes (from Hackage documentation)

- Baikai.Compat: AnthropicMessagesCompat :: !Bool -> !Bool -> !Bool -> !Bool -> AnthropicMessagesCompat
- Baikai.Compat: OpenAICompletionsCompat :: !MaxTokensField -> !Bool -> !Bool -> !Bool -> !ThinkingFormat -> !Maybe CacheControlFormat -> !Bool -> !Bool -> OpenAICompletionsCompat
- Baikai.Compat: [cacheControlFormat] :: OpenAICompletionsCompat -> !Maybe CacheControlFormat
- Baikai.Compat: [maxTokensField] :: OpenAICompletionsCompat -> !MaxTokensField
- Baikai.Compat: [requiresThinkingAsText] :: OpenAICompletionsCompat -> !Bool
- Baikai.Compat: [sendSessionAffinityHeaders] :: AnthropicMessagesCompat -> !Bool
- Baikai.Compat: [supportsCacheControlOnTools] :: AnthropicMessagesCompat -> !Bool
- Baikai.Compat: [supportsDeveloperRole] :: OpenAICompletionsCompat -> !Bool
- Baikai.Compat: [supportsEagerToolInputStreaming] :: AnthropicMessagesCompat -> !Bool
- Baikai.Compat: [supportsLongCacheRetention] :: AnthropicMessagesCompat -> !Bool
- Baikai.Compat: [supportsStrictMode] :: OpenAICompletionsCompat -> !Bool
- Baikai.Compat: [supportsUsageInStreaming] :: OpenAICompletionsCompat -> !Bool
- Baikai.Compat: [thinkingFormat] :: OpenAICompletionsCompat -> !ThinkingFormat
- Baikai.Context: Context :: !Maybe Text -> !Vector Message -> !Vector Tool -> Context
- Baikai.Context: [messages] :: Context -> !Vector Message
- Baikai.Context: [systemPrompt] :: Context -> !Maybe Text
- Baikai.Context: [tools] :: Context -> !Vector Tool
- Baikai.Interactive: InteractiveLaunchRequest :: !Maybe Text -> !Text -> !Maybe Text -> !Maybe FilePath -> ![FilePath] -> !InteractiveSafety -> ![Text] -> InteractiveLaunchRequest
- Baikai.Interactive: [extraArgs] :: InteractiveLaunchRequest -> ![Text]
- Baikai.Interactive: [extraDirs] :: InteractiveLaunchRequest -> ![FilePath]
- Baikai.Interactive: [model] :: InteractiveLaunchRequest -> !Maybe Text
- Baikai.Interactive: [safety] :: InteractiveLaunchRequest -> !InteractiveSafety
- Baikai.Interactive: [systemPrompt] :: InteractiveLaunchRequest -> !Maybe Text
- Baikai.Interactive: [userPrompt] :: InteractiveLaunchRequest -> !Text
- Baikai.Interactive: [workingDir] :: InteractiveLaunchRequest -> !Maybe FilePath
- Baikai.Model: Model :: !Text -> !Text -> !Api -> !Text -> !Text -> !Bool -> ![InputModality] -> !ModelCost -> !Natural -> !Natural -> !Map Text Text -> !Compat -> Model
- Baikai.Model: [api] :: Model -> !Api
- Baikai.Model: [baseUrl] :: Model -> !Text
- Baikai.Model: [compat] :: Model -> !Compat
- Baikai.Model: [contextWindow] :: Model -> !Natural
- Baikai.Model: [cost] :: Model -> !ModelCost
- Baikai.Model: [headers] :: Model -> !Map Text Text
- Baikai.Model: [input] :: Model -> ![InputModality]
- Baikai.Model: [maxOutputTokens] :: Model -> !Natural
- Baikai.Model: [modelId] :: Model -> !Text
- Baikai.Model: [name] :: Model -> !Text
- Baikai.Model: [provider] :: Model -> !Text
- Baikai.Model: [reasoning] :: Model -> !Bool
- Baikai.Model: unModel :: Model -> Text
- Baikai.Options: Options :: !Maybe Natural -> !Maybe Double -> !Maybe ApiKeySource -> !Maybe Int -> !Map Text Text -> !Map Text Value -> !Maybe ToolChoice -> !Maybe CacheRetention -> !Maybe ThinkingLevel -> !Maybe ResponseFormat -> Options
- Baikai.Options: [apiKey] :: Options -> !Maybe ApiKeySource
- Baikai.Options: [cacheRetention] :: Options -> !Maybe CacheRetention
- Baikai.Options: [headers] :: Options -> !Map Text Text
- Baikai.Options: [maxTokens] :: Options -> !Maybe Natural
- Baikai.Options: [metadata] :: Options -> !Map Text Value
- Baikai.Options: [responseFormat] :: Options -> !Maybe ResponseFormat
- Baikai.Options: [temperature] :: Options -> !Maybe Double
- Baikai.Options: [thinking] :: Options -> !Maybe ThinkingLevel
- Baikai.Options: [timeoutMs] :: Options -> !Maybe Int
- Baikai.Options: [toolChoice] :: Options -> !Maybe ToolChoice
- Baikai.Stream.Event: newtype StartPayload
+ Baikai.Auth: ApiKeyEnvChain :: ![String] -> ApiKeySource
+ Baikai.Auth: defaultApiKeyEnvForBaseUrl :: Text -> Maybe String
+ Baikai.Compat: AnthropicThinkingAdaptive :: AnthropicThinkingStyle
+ Baikai.Compat: AnthropicThinkingBudget :: AnthropicThinkingStyle
+ Baikai.Compat: data AnthropicThinkingStyle
+ Baikai.Compat: defaultAnthropicThinkingStyle :: Text -> AnthropicThinkingStyle
+ Baikai.Compat: hostMatchesSuffix :: Text -> Text -> Bool
+ Baikai.Compat: instance Data.Aeson.Types.FromJSON.FromJSON Baikai.Compat.AnthropicThinkingStyle
+ Baikai.Compat: instance Data.Aeson.Types.ToJSON.ToJSON Baikai.Compat.AnthropicThinkingStyle
+ Baikai.Compat: instance GHC.Classes.Eq Baikai.Compat.AnthropicThinkingStyle
+ Baikai.Compat: instance GHC.Internal.Generics.Generic Baikai.Compat.AnthropicThinkingStyle
+ Baikai.Compat: instance GHC.Internal.Show.Show Baikai.Compat.AnthropicThinkingStyle
+ Baikai.Compat: urlHost :: Text -> Maybe Text
+ Baikai.Content: emptyImageContent :: ImageContent
+ Baikai.Content: emptyTextContent :: TextContent
+ Baikai.Content: emptyThinkingContent :: ThinkingContent
+ Baikai.Content: emptyToolCall :: ToolCall
+ Baikai.Context: addMessage :: Message -> Context -> Context
+ Baikai.Context: addResponse :: Response -> Context -> Context
+ Baikai.Context: addUser :: Text -> Context -> Context
+ Baikai.Context: contextOf :: [Message] -> Context
+ Baikai.Context: emptyContext :: Context
+ Baikai.Context: instance GHC.Internal.Base.Monoid Baikai.Context.Context
+ Baikai.Context: instance GHC.Internal.Base.Semigroup Baikai.Context.Context
+ Baikai.Context: messages :: Context -> Vector Message
+ Baikai.Context: systemPrompt :: Context -> Maybe Text
+ Baikai.Context: systemUser :: Text -> Text -> Context
+ Baikai.Context: tools :: Context -> Vector Tool
+ Baikai.Cost: zeroCost :: Cost
+ Baikai.Cost: zeroCostBreakdown :: CostBreakdown
+ Baikai.Embedding: emptyEmbeddingModel :: EmbeddingModel
+ Baikai.Embedding: firstEmbedding :: Vector EmbeddingObject -> Either BaikaiError (Vector Double)
+ Baikai.Error: httpError :: Int -> Maybe Int -> Text -> BaikaiError
+ Baikai.Error: parseRetryAfterSeconds :: Text -> Maybe Int
+ Baikai.Interactive: extraArgs :: InteractiveLaunchRequest -> [Text]
+ Baikai.Interactive: extraDirs :: InteractiveLaunchRequest -> [FilePath]
+ Baikai.Interactive: interactiveLaunchRequest :: Text -> InteractiveLaunchRequest
+ Baikai.Interactive: interactiveLaunchResult :: InteractiveProvider -> ExitCode -> InteractiveLaunchResult
+ Baikai.Interactive: modelId :: InteractiveLaunchRequest -> Maybe Text
+ Baikai.Interactive: safety :: InteractiveLaunchRequest -> InteractiveSafety
+ Baikai.Interactive: systemPrompt :: InteractiveLaunchRequest -> Maybe Text
+ Baikai.Interactive: userPrompt :: InteractiveLaunchRequest -> Text
+ Baikai.Interactive: workingDir :: InteractiveLaunchRequest -> Maybe FilePath
+ Baikai.Model: api :: Model -> Api
+ Baikai.Model: baseUrl :: Model -> Text
+ Baikai.Model: compat :: Model -> Compat
+ Baikai.Model: contextWindow :: Model -> Natural
+ Baikai.Model: cost :: Model -> ModelCost
+ Baikai.Model: emptyModel :: Model
+ Baikai.Model: headers :: Model -> Map Text Text
+ Baikai.Model: input :: Model -> [InputModality]
+ Baikai.Model: maxOutputTokens :: Model -> Natural
+ Baikai.Model: mkModel :: Api -> Text -> Text -> Model
+ Baikai.Model: modelId :: Model -> Text
+ Baikai.Model: name :: Model -> Text
+ Baikai.Model: provider :: Model -> Text
+ Baikai.Model: reasoning :: Model -> Bool
+ Baikai.Model: zeroModelCost :: ModelCost
+ Baikai.Options: apiKey :: Options -> Maybe ApiKeySource
+ Baikai.Options: cacheRetention :: Options -> Maybe CacheRetention
+ Baikai.Options: emptyOptions :: Options
+ Baikai.Options: frequencyPenalty :: Options -> Maybe Double
+ Baikai.Options: headers :: Options -> Map Text Text
+ Baikai.Options: maxTokens :: Options -> Maybe Natural
+ Baikai.Options: metadata :: Options -> Map Text Value
+ Baikai.Options: presencePenalty :: Options -> Maybe Double
+ Baikai.Options: responseFormat :: Options -> Maybe ResponseFormat
+ Baikai.Options: seed :: Options -> Maybe Integer
+ Baikai.Options: stopSequences :: Options -> Maybe (Vector Text)
+ Baikai.Options: temperature :: Options -> Maybe Double
+ Baikai.Options: thinking :: Options -> Maybe ThinkingLevel
+ Baikai.Options: timeoutMs :: Options -> Maybe Int
+ Baikai.Options: toolChoice :: Options -> Maybe ToolChoice
+ Baikai.Options: topP :: Options -> Maybe Double
+ Baikai.Provider: assertRegistered :: ProviderRegistry -> [Api] -> IO ()
+ Baikai.Provider: completeText :: Model -> Text -> IO Text
+ Baikai.Provider: newProviderRegistryFrom :: [ApiProvider] -> IO ProviderRegistry
+ Baikai.Provider: runToolLoop :: Int -> (ToolCall -> IO ToolResult) -> Model -> Context -> Options -> IO (Context, Response)
+ Baikai.Provider: runToolLoopWith :: ProviderRegistry -> Int -> (ToolCall -> IO ToolResult) -> Model -> Context -> Options -> IO (Context, Response)
+ Baikai.Provider.Cli.Internal: wrapSystemPrompt :: Maybe Text -> Text -> Text
+ Baikai.Provider.Registry: assertRegistered :: ProviderRegistry -> [Api] -> IO ()
+ Baikai.Provider.Registry: completeText :: Model -> Text -> IO Text
+ Baikai.Provider.Registry: newProviderRegistryFrom :: [ApiProvider] -> IO ProviderRegistry
+ Baikai.Provider.Registry: runToolLoop :: Int -> (ToolCall -> IO ToolResult) -> Model -> Context -> Options -> IO (Context, Response)
+ Baikai.Provider.Registry: runToolLoopWith :: ProviderRegistry -> Int -> (ToolCall -> IO ToolResult) -> Model -> Context -> Options -> IO (Context, Response)
+ Baikai.Response: emptyResponse :: Response
+ Baikai.Response: errorResponse :: Model -> UTCTime -> Int -> BaikaiError -> Response
+ Baikai.Response: flattenAssistantText :: Vector AssistantContent -> Text
+ Baikai.Response: responseError :: Response -> Maybe BaikaiError
+ Baikai.Stream: instance GHC.Internal.Generics.Generic Baikai.Stream.TerminalSeen
+ Baikai.Stream: instance GHC.Internal.Show.Show Baikai.Stream.TerminalSeen
+ Baikai.Stream: streamRequestEach :: (AssistantMessageEvent -> IO ()) -> Model -> Context -> Options -> IO Response
+ Baikai.Stream: streamRequestEachWith :: ProviderRegistry -> (AssistantMessageEvent -> IO ()) -> Model -> Context -> Options -> IO Response
+ Baikai.Stream: streamRequestList :: Model -> Context -> Options -> IO [AssistantMessageEvent]
+ Baikai.Stream: streamRequestListWith :: ProviderRegistry -> Model -> Context -> Options -> IO [AssistantMessageEvent]
+ Baikai.Stream.Event: ThinkingEndPayload :: !Int -> !ThinkingContent -> ThinkingEndPayload
+ Baikai.Stream.Event: [responseId] :: TerminalPayload -> !Maybe Text
+ Baikai.Stream.Event: data StartPayload
+ Baikai.Stream.Event: data ThinkingEndPayload
+ Baikai.Stream.Event: instance Data.Aeson.Types.ToJSON.ToJSON Baikai.Stream.Event.ThinkingEndPayload
+ Baikai.Stream.Event: instance GHC.Classes.Eq Baikai.Stream.Event.ThinkingEndPayload
+ Baikai.Stream.Event: instance GHC.Internal.Generics.Generic Baikai.Stream.Event.ThinkingEndPayload
+ Baikai.Stream.Event: instance GHC.Internal.Show.Show Baikai.Stream.Event.ThinkingEndPayload
+ Baikai.Tool: emptyTool :: Tool
+ Baikai.Usage: zeroUsage :: Usage
- Baikai.Cost.Log: CallLogEntry :: !UTCTime -> !Text -> !Text -> !Maybe Natural -> !Maybe Natural -> !Maybe Natural -> !Maybe Natural -> !Maybe Scientific -> !Integer -> !Text -> CallLogEntry
+ Baikai.Cost.Log: CallLogEntry :: !UTCTime -> !Text -> !Text -> !Maybe Natural -> !Maybe Natural -> !Maybe Natural -> !Maybe Natural -> !Maybe Scientific -> !Int -> !Text -> CallLogEntry
- Baikai.Cost.Log: [latencyMs] :: CallLogEntry -> !Integer
+ Baikai.Cost.Log: [latencyMs] :: CallLogEntry -> !Int
- Baikai.Message: AssistantPayload :: !Vector AssistantContent -> !Usage -> !StopReason -> !Maybe Text -> !UTCTime -> AssistantPayload
+ Baikai.Message: AssistantPayload :: !Vector AssistantContent -> !Usage -> !StopReason -> !Maybe Text -> !Maybe UTCTime -> AssistantPayload
- Baikai.Message: ToolResultPayload :: !Text -> !Text -> !Vector ToolResultContent -> !Bool -> !UTCTime -> ToolResultPayload
+ Baikai.Message: ToolResultPayload :: !Text -> !Text -> !Vector ToolResultContent -> !Bool -> !Maybe UTCTime -> ToolResultPayload
- Baikai.Message: UserPayload :: !Vector UserContent -> !UTCTime -> UserPayload
+ Baikai.Message: UserPayload :: !Vector UserContent -> !Maybe UTCTime -> UserPayload
- Baikai.Message: [timestamp] :: ToolResultPayload -> !UTCTime
+ Baikai.Message: [timestamp] :: ToolResultPayload -> !Maybe UTCTime
- Baikai.Response: Response :: !AssistantPayload -> !Model -> !Api -> !Text -> !Maybe Text -> !Integer -> !Maybe BaikaiError -> Response
+ Baikai.Response: Response :: !AssistantPayload -> !Model -> !Api -> !Text -> !Maybe Text -> !Int -> !Maybe BaikaiError -> Response
- Baikai.Response: [latencyMs] :: Response -> !Integer
+ Baikai.Response: [latencyMs] :: Response -> !Int
- Baikai.Stream.Event: StartPayload :: Message -> StartPayload
+ Baikai.Stream.Event: StartPayload :: !Message -> !Maybe Text -> StartPayload
- Baikai.Stream.Event: TerminalPayload :: !StopReason -> !Message -> !Maybe BaikaiError -> TerminalPayload
+ Baikai.Stream.Event: TerminalPayload :: !StopReason -> !Message -> !Maybe Text -> !Maybe BaikaiError -> TerminalPayload
- Baikai.Stream.Event: ThinkingEnd :: BlockEndPayload -> AssistantMessageEvent
+ Baikai.Stream.Event: ThinkingEnd :: ThinkingEndPayload -> AssistantMessageEvent
- Baikai.Stream.Event: [content] :: BlockEndPayload -> !Text
+ Baikai.Stream.Event: [content] :: ThinkingEndPayload -> !ThinkingContent
- Baikai.Stream.Event: [partial] :: StartPayload -> Message
+ Baikai.Stream.Event: [partial] :: StartPayload -> !Message
- Baikai.Stream.Event: doneTerminal :: StopReason -> Message -> TerminalPayload
+ Baikai.Stream.Event: doneTerminal :: Maybe Text -> StopReason -> Message -> TerminalPayload
- Baikai.Stream.Event: errorTerminal :: StopReason -> Message -> Maybe BaikaiError -> TerminalPayload
+ Baikai.Stream.Event: errorTerminal :: Maybe Text -> StopReason -> Message -> BaikaiError -> TerminalPayload
- Baikai.Trace: CallFailed :: !Text -> !UTCTime -> !Text -> !Text -> !Integer -> !Text -> TraceEvent
+ Baikai.Trace: CallFailed :: !Text -> !UTCTime -> !Text -> !Text -> !Int -> !Text -> TraceEvent
- Baikai.Trace: CallFinished :: !Text -> !UTCTime -> !Text -> !Text -> !Integer -> !Maybe Natural -> !Maybe Natural -> !Maybe Scientific -> TraceEvent
+ Baikai.Trace: CallFinished :: !Text -> !UTCTime -> !Text -> !Text -> !Int -> !Maybe Natural -> !Maybe Natural -> !Maybe Scientific -> TraceEvent
- Baikai.Trace: [latencyMs] :: TraceEvent -> !Integer
+ Baikai.Trace: [latencyMs] :: TraceEvent -> !Int
- Baikai.Trace.Event: CallFailed :: !Text -> !UTCTime -> !Text -> !Text -> !Integer -> !Text -> TraceEvent
+ Baikai.Trace.Event: CallFailed :: !Text -> !UTCTime -> !Text -> !Text -> !Int -> !Text -> TraceEvent
- Baikai.Trace.Event: CallFinished :: !Text -> !UTCTime -> !Text -> !Text -> !Integer -> !Maybe Natural -> !Maybe Natural -> !Maybe Scientific -> TraceEvent
+ Baikai.Trace.Event: CallFinished :: !Text -> !UTCTime -> !Text -> !Text -> !Int -> !Maybe Natural -> !Maybe Natural -> !Maybe Scientific -> TraceEvent
- Baikai.Trace.Event: [latencyMs] :: TraceEvent -> !Integer
+ Baikai.Trace.Event: [latencyMs] :: TraceEvent -> !Int

Files

CHANGELOG.md view
@@ -7,6 +7,102 @@  ## [Unreleased] +## [baikai 0.3.0.0] - 2026-07-03++### Added++- Added the documented record-update bases `emptyOptions`, `emptyContext`,+  `emptyModel`, `emptyResponse`, `emptyTool`, `emptyTextContent`,+  `emptyThinkingContent`, `emptyToolCall`, `emptyImageContent`,+  `emptyEmbeddingModel`, plus zero-valued bases `zeroUsage`, `zeroCost`,+  `zeroCostBreakdown`, and `zeroModelCost`.+- Added `firstEmbedding`, a total accessor for OpenAI-compatible embedding+  responses.+- Added `responseError`, `errorResponse`, `httpError`, and+  `parseRetryAfterSeconds` for the in-band error contract.++### Changed++- **Breaking:** Constructors for evolvable records are no longer exported:+  `Options`, `Context`, `Model`, `OpenAICompletionsCompat`,+  `AnthropicMessagesCompat`, and `InteractiveLaunchRequest` are built from+  exported base values plus record updates.+- **Breaking:** The `_X` base values are deprecated in favor of the new+  `empty*` and `zero*` names; the aliases remain for this release.+- **Breaking:** Removed `unModel`; use `mkModel` or `emptyModel` record+  updates.+- **Breaking:** Renamed `InteractiveLaunchRequest.model` to `modelId`.+- **Breaking:** `Response.latencyMs` and trace event `latencyMs` fields are+  now `Int`.+- **Breaking:** `completeRequest` / `completeRequestWith` no longer throw+  `BaikaiError` for unregistered API tags; they return an error-shaped+  `Response`.+- **Breaking:** CLI providers now report subprocess/decode/provider failures+  in-band as error-shaped `Response`s.+- **Breaking:** `errorTerminal` now requires a `BaikaiError`, enforcing+  structured error details for `EventError` construction sites.+- Documented that `Baikai.Prelude` is a convenience module outside the PVP+  stability contract and that `.Internal` modules have no compatibility+  guarantees.++### Fixed++- Empty embedding `data` arrays now produce a typed `decodeError` instead of+  crashing on an empty vector.+- The model-fetch JSON renderer now delegates string escaping to aeson.+- The model generator now fails on sanitized Haskell identifier collisions+  instead of rendering duplicate bindings.+- Live HTTP status, `Retry-After`, and network-failure classification now+  works on both API providers.+- `content_filter` / Anthropic refusals terminate as classified `EventError`+  terminals, and `liftCompleteToStream` preserves error-shaped responses.++## [baikai-claude 0.3.0.0] - 2026-07-03++### Changed++- **Breaking:** `Baikai.Provider.Claude.ErrorClass` moved to+  `Baikai.Provider.Claude.Internal.ErrorClass`.+- **Breaking:** `mapRequest` and pure request-shaping helpers moved from+  `Baikai.Provider.Claude.Api` to+  `Baikai.Provider.Claude.Internal.Request`.+- **Breaking:** `ClaudeCliConfig` and `ClaudeInteractiveConfig` constructors+  are no longer exported; start from their default config values and update+  fields.+- **Breaking:** CLI and interactive `extraArgs` fields are now `[Text]`.++## [baikai-openai 0.3.0.0] - 2026-07-03++### Changed++- **Breaking:** `Baikai.Provider.OpenAI.ErrorClass` moved to+  `Baikai.Provider.OpenAI.Internal.ErrorClass`.+- **Breaking:** `mapRequest` and pure request-shaping helpers moved from+  `Baikai.Provider.OpenAI.Api` to+  `Baikai.Provider.OpenAI.Internal.Request`.+- **Breaking:** `CodexCliConfig` and `CodexInteractiveConfig` constructors are+  no longer exported; start from their default config values and update fields.+- **Breaking:** CLI and interactive `extraArgs` fields are now `[Text]`.++## [baikai-trace-otel 0.3.0.0] - 2026-07-03++### Changed++- Updated the `baikai` dependency bound to `^>=0.3.0`.+- Adjusted to the core trace event `latencyMs :: Int` type.++## [baikai-effectful 0.3.0.0] - 2026-07-03++### Changed++- Updated the `baikai` dependency bound to `^>=0.3.0`.++## [baikai-kit 0.1.0.1] - 2026-07-03++### Changed++- Updated the `baikai` dependency bound to `^>=0.3.0`.+ ## [baikai 0.2.0.0] - 2026-06-21  ### Added
baikai.cabal view
@@ -1,6 +1,6 @@ cabal-version:   3.4 name:            baikai-version:         0.2.0.0+version:         0.3.0.0 synopsis:        Unified Haskell interface for multiple AI providers description:   baikai provides a unified, provider-agnostic Haskell interface for working@@ -91,6 +91,7 @@   import:         common-options   hs-source-dirs: gen   main-is:        GenModels.hs+  other-modules:  GenModelsCore   build-depends:     , aeson     , baikai@@ -120,8 +121,10 @@     , containers     , directory     , filepath+    , generic-lens     , http-client     , http-client-tls+    , lens     , scientific     , text             ^>=2.1     , vector@@ -129,18 +132,25 @@ test-suite baikai-test   import:             common-options   type:               exitcode-stdio-1.0-  hs-source-dirs:     test fetch+  hs-source-dirs:     test fetch gen   main-is:            Main.hs   other-modules:     AgentAssetsSpec     CatalogSpec+    CliInternalSpec+    ContextSpec     CostSpec     EmbeddingSpec     ErrorInfoSpec     ErrorSpec     FetchModelsCore     FetchModelsSpec+    GenModelsCore+    GenModelsSpec+    HelpersSpec     InteractiveSpec+    StreamSpec+    SurfaceSpec     TraceSpec     UsageSpec @@ -153,13 +163,17 @@     , containers     , directory     , filepath+    , generic-lens+    , lens     , openai     , process     , scientific     , stm-    , streamly-core  >=0.3 && <0.5+    , streamly-core     >=0.3 && <0.5     , tasty     , tasty-hunit+    , tasty-quickcheck     , temporary     , text+    , time     , vector
fetch/FetchModels.hs view
@@ -1,6 +1,3 @@-{-# LANGUAGE LambdaCase #-}-{-# LANGUAGE OverloadedStrings #-}- -- | Executable @baikai-fetch-models@: refresh baikai's catalog JSON -- from upstream sources (primarily @https:\/\/models.dev\/api.json@). --@@ -33,12 +30,13 @@ -- writing). module FetchModels (main, fetchUpstream) where +import Baikai.Prelude import Control.Exception (catch) import Data.ByteString qualified as BS import Data.ByteString.Lazy qualified as BSL+import Data.Generics.Labels () import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map-import Data.Text (Text) import Data.Text qualified as Text import FetchModelsCore import Network.HTTP.Client (HttpException, httpLbs, parseUrlThrow, responseBody)@@ -47,6 +45,7 @@ import System.Environment (getArgs) import System.Exit (die) import System.FilePath (takeDirectory, (</>))+import System.IO (hPutStrLn, stderr)  -- | Which provider(s) to emit. data ProviderSel = SelOpenAI | SelAnthropic | SelAll@@ -54,21 +53,22 @@  -- | Parsed command-line options. data Options = Options-  { optFromUrl :: !String,-    optFromFile :: !(Maybe FilePath),-    optOutDir :: !(Maybe FilePath),-    optProvider :: !ProviderSel,-    optStdout :: !Bool+  { fromUrl :: !String,+    fromFile :: !(Maybe FilePath),+    outDir :: !(Maybe FilePath),+    provider :: !ProviderSel,+    stdout :: !Bool   }+  deriving stock (Generic)  defaultOptions :: Options defaultOptions =   Options-    { optFromUrl = "https://models.dev/api.json",-      optFromFile = Nothing,-      optOutDir = Nothing,-      optProvider = SelAll,-      optStdout = False+    { fromUrl = "https://models.dev/api.json",+      fromFile = Nothing,+      outDir = Nothing,+      provider = SelAll,+      stdout = False     }  main :: IO ()@@ -79,26 +79,28 @@   upstream <- case parseUpstream raw of     Left err -> die $ "baikai-fetch-models: failed to parse upstream JSON: " <> err     Right u -> pure u-  let catalogs = map (catalogFor upstream) (selectedSpecs (optProvider opts))-  if optStdout opts+  let catalogs =+        map (applyOverrides overrides . catalogFor upstream) (selectedSpecs (opts ^. #provider))+  mapM_ warnStale (staleOverrides overrides catalogs)+  if opts ^. #stdout     then mapM_ (BS.putStr . renderCatalog) catalogs     else do-      outDir <- resolveOutDir (optOutDir opts)-      mapM_ (writeCatalog outDir) catalogs+      dir <- resolveOutDir (opts ^. #outDir)+      mapM_ (writeCatalog dir) catalogs  -- | Obtain the raw upstream bytes: a local file when @--from-file@ is -- set, otherwise a live GET. A network/HTTP failure ('HttpException', -- which 'parseUrlThrow' raises for non-2xx responses) is turned into a -- clean non-zero exit with nothing written. loadUpstreamBytes :: Options -> IO BSL.ByteString-loadUpstreamBytes opts = case optFromFile opts of+loadUpstreamBytes opts = case opts ^. #fromFile of   Just path -> BSL.readFile path   Nothing ->-    fetchUpstream (optFromUrl opts)+    fetchUpstream (opts ^. #fromUrl)       `catch` \e ->         die $           "baikai-fetch-models: failed to fetch "-            <> optFromUrl opts+            <> opts ^. #fromUrl             <> ": "             <> show (e :: HttpException) @@ -111,21 +113,32 @@   req <- parseUrlThrow url   responseBody <$> httpLbs req manager +-- | Warn (to stderr, without failing) about an override that matched+-- no fetched model, so stale corrections get noticed.+warnStale :: (Text, Text) -> IO ()+warnStale (prov, mid) =+  hPutStrLn stderr $+    "baikai-fetch-models: warning: override for ("+      <> Text.unpack prov+      <> ", "+      <> Text.unpack mid+      <> ") matched no fetched model (stale?)"+ -- | Build a provider's catalog from the parsed upstream document, -- defaulting to an empty model map if the provider is absent. catalogFor :: Map Text (Map Text UpstreamModel) -> ProviderSpec -> Catalog catalogFor upstream spec =-  normalizeProvider spec (Map.findWithDefault Map.empty (psProvider spec) upstream)+  normalizeProvider spec (Map.findWithDefault Map.empty (spec ^. #provider) upstream)  -- | Write one catalog to @\<dir\>\/\<provider\>.json@ in a single -- 'BS.writeFile' (so a crash mid-run cannot leave a truncated file) -- and report what was written. writeCatalog :: FilePath -> Catalog -> IO () writeCatalog dir cat = do-  let path = dir </> (Text.unpack (cProvider cat) <> ".json")+  let path = dir </> (Text.unpack (cat ^. #provider) <> ".json")   BS.writeFile path (renderCatalog cat)   putStrLn $-    "Wrote " <> path <> " (" <> show (length (cModels cat)) <> " models)"+    "Wrote " <> path <> " (" <> show (length (cat ^. #models)) <> " models)"  -- | The provider specs selected by @--provider@. selectedSpecs :: ProviderSel -> [ProviderSpec]@@ -172,13 +185,13 @@ parseArgs = go defaultOptions   where     go o [] = pure o-    go o ("--from-url" : v : rest) = go o {optFromUrl = v} rest-    go o ("--from-file" : v : rest) = go o {optFromFile = Just v} rest-    go o ("--out-dir" : v : rest) = go o {optOutDir = Just v} rest+    go o ("--from-url" : v : rest) = go (o & #fromUrl .~ v) rest+    go o ("--from-file" : v : rest) = go (o & #fromFile ?~ v) rest+    go o ("--out-dir" : v : rest) = go (o & #outDir ?~ v) rest     go o ("--provider" : v : rest) = do       sel <- parseProvider v-      go o {optProvider = sel} rest-    go o ("--stdout" : rest) = go o {optStdout = True} rest+      go (o & #provider .~ sel) rest+    go o ("--stdout" : rest) = go (o & #stdout .~ True) rest     go _ (a : _) = die $ "baikai-fetch-models: unknown argument " <> show a  parseProvider :: String -> IO ProviderSel
fetch/FetchModelsCore.hs view
@@ -1,6 +1,3 @@-{-# LANGUAGE LambdaCase #-}-{-# LANGUAGE OverloadedStrings #-}- -- | Pure core of the @baikai-fetch-models@ tool: parse a -- models.dev-shaped payload, normalize it into baikai's catalog JSON -- shape, and render that JSON with formatting matched to the@@ -20,6 +17,21 @@ -- @openai@ this excludes Responses-API-only ids (@*-pro@, @*-codex@, -- @*-deep-research@) because @baikai/data/models/openai.json@ speaks -- @openai-chat-completions@ and 'Baikai.Api' has no Responses tag.+--+-- == Override philosophy+--+-- models.dev is not fully trustworthy, so a small, explicit override+-- layer ('overrides') is applied after normalization. Each entry is a+-- per-@(provider, modelId)@ correction that overwrites only the fields+-- it sets, and each MUST carry a dated comment justifying why upstream+-- is wrong (mirroring pi-mono's @generate-models.ts@). Keep the table+-- minimal: prefer fixing upstream or curating ids out over piling on+-- overrides. Overrides that match no fetched model are warned about+-- ('staleOverrides') rather than silently kept.+--+-- Records follow the project convention: no field prefixes; field+-- access and updates go through generic-lens @#label@ optics rather+-- than bare selectors or record-update syntax. module FetchModelsCore   ( -- * Upstream shape (subset of models.dev fields we consume)     UpstreamModel (..),@@ -38,16 +50,26 @@     anthropicInclude,     normalizeProvider, +    -- * Override layer+    Override (..),+    emptyOverride,+    overrides,+    applyOverrides,+    staleOverrides,+     -- * Rendering     renderCatalog,   ) where  import Baikai.Model (InputModality (..))-import Data.Aeson (FromJSON (..), (.!=), (.:), (.:?))-import Data.Aeson qualified as Aeson+import Baikai.Prelude+import Data.Aeson (Value (String), eitherDecode, encode, withObject, (.!=), (.:), (.:?))+import Data.Aeson.Types (Parser) import Data.ByteString (ByteString) import Data.ByteString.Lazy qualified as BSL+import Data.ByteString.Lazy qualified as LBS+import Data.Generics.Labels () import Data.List (sortOn) import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map@@ -55,9 +77,8 @@ import Data.Scientific (FPFormat (Fixed), Scientific, formatScientific) import Data.Set (Set) import Data.Set qualified as Set-import Data.Text (Text) import Data.Text qualified as Text-import Data.Text.Encoding (encodeUtf8)+import Data.Text.Encoding (decodeUtf8, encodeUtf8)  -- * Upstream shape ---------------------------------------------------- @@ -65,64 +86,66 @@ -- optional field defaults the way pi-mono's scraper does: missing -- numbers become 0 at normalization time, missing arrays become empty. data UpstreamModel = UpstreamModel-  { uId :: !Text,-    uName :: !(Maybe Text),-    uToolCall :: !Bool,-    uReasoning :: !Bool,-    uCtx :: !(Maybe Integer),-    uMaxOut :: !(Maybe Integer),-    uCostIn :: !(Maybe Scientific),-    uCostOut :: !(Maybe Scientific),-    uCacheRead :: !(Maybe Scientific),-    uCacheWrite :: !(Maybe Scientific),-    uInputMods :: ![Text]+  { modelId :: !Text,+    name :: !(Maybe Text),+    toolCall :: !Bool,+    reasoning :: !Bool,+    contextWindow :: !(Maybe Integer),+    maxOutputTokens :: !(Maybe Integer),+    inputCost :: !(Maybe Scientific),+    outputCost :: !(Maybe Scientific),+    cacheReadCost :: !(Maybe Scientific),+    cacheWriteCost :: !(Maybe Scientific),+    inputModalities :: ![Text]   }-  deriving stock (Eq, Show)+  deriving stock (Eq, Show, Generic)  instance FromJSON UpstreamModel where-  parseJSON = Aeson.withObject "UpstreamModel" $ \o -> do-    uId <- o .: "id"-    uName <- o .:? "name"-    uToolCall <- o .:? "tool_call" .!= False-    uReasoning <- o .:? "reasoning" .!= False-    mLimit <- o .:? "limit"-    mCost <- o .:? "cost"-    mMods <- o .:? "modalities"+  parseJSON = withObject "UpstreamModel" $ \o -> do+    mid <- o .: "id"+    nm <- o .:? "name"+    tc <- o .:? "tool_call" .!= False+    rs <- o .:? "reasoning" .!= False+    lim <- o .:? "limit" :: Parser (Maybe Limit)+    cst <- o .:? "cost" :: Parser (Maybe Cost)+    mods <- o .:? "modalities" :: Parser (Maybe Modalities)     pure       UpstreamModel-        { uId = uId,-          uName = uName,-          uToolCall = uToolCall,-          uReasoning = uReasoning,-          uCtx = mLimit >>= limitContext,-          uMaxOut = mLimit >>= limitOutput,-          uCostIn = mCost >>= costIn,-          uCostOut = mCost >>= costOut,-          uCacheRead = mCost >>= costCacheRead,-          uCacheWrite = mCost >>= costCacheWrite,-          uInputMods = maybe [] modalitiesInput mMods+        { modelId = mid,+          name = nm,+          toolCall = tc,+          reasoning = rs,+          contextWindow = lim >>= (^. #context),+          maxOutputTokens = lim >>= (^. #output),+          inputCost = cst >>= (^. #input),+          outputCost = cst >>= (^. #output),+          cacheReadCost = cst >>= (^. #cacheRead),+          cacheWriteCost = cst >>= (^. #cacheWrite),+          inputModalities = maybe [] (^. #input) mods         }  -- | @limit@ sub-object. data Limit = Limit-  { limitContext :: !(Maybe Integer),-    limitOutput :: !(Maybe Integer)+  { context :: !(Maybe Integer),+    output :: !(Maybe Integer)   }+  deriving stock (Show, Generic)  instance FromJSON Limit where-  parseJSON = Aeson.withObject "limit" $ \o ->+  parseJSON = withObject "limit" $ \o ->     Limit <$> o .:? "context" <*> o .:? "output"  -- | @cost@ sub-object (US dollars per million tokens). data Cost = Cost-  { costIn :: !(Maybe Scientific),-    costOut :: !(Maybe Scientific),-    costCacheRead :: !(Maybe Scientific),-    costCacheWrite :: !(Maybe Scientific)+  { input :: !(Maybe Scientific),+    output :: !(Maybe Scientific),+    cacheRead :: !(Maybe Scientific),+    cacheWrite :: !(Maybe Scientific)   }+  deriving stock (Show, Generic)  instance FromJSON Cost where-  parseJSON = Aeson.withObject "cost" $ \o ->+  parseJSON = withObject "cost" $ \o ->     Cost       <$> o .:? "input"       <*> o .:? "output"@@ -130,58 +153,62 @@       <*> o .:? "cache_write"  -- | @modalities@ sub-object; we only read @input@.-newtype Modalities = Modalities {modalitiesInput :: [Text]}+newtype Modalities = Modalities {input :: [Text]}+  deriving stock (Show, Generic)  instance FromJSON Modalities where-  parseJSON = Aeson.withObject "modalities" $ \o ->+  parseJSON = withObject "modalities" $ \o ->     Modalities <$> o .:? "input" .!= []  -- | One provider object in the models.dev document. We only read the -- @models@ map; every other provider field is ignored.-newtype UpstreamProvider = UpstreamProvider {upModels :: Map Text UpstreamModel}+newtype UpstreamProvider = UpstreamProvider {models :: Map Text UpstreamModel}+  deriving stock (Generic)  instance FromJSON UpstreamProvider where-  parseJSON = Aeson.withObject "UpstreamProvider" $ \o ->+  parseJSON = withObject "UpstreamProvider" $ \o ->     UpstreamProvider <$> o .:? "models" .!= Map.empty  -- | Parse a full models.dev document into @provider -> (modelId -> -- model)@. The top-level document is a JSON object keyed by provider -- id; each provider object carries a @models@ map keyed by model id. parseUpstream :: BSL.ByteString -> Either String (Map Text (Map Text UpstreamModel))-parseUpstream bs = Map.map upModels <$> Aeson.eitherDecode bs+parseUpstream bs =+  Map.map (^. #models) <$> (eitherDecode bs :: Either String (Map Text UpstreamProvider))  -- * Output catalog shape --------------------------------------------- --- | Per-million-token cost rates, in the catalog's field names.+-- | Per-million-token cost rates, named to mirror+-- 'Baikai.Model.ModelCost'. data CatalogCost = CatalogCost-  { ccInput :: !Scientific,-    ccOutput :: !Scientific,-    ccCacheRead :: !Scientific,-    ccCacheWrite :: !Scientific+  { inputCost :: !Scientific,+    outputCost :: !Scientific,+    cacheReadCost :: !Scientific,+    cacheWriteCost :: !Scientific   }-  deriving stock (Eq, Show)+  deriving stock (Eq, Show, Generic)  -- | One emitted catalog model. @enabled@ is always @true@ for emitted -- models, so it is not stored here; the renderer writes it literally. data CatalogModel = CatalogModel-  { cmId :: !Text,-    cmName :: !Text,-    cmReasoning :: !Bool,-    cmInput :: ![InputModality],-    cmCost :: !CatalogCost,-    cmContextWindow :: !Integer,-    cmMaxOutputTokens :: !Integer+  { modelId :: !Text,+    name :: !Text,+    reasoning :: !Bool,+    input :: ![InputModality],+    cost :: !CatalogCost,+    contextWindow :: !Integer,+    maxOutputTokens :: !Integer   }-  deriving stock (Eq, Show)+  deriving stock (Eq, Show, Generic)  -- | A whole catalog file: one provider and its curated models. data Catalog = Catalog-  { cProvider :: !Text,-    cBaseUrl :: !Text,-    cApi :: !Text,-    cModels :: ![CatalogModel]+  { provider :: !Text,+    baseUrl :: !Text,+    api :: !Text,+    models :: ![CatalogModel]   }-  deriving stock (Eq, Show)+  deriving stock (Eq, Show, Generic)  -- * Provider specs and normalization --------------------------------- @@ -189,11 +216,12 @@ -- catalog and the curation predicate selecting which upstream model -- ids to keep. data ProviderSpec = ProviderSpec-  { psProvider :: !Text,-    psBaseUrl :: !Text,-    psApi :: !Text,-    psInclude :: !(Text -> Bool)+  { provider :: !Text,+    baseUrl :: !Text,+    api :: !Text,+    include :: !(Text -> Bool)   }+  deriving stock (Generic)  -- | Curation include set for OpenAI: the chat-completions-compatible -- current line. Responses-API-only ids (@*-pro@, @*-codex@,@@ -239,20 +267,20 @@ openaiSpec :: ProviderSpec openaiSpec =   ProviderSpec-    { psProvider = "openai",-      psBaseUrl = "https://api.openai.com",-      psApi = "openai-chat-completions",-      psInclude = (`Set.member` openaiInclude)+    { provider = "openai",+      baseUrl = "https://api.openai.com",+      api = "openai-chat-completions",+      include = (`Set.member` openaiInclude)     }  -- | Provider spec for Anthropic's first-party messages endpoint. anthropicSpec :: ProviderSpec anthropicSpec =   ProviderSpec-    { psProvider = "anthropic",-      psBaseUrl = "https://api.anthropic.com",-      psApi = "anthropic-messages",-      psInclude = (`Set.member` anthropicInclude)+    { provider = "anthropic",+      baseUrl = "https://api.anthropic.com",+      api = "anthropic-messages",+      include = (`Set.member` anthropicInclude)     }  -- | Normalize one provider's upstream models into a 'Catalog'. Keeps@@ -263,38 +291,158 @@ normalizeProvider :: ProviderSpec -> Map Text UpstreamModel -> Catalog normalizeProvider spec upstream =   Catalog-    { cProvider = psProvider spec,-      cBaseUrl = psBaseUrl spec,-      cApi = psApi spec,-      cModels = sortOn cmId (map toCatalogModel kept)+    { provider = spec ^. #provider,+      baseUrl = spec ^. #baseUrl,+      api = spec ^. #api,+      models = sortOn (^. #modelId) (map toCatalogModel kept)     }   where     kept =       [ m       | m <- Map.elems upstream,-        uToolCall m,-        psInclude spec (uId m)+        m ^. #toolCall,+        (spec ^. #include) (m ^. #modelId)       ]     toCatalogModel m =       CatalogModel-        { cmId = uId m,-          cmName = fromMaybe (uId m) (uName m),-          cmReasoning = uReasoning m,-          cmInput =-            if "image" `elem` uInputMods m+        { modelId = m ^. #modelId,+          name = normalizeName (fromMaybe (m ^. #modelId) (m ^. #name)),+          reasoning = m ^. #reasoning,+          input =+            if "image" `elem` (m ^. #inputModalities)               then [InputText, InputImage]               else [InputText],-          cmCost =+          cost =             CatalogCost-              { ccInput = fromMaybe 0 (uCostIn m),-                ccOutput = fromMaybe 0 (uCostOut m),-                ccCacheRead = fromMaybe 0 (uCacheRead m),-                ccCacheWrite = fromMaybe 0 (uCacheWrite m)+              { inputCost = fromMaybe 0 (m ^. #inputCost),+                outputCost = fromMaybe 0 (m ^. #outputCost),+                cacheReadCost = fromMaybe 0 (m ^. #cacheReadCost),+                cacheWriteCost = fromMaybe 0 (m ^. #cacheWriteCost)               },-          cmContextWindow = fromMaybe 0 (uCtx m),-          cmMaxOutputTokens = fromMaybe 0 (uMaxOut m)+          contextWindow = fromMaybe 0 (m ^. #contextWindow),+          maxOutputTokens = fromMaybe 0 (m ^. #maxOutputTokens)         } +-- | Strip a trailing @" (latest)"@ display-name suffix that models.dev+-- attaches to the "latest" aliases (e.g. @"Claude Opus 4.5 (latest)"@).+-- This is a systematic upstream wart, so it is normalized for every+-- provider rather than via a per-model override.+normalizeName :: Text -> Text+normalizeName n = fromMaybe n (Text.stripSuffix " (latest)" n)++-- * Override layer ----------------------------------------------------++-- | A hand-maintained correction for one @(provider, modelId)@ pair.+-- Every field except the key is a @Maybe@: 'Nothing' leaves the+-- normalized value untouched, 'Just' overwrites it. Build entries from+-- 'emptyOverride' and set only the fields that need correcting.+data Override = Override+  { provider :: !Text,+    modelId :: !Text,+    name :: !(Maybe Text),+    reasoning :: !(Maybe Bool),+    inputCost :: !(Maybe Scientific),+    outputCost :: !(Maybe Scientific),+    cacheReadCost :: !(Maybe Scientific),+    cacheWriteCost :: !(Maybe Scientific),+    contextWindow :: !(Maybe Integer),+    maxOutputTokens :: !(Maybe Integer)+  }+  deriving stock (Eq, Show, Generic)++-- | An override that changes nothing, keyed by provider and model id.+emptyOverride :: Text -> Text -> Override+emptyOverride prov mid =+  Override+    { provider = prov,+      modelId = mid,+      name = Nothing,+      reasoning = Nothing,+      inputCost = Nothing,+      outputCost = Nothing,+      cacheReadCost = Nothing,+      cacheWriteCost = Nothing,+      contextWindow = Nothing,+      maxOutputTokens = Nothing+    }++-- | The hand-maintained correction table, applied after normalization.+-- Each entry MUST carry a dated comment explaining why upstream is+-- wrong, exactly as pi-mono's @generate-models.ts@ documents its+-- corrections. Order does not matter; entries are keyed by+-- @(provider, modelId)@.+overrides :: [Override]+overrides =+  [ -- 2026-06-21: models.dev has periodically reported Claude Opus+    -- cache_read at 3x the published rate (1.5 instead of 0.5 /Mtok) —+    -- the same wart pi-mono hard-corrects in generate-models.ts+    -- ("models.dev has 3x the correct pricing"). Pin cache_read to+    -- Anthropic's published 0.5 so an upstream regression cannot+    -- silently inflate cached-input cost.+    emptyOverride "anthropic" "claude-opus-4-5" & #cacheReadCost ?~ 0.5+  ]++-- | Apply all overrides matching this catalog's provider to its+-- models. A model with no matching override is returned unchanged.+applyOverrides :: [Override] -> Catalog -> Catalog+applyOverrides ovs cat = cat & #models %~ map applyMatching+  where+    prov = cat ^. #provider+    byKey =+      Map.fromList+        [((o ^. #provider, o ^. #modelId), o) | o <- ovs]+    applyMatching m =+      maybe m (`applyOne` m) (Map.lookup (prov, m ^. #modelId) byKey)++-- | Overwrite each 'CatalogModel' field for which the override carries+-- a 'Just'. 'Nothing' fields are left as normalized.+applyOne :: Override -> CatalogModel -> CatalogModel+applyOne o m =+  m+    & #name+    %~ overwrite (o ^. #name)+    & #reasoning+    %~ overwrite (o ^. #reasoning)+    & #cost+    . #inputCost+    %~ overwrite (o ^. #inputCost)+    & #cost+    . #outputCost+    %~ overwrite (o ^. #outputCost)+    & #cost+    . #cacheReadCost+    %~ overwrite (o ^. #cacheReadCost)+    & #cost+    . #cacheWriteCost+    %~ overwrite (o ^. #cacheWriteCost)+    & #contextWindow+    %~ overwrite (o ^. #contextWindow)+    & #maxOutputTokens+    %~ overwrite (o ^. #maxOutputTokens)+  where+    overwrite :: Maybe a -> a -> a+    overwrite = maybe id const++-- | Override keys @(provider, modelId)@ that match no model in any of+-- the given catalogs (restricted to providers actually present, so an+-- override for an unfetched provider is not falsely flagged). The tool+-- warns on these so stale overrides get noticed without failing.+staleOverrides :: [Override] -> [Catalog] -> [(Text, Text)]+staleOverrides ovs cats =+  [ (o ^. #provider, o ^. #modelId)+  | o <- ovs,+    (o ^. #provider) `Set.member` provs,+    (o ^. #provider, o ^. #modelId) `Set.notMember` present+  ]+  where+    provs = Set.fromList (map (^. #provider) cats)+    present =+      Set.fromList+        [ (cat ^. #provider, m ^. #modelId)+        | cat <- cats,+          m <- cat ^. #models+        ]+ -- * Rendering ---------------------------------------------------------  -- | Render a 'Catalog' as catalog JSON, byte-for-byte in the style of@@ -307,12 +455,12 @@ renderLines :: Catalog -> [Text] renderLines cat =   ["{"]-    ++ [ "  \"provider\": " <> jsonString (cProvider cat) <> ",",-         "  \"baseUrl\": " <> jsonString (cBaseUrl cat) <> ",",-         "  \"api\": " <> jsonString (cApi cat) <> ",",+    ++ [ "  \"provider\": " <> jsonString (cat ^. #provider) <> ",",+         "  \"baseUrl\": " <> jsonString (cat ^. #baseUrl) <> ",",+         "  \"api\": " <> jsonString (cat ^. #api) <> ",",          "  \"compat\": \"auto\","        ]-    ++ modelsSection (cModels cat)+    ++ modelsSection (cat ^. #models)     ++ ["}"]  modelsSection :: [CatalogModel] -> [Text]@@ -336,23 +484,23 @@ renderModel :: CatalogModel -> [Text] renderModel m =   [ "    {",-    "      \"id\": " <> jsonString (cmId m) <> ",",-    "      \"name\": " <> jsonString (cmName m) <> ",",-    "      \"reasoning\": " <> jsonBool (cmReasoning m) <> ",",-    "      \"input\": " <> renderInput (cmInput m) <> ",",+    "      \"id\": " <> jsonString (m ^. #modelId) <> ",",+    "      \"name\": " <> jsonString (m ^. #name) <> ",",+    "      \"reasoning\": " <> jsonBool (m ^. #reasoning) <> ",",+    "      \"input\": " <> renderInput (m ^. #input) <> ",",     "      \"cost\": {",-    "        \"input\": " <> renderNum (ccInput c) <> ",",-    "        \"output\": " <> renderNum (ccOutput c) <> ",",-    "        \"cacheRead\": " <> renderNum (ccCacheRead c) <> ",",-    "        \"cacheWrite\": " <> renderNum (ccCacheWrite c),+    "        \"input\": " <> renderNum (c ^. #inputCost) <> ",",+    "        \"output\": " <> renderNum (c ^. #outputCost) <> ",",+    "        \"cacheRead\": " <> renderNum (c ^. #cacheReadCost) <> ",",+    "        \"cacheWrite\": " <> renderNum (c ^. #cacheWriteCost),     "      },",-    "      \"contextWindow\": " <> Text.pack (show (cmContextWindow m)) <> ",",-    "      \"maxOutputTokens\": " <> Text.pack (show (cmMaxOutputTokens m)) <> ",",+    "      \"contextWindow\": " <> Text.pack (show (m ^. #contextWindow)) <> ",",+    "      \"maxOutputTokens\": " <> Text.pack (show (m ^. #maxOutputTokens)) <> ",",     "      \"enabled\": true",     "    }"   ]   where-    c = cmCost m+    c = m ^. #cost  renderInput :: [InputModality] -> Text renderInput ms = "[" <> Text.intercalate ", " (map one ms) <> "]"@@ -370,8 +518,7 @@ renderNum :: Scientific -> Text renderNum = Text.pack . formatScientific Fixed Nothing --- | Render a 'Text' as a JSON string literal, escaping backslashes and--- double quotes (sufficient for model ids and display names).+-- | Render a 'Text' as a JSON string literal. Delegate escaping to aeson so+-- control characters, quotes, and backslashes follow the JSON encoder exactly. jsonString :: Text -> Text-jsonString t =-  "\"" <> Text.replace "\"" "\\\"" (Text.replace "\\" "\\\\" t) <> "\""+jsonString = decodeUtf8 . LBS.toStrict . encode . String
gen/GenModels.hs view
@@ -1,5 +1,3 @@-{-# LANGUAGE LambdaCase #-}-{-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE OverloadedStrings #-}  -- | Generator executable that reads JSON catalog files from@@ -29,29 +27,17 @@ -- (enforced by @CatalogSpec@ in @baikai\/test\/@). module Main (main) where -import Baikai.Api (Api (..), parseApi)-import Baikai.Compat-  ( AnthropicMessagesCompat (..),-    CacheControlFormat (..),-    MaxTokensField (..),-    OpenAICompletionsCompat (..),-    ThinkingFormat (..),-    defaultAnthropicMessagesCompat,-    defaultOpenAICompletionsCompat,-  )-import Baikai.Model (InputModality (..)) import Control.Monad (forM)-import Data.Aeson (FromJSON (..), (.!=), (.:), (.:?)) import Data.Aeson qualified as Aeson-import Data.Aeson.Types (Parser, typeMismatch) import Data.ByteString.Lazy qualified as BSL import Data.List (sort, sortOn)-import Data.Ratio (denominator, numerator)-import Data.Scientific (Scientific)-import Data.Text (Text) import Data.Text qualified as Text import Data.Text.IO qualified as TIO-import Numeric.Natural (Natural)+import GenModelsCore+  ( checkIdentifierCollisions,+    flattenEntries,+    renderModule,+  ) import System.Directory (doesFileExist, getCurrentDirectory, listDirectory) import System.Environment (getArgs) import System.Exit (die)@@ -69,7 +55,10 @@       Left err -> die $ f <> ": " <> err       Right c -> pure c   let allEntries = concatMap flattenEntries catalogs-      sorted = sortOn fst allEntries+  case checkIdentifierCollisions allEntries of+    Left err -> die (Text.unpack err)+    Right () -> pure ()+  let sorted = sortOn fst allEntries       rendered = renderModule sorted   TIO.writeFile outputPath rendered   putStrLn $@@ -134,391 +123,3 @@ listCatalogFiles dir = do   entries <- listDirectory dir   pure $ sort [f | f <- entries, takeExtension f == ".json"]---- * Catalog data types --------------------------------------------------- | One catalog file, in source form.-data CatalogFile = CatalogFile-  { provider :: !Text,-    baseUrl :: !Text,-    api :: !Api,-    compat :: !CatalogCompat,-    models :: ![ModelEntry]-  }--instance FromJSON CatalogFile where-  parseJSON = Aeson.withObject "CatalogFile" $ \o ->-    CatalogFile-      <$> o .: "provider"-      <*> o .: "baseUrl"-      <*> (parseApi <$> o .: "api")-      <*> o .: "compat"-      <*> o .: "models"---- | A compat directive in the catalog. @"auto"@ defers to EP-5's--- @baseUrl@-driven auto-detection (rendered as 'CompatNone'). The two--- structured constructors carry a full override record.-data CatalogCompat-  = CatalogCompatAuto-  | CatalogCompatOpenAI !OpenAICompletionsCompat-  | CatalogCompatAnthropic !AnthropicMessagesCompat-  deriving stock (Show)--instance FromJSON CatalogCompat where-  parseJSON = \case-    Aeson.String "auto" -> pure CatalogCompatAuto-    Aeson.Object o -> do-      kind <- o .: "kind" :: Parser Text-      case kind of-        "openai-completions" ->-          CatalogCompatOpenAI <$> parseOpenAICompat o-        "anthropic-messages" ->-          CatalogCompatAnthropic <$> parseAnthropicCompat o-        _ ->-          fail $ "CatalogCompat: unknown kind " <> show kind-    v -> typeMismatch "CatalogCompat (expected \"auto\" or {\"kind\": ...})" v--parseOpenAICompat :: Aeson.Object -> Parser OpenAICompletionsCompat-parseOpenAICompat o = do-  let d = defaultOpenAICompletionsCompat-  mtf <- optionalField o "maxTokensField" parseMaxTokensField (maxTokensField d)-  sdr <- o .:? "supportsDeveloperRole" .!= supportsDeveloperRole d-  sst <- o .:? "supportsStrictMode" .!= supportsStrictMode d-  rtat <- o .:? "requiresThinkingAsText" .!= requiresThinkingAsText d-  tf <- optionalField o "thinkingFormat" parseThinkingFormat (thinkingFormat d)-  ccf <- optionalMaybeField o "cacheControlFormat" parseCacheControlFormat (cacheControlFormat d)-  sus <- o .:? "supportsUsageInStreaming" .!= d.supportsUsageInStreaming-  slcr <- o .:? "supportsLongCacheRetention" .!= d.supportsLongCacheRetention-  pure-    OpenAICompletionsCompat-      { maxTokensField = mtf,-        supportsDeveloperRole = sdr,-        supportsStrictMode = sst,-        requiresThinkingAsText = rtat,-        thinkingFormat = tf,-        cacheControlFormat = ccf,-        supportsUsageInStreaming = sus,-        supportsLongCacheRetention = slcr-      }--parseAnthropicCompat :: Aeson.Object -> Parser AnthropicMessagesCompat-parseAnthropicCompat o = do-  let d = defaultAnthropicMessagesCompat-  slcr <- o .:? "supportsLongCacheRetention" .!= d.supportsLongCacheRetention-  scot <- o .:? "supportsCacheControlOnTools" .!= d.supportsCacheControlOnTools-  seti <- o .:? "supportsEagerToolInputStreaming" .!= d.supportsEagerToolInputStreaming-  ssah <- o .:? "sendSessionAffinityHeaders" .!= d.sendSessionAffinityHeaders-  pure-    AnthropicMessagesCompat-      { supportsLongCacheRetention = slcr,-        supportsCacheControlOnTools = scot,-        supportsEagerToolInputStreaming = seti,-        sendSessionAffinityHeaders = ssah-      }--parseMaxTokensField :: Text -> Parser MaxTokensField-parseMaxTokensField = \case-  "max_tokens" -> pure MaxTokensField-  "max_completion_tokens" -> pure MaxCompletionTokensField-  t -> fail $ "unknown maxTokensField: " <> Text.unpack t--parseThinkingFormat :: Text -> Parser ThinkingFormat-parseThinkingFormat = \case-  "openai" -> pure ThinkingFormatOpenAI-  "openrouter" -> pure ThinkingFormatOpenRouter-  "deepseek" -> pure ThinkingFormatDeepseek-  "together" -> pure ThinkingFormatTogether-  "zai" -> pure ThinkingFormatZai-  "qwen" -> pure ThinkingFormatQwen-  "none" -> pure ThinkingFormatNone-  t -> fail $ "unknown thinkingFormat: " <> Text.unpack t--parseCacheControlFormat :: Text -> Parser CacheControlFormat-parseCacheControlFormat = \case-  "anthropic" -> pure CacheControlFormatAnthropic-  t -> fail $ "unknown cacheControlFormat: " <> Text.unpack t---- | Look up an optional field; missing returns @def@.-optionalField :: Aeson.Object -> Aeson.Key -> (Text -> Parser a) -> a -> Parser a-optionalField o key parser def = do-  mv <- o .:? key-  case mv of-    Nothing -> pure def-    Just v -> parser v---- | Look up an optional @Maybe@-typed field. JSON @null@ or absence--- yields @def@; otherwise the value is parsed through @parser@ and--- wrapped in 'Just'.-optionalMaybeField ::-  Aeson.Object ->-  Aeson.Key ->-  (Text -> Parser a) ->-  Maybe a ->-  Parser (Maybe a)-optionalMaybeField o key parser def = do-  mv <- o .:? key-  case mv of-    Nothing -> pure def-    Just Aeson.Null -> pure Nothing-    Just (Aeson.String t) -> Just <$> parser t-    Just v -> typeMismatch "string or null" v---- | One model row in a catalog file.-data ModelEntry = ModelEntry-  { entryId :: !Text,-    entryName :: !Text,-    entryReasoning :: !Bool,-    entryInput :: ![InputModality],-    entryCost :: !CostEntry,-    entryContextWindow :: !Natural,-    entryMaxOutputTokens :: !Natural,-    entryEnabled :: !Bool,-    entryCompatOverride :: !(Maybe CatalogCompat)-  }--instance FromJSON ModelEntry where-  parseJSON = Aeson.withObject "ModelEntry" $ \o ->-    ModelEntry-      <$> o .: "id"-      <*> o .: "name"-      <*> o .:? "reasoning" .!= False-      <*> (o .: "input" >>= traverse parseInputModality)-      <*> o .: "cost"-      <*> o .: "contextWindow"-      <*> o .: "maxOutputTokens"-      <*> o .:? "enabled" .!= True-      <*> o .:? "compat"--parseInputModality :: Text -> Parser InputModality-parseInputModality = \case-  "text" -> pure InputText-  "image" -> pure InputImage-  t -> fail $ "unknown input modality: " <> Text.unpack t---- | Per-million-token cost rates. Parsed as 'Scientific' so the--- round-trip into 'Rational' produces a small, canonical fraction--- (@1.5@ → @3 % 2@) rather than an arbitrary IEEE-754 approximation.-data CostEntry = CostEntry-  { costInput :: !Scientific,-    costOutput :: !Scientific,-    costCacheRead :: !Scientific,-    costCacheWrite :: !Scientific-  }--instance FromJSON CostEntry where-  parseJSON = Aeson.withObject "CostEntry" $ \o ->-    CostEntry-      <$> o .: "input"-      <*> o .: "output"-      <*> o .:? "cacheRead" .!= 0-      <*> o .:? "cacheWrite" .!= 0---- * Flattening ------------------------------------------------------------- | One generated Haskell identifier plus the 'Model'-shaped record--- it should be rendered as. Keeping these together lets the renderer--- stay a pure transformation over a flat list.-data GeneratedEntry = GeneratedEntry-  { genIdent :: !Text,-    genModelId :: !Text,-    genName :: !Text,-    genApi :: !Api,-    genProvider :: !Text,-    genBaseUrl :: !Text,-    genReasoning :: !Bool,-    genInput :: ![InputModality],-    genCost :: !CostEntry,-    genContextWindow :: !Natural,-    genMaxOutputTokens :: !Natural,-    genCompat :: !CatalogCompat-  }--flattenEntries :: CatalogFile -> [(Text, GeneratedEntry)]-flattenEntries c =-  [ (genIdent g, g)-  | m <- models c,-    entryEnabled m,-    let g =-          GeneratedEntry-            { genIdent =-                sanitizeIdentifier (provider c <> "_" <> entryId m),-              genModelId = entryId m,-              genName = entryName m,-              genApi = api c,-              genProvider = provider c,-              genBaseUrl = baseUrl c,-              genReasoning = entryReasoning m,-              genInput = entryInput m,-              genCost = entryCost m,-              genContextWindow = entryContextWindow m,-              genMaxOutputTokens = entryMaxOutputTokens m,-              genCompat =-                case entryCompatOverride m of-                  Just c' -> c'-                  Nothing -> compat c-            }-  ]---- | Replace any non-identifier character with @_@. Haskell allows--- letters, digits, underscore, and apostrophe; everything else--- (slash, dash, dot, colon, …) becomes an underscore.-sanitizeIdentifier :: Text -> Text-sanitizeIdentifier = Text.map replace-  where-    replace c-      | (c >= 'a' && c <= 'z')-          || (c >= 'A' && c <= 'Z')-          || (c >= '0' && c <= '9')-          || c == '_'-          || c == '\'' =-          c-      | otherwise = '_'---- * Source rendering ----------------------------------------------------renderModule :: [(Text, GeneratedEntry)] -> Text-renderModule entries =-  Text.unlines-    ( header-        ++ concatMap (renderEntry . snd) entries-    )-  where-    header =-      [ "-- AUTO-GENERATED by baikai-gen-models. Do not edit by hand.",-        "-- Regenerate with: cabal run baikai-gen-models",-        "{-# LANGUAGE OverloadedStrings #-}",-        "{-# OPTIONS_GHC -Wno-missing-export-lists #-}",-        "{-# OPTIONS_GHC -Wno-unused-imports #-}",-        "",-        "module Baikai.Models.Generated where",-        "",-        "import Baikai.Api (Api (..))",-        "import Baikai.Compat",-        "  ( AnthropicMessagesCompat (..)",-        "  , CacheControlFormat (..)",-        "  , MaxTokensField (..)",-        "  , OpenAICompletionsCompat (..)",-        "  , ThinkingFormat (..)",-        "  )",-        "import Baikai.Model",-        "  ( Compat (..)",-        "  , InputModality (..)",-        "  , Model (..)",-        "  , ModelCost (..)",-        "  )",-        "import Data.Map.Strict qualified as Map",-        "import Data.Ratio ((%))",-        ""-      ]--renderEntry :: GeneratedEntry -> [Text]-renderEntry g =-  [ genIdent g <> " :: Model",-    genIdent g <> " =",-    "  Model",-    "    { modelId = " <> renderText (genModelId g),-    "    , name = " <> renderText (genName g),-    "    , api = " <> renderApiCtor (genApi g),-    "    , provider = " <> renderText (genProvider g),-    "    , baseUrl = " <> renderText (genBaseUrl g),-    "    , reasoning = " <> renderBool (genReasoning g),-    "    , input = " <> renderInputList (genInput g),-    "    , cost = " <> renderCost (genCost g),-    "    , contextWindow = " <> Text.pack (show (genContextWindow g)),-    "    , maxOutputTokens = " <> Text.pack (show (genMaxOutputTokens g)),-    "    , headers = Map.empty",-    "    , compat = " <> renderCompat (genCompat g),-    "    }",-    ""-  ]--renderText :: Text -> Text-renderText t =-  "\""-    <> Text.replace "\"" "\\\"" (Text.replace "\\" "\\\\" t)-    <> "\""--renderBool :: Bool -> Text-renderBool True = "True"-renderBool False = "False"--renderInputList :: [InputModality] -> Text-renderInputList ms =-  "[" <> Text.intercalate ", " (map renderInput ms) <> "]"-  where-    renderInput InputText = "InputText"-    renderInput InputImage = "InputImage"--renderApiCtor :: Api -> Text-renderApiCtor = \case-  OpenAIChatCompletions -> "OpenAIChatCompletions"-  AnthropicMessages -> "AnthropicMessages"-  OpenAICompletionsCli -> "OpenAICompletionsCli"-  AnthropicMessagesCli -> "AnthropicMessagesCli"-  Custom t -> "Custom " <> renderText t--renderCost :: CostEntry -> Text-renderCost c =-  Text.intercalate-    "\n"-    [ "ModelCost",-      "        { inputCost = " <> renderRational (toRational (costInput c)),-      "        , outputCost = " <> renderRational (toRational (costOutput c)),-      "        , cacheReadCost = " <> renderRational (toRational (costCacheRead c)),-      "        , cacheWriteCost = " <> renderRational (toRational (costCacheWrite c)),-      "        }"-    ]--renderRational :: Rational -> Text-renderRational r =-  Text.pack (show (numerator r)) <> " % " <> Text.pack (show (denominator r))--renderCompat :: CatalogCompat -> Text-renderCompat = \case-  CatalogCompatAuto -> "CompatNone"-  CatalogCompatOpenAI c ->-    Text.intercalate-      "\n"-      [ "CompatOpenAICompletions",-        "        OpenAICompletionsCompat",-        "          { maxTokensField = " <> renderMaxTokensField c.maxTokensField,-        "          , supportsDeveloperRole = " <> renderBool c.supportsDeveloperRole,-        "          , supportsStrictMode = " <> renderBool c.supportsStrictMode,-        "          , requiresThinkingAsText = " <> renderBool c.requiresThinkingAsText,-        "          , thinkingFormat = " <> renderThinkingFormat c.thinkingFormat,-        "          , cacheControlFormat = " <> renderMaybeCacheControl c.cacheControlFormat,-        "          , supportsUsageInStreaming = " <> renderBool c.supportsUsageInStreaming,-        "          , supportsLongCacheRetention = " <> renderBool c.supportsLongCacheRetention,-        "          }"-      ]-  CatalogCompatAnthropic c ->-    Text.intercalate-      "\n"-      [ "CompatAnthropicMessages",-        "        AnthropicMessagesCompat",-        "          { supportsLongCacheRetention = " <> renderBool c.supportsLongCacheRetention,-        "          , supportsCacheControlOnTools = " <> renderBool c.supportsCacheControlOnTools,-        "          , supportsEagerToolInputStreaming = " <> renderBool c.supportsEagerToolInputStreaming,-        "          , sendSessionAffinityHeaders = " <> renderBool c.sendSessionAffinityHeaders,-        "          }"-      ]--renderMaxTokensField :: MaxTokensField -> Text-renderMaxTokensField = \case-  MaxCompletionTokensField -> "MaxCompletionTokensField"-  MaxTokensField -> "MaxTokensField"--renderThinkingFormat :: ThinkingFormat -> Text-renderThinkingFormat = \case-  ThinkingFormatOpenAI -> "ThinkingFormatOpenAI"-  ThinkingFormatOpenRouter -> "ThinkingFormatOpenRouter"-  ThinkingFormatDeepseek -> "ThinkingFormatDeepseek"-  ThinkingFormatTogether -> "ThinkingFormatTogether"-  ThinkingFormatZai -> "ThinkingFormatZai"-  ThinkingFormatQwen -> "ThinkingFormatQwen"-  ThinkingFormatNone -> "ThinkingFormatNone"--renderMaybeCacheControl :: Maybe CacheControlFormat -> Text-renderMaybeCacheControl = \case-  Nothing -> "Nothing"-  Just CacheControlFormatAnthropic -> "Just CacheControlFormatAnthropic"
+ gen/GenModelsCore.hs view
@@ -0,0 +1,490 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE OverloadedStrings #-}++-- | Pure core for @baikai-gen-models@: parse catalog JSON, flatten enabled+-- entries, reject generated identifier collisions, and render the generated+-- Haskell module. The executable wrapper owns only argument parsing, file IO,+-- and default path resolution.+module GenModelsCore+  ( CatalogFile (..),+    CatalogCompat (..),+    ModelEntry (..),+    CostEntry (..),+    GeneratedEntry (..),+    flattenEntries,+    checkIdentifierCollisions,+    sanitizeIdentifier,+    renderModule,+  )+where++import Baikai.Api (Api (..), parseApi)+import Baikai.Compat+  ( AnthropicMessagesCompat+      ( sendSessionAffinityHeaders,+        supportsCacheControlOnTools,+        supportsLongCacheRetention,+        thinkingStyle+      ),+    AnthropicThinkingStyle (..),+    CacheControlFormat (..),+    MaxTokensField (..),+    OpenAICompletionsCompat+      ( cacheControlFormat,+        maxTokensField,+        requiresThinkingAsText,+        supportsLongCacheRetention,+        supportsStrictMode,+        supportsUsageInStreaming,+        thinkingFormat+      ),+    ThinkingFormat (..),+    defaultAnthropicMessagesCompat,+    defaultOpenAICompletionsCompat,+  )+import Baikai.Model (InputModality (..))+import Data.Aeson (FromJSON (..), (.!=), (.:), (.:?))+import Data.Aeson qualified as Aeson+import Data.Aeson.Types (Parser, typeMismatch)+import Data.Map.Strict qualified as Map+import Data.Ratio (denominator, numerator)+import Data.Scientific (Scientific)+import Data.Text (Text)+import Data.Text qualified as Text+import Numeric.Natural (Natural)++-- * Catalog data types -----------------------------------------------++-- | One catalog file, in source form.+data CatalogFile = CatalogFile+  { provider :: !Text,+    baseUrl :: !Text,+    api :: !Api,+    compat :: !CatalogCompat,+    models :: ![ModelEntry]+  }++instance FromJSON CatalogFile where+  parseJSON = Aeson.withObject "CatalogFile" $ \o ->+    CatalogFile+      <$> o .: "provider"+      <*> o .: "baseUrl"+      <*> (parseApi <$> o .: "api")+      <*> o .: "compat"+      <*> o .: "models"++-- | A compat directive in the catalog. @"auto"@ defers to EP-5's+-- @baseUrl@-driven auto-detection (rendered as 'CompatNone'). The two+-- structured constructors carry a full override record.+data CatalogCompat+  = CatalogCompatAuto+  | CatalogCompatOpenAI !OpenAICompletionsCompat+  | CatalogCompatAnthropic !AnthropicMessagesCompat+  deriving stock (Show)++instance FromJSON CatalogCompat where+  parseJSON = \case+    Aeson.String "auto" -> pure CatalogCompatAuto+    Aeson.Object o -> do+      kind <- o .: "kind" :: Parser Text+      case kind of+        "openai-completions" ->+          CatalogCompatOpenAI <$> parseOpenAICompat o+        "anthropic-messages" ->+          CatalogCompatAnthropic <$> parseAnthropicCompat o+        _ ->+          fail $ "CatalogCompat: unknown kind " <> show kind+    v -> typeMismatch "CatalogCompat (expected \"auto\" or {\"kind\": ...})" v++parseOpenAICompat :: Aeson.Object -> Parser OpenAICompletionsCompat+parseOpenAICompat o = do+  let d = defaultOpenAICompletionsCompat+  mtf <- optionalField o "maxTokensField" parseMaxTokensField (maxTokensField d)+  sst <- o .:? "supportsStrictMode" .!= supportsStrictMode d+  rtat <- o .:? "requiresThinkingAsText" .!= requiresThinkingAsText d+  tf <- optionalField o "thinkingFormat" parseThinkingFormat (thinkingFormat d)+  ccf <- optionalMaybeField o "cacheControlFormat" parseCacheControlFormat (cacheControlFormat d)+  sus <- o .:? "supportsUsageInStreaming" .!= d.supportsUsageInStreaming+  slcr <- o .:? "supportsLongCacheRetention" .!= d.supportsLongCacheRetention+  pure+    d+      { maxTokensField = mtf,+        supportsStrictMode = sst,+        requiresThinkingAsText = rtat,+        thinkingFormat = tf,+        cacheControlFormat = ccf,+        supportsUsageInStreaming = sus,+        supportsLongCacheRetention = slcr+      }++parseAnthropicCompat :: Aeson.Object -> Parser AnthropicMessagesCompat+parseAnthropicCompat o = do+  let d = defaultAnthropicMessagesCompat+  slcr <- o .:? "supportsLongCacheRetention" .!= d.supportsLongCacheRetention+  scot <- o .:? "supportsCacheControlOnTools" .!= d.supportsCacheControlOnTools+  ssah <- o .:? "sendSessionAffinityHeaders" .!= d.sendSessionAffinityHeaders+  ts <- o .:? "thinkingStyle" .!= d.thinkingStyle+  pure+    d+      { supportsLongCacheRetention = slcr,+        supportsCacheControlOnTools = scot,+        sendSessionAffinityHeaders = ssah,+        thinkingStyle = ts+      }++parseMaxTokensField :: Text -> Parser MaxTokensField+parseMaxTokensField = \case+  "max_tokens" -> pure MaxTokensField+  "max_completion_tokens" -> pure MaxCompletionTokensField+  t -> fail $ "unknown maxTokensField: " <> Text.unpack t++parseThinkingFormat :: Text -> Parser ThinkingFormat+parseThinkingFormat = \case+  "openai" -> pure ThinkingFormatOpenAI+  "openrouter" -> pure ThinkingFormatOpenRouter+  "deepseek" -> pure ThinkingFormatDeepseek+  "together" -> pure ThinkingFormatTogether+  "zai" -> pure ThinkingFormatZai+  "qwen" -> pure ThinkingFormatQwen+  "none" -> pure ThinkingFormatNone+  t -> fail $ "unknown thinkingFormat: " <> Text.unpack t++parseCacheControlFormat :: Text -> Parser CacheControlFormat+parseCacheControlFormat = \case+  "anthropic" -> pure CacheControlFormatAnthropic+  t -> fail $ "unknown cacheControlFormat: " <> Text.unpack t++-- | Look up an optional field; missing returns @def@.+optionalField :: Aeson.Object -> Aeson.Key -> (Text -> Parser a) -> a -> Parser a+optionalField o key parser def = do+  mv <- o .:? key+  case mv of+    Nothing -> pure def+    Just v -> parser v++-- | Look up an optional @Maybe@-typed field. JSON @null@ or absence+-- yields @def@; otherwise the value is parsed through @parser@ and+-- wrapped in 'Just'.+optionalMaybeField ::+  Aeson.Object ->+  Aeson.Key ->+  (Text -> Parser a) ->+  Maybe a ->+  Parser (Maybe a)+optionalMaybeField o key parser def = do+  mv <- o .:? key+  case mv of+    Nothing -> pure def+    Just Aeson.Null -> pure Nothing+    Just (Aeson.String t) -> Just <$> parser t+    Just v -> typeMismatch "string or null" v++-- | One model row in a catalog file.+data ModelEntry = ModelEntry+  { entryId :: !Text,+    entryName :: !Text,+    entryReasoning :: !Bool,+    entryInput :: ![InputModality],+    entryCost :: !CostEntry,+    entryContextWindow :: !Natural,+    entryMaxOutputTokens :: !Natural,+    entryEnabled :: !Bool,+    entryCompatOverride :: !(Maybe CatalogCompat)+  }++instance FromJSON ModelEntry where+  parseJSON = Aeson.withObject "ModelEntry" $ \o ->+    ModelEntry+      <$> o .: "id"+      <*> o .: "name"+      <*> o .:? "reasoning" .!= False+      <*> (o .: "input" >>= traverse parseInputModality)+      <*> o .: "cost"+      <*> o .: "contextWindow"+      <*> o .: "maxOutputTokens"+      <*> o .:? "enabled" .!= True+      <*> o .:? "compat"++parseInputModality :: Text -> Parser InputModality+parseInputModality = \case+  "text" -> pure InputText+  "image" -> pure InputImage+  t -> fail $ "unknown input modality: " <> Text.unpack t++-- | Per-million-token cost rates. Parsed as 'Scientific' so the+-- round-trip into 'Rational' produces a small, canonical fraction+-- (@1.5@ -> @3 % 2@) rather than an arbitrary IEEE-754 approximation.+data CostEntry = CostEntry+  { costInput :: !Scientific,+    costOutput :: !Scientific,+    costCacheRead :: !Scientific,+    costCacheWrite :: !Scientific+  }++instance FromJSON CostEntry where+  parseJSON = Aeson.withObject "CostEntry" $ \o ->+    CostEntry+      <$> o .: "input"+      <*> o .: "output"+      <*> o .:? "cacheRead" .!= 0+      <*> o .:? "cacheWrite" .!= 0++-- * Flattening ---------------------------------------------------------++-- | One generated Haskell identifier plus the 'Model'-shaped record+-- it should be rendered as. Keeping these together lets the renderer+-- stay a pure transformation over a flat list.+data GeneratedEntry = GeneratedEntry+  { ident :: !Text,+    modelId :: !Text,+    name :: !Text,+    api :: !Api,+    provider :: !Text,+    baseUrl :: !Text,+    reasoning :: !Bool,+    input :: ![InputModality],+    cost :: !CostEntry,+    contextWindow :: !Natural,+    maxOutputTokens :: !Natural,+    compat :: !CatalogCompat+  }++flattenEntries :: CatalogFile -> [(Text, GeneratedEntry)]+flattenEntries c =+  [ (g.ident, g)+  | m <- models c,+    entryEnabled m,+    let g =+          GeneratedEntry+            { ident =+                sanitizeIdentifier (c.provider <> "_" <> entryId m),+              modelId = entryId m,+              name = entryName m,+              api = c.api,+              provider = c.provider,+              baseUrl = c.baseUrl,+              reasoning = entryReasoning m,+              input = entryInput m,+              cost = entryCost m,+              contextWindow = entryContextWindow m,+              maxOutputTokens = entryMaxOutputTokens m,+              compat =+                case entryCompatOverride m of+                  Just c' -> c'+                  Nothing -> c.compat+            }+  ]++-- | Reject catalogs whose sanitized generated Haskell binding names collide.+-- Without this check, two distinct upstream model ids such as @foo-bar@ and+-- @foo_bar@ would silently render duplicate top-level declarations.+checkIdentifierCollisions :: [(Text, GeneratedEntry)] -> Either Text ()+checkIdentifierCollisions entries =+  case Map.toList collisions of+    [] -> Right ()+    dupes ->+      Left $+        "duplicate generated model identifiers: "+          <> Text.intercalate "; " (map renderCollision dupes)+  where+    grouped =+      Map.fromListWith+        (++)+        [(i, [entry]) | (i, entry) <- entries]+    collisions = Map.filter ((> 1) . length) grouped+    renderCollision (i, es) =+      i+        <> " from "+        <> Text.intercalate ", " (map origin (reverse es))+    origin e = e.provider <> "/" <> e.modelId++-- | Replace any non-identifier character with @_@. Haskell allows+-- letters, digits, underscore, and apostrophe; everything else+-- (slash, dash, dot, colon, ...) becomes an underscore.+sanitizeIdentifier :: Text -> Text+sanitizeIdentifier = Text.map replace+  where+    replace c+      | (c >= 'a' && c <= 'z')+          || (c >= 'A' && c <= 'Z')+          || (c >= '0' && c <= '9')+          || c == '_'+          || c == '\'' =+          c+      | otherwise = '_'++-- * Source rendering --------------------------------------------------++renderModule :: [(Text, GeneratedEntry)] -> Text+renderModule entries =+  Text.unlines $+    dropTrailingEmpty+      ( header+          ++ concatMap (renderEntry . snd) entries+      )+  where+    dropTrailingEmpty = \case+      [] -> []+      xs+        | last xs == "" -> init xs+        | otherwise -> xs++    header =+      [ "-- AUTO-GENERATED by baikai-gen-models. Do not edit by hand.",+        "-- Regenerate with: cabal run baikai-gen-models",+        "{-# LANGUAGE OverloadedStrings #-}",+        "{-# OPTIONS_GHC -Wno-missing-export-lists #-}",+        "{-# OPTIONS_GHC -Wno-unused-imports #-}",+        "",+        "module Baikai.Models.Generated where",+        "",+        "import Baikai.Api (Api (..))",+        "import Baikai.Compat",+        "  ( AnthropicThinkingStyle (..),",+        "    CacheControlFormat (..),",+        "    MaxTokensField (..),",+        "    ThinkingFormat (..),",+        "    defaultAnthropicMessagesCompat,",+        "    defaultOpenAICompletionsCompat,",+        "  )",+        "import Baikai.Model",+        "  ( Compat (..),",+        "    InputModality (..),",+        "    Model,",+        "    ModelCost (..),",+        "    api,",+        "    baseUrl,",+        "    compat,",+        "    contextWindow,",+        "    cost,",+        "    emptyModel,",+        "    headers,",+        "    input,",+        "    maxOutputTokens,",+        "    modelId,",+        "    name,",+        "    provider,",+        "    reasoning,",+        "  )",+        "import Data.Map.Strict qualified as Map",+        "import Data.Ratio ((%))",+        ""+      ]++renderEntry :: GeneratedEntry -> [Text]+renderEntry g =+  [ g.ident <> " :: Model",+    g.ident <> " =",+    "  emptyModel",+    "    { modelId = " <> renderText g.modelId <> ",",+    "      name = " <> renderText g.name <> ",",+    "      api = " <> renderApiCtor g.api <> ",",+    "      provider = " <> renderText g.provider <> ",",+    "      baseUrl = " <> renderText g.baseUrl <> ",",+    "      reasoning = " <> renderBool g.reasoning <> ",",+    "      input = " <> renderInputList g.input <> ",",+    "      cost =",+    renderCost g.cost <> ",",+    "      contextWindow = " <> Text.pack (show g.contextWindow) <> ",",+    "      maxOutputTokens = " <> Text.pack (show g.maxOutputTokens) <> ",",+    "      headers = Map.empty,",+    "      compat = " <> renderCompat g.compat,+    "    }",+    ""+  ]++renderText :: Text -> Text+renderText t =+  "\""+    <> Text.replace "\"" "\\\"" (Text.replace "\\" "\\\\" t)+    <> "\""++renderBool :: Bool -> Text+renderBool True = "True"+renderBool False = "False"++renderInputList :: [InputModality] -> Text+renderInputList ms =+  "[" <> Text.intercalate ", " (map renderInput ms) <> "]"+  where+    renderInput InputText = "InputText"+    renderInput InputImage = "InputImage"++renderApiCtor :: Api -> Text+renderApiCtor = \case+  OpenAIChatCompletions -> "OpenAIChatCompletions"+  AnthropicMessages -> "AnthropicMessages"+  OpenAICompletionsCli -> "OpenAICompletionsCli"+  AnthropicMessagesCli -> "AnthropicMessagesCli"+  Custom t -> "Custom " <> renderText t++renderCost :: CostEntry -> Text+renderCost c =+  Text.intercalate+    "\n"+    [ "        ModelCost",+      "          { inputCost = " <> renderRational (toRational (costInput c)) <> ",",+      "            outputCost = " <> renderRational (toRational (costOutput c)) <> ",",+      "            cacheReadCost = " <> renderRational (toRational (costCacheRead c)) <> ",",+      "            cacheWriteCost = " <> renderRational (toRational (costCacheWrite c)),+      "          }"+    ]++renderRational :: Rational -> Text+renderRational r =+  Text.pack (show (numerator r)) <> " % " <> Text.pack (show (denominator r))++renderCompat :: CatalogCompat -> Text+renderCompat = \case+  CatalogCompatAuto -> "CompatNone"+  CatalogCompatOpenAI c ->+    Text.intercalate+      "\n"+      [ "CompatOpenAICompletions",+        "        defaultOpenAICompletionsCompat",+        "          { maxTokensField = " <> renderMaxTokensField c.maxTokensField <> ",",+        "            supportsStrictMode = " <> renderBool c.supportsStrictMode <> ",",+        "            requiresThinkingAsText = " <> renderBool c.requiresThinkingAsText <> ",",+        "            thinkingFormat = " <> renderThinkingFormat c.thinkingFormat <> ",",+        "            cacheControlFormat = " <> renderMaybeCacheControl c.cacheControlFormat <> ",",+        "            supportsUsageInStreaming = " <> renderBool c.supportsUsageInStreaming <> ",",+        "            supportsLongCacheRetention = " <> renderBool c.supportsLongCacheRetention,+        "          }"+      ]+  CatalogCompatAnthropic c ->+    Text.intercalate+      "\n"+      [ "CompatAnthropicMessages",+        "        defaultAnthropicMessagesCompat",+        "          { supportsLongCacheRetention = " <> renderBool c.supportsLongCacheRetention <> ",",+        "            supportsCacheControlOnTools = " <> renderBool c.supportsCacheControlOnTools <> ",",+        "            sendSessionAffinityHeaders = " <> renderBool c.sendSessionAffinityHeaders <> ",",+        "            thinkingStyle = " <> renderAnthropicThinkingStyle c.thinkingStyle,+        "          }"+      ]++renderMaxTokensField :: MaxTokensField -> Text+renderMaxTokensField = \case+  MaxCompletionTokensField -> "MaxCompletionTokensField"+  MaxTokensField -> "MaxTokensField"++renderThinkingFormat :: ThinkingFormat -> Text+renderThinkingFormat = \case+  ThinkingFormatOpenAI -> "ThinkingFormatOpenAI"+  ThinkingFormatOpenRouter -> "ThinkingFormatOpenRouter"+  ThinkingFormatDeepseek -> "ThinkingFormatDeepseek"+  ThinkingFormatTogether -> "ThinkingFormatTogether"+  ThinkingFormatZai -> "ThinkingFormatZai"+  ThinkingFormatQwen -> "ThinkingFormatQwen"+  ThinkingFormatNone -> "ThinkingFormatNone"++renderAnthropicThinkingStyle :: AnthropicThinkingStyle -> Text+renderAnthropicThinkingStyle = \case+  AnthropicThinkingBudget -> "AnthropicThinkingBudget"+  AnthropicThinkingAdaptive -> "AnthropicThinkingAdaptive"++renderMaybeCacheControl :: Maybe CacheControlFormat -> Text+renderMaybeCacheControl = \case+  Nothing -> "Nothing"+  Just CacheControlFormatAnthropic -> "Just CacheControlFormatAnthropic"
src/Baikai.hs view
@@ -2,6 +2,18 @@ -- gives a downstream consumer everything needed to declare a -- 'Model', build a 'Context' and 'Options', and dispatch a call -- through the registered handler.+--+-- This umbrella intentionally omits opt-in subsystems with their own+-- vocabularies: tracing, OpenTelemetry sinks, embeddings, call-log+-- plumbing, pricing lookup internals, the generated model catalog, and+-- "Baikai.Prelude". Import those modules directly when you need them.+--+-- Evolvable configuration records expose their type, field selectors,+-- and an @empty*@ or @default*@ base value rather than their data+-- constructor. Build those records by record update so adding fields in+-- a later release does not force source changes. Closed sums and+-- provider-produced payload records remain constructible where callers+-- need to pattern match or build fixtures. module Baikai   ( -- * Types     module Baikai.AgentAssets,
src/Baikai/Auth.hs view
@@ -8,11 +8,13 @@ -- value does not read the environment. module Baikai.Auth   ( ApiKeySource (..),+    defaultApiKeyEnvForBaseUrl,     renderApiKeySourceForDebug,     resolveApiKey,   ) where +import Baikai.Compat (hostMatchesSuffix, urlHost) import Baikai.Error (authError) import Control.Exception (throwIO) import Control.Monad.IO.Class (MonadIO, liftIO)@@ -24,6 +26,7 @@ data ApiKeySource   = ApiKeyLiteral !Text   | ApiKeyEnv !String+  | ApiKeyEnvChain ![String]   deriving stock (Eq)  instance Show ApiKeySource where@@ -39,13 +42,43 @@       [ "source" .= ("env" :: Text),         "name" .= name       ]+  toJSON (ApiKeyEnvChain names) =+    object+      [ "source" .= ("env-chain" :: Text),+        "names" .= names+      ] +-- | Conventional API-key environment variable for a known provider+-- host. Unknown hosts return 'Nothing' so callers can require an+-- explicit 'ApiKeySource' instead of leaking another provider's+-- credential.+defaultApiKeyEnvForBaseUrl :: Text -> Maybe String+defaultApiKeyEnvForBaseUrl baseUrl = do+  host <- urlHost baseUrl+  match host+  where+    match host+      | hostMatchesSuffix host "api.openai.com" = Just "OPENAI_API_KEY"+      | hostMatchesSuffix host "api.deepseek.com" = Just "DEEPSEEK_API_KEY"+      | hostMatchesSuffix host "openrouter.ai" = Just "OPENROUTER_API_KEY"+      | hostMatchesSuffix host "together.xyz" = Just "TOGETHER_API_KEY"+      | hostMatchesSuffix host "together.ai" = Just "TOGETHER_API_KEY"+      | hostMatchesSuffix host "z.ai" = Just "ZAI_API_KEY"+      | hostMatchesSuffix host "dashscope.aliyuncs.com" = Just "DASHSCOPE_API_KEY"+      | hostMatchesSuffix host "dashscope-intl.aliyuncs.com" = Just "DASHSCOPE_API_KEY"+      | hostMatchesSuffix host "qwen.ai" = Just "DASHSCOPE_API_KEY"+      | hostMatchesSuffix host "api.anthropic.com" = Just "ANTHROPIC_API_KEY"+      | hostMatchesSuffix host "fireworks.ai" = Just "FIREWORKS_API_KEY"+      | otherwise = Nothing+ -- | Render a credential source for logs, test failures, and debugging without -- exposing literal secret material. renderApiKeySourceForDebug :: ApiKeySource -> Text renderApiKeySourceForDebug (ApiKeyLiteral _) = "ApiKeyLiteral <redacted>" renderApiKeySourceForDebug (ApiKeyEnv name) =   "ApiKeyEnv " <> Text.pack (show name)+renderApiKeySourceForDebug (ApiKeyEnvChain names) =+  "ApiKeyEnvChain " <> Text.pack (show names)  -- | Resolve a key source to a plain 'Text'. Throws a 'BaikaiError' in the -- 'Baikai.Error.AuthError' category if 'ApiKeyEnv' is used and the named variable@@ -57,3 +90,16 @@     Environment.lookupEnv name >>= \case       Just v -> pure (Text.pack v)       Nothing -> throwIO (authError ("env var " <> Text.pack name <> " is not set"))+resolveApiKey (ApiKeyEnvChain names) =+  liftIO (go names)+  where+    go [] =+      throwIO+        (authError ("none of the env vars " <> renderedNames <> " are set"))+    go (name : rest) =+      Environment.lookupEnv name >>= \case+        Just v -> pure (Text.pack v)+        Nothing -> go rest+    renderedNames = case names of+      [] -> "<empty>"+      _ -> Text.intercalate ", " (Text.pack <$> names)
src/Baikai/Compat.hs view
@@ -14,31 +14,44 @@ -- on a per-'Baikai.Model.Model' record means the same provider -- handler can serve both hosts without any per-host code branching. ----- These record constructors are intentionally public in baikai 0.1.--- They are the supported escape hatch for hand-rolled models and--- generated catalog overrides when a host speaks a familiar protocol--- but rejects or requires a small request-shaping difference. New--- fields or enum constructors may be added in later minor versions as--- new provider quirks are discovered; callers that want fewer upgrade--- edits should start from the @default*@ values and use record updates--- rather than constructing every field from scratch.+-- The records export their field selectors but not their data+-- constructors. They are expected to grow as provider quirks are+-- discovered, so callers should start from the @default*@ values and+-- use record updates for the fields they need to override. -- -- Auto-detection from a 'Baikai.Model.Model' @baseUrl@ provides -- reasonable defaults so callers rarely need to spell out a full -- compat record. module Baikai.Compat   ( -- * OpenAI Chat Completions compat-    OpenAICompletionsCompat (..),+    OpenAICompletionsCompat+      ( maxTokensField,+        supportsStrictMode,+        requiresThinkingAsText,+        thinkingFormat,+        cacheControlFormat,+        supportsUsageInStreaming,+        supportsLongCacheRetention+      ),     defaultOpenAICompletionsCompat,     MaxTokensField (..),     ThinkingFormat (..),     CacheControlFormat (..),      -- * Anthropic Messages compat-    AnthropicMessagesCompat (..),+    AnthropicMessagesCompat+      ( supportsLongCacheRetention,+        supportsCacheControlOnTools,+        sendSessionAffinityHeaders,+        thinkingStyle+      ),+    AnthropicThinkingStyle (..),     defaultAnthropicMessagesCompat,+    defaultAnthropicThinkingStyle,      -- * Auto-detection from baseUrl+    urlHost,+    hostMatchesSuffix,     autoDetectOpenAICompletions,     autoDetectAnthropicMessages,   )@@ -91,6 +104,16 @@   deriving stock (Eq, Show, Generic)   deriving anyclass (FromJSON, ToJSON) +-- | Which request shape an Anthropic-compatible host/model accepts+-- for extended thinking. Budget-era models take+-- @{"type":"enabled","budget_tokens":N}@; adaptive-era models take+-- @{"type":"adaptive"}@ with depth guided by @output_config.effort@.+data AnthropicThinkingStyle+  = AnthropicThinkingBudget+  | AnthropicThinkingAdaptive+  deriving stock (Eq, Show, Generic)+  deriving anyclass (FromJSON, ToJSON)+ -- | Feature flags for one OpenAI-compatible host. -- -- Every flag has a default that reproduces OpenAI's own behaviour —@@ -98,34 +121,45 @@ -- override the differing fields only. data OpenAICompletionsCompat = OpenAICompletionsCompat   { -- | Where the max-output-tokens cap goes in the request body.+    --   Consumed by @Baikai.Provider.OpenAI.Shape.renameMaxTokens@,+    --   which rewrites @max_completion_tokens@ to @max_tokens@+    --   for hosts that require the legacy key.     maxTokensField :: !MaxTokensField,-    -- | Whether the host accepts the @developer@ message role-    --   (OpenAI's o-series) or only @system@.-    supportsDeveloperRole :: !Bool,     -- | Whether the host accepts @strict: true@ on function tool-    --   definitions. Set 'False' to drop the field on hosts that-    --   reject it.+    --   definitions. Consumed by+    --   @Baikai.Provider.OpenAI.Api.mkOpenAIResponseFormat@ and+    --   @Baikai.Provider.OpenAI.Shape.dropUnsupportedStrict@ to+    --   omit JSON-schema @strict@ on hosts that reject it.     supportsStrictMode :: !Bool,     -- | Whether the host smuggles thinking into the assistant text as-    --   @\<thinking\>...\</thinking\>@ markers. When 'True', the-    --   provider's stream transformer extracts those markers into-    --   typed thinking deltas on the way out. (The transformer itself-    --   is wired in by EP-3; EP-5 only flips the switch.)+    --   @\<think\>...\</think\>@ or+    --   @\<thinking\>...\</thinking\>@ markers. Field-based reasoning+    --   extraction (for @reasoning_content@ / @reasoning@ deltas) is+    --   unconditional; this flag enables the incremental tag scanner+    --   in @Baikai.Provider.OpenAI.Api.translateTextLikeDelta@ for+    --   hosts that do not split reasoning into a separate field.     requiresThinkingAsText :: !Bool,     -- | The wire shape the host accepts for reasoning-effort-    --   preferences.+    --   preferences. Consumed by+    --   @Baikai.Provider.OpenAI.Api.applyThinkingFormat@ for the+    --   OpenAI-native field and by+    --   @Baikai.Provider.OpenAI.Shape.injectThinkingShape@ for+    --   OpenAI-compatible host-specific JSON keys.     thinkingFormat :: !ThinkingFormat,     -- | Whether the host accepts Anthropic-style @cache_control@     --   markers (some OpenRouter routes pass them through to an-    --   Anthropic backend).+    --   Anthropic backend). Consumed by+    --   @Baikai.Provider.OpenAI.Shape.injectCacheControl@.     cacheControlFormat :: !(Maybe CacheControlFormat),     -- | Whether the host emits a final @usage@ chunk in streaming-    --   responses. OpenAI does; older OpenAI-compatible servers may-    --   not. Currently advisory.+    --   responses. Consumed by+    --   @Baikai.Provider.OpenAI.Shape.streamRequestBody@ to include+    --   or omit @stream_options.include_usage@.     supportsUsageInStreaming :: !Bool,     -- | Whether the host honours long (1h) cache TTLs through the-    --   Anthropic-style cache_control marker. Currently only relevant-    --   when 'cacheControlFormat' is 'Just CacheControlFormatAnthropic'.+    --   Anthropic-style cache_control marker. Consumed by+    --   @Baikai.Provider.OpenAI.Shape.injectCacheControl@ when+    --   'cacheControlFormat' is 'Just CacheControlFormatAnthropic'.     supportsLongCacheRetention :: !Bool   }   deriving stock (Eq, Show, Generic)@@ -136,7 +170,6 @@ defaultOpenAICompletionsCompat =   OpenAICompletionsCompat     { maxTokensField = MaxCompletionTokensField,-      supportsDeveloperRole = True,       supportsStrictMode = True,       requiresThinkingAsText = False,       thinkingFormat = ThinkingFormatOpenAI,@@ -150,17 +183,24 @@   { -- | Whether the host honours Anthropic's     --   @cache_control.ttl: "1h"@ long-retention marker. When 'False',     --   long-retention preferences silently downgrade to ephemeral.+    --   Consumed by @Baikai.Provider.Claude.Api.computeCacheControl@+    --   for top-level cache markers and by+    --   @Baikai.Provider.Claude.Shape.injectToolCacheControl@ for+    --   tool cache markers.     supportsLongCacheRetention :: !Bool,     -- | Whether tool definitions accept @cache_control@ markers.+    --   Consumed by+    --   @Baikai.Provider.Claude.Shape.injectToolCacheControl@.     --   Anthropic's own host does; some compatible hosts do not.     supportsCacheControlOnTools :: !Bool,-    -- | Whether the host supports per-tool eager input streaming-    --   (Anthropic's @fine-grained-tool-streaming@ beta). Currently-    --   advisory; the SDK does not expose the field directly.-    supportsEagerToolInputStreaming :: !Bool,     -- | Whether to add session-affinity headers on every request-    --   (Fireworks-style routing).-    sendSessionAffinityHeaders :: !Bool+    --   (Fireworks-style routing). Consumed by+    --   @Baikai.Provider.Claude.Transport.requestHeaders@.+    sendSessionAffinityHeaders :: !Bool,+    -- | Which extended-thinking request shape to send for the+    --   selected model generation. Consumed by+    --   @Baikai.Provider.Claude.Api.computeThinking@.+    thinkingStyle :: !AnthropicThinkingStyle   }   deriving stock (Eq, Show, Generic)   deriving anyclass (FromJSON, ToJSON)@@ -171,52 +211,64 @@   AnthropicMessagesCompat     { supportsLongCacheRetention = True,       supportsCacheControlOnTools = True,-      supportsEagerToolInputStreaming = True,-      sendSessionAffinityHeaders = False+      sendSessionAffinityHeaders = False,+      thinkingStyle = AnthropicThinkingBudget     } +-- | The thinking style a first-party Anthropic model id defaults to+-- when the model carries no explicit compat record. Unknown ids+-- default to the budget style used by earlier model generations.+defaultAnthropicThinkingStyle :: Text -> AnthropicThinkingStyle+defaultAnthropicThinkingStyle modelId+  | adaptive "claude-opus-4-6" = AnthropicThinkingAdaptive+  | adaptive "claude-opus-4-7" = AnthropicThinkingAdaptive+  | adaptive "claude-opus-4-8" = AnthropicThinkingAdaptive+  | adaptive "claude-fable-5" = AnthropicThinkingAdaptive+  | otherwise = AnthropicThinkingBudget+  where+    adaptive prefix = prefix `Text.isPrefixOf` modelId+ -- | Pick a sensible compat record for an unknown OpenAI-compatible -- host based on its @baseUrl@. Falls back to -- 'defaultOpenAICompletionsCompat' for hosts the table does not -- recognise. autoDetectOpenAICompletions :: Text -> OpenAICompletionsCompat autoDetectOpenAICompletions url-  | hasInfix "api.openai.com" = defaultOpenAICompletionsCompat-  | hasInfix "api.deepseek.com" =+  | matches "api.openai.com" = defaultOpenAICompletionsCompat+  | matches "api.deepseek.com" =       defaultOpenAICompletionsCompat         { thinkingFormat = ThinkingFormatDeepseek,           requiresThinkingAsText = True,           maxTokensField = MaxTokensField,-          supportsStrictMode = False,-          supportsDeveloperRole = False+          supportsStrictMode = False         }-  | hasInfix "openrouter.ai" =+  | matches "openrouter.ai" =       defaultOpenAICompletionsCompat         { thinkingFormat = ThinkingFormatOpenRouter,           supportsStrictMode = False,-          supportsDeveloperRole = False,           cacheControlFormat = Just CacheControlFormatAnthropic         }-  | hasInfix "together.xyz" || hasInfix "api.together.ai" =+  | matches "together.xyz" || matches "together.ai" =       defaultOpenAICompletionsCompat         { thinkingFormat = ThinkingFormatTogether,-          supportsStrictMode = False,-          supportsDeveloperRole = False+          supportsStrictMode = False         }-  | hasInfix "z.ai" =+  | matches "z.ai" =       defaultOpenAICompletionsCompat         { thinkingFormat = ThinkingFormatZai,           supportsStrictMode = False         }-  | hasInfix "dashscope" || hasInfix "qwen" =+  | matches "dashscope.aliyuncs.com"+      || matches "dashscope-intl.aliyuncs.com"+      || matches "qwen.ai" =       defaultOpenAICompletionsCompat         { thinkingFormat = ThinkingFormatQwen,           supportsStrictMode = False         }   | otherwise = defaultOpenAICompletionsCompat   where-    hasInfix :: Text -> Bool-    hasInfix needle = needle `Text.isInfixOf` url+    host = urlHost url+    matches suffix = maybe False (`hostMatchesSuffix` suffix) host  -- | Pick a sensible compat record for an unknown -- Anthropic-compatible host based on its @baseUrl@. Falls back to@@ -224,8 +276,8 @@ -- recognise. autoDetectAnthropicMessages :: Text -> AnthropicMessagesCompat autoDetectAnthropicMessages url-  | hasInfix "api.anthropic.com" = defaultAnthropicMessagesCompat-  | hasInfix "fireworks.ai" =+  | matches "api.anthropic.com" = defaultAnthropicMessagesCompat+  | matches "fireworks.ai" =       defaultAnthropicMessagesCompat         { supportsCacheControlOnTools = False,           sendSessionAffinityHeaders = True,@@ -233,5 +285,29 @@         }   | otherwise = defaultAnthropicMessagesCompat   where-    hasInfix :: Text -> Bool-    hasInfix needle = needle `Text.isInfixOf` url+    host = urlHost url+    matches suffix = maybe False (`hostMatchesSuffix` suffix) host++-- | Extract a hostname from a URL-ish value. This is intentionally+-- small and total rather than a validating URI parser: it drops an+-- optional scheme, optional userinfo, then stops at '/', ':', '?', or+-- '#'. Empty results return 'Nothing'.+urlHost :: Text -> Maybe Text+urlHost raw =+  let noScheme = case Text.breakOn "://" raw of+        (_, rest) | not (Text.null rest) -> Text.drop 3 rest+        _ -> raw+      noUser = last (Text.splitOn "@" noScheme)+      host = Text.toLower (Text.strip (Text.takeWhile hostChar noUser))+   in if Text.null host then Nothing else Just host+  where+    hostChar c = c /= '/' && c /= ':' && c /= '?' && c /= '#'++-- | Match a hostname against a suffix at a label boundary.+hostMatchesSuffix :: Text -> Text -> Bool+hostMatchesSuffix host suffix =+  let h = Text.toLower (Text.strip host)+      s = Text.toLower (Text.strip suffix)+   in not (Text.null h)+        && not (Text.null s)+        && (h == s || ("." <> s) `Text.isSuffixOf` h)
src/Baikai/Content.hs view
@@ -28,6 +28,10 @@     ToolResultContent (..),      -- * Smart defaults+    emptyTextContent,+    emptyThinkingContent,+    emptyToolCall,+    emptyImageContent,     _TextContent,     _ThinkingContent,     _ToolCall,@@ -70,7 +74,9 @@ -- provider; it must be threaded back into the next request unchanged for -- the model to resume from the same state. @redacted@ is 'True' when the -- provider hid the underlying content (Anthropic flags this when its--- safety system removes a thinking block from the wire response).+-- safety system removes a thinking block from the wire response). When+-- @redacted@ is 'True', @thinking@ holds the provider's opaque encrypted+-- payload verbatim; callers must not display or edit it. data ThinkingContent = ThinkingContent   { thinking :: !Text,     signature :: !(Maybe Text),@@ -116,27 +122,27 @@   | ToolResultImage !ImageContent   deriving stock (Eq, Show, Generic) -_TextContent :: TextContent-_TextContent = TextContent {text = Text.empty}+emptyTextContent :: TextContent+emptyTextContent = TextContent {text = Text.empty} -_ThinkingContent :: ThinkingContent-_ThinkingContent =+emptyThinkingContent :: ThinkingContent+emptyThinkingContent =   ThinkingContent     { thinking = Text.empty,       signature = Nothing,       redacted = False     } -_ToolCall :: ToolCall-_ToolCall =+emptyToolCall :: ToolCall+emptyToolCall =   ToolCall     { id_ = Text.empty,       name = Text.empty,       arguments = Null     } -_ImageContent :: ImageContent-_ImageContent =+emptyImageContent :: ImageContent+emptyImageContent =   ImageContent     { imageData = BS.empty,       mimeType = Text.empty@@ -210,3 +216,19 @@  instance ToJSON ToolResultContent where   toJSON = genericToJSON contentSumOptions++{-# DEPRECATED _TextContent "Use emptyTextContent instead." #-}+_TextContent :: TextContent+_TextContent = emptyTextContent++{-# DEPRECATED _ThinkingContent "Use emptyThinkingContent instead." #-}+_ThinkingContent :: ThinkingContent+_ThinkingContent = emptyThinkingContent++{-# DEPRECATED _ToolCall "Use emptyToolCall instead." #-}+_ToolCall :: ToolCall+_ToolCall = emptyToolCall++{-# DEPRECATED _ImageContent "Use emptyImageContent instead." #-}+_ImageContent :: ImageContent+_ImageContent = emptyImageContent
src/Baikai/Context.hs view
@@ -14,17 +14,27 @@ -- referenced by the @tools@ field, so 'Baikai.Tool' cannot itself -- depend on 'Context'). module Baikai.Context-  ( Context (..),+  ( Context,+    systemPrompt,+    messages,+    tools,+    emptyContext,     _Context,+    contextOf,+    systemUser,+    addUser,+    addMessage,+    addResponse,     appendToolResult,     appendToolResultText,   ) where  import Baikai.Content (AssistantContent (..), ToolCall (..))-import Baikai.Message (Message (..), ToolResult, toolResultFromCallNow, toolResultText)+import Baikai.Message (Message (..), ToolResult, toolResultFromCallNow, toolResultText, user) import Baikai.Response (Response (..), responseMessage) import Baikai.Tool (Tool)+import Control.Applicative ((<|>)) import Control.Lens ((&), (.~), (^.)) import Data.Aeson (ToJSON) import Data.Generics.Labels ()@@ -41,14 +51,51 @@   deriving stock (Eq, Show, Generic)   deriving anyclass (ToJSON) -_Context :: Context-_Context =+emptyContext :: Context+emptyContext =   Context     { systemPrompt = Nothing,       messages = V.empty,       tools = V.empty     } +instance Semigroup Context where+  a <> b =+    Context+      { systemPrompt = systemPrompt a <|> systemPrompt b,+        messages = messages a <> messages b,+        tools = tools a <> tools b+      }++instance Monoid Context where+  mempty = emptyContext++-- | Build a context from an existing list of messages.+contextOf :: [Message] -> Context+contextOf msgs =+  emptyContext {messages = V.fromList msgs}++-- | Build a context with a system prompt and one user message.+systemUser :: Text -> Text -> Context+systemUser sys prompt =+  emptyContext+    { systemPrompt = Just sys,+      messages = V.singleton (user prompt)+    }++-- | Append a one-text-block user message.+addUser :: Text -> Context -> Context+addUser t = addMessage (user t)++-- | Append a message to the conversation.+addMessage :: Message -> Context -> Context+addMessage msg ctx =+  ctx {messages = V.snoc (messages ctx) msg}++-- | Append a provider response as an assistant message.+addResponse :: Response -> Context -> Context+addResponse resp = addMessage (responseMessage resp)+ -- | Execute every 'AssistantToolCall' in @resp@'s message via the -- caller-supplied dispatcher, then append the assistant message and -- one 'Baikai.Message.ToolResultMessage' per call to @ctx@. The@@ -92,3 +139,7 @@   IO Context appendToolResultText ctx resp dispatcher =   appendToolResult ctx resp (fmap toolResultText . dispatcher)++{-# DEPRECATED _Context "Use emptyContext instead." #-}+_Context :: Context+_Context = emptyContext
src/Baikai/Cost.hs view
@@ -1,6 +1,8 @@ module Baikai.Cost   ( Cost (..),     CostBreakdown (..),+    zeroCost,+    zeroCostBreakdown,     _Cost,     _CostBreakdown,     usdAsScientific,@@ -25,8 +27,8 @@   }   deriving stock (Eq, Show, Generic) -_CostBreakdown :: CostBreakdown-_CostBreakdown =+zeroCostBreakdown :: CostBreakdown+zeroCostBreakdown =   CostBreakdown     { inputUsd = 0,       outputUsd = 0,@@ -34,8 +36,8 @@       cachedWriteUsd = 0     } -_Cost :: Cost-_Cost = Cost {usd = 0, breakdown = _CostBreakdown}+zeroCost :: Cost+zeroCost = Cost {usd = 0, breakdown = zeroCostBreakdown}  -- Field-wise combination so callers can total per-call costs with -- '(<>)'/'mconcat'. 'mempty' reuses the existing zero value, so the@@ -51,13 +53,13 @@       }  instance Monoid CostBreakdown where-  mempty = _CostBreakdown+  mempty = zeroCostBreakdown  instance Semigroup Cost where   a <> b = Cost {usd = usd a + usd b, breakdown = breakdown a <> breakdown b}  instance Monoid Cost where-  mempty = _Cost+  mempty = zeroCost  instance ToJSON CostBreakdown where   toJSON cb =@@ -80,3 +82,11 @@  ratToSci :: Rational -> Scientific ratToSci = fst . fromRationalRepetendUnlimited++{-# DEPRECATED _CostBreakdown "Use zeroCostBreakdown instead." #-}+_CostBreakdown :: CostBreakdown+_CostBreakdown = zeroCostBreakdown++{-# DEPRECATED _Cost "Use zeroCost instead." #-}+_Cost :: Cost+_Cost = zeroCost
src/Baikai/Cost/Log.hs view
@@ -11,6 +11,10 @@ -- > withCallLog (CallLogConfig "/tmp/baikai.jsonl" True) $ \h -> do -- >   _ <- runRequestWithLog h model context options -- >   pure ()+--+-- If the worker cannot open or write the log file, the close path+-- reports one warning on stderr and returns. Logging failures do not+-- mask the request body or hang release actions. module Baikai.Cost.Log   ( CallLogConfig (..),     CallLogEntry (..),@@ -45,8 +49,9 @@ import Control.Concurrent (forkIO) import Control.Concurrent.Chan (Chan, newChan, readChan, writeChan) import Control.Concurrent.MVar (MVar, newEmptyMVar, putMVar, takeMVar)-import Control.Exception (bracket)+import Control.Exception (SomeException, bracket, displayException, try) import Control.Lens ((^.))+import Control.Monad (forM_) import Control.Monad.IO.Class (MonadIO, liftIO) import Control.Monad.IO.Unlift (MonadUnliftIO, withRunInIO) import Data.Aeson (FromJSON, ToJSON)@@ -55,6 +60,7 @@ import Data.Foldable (find) import Data.Function ((&)) import Data.Generics.Labels ()+import Data.IORef (IORef, newIORef, readIORef, writeIORef) import Data.Scientific (Scientific) import Data.Text (Text) import Data.Text qualified as Text@@ -64,7 +70,7 @@ import Numeric.Natural (Natural) import Streamly.Data.Fold qualified as Fold import Streamly.Data.Stream qualified as Stream-import System.IO (BufferMode (LineBuffering), IOMode (AppendMode), hSetBuffering, withFile)+import System.IO (BufferMode (LineBuffering), IOMode (AppendMode), hPutStrLn, hSetBuffering, stderr, withFile)  -- | Where (and whether) to write the call log. data CallLogConfig = CallLogConfig@@ -85,7 +91,7 @@     cachedInputTokens :: !(Maybe Natural),     reasoningTokens :: !(Maybe Natural),     usd :: !(Maybe Scientific),-    latencyMs :: !Integer,+    latencyMs :: !Int,     promptSummary :: !Text   }   deriving stock (Eq, Show, Generic)@@ -95,7 +101,8 @@ data CallLogHandle = CallLogHandle   { chan :: !(Chan (Maybe CallLogEntry)),     done :: !(MVar ()),-    cfg :: !CallLogConfig+    cfg :: !CallLogConfig,+    workerError :: !(IORef (Maybe SomeException))   }  -- | Open a handle. When @enabled = True@, fork the worker thread@@ -104,12 +111,13 @@ openCallLog c = liftIO $ do   ch <- newChan   d <- newEmptyMVar+  e <- newIORef Nothing   case enabled c of     False -> putMVar d ()     True -> do-      _ <- forkIO (worker (path c) ch d)+      _ <- forkIO (worker (path c) ch d e)       pure ()-  pure CallLogHandle {chan = ch, done = d, cfg = c}+  pure CallLogHandle {chan = ch, done = d, cfg = c, workerError = e}  -- | Signal shutdown and block until the worker has drained every -- pending entry to disk.@@ -119,6 +127,11 @@     True -> writeChan (chan h) Nothing     False -> pure ()   takeMVar (done h)+  merr <- readIORef (workerError h)+  forM_ merr $ \e ->+    hPutStrLn+      stderr+      ("baikai: call log worker failed; pending entries were dropped: " <> displayException e)  -- | Bracketed lifetime: open the handle, run the body, close -- exactly once on every path (including exceptions).@@ -185,20 +198,26 @@  -- | Worker loop: pull 'Maybe CallLogEntry' off the channel, drain -- through a streamly fold that writes each entry as one JSON line.-worker :: FilePath -> Chan (Maybe CallLogEntry) -> MVar () -> IO ()-worker p ch d =-  withFile p AppendMode $ \fh -> do-    hSetBuffering fh LineBuffering-    let step :: () -> IO (Maybe (CallLogEntry, ()))-        step () = do-          msg <- readChan ch-          case msg of-            Nothing -> pure Nothing-            Just e -> pure (Just (e, ()))-    Stream.unfoldrM step ()-      & Stream.fold (Fold.drainMapM (writeEntry fh))-    putMVar d ()+worker :: FilePath -> Chan (Maybe CallLogEntry) -> MVar () -> IORef (Maybe SomeException) -> IO ()+worker p ch d errRef = do+  result <- try drainToFile :: IO (Either SomeException ())+  case result of+    Left e -> writeIORef errRef (Just e)+    Right () -> pure ()+  putMVar d ()   where+    drainToFile =+      withFile p AppendMode $ \fh -> do+        hSetBuffering fh LineBuffering+        let step :: () -> IO (Maybe (CallLogEntry, ()))+            step () = do+              msg <- readChan ch+              case msg of+                Nothing -> pure Nothing+                Just e -> pure (Just (e, ()))+        Stream.unfoldrM step ()+          & Stream.fold (Fold.drainMapM (writeEntry fh))+     writeEntry fh entry =       BSL.hPut fh (Aeson.encode entry <> "\n") 
src/Baikai/Cost/Pricing.hs view
@@ -4,7 +4,7 @@ -- Pricing rates live on 'Baikai.Model.Model.cost' directly, so the -- computation collapses to a record-field access. Models without -- published pricing carry a zero 'Baikai.Model.ModelCost' (the--- default in '_Model'), producing a zero 'Cost'.+-- default in 'emptyModel'), producing a zero 'Cost'. module Baikai.Cost.Pricing   ( computeCost,     attachCost,
src/Baikai/Embedding.hs view
@@ -16,15 +16,19 @@ -- for embeddings. module Baikai.Embedding   ( EmbeddingModel (..),+    emptyEmbeddingModel,     _EmbeddingModel,     openAIEmbeddingModel,     mkEmbeddingRequest,+    firstEmbedding,     embed,     embedOne,   ) where  import Baikai.Auth (ApiKeySource (..), resolveApiKey)+import Baikai.Error (BaikaiError, decodeError)+import Control.Exception (throwIO) import Data.Text (Text) import Data.Vector (Vector) import Data.Vector qualified as V@@ -48,8 +52,8 @@  -- | A blank embedding model; a record-update target for hand-built models. Keyed -- on @OPENAI_API_KEY@ by default.-_EmbeddingModel :: EmbeddingModel-_EmbeddingModel =+emptyEmbeddingModel :: EmbeddingModel+emptyEmbeddingModel =   EmbeddingModel     { modelId = "",       baseUrl = "",@@ -61,7 +65,7 @@ -- dimensionality. openAIEmbeddingModel :: Text -> EmbeddingModel openAIEmbeddingModel mid =-  _EmbeddingModel+  emptyEmbeddingModel     { modelId = mid,       baseUrl = "https://api.openai.com",       dimensions = Nothing,@@ -78,6 +82,18 @@       Emb.dimensions = dimensions m     } +-- | Extract the first embedding vector from an SDK response. OpenAI's+-- embeddings endpoint normally returns one item per input, but a malformed or+-- compatible endpoint can return an empty @data@ array. Treat that as a typed+-- decode failure instead of indexing the empty vector.+firstEmbedding :: Vector Emb.EmbeddingObject -> Either BaikaiError (Vector Double)+firstEmbedding objs =+  case V.uncons objs of+    Nothing ->+      Left (decodeError "embeddings response contained no data")+    Just (obj, _) ->+      Right (Emb.embedding obj)+ -- | Embed a batch of texts: one vector per input text, in input order. The SDK's -- @CreateEmbeddings.input@ is a single 'Text', so this loops one call per text. The -- transport exception (a Servant client error) is let propagate — error remapping@@ -92,14 +108,22 @@   where     embedText create t = do       objs <- create (mkEmbeddingRequest m t)-      pure (Emb.embedding (V.head objs))+      either throwIO pure (firstEmbedding objs)  -- | Embed a single text. embedOne :: EmbeddingModel -> Text -> IO (Vector Double)-embedOne m t = V.head <$> embed m [t]+embedOne m t = do+  vs <- embed m [t]+  case V.uncons vs of+    Just (v, _) -> pure v+    Nothing -> throwIO (decodeError "embedding batch unexpectedly returned no vectors")  -- | Substitute the OpenAI default for an empty base URL (as the chat provider does). urlOf :: EmbeddingModel -> Text urlOf m = case baseUrl m of   "" -> "https://api.openai.com"   u -> u++{-# DEPRECATED _EmbeddingModel "Use emptyEmbeddingModel instead." #-}+_EmbeddingModel :: EmbeddingModel+_EmbeddingModel = emptyEmbeddingModel
src/Baikai/Error.hs view
@@ -15,6 +15,8 @@     isRetryable,      -- * Pure classification helpers for provider packages+    httpError,+    parseRetryAfterSeconds,     classifyHttpStatus,     classifyHttpStatusWithBody,     bodyIndicatesOverflow,@@ -34,6 +36,7 @@ import Data.Text (Text) import Data.Text qualified as Text import GHC.Generics (Generic)+import Text.Read (readMaybe)  -- | A provider-neutral category for a failed call. Callers switch on -- this to decide policy (retry, surface to user, abort). The set is@@ -162,6 +165,28 @@   RateLimited -> True   TransientError -> True   _ -> False++-- | Parse an integer-valued @Retry-After@ header as seconds. HTTP-date+-- values and malformed values yield 'Nothing'.+parseRetryAfterSeconds :: Text -> Maybe Int+parseRetryAfterSeconds raw = do+  n <- readMaybe (Text.unpack (Text.strip raw))+  if n >= 0 then Just n else Nothing++-- | Build a classified error from an HTTP failure's status, optional+-- parsed @Retry-After@ seconds, and response body text.+httpError :: Int -> Maybe Int -> Text -> BaikaiError+httpError status retryAfter body =+  (baseError (classifyHttpStatusWithBody status retryAfter body) msg)+    { httpStatus = Just status,+      retryAfterSeconds = retryAfter+    }+  where+    snippet = Text.take 300 body+    msg =+      "HTTP "+        <> Text.pack (show status)+        <> if Text.null snippet then "" else ": " <> snippet  -- | Map an HTTP status code (and an optional already-parsed -- retry-after-seconds value) to a category. Pure and network-free so it
src/Baikai/Interactive.hs view
@@ -7,11 +7,20 @@ module Baikai.Interactive   ( InteractiveProvider (..),     InteractiveScope (..),-    InteractiveLaunchRequest (..),+    InteractiveLaunchRequest,+    systemPrompt,+    userPrompt,+    modelId,+    workingDir,+    extraDirs,+    safety,+    extraArgs,     InteractiveSafety (..),     CodexSandboxMode (..),     CodexApprovalPolicy (..),     InteractiveLaunchResult (..),+    interactiveLaunchRequest,+    interactiveLaunchResult,     _InteractiveLaunchRequest,     _InteractiveLaunchResult,     renderInteractiveProvider,@@ -43,7 +52,7 @@ data InteractiveLaunchRequest = InteractiveLaunchRequest   { systemPrompt :: !(Maybe Text),     userPrompt :: !Text,-    model :: !(Maybe Text),+    modelId :: !(Maybe Text),     workingDir :: !(Maybe FilePath),     extraDirs :: ![FilePath],     safety :: !InteractiveSafety,@@ -80,24 +89,32 @@   }   deriving stock (Eq, Show, Generic) -_InteractiveLaunchRequest :: Text -> InteractiveLaunchRequest-_InteractiveLaunchRequest prompt =+interactiveLaunchRequest :: Text -> InteractiveLaunchRequest+interactiveLaunchRequest prompt =   InteractiveLaunchRequest     { systemPrompt = Nothing,       userPrompt = prompt,-      model = Nothing,+      modelId = Nothing,       workingDir = Nothing,       extraDirs = [],       safety = DefaultSafety,       extraArgs = []     } -_InteractiveLaunchResult :: InteractiveProvider -> ExitCode -> InteractiveLaunchResult-_InteractiveLaunchResult p code =+interactiveLaunchResult :: InteractiveProvider -> ExitCode -> InteractiveLaunchResult+interactiveLaunchResult p code =   InteractiveLaunchResult     { provider = p,       exitCode = code     }++{-# DEPRECATED _InteractiveLaunchRequest "Use interactiveLaunchRequest instead." #-}+_InteractiveLaunchRequest :: Text -> InteractiveLaunchRequest+_InteractiveLaunchRequest = interactiveLaunchRequest++{-# DEPRECATED _InteractiveLaunchResult "Use interactiveLaunchResult instead." #-}+_InteractiveLaunchResult :: InteractiveProvider -> ExitCode -> InteractiveLaunchResult+_InteractiveLaunchResult = interactiveLaunchResult  renderInteractiveProvider :: InteractiveProvider -> Text renderInteractiveProvider InteractiveClaude = "claude"
src/Baikai/Message.hs view
@@ -3,17 +3,17 @@ -- A 'Message' is one of three constructors: -- -- * 'UserMessage' — caller input, carrying a 'UserPayload' (its---   'content' is a vector of text and inline image blocks, plus a---   creation timestamp).+--   'content' is a vector of text and inline image blocks, plus an+--   optional creation timestamp). -- * 'AssistantMessage' — model output, carrying an 'AssistantPayload' --   (its 'content' holds 'AssistantText', 'AssistantThinking', and --   'AssistantToolCall' blocks, alongside the call's 'Usage' (which now --   embeds 'Cost' in-place), the 'StopReason' the model reported, an---   optional 'errorMessage', and a timestamp).+--   optional 'errorMessage', and an optional timestamp). -- * 'ToolResultMessage' — caller-supplied output for a model-issued --   tool call, carrying a 'ToolResultPayload' (the tool-call id it --   answers, the tool's name, the result 'content' (text or image), an---   'isError' flag, and a timestamp).+--   'isError' flag, and an optional timestamp). -- -- The 'system' constructor from prior versions is removed: system -- prompts live on 'Baikai.Request.Request.systemPrompt'. The 'Role'@@ -70,7 +70,7 @@     UserContent (..),   ) import Baikai.StopReason (StopReason (..))-import Baikai.Usage (Usage, _Usage)+import Baikai.Usage (Usage, zeroUsage) import Data.Aeson (ToJSON) import Data.Text (Text) import Data.Time (UTCTime, getCurrentTime)@@ -85,11 +85,11 @@   deriving stock (Eq, Show, Generic)   deriving anyclass (ToJSON) --- | Caller input: a vector of text and inline image blocks plus a--- creation timestamp.+-- | Caller input: a vector of text and inline image blocks plus an+-- optional creation timestamp. data UserPayload = UserPayload   { content :: !(Vector UserContent),-    timestamp :: !UTCTime+    timestamp :: !(Maybe UTCTime)   }   deriving stock (Eq, Show, Generic)   deriving anyclass (ToJSON)@@ -97,26 +97,28 @@ -- | Model output: the assistant content blocks ('AssistantText', -- 'AssistantThinking', 'AssistantToolCall'), the call's 'Usage' (which -- embeds 'Cost' in-place), the reported 'StopReason', an optional--- 'errorMessage', and a creation timestamp.+-- 'errorMessage' (failure text or a non-fatal provider diagnostic;+-- use 'stopReason'/'Response.errorInfo' to detect failures), and a+-- optional creation timestamp. data AssistantPayload = AssistantPayload   { content :: !(Vector AssistantContent),     usage :: !Usage,     stopReason :: !StopReason,     errorMessage :: !(Maybe Text),-    timestamp :: !UTCTime+    timestamp :: !(Maybe UTCTime)   }   deriving stock (Eq, Show, Generic)   deriving anyclass (ToJSON)  -- | Caller-supplied output for a model-issued tool call: the tool-call -- id it answers, the tool's name, the result 'content' (text or--- image), an 'isError' flag, and a creation timestamp.+-- image), an 'isError' flag, and an optional creation timestamp. data ToolResultPayload = ToolResultPayload   { toolCallId :: !Text,     toolName :: !Text,     content :: !(Vector ToolResultContent),     isError :: !Bool,-    timestamp :: !UTCTime+    timestamp :: !(Maybe UTCTime)   }   deriving stock (Eq, Show, Generic)   deriving anyclass (ToJSON)@@ -134,14 +136,16 @@   deriving stock (Eq, Show, Generic)   deriving anyclass (ToJSON) -defaultTimestamp :: UTCTime-defaultTimestamp = read "2000-01-01 00:00:00 UTC"---- | Build a one-text-block user message with a deterministic fixture--- timestamp. Prefer 'userAt' when the timestamp matters, or 'userNow'--- when the timestamp should be sampled in 'IO'.+-- | Build a one-text-block user message without recording a timestamp.+-- Prefer 'userAt' when the timestamp matters, or 'userNow' when the+-- timestamp should be sampled in 'IO'. user :: Text -> Message-user = userAt defaultTimestamp+user t =+  UserMessage+    UserPayload+      { content = V.singleton (UserText (TextContent t)),+        timestamp = Nothing+      }  -- | Build a one-text-block user message at an explicit timestamp. userAt :: UTCTime -> Text -> Message@@ -149,7 +153,7 @@   UserMessage     UserPayload       { content = V.singleton (UserText (TextContent t)),-        timestamp = ts+        timestamp = Just ts       }  -- | Build a one-text-block user message using the current time.@@ -159,9 +163,17 @@   pure (userAt now t)  -- | Build a user message carrying one inline image alongside optional--- preceding text, using a deterministic fixture timestamp.+-- preceding text, without recording a timestamp. userImage :: ImageContent -> Maybe Text -> Message-userImage = userImageAt defaultTimestamp+userImage img mPrefix =+  let prefix = case mPrefix of+        Nothing -> V.empty+        Just t -> V.singleton (UserText (TextContent t))+   in UserMessage+        UserPayload+          { content = prefix <> V.singleton (UserImage img),+            timestamp = Nothing+          }  -- | Build a user image message at an explicit timestamp. Bytes are -- stored decoded; the JSON encoding base64-encodes them on the wire.@@ -173,7 +185,7 @@    in UserMessage         UserPayload           { content = prefix <> V.singleton (UserImage img),-            timestamp = ts+            timestamp = Just ts           }  -- | Build a user image message using the current time.@@ -182,10 +194,18 @@   now <- getCurrentTime   pure (userImageAt now img mPrefix) --- | Build a one-text-block assistant message with zero usage and a--- @stop@ stop reason, using a deterministic fixture timestamp.+-- | Build a one-text-block assistant message with zero usage, a @stop@+-- stop reason, and no recorded timestamp. assistant :: Text -> Message-assistant = assistantAt defaultTimestamp+assistant t =+  AssistantMessage+    AssistantPayload+      { content = V.singleton (AssistantText (TextContent t)),+        usage = zeroUsage,+        stopReason = Stop,+        errorMessage = Nothing,+        timestamp = Nothing+      }  -- | Build a one-text-block assistant message at an explicit timestamp. assistantAt :: UTCTime -> Text -> Message@@ -193,10 +213,10 @@   AssistantMessage     AssistantPayload       { content = V.singleton (AssistantText (TextContent t)),-        usage = _Usage,+        usage = zeroUsage,         stopReason = Stop,         errorMessage = Nothing,-        timestamp = ts+        timestamp = Just ts       }  -- | Build a one-text-block assistant message using the current time.@@ -206,10 +226,17 @@   pure (assistantAt now t)  -- | Build a tool-result message answering a prior 'AssistantToolCall'--- using rich content blocks and a deterministic fixture timestamp.+-- using rich content blocks, without recording a timestamp. toolResultMessage :: Text -> Text -> ToolResult -> Message-toolResultMessage callId name =-  toolResultMessageAt defaultTimestamp callId name+toolResultMessage callId name ToolResult {content = blocks, isError = err} =+  ToolResultMessage+    ToolResultPayload+      { toolCallId = callId,+        toolName = name,+        content = blocks,+        isError = err,+        timestamp = Nothing+      }  -- | Build a tool-result message at an explicit timestamp. toolResultMessageAt :: UTCTime -> Text -> Text -> ToolResult -> Message@@ -220,7 +247,7 @@         toolName = name,         content = blocks,         isError = err,-        timestamp = ts+        timestamp = Just ts       }  -- | Build a tool-result message using the current time.@@ -251,7 +278,14 @@ -- when the result has multiple blocks or image content. toolResult :: Text -> Text -> Text -> Bool -> Message toolResult callId name body err =-  toolResultAt defaultTimestamp callId name body err+  toolResultMessage+    callId+    name+    ( ToolResult+        { content = V.singleton (ToolResultText (TextContent body)),+          isError = err+        }+    )  -- | Build a one-text-block tool-result message at an explicit timestamp. toolResultAt :: UTCTime -> Text -> Text -> Text -> Bool -> Message
src/Baikai/Model.hs view
@@ -7,16 +7,30 @@ -- placeholder until EP-5 populates the real shims). -- -- The previous newtype @Model = Model Text@ — a thin tag for the--- model id — is retired. The 'unModel' accessor is preserved as a--- convenience until callers fully migrate to 'modelId'.+-- model id — is retired. Use 'modelId' to read the selected upstream+-- model identifier, or 'mkModel' to build a dispatchable record. module Baikai.Model   ( -- * Model-    Model (..),+    Model,+    modelId,+    name,+    api,+    provider,+    baseUrl,+    reasoning,+    input,+    cost,+    contextWindow,+    maxOutputTokens,+    headers,+    compat,+    emptyModel,     _Model,-    unModel,+    mkModel,      -- * Cost rates     ModelCost (..),+    zeroModelCost,     _ModelCost,      -- * Capabilities@@ -29,12 +43,13 @@   ) where -import Baikai.Api (Api (..))+import Baikai.Api (Api (..), renderApi) import Baikai.Compat-  ( AnthropicMessagesCompat,+  ( AnthropicMessagesCompat (..),     OpenAICompletionsCompat,     autoDetectAnthropicMessages,     autoDetectOpenAICompletions,+    defaultAnthropicThinkingStyle,   ) import Data.Aeson (FromJSON, ToJSON) import Data.Map.Strict (Map)@@ -90,7 +105,10 @@ anthropicMessagesCompatFor :: Model -> AnthropicMessagesCompat anthropicMessagesCompatFor m = case compat m of   CompatAnthropicMessages c -> c-  _ -> autoDetectAnthropicMessages (baseUrl m)+  _ ->+    (autoDetectAnthropicMessages (baseUrl m))+      { thinkingStyle = defaultAnthropicThinkingStyle (modelId m)+      }  -- | The data record baikai dispatches on. data Model = Model@@ -110,15 +128,10 @@   deriving stock (Eq, Show, Generic)   deriving anyclass (FromJSON, ToJSON) --- | The model id as 'Text'. Kept for ergonomic access from call--- sites that previously held a newtype-wrapped 'Text'.-unModel :: Model -> Text-unModel = modelId- -- | A zero 'ModelCost' across all rates. Useful as a default for -- models without published pricing (CLI providers, custom hosts).-_ModelCost :: ModelCost-_ModelCost =+zeroModelCost :: ModelCost+zeroModelCost =   ModelCost     { inputCost = 0,       outputCost = 0,@@ -128,8 +141,8 @@  -- | A blank 'Model'. Useful as a record-update base for hand-rolled -- 'Model' values in tests and one-shot scripts.-_Model :: Model-_Model =+emptyModel :: Model+emptyModel =   Model     { modelId = "",       name = "",@@ -138,9 +151,31 @@       baseUrl = "",       reasoning = False,       input = [InputText],-      cost = _ModelCost,+      cost = zeroModelCost,       contextWindow = 0,       maxOutputTokens = 0,       headers = Map.empty,       compat = CompatNone     }++-- | Build a dispatchable 'Model' from its three discriminators: the+-- 'Api' tag used for handler lookup, the upstream model id, and the+-- base URL. 'name' defaults to the model id, 'provider' defaults to+-- 'renderApi' of the tag, and all other fields come from 'emptyModel'.+mkModel :: Api -> Text -> Text -> Model+mkModel apiTag modelId_ baseUrl_ =+  emptyModel+    { modelId = modelId_,+      name = modelId_,+      api = apiTag,+      provider = renderApi apiTag,+      baseUrl = baseUrl_+    }++{-# DEPRECATED _ModelCost "Use zeroModelCost instead." #-}+_ModelCost :: ModelCost+_ModelCost = zeroModelCost++{-# DEPRECATED _Model "Use emptyModel instead." #-}+_Model :: Model+_Model = emptyModel
src/Baikai/Models/Generated.hs view
@@ -8,678 +8,721 @@  import Baikai.Api (Api (..)) import Baikai.Compat-  ( AnthropicMessagesCompat (..)-  , CacheControlFormat (..)-  , MaxTokensField (..)-  , OpenAICompletionsCompat (..)-  , ThinkingFormat (..)-  )-import Baikai.Model-  ( Compat (..)-  , InputModality (..)-  , Model (..)-  , ModelCost (..)-  )-import Data.Map.Strict qualified as Map-import Data.Ratio ((%))--anthropic_claude_fable_5 :: Model-anthropic_claude_fable_5 =-  Model-    { modelId = "claude-fable-5"-    , name = "Claude Fable 5"-    , api = AnthropicMessages-    , provider = "anthropic"-    , baseUrl = "https://api.anthropic.com"-    , reasoning = True-    , input = [InputText, InputImage]-    , cost = ModelCost-        { inputCost = 10 % 1-        , outputCost = 50 % 1-        , cacheReadCost = 1 % 1-        , cacheWriteCost = 25 % 2-        }-    , contextWindow = 1000000-    , maxOutputTokens = 128000-    , headers = Map.empty-    , compat = CompatNone-    }--anthropic_claude_haiku_4_5 :: Model-anthropic_claude_haiku_4_5 =-  Model-    { modelId = "claude-haiku-4-5"-    , name = "Claude Haiku 4.5"-    , api = AnthropicMessages-    , provider = "anthropic"-    , baseUrl = "https://api.anthropic.com"-    , reasoning = True-    , input = [InputText, InputImage]-    , cost = ModelCost-        { inputCost = 1 % 1-        , outputCost = 5 % 1-        , cacheReadCost = 1 % 10-        , cacheWriteCost = 5 % 4-        }-    , contextWindow = 200000-    , maxOutputTokens = 64000-    , headers = Map.empty-    , compat = CompatNone-    }--anthropic_claude_opus_4_5 :: Model-anthropic_claude_opus_4_5 =-  Model-    { modelId = "claude-opus-4-5"-    , name = "Claude Opus 4.5"-    , api = AnthropicMessages-    , provider = "anthropic"-    , baseUrl = "https://api.anthropic.com"-    , reasoning = True-    , input = [InputText, InputImage]-    , cost = ModelCost-        { inputCost = 5 % 1-        , outputCost = 25 % 1-        , cacheReadCost = 1 % 2-        , cacheWriteCost = 25 % 4-        }-    , contextWindow = 200000-    , maxOutputTokens = 64000-    , headers = Map.empty-    , compat = CompatNone-    }--anthropic_claude_opus_4_6 :: Model-anthropic_claude_opus_4_6 =-  Model-    { modelId = "claude-opus-4-6"-    , name = "Claude Opus 4.6"-    , api = AnthropicMessages-    , provider = "anthropic"-    , baseUrl = "https://api.anthropic.com"-    , reasoning = True-    , input = [InputText, InputImage]-    , cost = ModelCost-        { inputCost = 5 % 1-        , outputCost = 25 % 1-        , cacheReadCost = 1 % 2-        , cacheWriteCost = 25 % 4-        }-    , contextWindow = 1000000-    , maxOutputTokens = 128000-    , headers = Map.empty-    , compat = CompatNone-    }--anthropic_claude_opus_4_7 :: Model-anthropic_claude_opus_4_7 =-  Model-    { modelId = "claude-opus-4-7"-    , name = "Claude Opus 4.7"-    , api = AnthropicMessages-    , provider = "anthropic"-    , baseUrl = "https://api.anthropic.com"-    , reasoning = True-    , input = [InputText, InputImage]-    , cost = ModelCost-        { inputCost = 5 % 1-        , outputCost = 25 % 1-        , cacheReadCost = 1 % 2-        , cacheWriteCost = 25 % 4-        }-    , contextWindow = 1000000-    , maxOutputTokens = 128000-    , headers = Map.empty-    , compat = CompatNone-    }--anthropic_claude_opus_4_8 :: Model-anthropic_claude_opus_4_8 =-  Model-    { modelId = "claude-opus-4-8"-    , name = "Claude Opus 4.8"-    , api = AnthropicMessages-    , provider = "anthropic"-    , baseUrl = "https://api.anthropic.com"-    , reasoning = True-    , input = [InputText, InputImage]-    , cost = ModelCost-        { inputCost = 5 % 1-        , outputCost = 25 % 1-        , cacheReadCost = 1 % 2-        , cacheWriteCost = 25 % 4-        }-    , contextWindow = 1000000-    , maxOutputTokens = 128000-    , headers = Map.empty-    , compat = CompatNone-    }--anthropic_claude_sonnet_4_5 :: Model-anthropic_claude_sonnet_4_5 =-  Model-    { modelId = "claude-sonnet-4-5"-    , name = "Claude Sonnet 4.5"-    , api = AnthropicMessages-    , provider = "anthropic"-    , baseUrl = "https://api.anthropic.com"-    , reasoning = True-    , input = [InputText, InputImage]-    , cost = ModelCost-        { inputCost = 3 % 1-        , outputCost = 15 % 1-        , cacheReadCost = 3 % 10-        , cacheWriteCost = 15 % 4-        }-    , contextWindow = 200000-    , maxOutputTokens = 64000-    , headers = Map.empty-    , compat = CompatNone-    }--anthropic_claude_sonnet_4_6 :: Model-anthropic_claude_sonnet_4_6 =-  Model-    { modelId = "claude-sonnet-4-6"-    , name = "Claude Sonnet 4.6"-    , api = AnthropicMessages-    , provider = "anthropic"-    , baseUrl = "https://api.anthropic.com"-    , reasoning = True-    , input = [InputText, InputImage]-    , cost = ModelCost-        { inputCost = 3 % 1-        , outputCost = 15 % 1-        , cacheReadCost = 3 % 10-        , cacheWriteCost = 15 % 4-        }-    , contextWindow = 1000000-    , maxOutputTokens = 64000-    , headers = Map.empty-    , compat = CompatNone-    }--deepseek_deepseek_chat :: Model-deepseek_deepseek_chat =-  Model-    { modelId = "deepseek-chat"-    , name = "DeepSeek Chat"-    , api = OpenAIChatCompletions-    , provider = "deepseek"-    , baseUrl = "https://api.deepseek.com"-    , reasoning = False-    , input = [InputText]-    , cost = ModelCost-        { inputCost = 27 % 100-        , outputCost = 11 % 10-        , cacheReadCost = 7 % 100-        , cacheWriteCost = 0 % 1-        }-    , contextWindow = 64000-    , maxOutputTokens = 8192-    , headers = Map.empty-    , compat = CompatNone-    }--deepseek_deepseek_reasoner :: Model-deepseek_deepseek_reasoner =-  Model-    { modelId = "deepseek-reasoner"-    , name = "DeepSeek Reasoner"-    , api = OpenAIChatCompletions-    , provider = "deepseek"-    , baseUrl = "https://api.deepseek.com"-    , reasoning = True-    , input = [InputText]-    , cost = ModelCost-        { inputCost = 11 % 20-        , outputCost = 219 % 100-        , cacheReadCost = 7 % 50-        , cacheWriteCost = 0 % 1-        }-    , contextWindow = 64000-    , maxOutputTokens = 8192-    , headers = Map.empty-    , compat = CompatNone-    }--openai_gpt_4_1 :: Model-openai_gpt_4_1 =-  Model-    { modelId = "gpt-4.1"-    , name = "GPT-4.1"-    , api = OpenAIChatCompletions-    , provider = "openai"-    , baseUrl = "https://api.openai.com"-    , reasoning = False-    , input = [InputText, InputImage]-    , cost = ModelCost-        { inputCost = 2 % 1-        , outputCost = 8 % 1-        , cacheReadCost = 1 % 2-        , cacheWriteCost = 0 % 1-        }-    , contextWindow = 1047576-    , maxOutputTokens = 32768-    , headers = Map.empty-    , compat = CompatNone-    }--openai_gpt_4_1_mini :: Model-openai_gpt_4_1_mini =-  Model-    { modelId = "gpt-4.1-mini"-    , name = "GPT-4.1 mini"-    , api = OpenAIChatCompletions-    , provider = "openai"-    , baseUrl = "https://api.openai.com"-    , reasoning = False-    , input = [InputText, InputImage]-    , cost = ModelCost-        { inputCost = 2 % 5-        , outputCost = 8 % 5-        , cacheReadCost = 1 % 10-        , cacheWriteCost = 0 % 1-        }-    , contextWindow = 1047576-    , maxOutputTokens = 32768-    , headers = Map.empty-    , compat = CompatNone-    }--openai_gpt_4_1_nano :: Model-openai_gpt_4_1_nano =-  Model-    { modelId = "gpt-4.1-nano"-    , name = "GPT-4.1 nano"-    , api = OpenAIChatCompletions-    , provider = "openai"-    , baseUrl = "https://api.openai.com"-    , reasoning = False-    , input = [InputText, InputImage]-    , cost = ModelCost-        { inputCost = 1 % 10-        , outputCost = 2 % 5-        , cacheReadCost = 1 % 40-        , cacheWriteCost = 0 % 1-        }-    , contextWindow = 1047576-    , maxOutputTokens = 32768-    , headers = Map.empty-    , compat = CompatNone-    }--openai_gpt_4o :: Model-openai_gpt_4o =-  Model-    { modelId = "gpt-4o"-    , name = "GPT-4o"-    , api = OpenAIChatCompletions-    , provider = "openai"-    , baseUrl = "https://api.openai.com"-    , reasoning = False-    , input = [InputText, InputImage]-    , cost = ModelCost-        { inputCost = 5 % 2-        , outputCost = 10 % 1-        , cacheReadCost = 5 % 4-        , cacheWriteCost = 0 % 1-        }-    , contextWindow = 128000-    , maxOutputTokens = 16384-    , headers = Map.empty-    , compat = CompatNone-    }--openai_gpt_4o_mini :: Model-openai_gpt_4o_mini =-  Model-    { modelId = "gpt-4o-mini"-    , name = "GPT-4o mini"-    , api = OpenAIChatCompletions-    , provider = "openai"-    , baseUrl = "https://api.openai.com"-    , reasoning = False-    , input = [InputText, InputImage]-    , cost = ModelCost-        { inputCost = 3 % 20-        , outputCost = 3 % 5-        , cacheReadCost = 3 % 40-        , cacheWriteCost = 0 % 1-        }-    , contextWindow = 128000-    , maxOutputTokens = 16384-    , headers = Map.empty-    , compat = CompatNone-    }--openai_gpt_5 :: Model-openai_gpt_5 =-  Model-    { modelId = "gpt-5"-    , name = "GPT-5"-    , api = OpenAIChatCompletions-    , provider = "openai"-    , baseUrl = "https://api.openai.com"-    , reasoning = True-    , input = [InputText, InputImage]-    , cost = ModelCost-        { inputCost = 5 % 4-        , outputCost = 10 % 1-        , cacheReadCost = 1 % 8-        , cacheWriteCost = 0 % 1-        }-    , contextWindow = 400000-    , maxOutputTokens = 128000-    , headers = Map.empty-    , compat = CompatNone-    }--openai_gpt_5_1 :: Model-openai_gpt_5_1 =-  Model-    { modelId = "gpt-5.1"-    , name = "GPT-5.1"-    , api = OpenAIChatCompletions-    , provider = "openai"-    , baseUrl = "https://api.openai.com"-    , reasoning = True-    , input = [InputText, InputImage]-    , cost = ModelCost-        { inputCost = 5 % 4-        , outputCost = 10 % 1-        , cacheReadCost = 1 % 8-        , cacheWriteCost = 0 % 1-        }-    , contextWindow = 400000-    , maxOutputTokens = 128000-    , headers = Map.empty-    , compat = CompatNone-    }--openai_gpt_5_2 :: Model-openai_gpt_5_2 =-  Model-    { modelId = "gpt-5.2"-    , name = "GPT-5.2"-    , api = OpenAIChatCompletions-    , provider = "openai"-    , baseUrl = "https://api.openai.com"-    , reasoning = True-    , input = [InputText, InputImage]-    , cost = ModelCost-        { inputCost = 7 % 4-        , outputCost = 14 % 1-        , cacheReadCost = 7 % 40-        , cacheWriteCost = 0 % 1-        }-    , contextWindow = 400000-    , maxOutputTokens = 128000-    , headers = Map.empty-    , compat = CompatNone-    }--openai_gpt_5_4 :: Model-openai_gpt_5_4 =-  Model-    { modelId = "gpt-5.4"-    , name = "GPT-5.4"-    , api = OpenAIChatCompletions-    , provider = "openai"-    , baseUrl = "https://api.openai.com"-    , reasoning = True-    , input = [InputText, InputImage]-    , cost = ModelCost-        { inputCost = 5 % 2-        , outputCost = 15 % 1-        , cacheReadCost = 1 % 4-        , cacheWriteCost = 0 % 1-        }-    , contextWindow = 1050000-    , maxOutputTokens = 128000-    , headers = Map.empty-    , compat = CompatNone-    }--openai_gpt_5_4_mini :: Model-openai_gpt_5_4_mini =-  Model-    { modelId = "gpt-5.4-mini"-    , name = "GPT-5.4 mini"-    , api = OpenAIChatCompletions-    , provider = "openai"-    , baseUrl = "https://api.openai.com"-    , reasoning = True-    , input = [InputText, InputImage]-    , cost = ModelCost-        { inputCost = 3 % 4-        , outputCost = 9 % 2-        , cacheReadCost = 3 % 40-        , cacheWriteCost = 0 % 1-        }-    , contextWindow = 400000-    , maxOutputTokens = 128000-    , headers = Map.empty-    , compat = CompatNone-    }--openai_gpt_5_4_nano :: Model-openai_gpt_5_4_nano =-  Model-    { modelId = "gpt-5.4-nano"-    , name = "GPT-5.4 nano"-    , api = OpenAIChatCompletions-    , provider = "openai"-    , baseUrl = "https://api.openai.com"-    , reasoning = True-    , input = [InputText, InputImage]-    , cost = ModelCost-        { inputCost = 1 % 5-        , outputCost = 5 % 4-        , cacheReadCost = 1 % 50-        , cacheWriteCost = 0 % 1-        }-    , contextWindow = 400000-    , maxOutputTokens = 128000-    , headers = Map.empty-    , compat = CompatNone-    }--openai_gpt_5_5 :: Model-openai_gpt_5_5 =-  Model-    { modelId = "gpt-5.5"-    , name = "GPT-5.5"-    , api = OpenAIChatCompletions-    , provider = "openai"-    , baseUrl = "https://api.openai.com"-    , reasoning = True-    , input = [InputText, InputImage]-    , cost = ModelCost-        { inputCost = 5 % 1-        , outputCost = 30 % 1-        , cacheReadCost = 1 % 2-        , cacheWriteCost = 0 % 1-        }-    , contextWindow = 1050000-    , maxOutputTokens = 128000-    , headers = Map.empty-    , compat = CompatNone-    }--openai_gpt_5_mini :: Model-openai_gpt_5_mini =-  Model-    { modelId = "gpt-5-mini"-    , name = "GPT-5 Mini"-    , api = OpenAIChatCompletions-    , provider = "openai"-    , baseUrl = "https://api.openai.com"-    , reasoning = True-    , input = [InputText, InputImage]-    , cost = ModelCost-        { inputCost = 1 % 4-        , outputCost = 2 % 1-        , cacheReadCost = 1 % 40-        , cacheWriteCost = 0 % 1-        }-    , contextWindow = 400000-    , maxOutputTokens = 128000-    , headers = Map.empty-    , compat = CompatNone-    }--openai_gpt_5_nano :: Model-openai_gpt_5_nano =-  Model-    { modelId = "gpt-5-nano"-    , name = "GPT-5 Nano"-    , api = OpenAIChatCompletions-    , provider = "openai"-    , baseUrl = "https://api.openai.com"-    , reasoning = True-    , input = [InputText, InputImage]-    , cost = ModelCost-        { inputCost = 1 % 20-        , outputCost = 2 % 5-        , cacheReadCost = 1 % 200-        , cacheWriteCost = 0 % 1-        }-    , contextWindow = 400000-    , maxOutputTokens = 128000-    , headers = Map.empty-    , compat = CompatNone-    }--openai_o1 :: Model-openai_o1 =-  Model-    { modelId = "o1"-    , name = "o1"-    , api = OpenAIChatCompletions-    , provider = "openai"-    , baseUrl = "https://api.openai.com"-    , reasoning = True-    , input = [InputText, InputImage]-    , cost = ModelCost-        { inputCost = 15 % 1-        , outputCost = 60 % 1-        , cacheReadCost = 15 % 2-        , cacheWriteCost = 0 % 1-        }-    , contextWindow = 200000-    , maxOutputTokens = 100000-    , headers = Map.empty-    , compat = CompatNone-    }--openai_o3 :: Model-openai_o3 =-  Model-    { modelId = "o3"-    , name = "o3"-    , api = OpenAIChatCompletions-    , provider = "openai"-    , baseUrl = "https://api.openai.com"-    , reasoning = True-    , input = [InputText, InputImage]-    , cost = ModelCost-        { inputCost = 2 % 1-        , outputCost = 8 % 1-        , cacheReadCost = 1 % 2-        , cacheWriteCost = 0 % 1-        }-    , contextWindow = 200000-    , maxOutputTokens = 100000-    , headers = Map.empty-    , compat = CompatNone-    }--openai_o3_mini :: Model-openai_o3_mini =-  Model-    { modelId = "o3-mini"-    , name = "o3-mini"-    , api = OpenAIChatCompletions-    , provider = "openai"-    , baseUrl = "https://api.openai.com"-    , reasoning = True-    , input = [InputText]-    , cost = ModelCost-        { inputCost = 11 % 10-        , outputCost = 22 % 5-        , cacheReadCost = 11 % 20-        , cacheWriteCost = 0 % 1-        }-    , contextWindow = 200000-    , maxOutputTokens = 100000-    , headers = Map.empty-    , compat = CompatNone-    }--openai_o4_mini :: Model-openai_o4_mini =-  Model-    { modelId = "o4-mini"-    , name = "o4-mini"-    , api = OpenAIChatCompletions-    , provider = "openai"-    , baseUrl = "https://api.openai.com"-    , reasoning = True-    , input = [InputText, InputImage]-    , cost = ModelCost-        { inputCost = 11 % 10-        , outputCost = 22 % 5-        , cacheReadCost = 11 % 40-        , cacheWriteCost = 0 % 1-        }-    , contextWindow = 200000-    , maxOutputTokens = 100000-    , headers = Map.empty-    , compat = CompatNone-    }--openrouter_anthropic_claude_sonnet_4 :: Model-openrouter_anthropic_claude_sonnet_4 =-  Model-    { modelId = "anthropic/claude-sonnet-4"-    , name = "Claude Sonnet 4 via OpenRouter"-    , api = OpenAIChatCompletions-    , provider = "openrouter"-    , baseUrl = "https://openrouter.ai/api"-    , reasoning = False-    , input = [InputText, InputImage]-    , cost = ModelCost-        { inputCost = 3 % 1-        , outputCost = 15 % 1-        , cacheReadCost = 3 % 10-        , cacheWriteCost = 15 % 4-        }-    , contextWindow = 200000-    , maxOutputTokens = 8192-    , headers = Map.empty-    , compat = CompatNone-    }--openrouter_openai_gpt_4o_mini :: Model-openrouter_openai_gpt_4o_mini =-  Model-    { modelId = "openai/gpt-4o-mini"-    , name = "GPT-4o mini via OpenRouter"-    , api = OpenAIChatCompletions-    , provider = "openrouter"-    , baseUrl = "https://openrouter.ai/api"-    , reasoning = False-    , input = [InputText, InputImage]-    , cost = ModelCost-        { inputCost = 3 % 20-        , outputCost = 3 % 5-        , cacheReadCost = 3 % 40-        , cacheWriteCost = 0 % 1-        }-    , contextWindow = 128000-    , maxOutputTokens = 16384-    , headers = Map.empty-    , compat = CompatNone-    }-+  ( AnthropicThinkingStyle (..),+    CacheControlFormat (..),+    MaxTokensField (..),+    ThinkingFormat (..),+    defaultAnthropicMessagesCompat,+    defaultOpenAICompletionsCompat,+  )+import Baikai.Model+  ( Compat (..),+    InputModality (..),+    Model,+    ModelCost (..),+    api,+    baseUrl,+    compat,+    contextWindow,+    cost,+    emptyModel,+    headers,+    input,+    maxOutputTokens,+    modelId,+    name,+    provider,+    reasoning,+  )+import Data.Map.Strict qualified as Map+import Data.Ratio ((%))++anthropic_claude_fable_5 :: Model+anthropic_claude_fable_5 =+  emptyModel+    { modelId = "claude-fable-5",+      name = "Claude Fable 5",+      api = AnthropicMessages,+      provider = "anthropic",+      baseUrl = "https://api.anthropic.com",+      reasoning = True,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 10 % 1,+            outputCost = 50 % 1,+            cacheReadCost = 1 % 1,+            cacheWriteCost = 25 % 2+          },+      contextWindow = 1000000,+      maxOutputTokens = 128000,+      headers = Map.empty,+      compat = CompatNone+    }++anthropic_claude_haiku_4_5 :: Model+anthropic_claude_haiku_4_5 =+  emptyModel+    { modelId = "claude-haiku-4-5",+      name = "Claude Haiku 4.5",+      api = AnthropicMessages,+      provider = "anthropic",+      baseUrl = "https://api.anthropic.com",+      reasoning = True,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 1 % 1,+            outputCost = 5 % 1,+            cacheReadCost = 1 % 10,+            cacheWriteCost = 5 % 4+          },+      contextWindow = 200000,+      maxOutputTokens = 64000,+      headers = Map.empty,+      compat = CompatNone+    }++anthropic_claude_opus_4_5 :: Model+anthropic_claude_opus_4_5 =+  emptyModel+    { modelId = "claude-opus-4-5",+      name = "Claude Opus 4.5",+      api = AnthropicMessages,+      provider = "anthropic",+      baseUrl = "https://api.anthropic.com",+      reasoning = True,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 5 % 1,+            outputCost = 25 % 1,+            cacheReadCost = 1 % 2,+            cacheWriteCost = 25 % 4+          },+      contextWindow = 200000,+      maxOutputTokens = 64000,+      headers = Map.empty,+      compat = CompatNone+    }++anthropic_claude_opus_4_6 :: Model+anthropic_claude_opus_4_6 =+  emptyModel+    { modelId = "claude-opus-4-6",+      name = "Claude Opus 4.6",+      api = AnthropicMessages,+      provider = "anthropic",+      baseUrl = "https://api.anthropic.com",+      reasoning = True,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 5 % 1,+            outputCost = 25 % 1,+            cacheReadCost = 1 % 2,+            cacheWriteCost = 25 % 4+          },+      contextWindow = 1000000,+      maxOutputTokens = 128000,+      headers = Map.empty,+      compat = CompatNone+    }++anthropic_claude_opus_4_7 :: Model+anthropic_claude_opus_4_7 =+  emptyModel+    { modelId = "claude-opus-4-7",+      name = "Claude Opus 4.7",+      api = AnthropicMessages,+      provider = "anthropic",+      baseUrl = "https://api.anthropic.com",+      reasoning = True,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 5 % 1,+            outputCost = 25 % 1,+            cacheReadCost = 1 % 2,+            cacheWriteCost = 25 % 4+          },+      contextWindow = 1000000,+      maxOutputTokens = 128000,+      headers = Map.empty,+      compat = CompatNone+    }++anthropic_claude_opus_4_8 :: Model+anthropic_claude_opus_4_8 =+  emptyModel+    { modelId = "claude-opus-4-8",+      name = "Claude Opus 4.8",+      api = AnthropicMessages,+      provider = "anthropic",+      baseUrl = "https://api.anthropic.com",+      reasoning = True,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 5 % 1,+            outputCost = 25 % 1,+            cacheReadCost = 1 % 2,+            cacheWriteCost = 25 % 4+          },+      contextWindow = 1000000,+      maxOutputTokens = 128000,+      headers = Map.empty,+      compat = CompatNone+    }++anthropic_claude_sonnet_4_5 :: Model+anthropic_claude_sonnet_4_5 =+  emptyModel+    { modelId = "claude-sonnet-4-5",+      name = "Claude Sonnet 4.5",+      api = AnthropicMessages,+      provider = "anthropic",+      baseUrl = "https://api.anthropic.com",+      reasoning = True,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 3 % 1,+            outputCost = 15 % 1,+            cacheReadCost = 3 % 10,+            cacheWriteCost = 15 % 4+          },+      contextWindow = 200000,+      maxOutputTokens = 64000,+      headers = Map.empty,+      compat = CompatNone+    }++anthropic_claude_sonnet_4_6 :: Model+anthropic_claude_sonnet_4_6 =+  emptyModel+    { modelId = "claude-sonnet-4-6",+      name = "Claude Sonnet 4.6",+      api = AnthropicMessages,+      provider = "anthropic",+      baseUrl = "https://api.anthropic.com",+      reasoning = True,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 3 % 1,+            outputCost = 15 % 1,+            cacheReadCost = 3 % 10,+            cacheWriteCost = 15 % 4+          },+      contextWindow = 1000000,+      maxOutputTokens = 64000,+      headers = Map.empty,+      compat = CompatNone+    }++deepseek_deepseek_chat :: Model+deepseek_deepseek_chat =+  emptyModel+    { modelId = "deepseek-chat",+      name = "DeepSeek Chat",+      api = OpenAIChatCompletions,+      provider = "deepseek",+      baseUrl = "https://api.deepseek.com",+      reasoning = False,+      input = [InputText],+      cost =+        ModelCost+          { inputCost = 27 % 100,+            outputCost = 11 % 10,+            cacheReadCost = 7 % 100,+            cacheWriteCost = 0 % 1+          },+      contextWindow = 64000,+      maxOutputTokens = 8192,+      headers = Map.empty,+      compat = CompatNone+    }++deepseek_deepseek_reasoner :: Model+deepseek_deepseek_reasoner =+  emptyModel+    { modelId = "deepseek-reasoner",+      name = "DeepSeek Reasoner",+      api = OpenAIChatCompletions,+      provider = "deepseek",+      baseUrl = "https://api.deepseek.com",+      reasoning = True,+      input = [InputText],+      cost =+        ModelCost+          { inputCost = 11 % 20,+            outputCost = 219 % 100,+            cacheReadCost = 7 % 50,+            cacheWriteCost = 0 % 1+          },+      contextWindow = 64000,+      maxOutputTokens = 8192,+      headers = Map.empty,+      compat = CompatNone+    }++openai_gpt_4_1 :: Model+openai_gpt_4_1 =+  emptyModel+    { modelId = "gpt-4.1",+      name = "GPT-4.1",+      api = OpenAIChatCompletions,+      provider = "openai",+      baseUrl = "https://api.openai.com",+      reasoning = False,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 2 % 1,+            outputCost = 8 % 1,+            cacheReadCost = 1 % 2,+            cacheWriteCost = 0 % 1+          },+      contextWindow = 1047576,+      maxOutputTokens = 32768,+      headers = Map.empty,+      compat = CompatNone+    }++openai_gpt_4_1_mini :: Model+openai_gpt_4_1_mini =+  emptyModel+    { modelId = "gpt-4.1-mini",+      name = "GPT-4.1 mini",+      api = OpenAIChatCompletions,+      provider = "openai",+      baseUrl = "https://api.openai.com",+      reasoning = False,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 2 % 5,+            outputCost = 8 % 5,+            cacheReadCost = 1 % 10,+            cacheWriteCost = 0 % 1+          },+      contextWindow = 1047576,+      maxOutputTokens = 32768,+      headers = Map.empty,+      compat = CompatNone+    }++openai_gpt_4_1_nano :: Model+openai_gpt_4_1_nano =+  emptyModel+    { modelId = "gpt-4.1-nano",+      name = "GPT-4.1 nano",+      api = OpenAIChatCompletions,+      provider = "openai",+      baseUrl = "https://api.openai.com",+      reasoning = False,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 1 % 10,+            outputCost = 2 % 5,+            cacheReadCost = 1 % 40,+            cacheWriteCost = 0 % 1+          },+      contextWindow = 1047576,+      maxOutputTokens = 32768,+      headers = Map.empty,+      compat = CompatNone+    }++openai_gpt_4o :: Model+openai_gpt_4o =+  emptyModel+    { modelId = "gpt-4o",+      name = "GPT-4o",+      api = OpenAIChatCompletions,+      provider = "openai",+      baseUrl = "https://api.openai.com",+      reasoning = False,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 5 % 2,+            outputCost = 10 % 1,+            cacheReadCost = 5 % 4,+            cacheWriteCost = 0 % 1+          },+      contextWindow = 128000,+      maxOutputTokens = 16384,+      headers = Map.empty,+      compat = CompatNone+    }++openai_gpt_4o_mini :: Model+openai_gpt_4o_mini =+  emptyModel+    { modelId = "gpt-4o-mini",+      name = "GPT-4o mini",+      api = OpenAIChatCompletions,+      provider = "openai",+      baseUrl = "https://api.openai.com",+      reasoning = False,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 3 % 20,+            outputCost = 3 % 5,+            cacheReadCost = 3 % 40,+            cacheWriteCost = 0 % 1+          },+      contextWindow = 128000,+      maxOutputTokens = 16384,+      headers = Map.empty,+      compat = CompatNone+    }++openai_gpt_5 :: Model+openai_gpt_5 =+  emptyModel+    { modelId = "gpt-5",+      name = "GPT-5",+      api = OpenAIChatCompletions,+      provider = "openai",+      baseUrl = "https://api.openai.com",+      reasoning = True,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 5 % 4,+            outputCost = 10 % 1,+            cacheReadCost = 1 % 8,+            cacheWriteCost = 0 % 1+          },+      contextWindow = 400000,+      maxOutputTokens = 128000,+      headers = Map.empty,+      compat = CompatNone+    }++openai_gpt_5_1 :: Model+openai_gpt_5_1 =+  emptyModel+    { modelId = "gpt-5.1",+      name = "GPT-5.1",+      api = OpenAIChatCompletions,+      provider = "openai",+      baseUrl = "https://api.openai.com",+      reasoning = True,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 5 % 4,+            outputCost = 10 % 1,+            cacheReadCost = 1 % 8,+            cacheWriteCost = 0 % 1+          },+      contextWindow = 400000,+      maxOutputTokens = 128000,+      headers = Map.empty,+      compat = CompatNone+    }++openai_gpt_5_2 :: Model+openai_gpt_5_2 =+  emptyModel+    { modelId = "gpt-5.2",+      name = "GPT-5.2",+      api = OpenAIChatCompletions,+      provider = "openai",+      baseUrl = "https://api.openai.com",+      reasoning = True,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 7 % 4,+            outputCost = 14 % 1,+            cacheReadCost = 7 % 40,+            cacheWriteCost = 0 % 1+          },+      contextWindow = 400000,+      maxOutputTokens = 128000,+      headers = Map.empty,+      compat = CompatNone+    }++openai_gpt_5_4 :: Model+openai_gpt_5_4 =+  emptyModel+    { modelId = "gpt-5.4",+      name = "GPT-5.4",+      api = OpenAIChatCompletions,+      provider = "openai",+      baseUrl = "https://api.openai.com",+      reasoning = True,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 5 % 2,+            outputCost = 15 % 1,+            cacheReadCost = 1 % 4,+            cacheWriteCost = 0 % 1+          },+      contextWindow = 1050000,+      maxOutputTokens = 128000,+      headers = Map.empty,+      compat = CompatNone+    }++openai_gpt_5_4_mini :: Model+openai_gpt_5_4_mini =+  emptyModel+    { modelId = "gpt-5.4-mini",+      name = "GPT-5.4 mini",+      api = OpenAIChatCompletions,+      provider = "openai",+      baseUrl = "https://api.openai.com",+      reasoning = True,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 3 % 4,+            outputCost = 9 % 2,+            cacheReadCost = 3 % 40,+            cacheWriteCost = 0 % 1+          },+      contextWindow = 400000,+      maxOutputTokens = 128000,+      headers = Map.empty,+      compat = CompatNone+    }++openai_gpt_5_4_nano :: Model+openai_gpt_5_4_nano =+  emptyModel+    { modelId = "gpt-5.4-nano",+      name = "GPT-5.4 nano",+      api = OpenAIChatCompletions,+      provider = "openai",+      baseUrl = "https://api.openai.com",+      reasoning = True,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 1 % 5,+            outputCost = 5 % 4,+            cacheReadCost = 1 % 50,+            cacheWriteCost = 0 % 1+          },+      contextWindow = 400000,+      maxOutputTokens = 128000,+      headers = Map.empty,+      compat = CompatNone+    }++openai_gpt_5_5 :: Model+openai_gpt_5_5 =+  emptyModel+    { modelId = "gpt-5.5",+      name = "GPT-5.5",+      api = OpenAIChatCompletions,+      provider = "openai",+      baseUrl = "https://api.openai.com",+      reasoning = True,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 5 % 1,+            outputCost = 30 % 1,+            cacheReadCost = 1 % 2,+            cacheWriteCost = 0 % 1+          },+      contextWindow = 1050000,+      maxOutputTokens = 128000,+      headers = Map.empty,+      compat = CompatNone+    }++openai_gpt_5_mini :: Model+openai_gpt_5_mini =+  emptyModel+    { modelId = "gpt-5-mini",+      name = "GPT-5 Mini",+      api = OpenAIChatCompletions,+      provider = "openai",+      baseUrl = "https://api.openai.com",+      reasoning = True,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 1 % 4,+            outputCost = 2 % 1,+            cacheReadCost = 1 % 40,+            cacheWriteCost = 0 % 1+          },+      contextWindow = 400000,+      maxOutputTokens = 128000,+      headers = Map.empty,+      compat = CompatNone+    }++openai_gpt_5_nano :: Model+openai_gpt_5_nano =+  emptyModel+    { modelId = "gpt-5-nano",+      name = "GPT-5 Nano",+      api = OpenAIChatCompletions,+      provider = "openai",+      baseUrl = "https://api.openai.com",+      reasoning = True,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 1 % 20,+            outputCost = 2 % 5,+            cacheReadCost = 1 % 200,+            cacheWriteCost = 0 % 1+          },+      contextWindow = 400000,+      maxOutputTokens = 128000,+      headers = Map.empty,+      compat = CompatNone+    }++openai_o1 :: Model+openai_o1 =+  emptyModel+    { modelId = "o1",+      name = "o1",+      api = OpenAIChatCompletions,+      provider = "openai",+      baseUrl = "https://api.openai.com",+      reasoning = True,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 15 % 1,+            outputCost = 60 % 1,+            cacheReadCost = 15 % 2,+            cacheWriteCost = 0 % 1+          },+      contextWindow = 200000,+      maxOutputTokens = 100000,+      headers = Map.empty,+      compat = CompatNone+    }++openai_o3 :: Model+openai_o3 =+  emptyModel+    { modelId = "o3",+      name = "o3",+      api = OpenAIChatCompletions,+      provider = "openai",+      baseUrl = "https://api.openai.com",+      reasoning = True,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 2 % 1,+            outputCost = 8 % 1,+            cacheReadCost = 1 % 2,+            cacheWriteCost = 0 % 1+          },+      contextWindow = 200000,+      maxOutputTokens = 100000,+      headers = Map.empty,+      compat = CompatNone+    }++openai_o3_mini :: Model+openai_o3_mini =+  emptyModel+    { modelId = "o3-mini",+      name = "o3-mini",+      api = OpenAIChatCompletions,+      provider = "openai",+      baseUrl = "https://api.openai.com",+      reasoning = True,+      input = [InputText],+      cost =+        ModelCost+          { inputCost = 11 % 10,+            outputCost = 22 % 5,+            cacheReadCost = 11 % 20,+            cacheWriteCost = 0 % 1+          },+      contextWindow = 200000,+      maxOutputTokens = 100000,+      headers = Map.empty,+      compat = CompatNone+    }++openai_o4_mini :: Model+openai_o4_mini =+  emptyModel+    { modelId = "o4-mini",+      name = "o4-mini",+      api = OpenAIChatCompletions,+      provider = "openai",+      baseUrl = "https://api.openai.com",+      reasoning = True,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 11 % 10,+            outputCost = 22 % 5,+            cacheReadCost = 11 % 40,+            cacheWriteCost = 0 % 1+          },+      contextWindow = 200000,+      maxOutputTokens = 100000,+      headers = Map.empty,+      compat = CompatNone+    }++openrouter_anthropic_claude_sonnet_4 :: Model+openrouter_anthropic_claude_sonnet_4 =+  emptyModel+    { modelId = "anthropic/claude-sonnet-4",+      name = "Claude Sonnet 4 via OpenRouter",+      api = OpenAIChatCompletions,+      provider = "openrouter",+      baseUrl = "https://openrouter.ai/api",+      reasoning = False,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 3 % 1,+            outputCost = 15 % 1,+            cacheReadCost = 3 % 10,+            cacheWriteCost = 15 % 4+          },+      contextWindow = 200000,+      maxOutputTokens = 8192,+      headers = Map.empty,+      compat = CompatNone+    }++openrouter_openai_gpt_4o_mini :: Model+openrouter_openai_gpt_4o_mini =+  emptyModel+    { modelId = "openai/gpt-4o-mini",+      name = "GPT-4o mini via OpenRouter",+      api = OpenAIChatCompletions,+      provider = "openrouter",+      baseUrl = "https://openrouter.ai/api",+      reasoning = False,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 3 % 20,+            outputCost = 3 % 5,+            cacheReadCost = 3 % 40,+            cacheWriteCost = 0 % 1+          },+      contextWindow = 128000,+      maxOutputTokens = 16384,+      headers = Map.empty,+      compat = CompatNone+    }
src/Baikai/Options.hs view
@@ -1,12 +1,31 @@ -- | The 'Options' record — the per-call knobs that vary between -- requests on the same conversation. ----- 'apiKey' is 'Nothing' by default; the registered handler then--- consults a provider-specific env var--- (@ANTHROPIC_API_KEY@ for Anthropic, @OPENAI_API_KEY@ for OpenAI).+-- 'apiKey' is 'Nothing' by default; API providers then consult the+-- host-specific env var from 'Baikai.Auth.defaultApiKeyEnvForBaseUrl'+-- after substituting their default base URL. Unknown hosts require an+-- explicit key source instead of falling back to another provider's+-- credential. -- 'maxTokens' defaults to 'Nothing'; the handler falls back to the -- chosen model's 'Baikai.Model.maxOutputTokens'.+-- 'topP' and 'stopSequences' are honored by the Anthropic and+-- OpenAI-compatible API providers. 'seed', 'frequencyPenalty', and+-- 'presencePenalty' are honored by the OpenAI-compatible API+-- provider. Providers with no corresponding upstream parameter+-- silently omit the field, matching the existing drop policy for+-- unsupported per-call knobs on CLI providers. --+-- 'timeoutMs' is a wall-clock bound on the entire API streaming call+-- in the OpenAI and Claude providers: connection setup, response+-- headers, and full stream drain. On expiry the stream terminates+-- in-band with a retryable transient 'Baikai.Error.BaikaiError'.+--+-- 'headers' are per-call HTTP header overrides for API providers.+-- Provider defaults are built first, then 'Baikai.Model.headers',+-- then this field; later values replace earlier ones by+-- case-insensitive header name, including auth headers for callers+-- intentionally fronting a gateway.+-- -- EP-4 added @toolChoice@. EP-5 adds @cacheRetention@ and @thinking@ -- (provider-agnostic preferences that each provider maps to its own -- primitive — see 'Baikai.CacheRetention' and 'Baikai.ThinkingLevel'@@ -14,7 +33,23 @@ -- provider-agnostic structured-output preference — see -- 'Baikai.ResponseFormat'. module Baikai.Options-  ( Options (..),+  ( Options,+    maxTokens,+    temperature,+    apiKey,+    timeoutMs,+    headers,+    metadata,+    toolChoice,+    cacheRetention,+    thinking,+    responseFormat,+    topP,+    stopSequences,+    seed,+    frequencyPenalty,+    presencePenalty,+    emptyOptions,     _Options,   ) where@@ -28,6 +63,7 @@ import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map import Data.Text (Text)+import Data.Vector (Vector) import GHC.Generics (Generic) import Numeric.Natural (Natural) @@ -41,13 +77,18 @@     toolChoice :: !(Maybe ToolChoice),     cacheRetention :: !(Maybe CacheRetention),     thinking :: !(Maybe ThinkingLevel),-    responseFormat :: !(Maybe ResponseFormat)+    responseFormat :: !(Maybe ResponseFormat),+    topP :: !(Maybe Double),+    stopSequences :: !(Maybe (Vector Text)),+    seed :: !(Maybe Integer),+    frequencyPenalty :: !(Maybe Double),+    presencePenalty :: !(Maybe Double)   }   deriving stock (Eq, Show, Generic)   deriving anyclass (ToJSON) -_Options :: Options-_Options =+emptyOptions :: Options+emptyOptions =   Options     { maxTokens = Nothing,       temperature = Nothing,@@ -58,5 +99,14 @@       toolChoice = Nothing,       cacheRetention = Nothing,       thinking = Nothing,-      responseFormat = Nothing+      responseFormat = Nothing,+      topP = Nothing,+      stopSequences = Nothing,+      seed = Nothing,+      frequencyPenalty = Nothing,+      presencePenalty = Nothing     }++{-# DEPRECATED _Options "Use emptyOptions instead." #-}+_Options :: Options+_Options = emptyOptions
src/Baikai/Prelude.hs view
@@ -7,6 +7,12 @@ --   `Data.Generics.Labels` is imported for its orphan `IsLabel` instance --   only (no values are re-exported); the instance still propagates to --   anything that imports this Prelude.+--+--   This module is a convenience prelude, not a PVP-stable public API+--   contract. Its re-export set follows baikai's internal needs and may+--   change when upstream prelude, lens, or generic-lens usage changes.+--   Application code that needs a stable import surface should import+--   the upstream modules it uses directly. module Baikai.Prelude   ( -- * Lens vocabulary     module Control.Lens,
src/Baikai/Provider.hs view
@@ -13,25 +13,35 @@   ( ApiProvider (..),     ProviderRegistry,     newProviderRegistry,+    newProviderRegistryFrom,     globalProviderRegistry,     registerApiProviderWith,     registerApiProvider,+    assertRegistered,     lookupApiProviderWith,     lookupApiProvider,     completeRequestWith,     completeRequest,+    runToolLoopWith,+    runToolLoop,+    completeText,   ) where  import Baikai.Provider.Registry   ( ApiProvider (..),     ProviderRegistry,+    assertRegistered,     completeRequest,     completeRequestWith,+    completeText,     globalProviderRegistry,     lookupApiProvider,     lookupApiProviderWith,     newProviderRegistry,+    newProviderRegistryFrom,     registerApiProvider,     registerApiProviderWith,+    runToolLoop,+    runToolLoopWith,   )
src/Baikai/Provider/Cli/Internal.hs view
@@ -2,11 +2,13 @@ -- and @baikai-openai@. -- -- Not re-exported from "Baikai"; vendor packages import this module--- directly. Anything exported here is considered part of the core--- library's interface to the CLI provider packages and should not be--- relied on by application code.+-- directly. This is an internal interface and is not covered by PVP+-- stability guarantees: names, types, and semantics may change in+-- minor releases. Application code should use the public provider+-- modules instead. module Baikai.Provider.Cli.Internal   ( renderPrompt,+    wrapSystemPrompt,     maybeApply,     decodeUtf8Lenient,     extractAgentMessage,@@ -92,6 +94,23 @@       where         pickT (ToolResultText (TextContent t)) = Just t         pickT _ = Nothing++-- | Wrap a rendered prompt with a system-instruction preamble for+-- CLIs that expose no native system-prompt flag. 'Nothing' and+-- blank system prompts return the body unchanged. The textual+-- format is shared by the codex batch provider and the codex+-- interactive launcher.+wrapSystemPrompt :: Maybe Text -> Text -> Text+wrapSystemPrompt msp body = case Text.strip <$> msp of+  Nothing -> body+  Just "" -> body+  Just sp ->+    Text.concat+      [ "System instructions:\n",+        sp,+        "\n\nUser request:\n",+        body+      ]  -- | @maybeApply ma f x@ applies @f a x@ when @ma = Just a@, -- otherwise returns @x@. Useful for threading optional configuration
src/Baikai/Provider/Registry.hs view
@@ -9,34 +9,49 @@ -- sets, or use the global convenience registry for simple scripts. -- -- 'completeRequest' looks the handler up by the 'Model'\'s 'Api'--- tag and dispatches; it throws a 'Baikai.Error.BaikaiError' in the--- 'Baikai.Error.ProviderUnavailable' category when no handler is--- registered for that tag.+-- tag and dispatches; when no handler is registered it returns an+-- error-shaped 'Response' in the 'Baikai.Error.ProviderUnavailable'+-- category. module Baikai.Provider.Registry   ( ApiProvider (..),     ProviderRegistry,     newProviderRegistry,+    newProviderRegistryFrom,     globalProviderRegistry,     registerApiProviderWith,     registerApiProvider,+    assertRegistered,     lookupApiProviderWith,     lookupApiProvider,     completeRequestWith,     completeRequest,+    runToolLoopWith,+    runToolLoop,+    completeText,   ) where  import Baikai.Api (Api, renderApi)-import Baikai.Context (Context)+import Baikai.Content (AssistantContent (..), ToolCall)+import Baikai.Context (Context, appendToolResult, contextOf) import Baikai.Error (providerUnavailable)-import Baikai.Model (Model (..))-import Baikai.Options (Options)-import Baikai.Response (Response)+import Baikai.Message (AssistantPayload (..), ToolResult, toolResultErrorText, user)+import Baikai.Model (Model)+import Baikai.Model qualified as Model+import Baikai.Options (Options, emptyOptions)+import Baikai.Response (Response (..), errorResponse, flattenAssistantBlocks, flattenAssistantText, responseError)+import Baikai.StopReason (StopReason (..)) import Baikai.Stream.Event (AssistantMessageEvent)-import Control.Exception (throwIO)+import Control.Exception qualified as Exception+import Control.Monad (forM, unless)+import Data.Foldable (traverse_) import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef) import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Time (getCurrentTime)+import Data.Vector qualified as Vector import Streamly.Data.Stream (Stream) import System.IO.Unsafe (unsafePerformIO) @@ -59,6 +74,14 @@ newProviderRegistry :: IO ProviderRegistry newProviderRegistry = ProviderRegistry <$> newIORef Map.empty +-- | Construct a registry pre-populated with the given providers. Later entries+-- win on tag collisions, matching 'registerApiProviderWith'.+newProviderRegistryFrom :: [ApiProvider] -> IO ProviderRegistry+newProviderRegistryFrom providers = do+  reg <- newProviderRegistry+  traverse_ (registerApiProviderWith reg) providers+  pure reg+ -- | Process-global handler registry used by the legacy convenience functions. -- The 'unsafePerformIO' + 'NOINLINE' pattern mirrors 'Baikai.Trace'\'s -- @eventCounter@; this is a single shared handle for the lifetime of the@@ -78,6 +101,25 @@ registerApiProvider :: ApiProvider -> IO () registerApiProvider = registerApiProviderWith globalProviderRegistry +-- | Startup preflight: throw 'Baikai.Error.ProviderUnavailable' listing every+-- requested API tag that has no registered handler.+assertRegistered :: ProviderRegistry -> [Api] -> IO ()+assertRegistered reg tags = do+  missing <-+    fmap concat $+      forM tags $ \tag -> do+        found <- lookupApiProviderWith reg tag+        pure $ case found of+          Nothing -> [tag]+          Just _ -> []+  unless (null missing) $+    Exception.throwIO+      ( providerUnavailable+          ( "no provider registered for: "+              <> Text.intercalate ", " (renderApi <$> missing)+          )+      )+ -- | Look up the handler registered for an 'Api' tag. lookupApiProviderWith :: ProviderRegistry -> Api -> IO (Maybe ApiProvider) lookupApiProviderWith reg tag = Map.lookup tag <$> readIORef (registryRef reg)@@ -88,17 +130,107 @@ lookupApiProvider = lookupApiProviderWith globalProviderRegistry  -- | Dispatch a synchronous request through the registered handler--- for the model's 'Api' tag. Throws a 'ProviderUnavailable' error when no handler--- is registered for that tag.+-- for the model's 'Api' tag. Returns an error-shaped 'Response' when+-- no handler is registered for that tag. completeRequestWith :: ProviderRegistry -> Model -> Context -> Options -> IO Response completeRequestWith reg m ctx opts = do-  mProvider <- lookupApiProviderWith reg (api m)+  mProvider <- lookupApiProviderWith reg (Model.api m)   case mProvider of     Just p -> complete p m ctx opts-    Nothing ->-      throwIO $-        providerUnavailable ("No provider registered for API: " <> renderApi (api m))+    Nothing -> do+      now <- getCurrentTime+      pure $+        errorResponse+          m+          now+          0+          (providerUnavailable ("No provider registered for API: " <> renderApi (Model.api m)))  -- | Dispatch a synchronous request through the process-global registry. completeRequest :: Model -> Context -> Options -> IO Response completeRequest = completeRequestWith globalProviderRegistry++-- | Drive a tool-using conversation through the process-global registry.+--+-- The returned 'Context' contains the input context plus only fully resolved+-- assistant/tool-result exchanges, so it is always valid to replay. The final+-- 'Response' is not appended; callers that want the full transcript can use+-- 'Baikai.Context.addResponse'. 'Options' is passed unchanged each turn, so+-- forcing tools in options can exhaust the budget; loops should usually let+-- the provider choose tools automatically.+runToolLoop ::+  Int ->+  (ToolCall -> IO ToolResult) ->+  Model ->+  Context ->+  Options ->+  IO (Context, Response)+runToolLoop = runToolLoopWith globalProviderRegistry++-- | Drive a tool-using conversation through an explicit registry.+--+-- The turn budget is clamped to at least one model call. Synchronous dispatcher+-- exceptions become error tool results so the model can recover; asynchronous+-- exceptions are rethrown. Dispatchers should return 'toolResultErrorText' for+-- unknown tool names rather than throwing.+runToolLoopWith ::+  ProviderRegistry ->+  Int ->+  (ToolCall -> IO ToolResult) ->+  Model ->+  Context ->+  Options ->+  IO (Context, Response)+runToolLoopWith reg budget dispatcher model ctx0 opts =+  go (max 1 budget) ctx0+  where+    go remaining ctx = do+      resp <- completeRequestWith reg model ctx opts+      if shouldStop remaining resp+        then pure (ctx, resp)+        else do+          ctx' <- appendToolResult ctx resp (safeDispatcher dispatcher)+          go (remaining - 1) ctx'++    shouldStop remaining resp =+      responseError resp /= Nothing+        || responseStopReason resp /= ToolUse+        || Vector.null (responseToolCalls resp)+        || remaining <= 1++-- | One-shot text completion through the global registry. Throws the+-- 'Baikai.Error.BaikaiError' when the response is error-shaped.+completeText :: Model -> Text -> IO Text+completeText model prompt = do+  resp <- completeRequest model (contextOf [user prompt]) emptyOptions+  case responseError resp of+    Just err -> Exception.throwIO err+    Nothing -> pure (flattenAssistantText (flattenAssistantBlocks resp))++safeDispatcher :: (ToolCall -> IO ToolResult) -> ToolCall -> IO ToolResult+safeDispatcher dispatcher tc = do+  result <- trySync (dispatcher tc)+  case result of+    Right ok -> pure ok+    Left e -> pure (toolResultErrorText (Text.pack (Exception.displayException e)))++trySync :: IO a -> IO (Either Exception.SomeException a)+trySync action = do+  result <- Exception.try action+  case result of+    Left e+      | Just (Exception.SomeAsyncException _) <-+          (Exception.fromException e :: Maybe Exception.SomeAsyncException) ->+          Exception.throwIO e+      | otherwise -> pure (Left e)+    Right a -> pure (Right a)++responseStopReason :: Response -> StopReason+responseStopReason Response {message = AssistantPayload {stopReason = sr}} = sr++responseToolCalls :: Response -> Vector.Vector ToolCall+responseToolCalls Response {message = AssistantPayload {content = blocks}} =+  Vector.mapMaybe toolCallOf blocks+  where+    toolCallOf (AssistantToolCall tc) = Just tc+    toolCallOf _ = Nothing
src/Baikai/Response.hs view
@@ -5,26 +5,34 @@ -- 'Baikai.Api'), the input 'Model' echoed back, the provider name, -- an optional 'responseId', and the measured 'latencyMs'. The -- 'message' carries only assistant content blocks, 'Usage' (with--- 'Cost' embedded), 'StopReason', optional error text, and timestamp.+-- 'Cost' embedded), 'StopReason', optional error text, and an optional+-- timestamp. ----- 'flattenAssistantBlocks' is a convenience accessor that flattens--- the message's content blocks.+-- 'flattenAssistantBlocks' and 'flattenAssistantText' are convenience+-- accessors for the message's content blocks. module Baikai.Response   ( Response (..),+    emptyResponse,     _Response,     responseMessage,     flattenAssistantBlocks,+    flattenAssistantText,+    responseError,+    errorResponse,   ) where  import Baikai.Api (Api (..))-import Baikai.Content (AssistantContent)-import Baikai.Error (BaikaiError)+import Baikai.Content (AssistantContent (..), TextContent (..))+import Baikai.Error (BaikaiError, providerError)+import Baikai.Error qualified as Error import Baikai.Message (AssistantPayload (..), Message (..))-import Baikai.Model (Model, _Model)+import Baikai.Model (Model, emptyModel)+import Baikai.Model qualified as Model import Baikai.StopReason (StopReason (..))-import Baikai.Usage (_Usage)+import Baikai.Usage (zeroUsage) import Data.Text (Text)+import Data.Text qualified as Text import Data.Time (UTCTime) import Data.Vector (Vector) import Data.Vector qualified as V@@ -37,13 +45,13 @@     api :: !Api,     provider :: !Text,     responseId :: !(Maybe Text),-    latencyMs :: !Integer,+    latencyMs :: !Int,     -- | Structured error detail when the call failed in-band     -- (@stopReason = ErrorReason@): the category, HTTP status, and any-    -- retry-after hint. 'Nothing' on success or when the provider could-    -- not classify the failure. Lets a caller branch on-    -- 'Baikai.Error.category' / 'Baikai.Error.isRetryable' without-    -- parsing 'errorMessage' text.+    -- retry-after hint. Conforming providers set this to 'Just' for+    -- error-shaped responses and 'Nothing' on success. Use+    -- 'responseError' for the normalized failure view; it synthesizes+    -- an 'OtherError' if a nonconforming provider omits this field.     errorInfo :: !(Maybe BaikaiError)   }   deriving stock (Eq, Show, Generic)@@ -51,18 +59,18 @@ -- | A blank assistant turn at epoch start. Useful as a fixture base -- for tests and as the default in error paths where no message was -- received.-_Response :: Response-_Response =+emptyResponse :: Response+emptyResponse =   Response     { message =         AssistantPayload           { content = V.empty,-            usage = _Usage,+            usage = zeroUsage,             stopReason = Stop,             errorMessage = Nothing,-            timestamp = read "2000-01-01 00:00:00 UTC" :: UTCTime+            timestamp = Nothing           },-      model = _Model,+      model = emptyModel,       api = Custom "",       provider = "",       responseId = Nothing,@@ -77,3 +85,48 @@ -- | Pull the response's assistant content blocks out. flattenAssistantBlocks :: Response -> Vector AssistantContent flattenAssistantBlocks Response {message = AssistantPayload {content = c}} = c++-- | Concatenate all assistant text blocks, ignoring thinking and tool+-- call blocks.+flattenAssistantText :: Vector AssistantContent -> Text+flattenAssistantText =+  Text.concat . V.toList . V.mapMaybe textOf+  where+    textOf (AssistantText (TextContent t)) = Just t+    textOf _ = Nothing++-- | The single question callers ask about failure. Returns 'Just'+-- exactly when the response is error-shaped (@stopReason =+-- ErrorReason@): the provider's classified 'errorInfo' when present,+-- otherwise a synthesized 'OtherError' carrying the response's+-- 'errorMessage' text.+responseError :: Response -> Maybe BaikaiError+responseError Response {message = AssistantPayload {stopReason = sr, errorMessage = em}, errorInfo = ei}+  | sr == ErrorReason = Just (maybe (providerError fallback) id ei)+  | otherwise = Nothing+  where+    fallback = maybe "call failed with no error detail" id em++-- | Build a conformant error-shaped 'Response' for a failed call.+errorResponse :: Model -> UTCTime -> Int -> BaikaiError -> Response+errorResponse m ts latency err =+  Response+    { message =+        AssistantPayload+          { content = V.empty,+            usage = zeroUsage,+            stopReason = ErrorReason,+            errorMessage = Just (Error.message err),+            timestamp = Just ts+          },+      model = m,+      api = Model.api m,+      provider = Model.provider m,+      responseId = Nothing,+      latencyMs = latency,+      errorInfo = Just err+    }++{-# DEPRECATED _Response "Use emptyResponse instead." #-}+_Response :: Response+_Response = emptyResponse
src/Baikai/Stream.hs view
@@ -4,9 +4,9 @@ -- -- 'streamRequest' looks up the per-API handler registered for a -- model's 'Api' tag and returns its event stream. When no handler is--- registered the function returns a one-event error stream (a single--- 'EventError' with @stopReason = ErrorReason@) so a caller iterating--- the stream always gets at least one terminal event.+-- registered the function returns a synthetic 'EventStart' followed by+-- a terminal 'EventError' with @stopReason = ErrorReason@, preserving+-- the protocol that every core-owned stream starts with 'EventStart'. -- -- 'streamingComplete' folds an event stream into the synchronous -- 'Response' shape. Vendor providers register both the @stream@@@ -17,6 +17,10 @@ module Baikai.Stream   ( streamRequest,     streamRequestWith,+    streamRequestEach,+    streamRequestEachWith,+    streamRequestList,+    streamRequestListWith,     streamingComplete,     reassembleResponse,     liftCompleteToStream,@@ -31,7 +35,7 @@   ) import Baikai.Content qualified as Content import Baikai.Context (Context)-import Baikai.Error (BaikaiError, providerUnavailable)+import Baikai.Error (BaikaiError, providerError, providerUnavailable) import Baikai.Message (AssistantPayload (..), Message (AssistantMessage)) import Baikai.Message qualified as Msg import Baikai.Model (Model)@@ -42,7 +46,7 @@     globalProviderRegistry,     lookupApiProviderWith,   )-import Baikai.Response (Response (..), responseMessage)+import Baikai.Response (Response (..), responseError, responseMessage) import Baikai.StopReason (StopReason (..)) import Baikai.Stream.Event   ( AssistantMessageEvent (..),@@ -51,11 +55,13 @@     IndexPayload (..),     StartPayload (..),     TerminalPayload (..),+    ThinkingEndPayload (..),     ToolCallEndPayload (..),     doneTerminal,     errorTerminal,   )-import Baikai.Usage (_Usage)+import Baikai.Usage (zeroUsage)+import Control.Applicative ((<|>)) import Control.Exception (fromException) import Control.Exception qualified import Control.Lens ((%~), (&), (.~), (^.))@@ -78,9 +84,8 @@ import Streamly.Data.Stream qualified as Stream  -- | Dispatch a streaming call through the registered handler for the--- model's 'Api' tag. Returns a one-event error stream when no handler--- is registered for that tag — the caller always sees at least one--- terminal event.+-- model's 'Api' tag. Returns an 'EventStart' then 'EventError' stream+-- when no handler is registered for that tag. streamRequest :: Model -> Context -> Options -> Stream IO AssistantMessageEvent streamRequest = streamRequestWith globalProviderRegistry @@ -97,8 +102,54 @@     mProvider <- lookupApiProviderWith reg (m ^. #api)     case mProvider of       Just p -> pure (stream p m ctx opts)-      Nothing -> pure (Stream.fromEffect (noProviderEvent m))+      Nothing -> Stream.fromList <$> noProviderEvents m +-- | Stream a request through the process-global registry, invoking the+-- callback once per event, then return the same reassembled 'Response'+-- that 'streamingComplete' would produce. Callback exceptions propagate.+streamRequestEach ::+  (AssistantMessageEvent -> IO ()) ->+  Model ->+  Context ->+  Options ->+  IO Response+streamRequestEach = streamRequestEachWith globalProviderRegistry++-- | Stream a request through an explicit registry, invoking the callback once+-- per event. The underlying event protocol is the same as 'streamRequest':+-- provider streams emit exactly one terminal event.+streamRequestEachWith ::+  ProviderRegistry ->+  (AssistantMessageEvent -> IO ()) ->+  Model ->+  Context ->+  Options ->+  IO Response+streamRequestEachWith reg callback m ctx opts =+  Stream.fold+    (reassembleResponse m)+    ( Stream.mapM+        ( \event -> do+            callback event+            pure event+        )+        (streamRequestWith reg m ctx opts)+    )++-- | Collect the event stream from the process-global registry into a list.+streamRequestList :: Model -> Context -> Options -> IO [AssistantMessageEvent]+streamRequestList = streamRequestListWith globalProviderRegistry++-- | Collect the event stream from an explicit registry into a list.+streamRequestListWith ::+  ProviderRegistry ->+  Model ->+  Context ->+  Options ->+  IO [AssistantMessageEvent]+streamRequestListWith reg m ctx opts =+  Stream.toList (streamRequestWith reg m ctx opts)+ -- | Drain an event stream into a 'Response' by folding events into -- the assembled message. streamingComplete ::@@ -115,34 +166,46 @@ -- reassembly logic. reassembleResponse :: Model -> Fold IO AssistantMessageEvent Response reassembleResponse m =-  Fold.rmapM finalizeState (Fold.foldl' step (initialState m))+  Fold.rmapM+    finalizeState+    (Fold.foldlM' (\s e -> pure (step s e)) (initialState m <$> getCurrentTime))  -- | One frozen-in-time view of the partial assembly. data ReassemblyState = ReassemblyState   { model :: !Model,     -- | 'Just' once 'EventStart' has been observed.     skeleton :: !(Maybe Message),-    -- | Captured from the EventStart message's timestamp.-    startTime :: !(Maybe UTCTime),+    -- | Captured by the reassembler when the fold starts driving the stream.+    wallStart :: !UTCTime,+    -- | Provider message id, preferring the terminal payload over the start payload.+    responseId :: !(Maybe Text),     -- | Content blocks closed so far, keyed by @contentIndex@.     blocks :: !(IntMap AssistantContent),     -- | Open text accumulators, indexed by @contentIndex@.     textBuf :: !(IntMap Text),     thinkBuf :: !(IntMap Text),     toolArgsBuf :: !(IntMap Text),-    -- | 'Just (reason, msg, errorInfo)' once 'EventDone' or 'EventError'-    -- fires. @errorInfo@ is the structured error from an 'EventError'-    -- terminal (if the provider classified it), 'Nothing' otherwise.-    terminal :: !(Maybe (StopReason, Message, Maybe BaikaiError))+    -- | 'Just' once 'EventDone' or 'EventError' fires.+    terminal :: !(Maybe TerminalSeen)   }   deriving stock (Show, Generic) -initialState :: Model -> ReassemblyState-initialState m =+-- | What the terminal event told us, plus which terminal it was.+data TerminalSeen = TerminalSeen+  { reason :: !StopReason,+    message :: !Message,+    errorInfo :: !(Maybe BaikaiError),+    failed :: !Bool+  }+  deriving stock (Show, Generic)++initialState :: Model -> UTCTime -> ReassemblyState+initialState m start =   ReassemblyState     { model = m,       skeleton = Nothing,-      startTime = Nothing,+      wallStart = start,+      responseId = Nothing,       blocks = IntMap.empty,       textBuf = IntMap.empty,       thinkBuf = IntMap.empty,@@ -152,11 +215,8 @@  step :: ReassemblyState -> AssistantMessageEvent -> ReassemblyState step s = \case-  EventStart StartPayload {partial = sk} ->-    let t = case sk of-          AssistantMessage AssistantPayload {Msg.timestamp = ts} -> Just ts-          _ -> Nothing-     in s & #skeleton .~ Just sk & #startTime .~ t+  EventStart StartPayload {partial = sk, responseId = rid} ->+    s & #skeleton .~ Just sk & #responseId .~ rid   TextStart IndexPayload {contentIndex = i} ->     s & #textBuf %~ IntMap.insert i Text.empty   TextDelta DeltaPayload {contentIndex = i, delta = d} ->@@ -169,14 +229,12 @@     s & #thinkBuf %~ IntMap.insert i Text.empty   ThinkingDelta DeltaPayload {contentIndex = i, delta = d} ->     s & #thinkBuf %~ IntMap.insertWith (\new old -> old <> new) i d-  ThinkingEnd BlockEndPayload {contentIndex = i, content = body} ->+  ThinkingEnd ThinkingEndPayload {contentIndex = i, content = tc} ->     s       & #blocks         %~ IntMap.insert           i-          ( AssistantThinking-              ThinkingContent {thinking = body, signature = Nothing, redacted = False}-          )+          (AssistantThinking tc)       & #thinkBuf %~ IntMap.delete i   ToolCallStart IndexPayload {contentIndex = i} ->     s & #toolArgsBuf %~ IntMap.insert i Text.empty@@ -186,55 +244,116 @@     s       & #blocks %~ IntMap.insert i (AssistantToolCall tc)       & #toolArgsBuf %~ IntMap.delete i-  EventDone TerminalPayload {reason = r, message = msg} ->-    s & #terminal .~ Just (r, msg, Nothing)-  EventError TerminalPayload {reason = r, message = msg, errorInfo = ei} ->-    s & #terminal .~ Just (r, msg, ei)+  EventDone TerminalPayload {reason = r, message = msg, responseId = rid} ->+    s+      & #terminal+        .~ Just TerminalSeen {reason = r, message = msg, errorInfo = Nothing, failed = False}+      & #responseId %~ (\old -> rid <|> old)+  EventError TerminalPayload {reason = r, message = msg, responseId = rid, errorInfo = ei} ->+    s+      & #terminal+        .~ Just TerminalSeen {reason = r, message = msg, errorInfo = ei, failed = True}+      & #responseId %~ (\old -> rid <|> old)  finalizeState :: ReassemblyState -> IO Response finalizeState s = do   now <- getCurrentTime   let m = s ^. #model-      assembled = assembleBlocks s-      (terminalMsg, terminalReason, terminalError) = case s ^. #terminal of-        Just (r, msg, ei) -> (msg, r, ei)-        Nothing -> (synthesizeTerminal now assembled, Stop, Nothing)-      message' = overrideBlocksAndReason terminalReason terminalMsg assembled now-      endTs = message' ^. #timestamp-      latency = case s ^. #startTime of-        Just startTs ->-          round (realToFrac (Data.Time.Clock.diffUTCTime endTs startTs) * (1000 :: Double))-        Nothing -> 0+      assembled = assembledBlocks s+      dangling = danglingBlocks s+      (terminalMsg, terminalReason, terminalError, terminalFailed, sawTerminal) =+        case s ^. #terminal of+          Just TerminalSeen {reason = r, message = msg, errorInfo = ei, failed = failed'} ->+            (msg, r, ei, failed', True)+          Nothing -> (synthesizeTerminal now assembled, Stop, Nothing, False, False)+      terminalContent = messageContent terminalMsg+      normalizedError = case (terminalReason, terminalError) of+        (ErrorReason, Nothing) -> Just (providerError (messageErrorText terminalMsg))+        _ -> terminalError+      finalContent+        | not sawTerminal = assembled+        | Vector.null terminalContent = assembled+        | terminalFailed = terminalContent <> Vector.fromList (IntMap.elems dangling)+        | otherwise = terminalContent+      message' = overrideBlocksAndReason terminalReason terminalMsg finalContent now+      latency = case (s ^. #skeleton >>= messageTimestamp, assistantPayloadTimestamp message') of+        (Just startTs, Just endTs) -> millisBetween startTs endTs+        _ -> 0   pure     Response       { message = message',         model = m,         api = m ^. #api,         provider = m ^. #provider,-        responseId = Nothing,+        responseId = s ^. #responseId,         latencyMs = latency,-        errorInfo = terminalError+        errorInfo = normalizedError       } --- | Project the closed content blocks in 'contentIndex' order, plus--- any still-open text/thinking accumulators flushed as best-effort--- content (recovery path; providers should not exercise this).-assembleBlocks :: ReassemblyState -> Vector AssistantContent-assembleBlocks s =-  let closed = IntMap.elems (s ^. #blocks)-      danglingText =-        [ AssistantText (TextContent t)-        | (_, t) <- IntMap.toAscList (s ^. #textBuf),-          not (Text.null t)-        ]-      danglingThink =-        [ AssistantThinking-            ThinkingContent {thinking = t, signature = Nothing, redacted = False}-        | (_, t) <- IntMap.toAscList (s ^. #thinkBuf),-          not (Text.null t)-        ]-   in Vector.fromList (closed <> danglingText <> danglingThink)+-- | Project the event-assembled content in 'contentIndex' order,+-- including still-open buffers as best-effort recovery content.+assembledBlocks :: ReassemblyState -> Vector AssistantContent+assembledBlocks s =+  Vector.fromList (IntMap.elems (IntMap.union (s ^. #blocks) (danglingBlocks s))) +-- | Convert any still-open buffers into inspectable content. This is+-- the recovery path for streams that die before an @_End@ event. Partial+-- tool arguments are preserved as decoded JSON when possible, otherwise+-- as an 'Aeson.String' carrying the raw accumulated text.+danglingBlocks :: ReassemblyState -> IntMap AssistantContent+danglingBlocks s =+  IntMap.unions+    [ IntMap.mapMaybe textBlock (s ^. #textBuf),+      IntMap.mapMaybe thinkingBlock (s ^. #thinkBuf),+      IntMap.mapMaybe toolBlock (s ^. #toolArgsBuf)+    ]+  where+    textBlock t+      | Text.null t = Nothing+      | otherwise = Just (AssistantText (TextContent t))++    thinkingBlock t+      | Text.null t = Nothing+      | otherwise =+          Just (AssistantThinking ThinkingContent {thinking = t, signature = Nothing, redacted = False})++    toolBlock raw+      | Text.null raw = Nothing+      | otherwise =+          let decoded = case Aeson.eitherDecodeStrict (Text.encodeUtf8 raw) of+                Right v -> v+                Left _ -> Aeson.String raw+           in Just+                ( AssistantToolCall+                    Content.ToolCall+                      { Content.id_ = "",+                        Content.name = "",+                        Content.arguments = decoded+                      }+                )++messageContent :: Message -> Vector AssistantContent+messageContent = \case+  AssistantMessage AssistantPayload {Msg.content = c} -> c+  _ -> Vector.empty++messageErrorText :: Message -> Text+messageErrorText = \case+  AssistantMessage AssistantPayload {Msg.errorMessage = Just em} -> em+  _ -> "call failed with no error detail"++messageTimestamp :: Message -> Maybe UTCTime+messageTimestamp = \case+  AssistantMessage AssistantPayload {Msg.timestamp = ts} -> ts+  _ -> Nothing++assistantPayloadTimestamp :: AssistantPayload -> Maybe UTCTime+assistantPayloadTimestamp AssistantPayload {Msg.timestamp = ts} = ts++millisBetween :: UTCTime -> UTCTime -> Int+millisBetween start end =+  max 0 (round (realToFrac (Data.Time.Clock.diffUTCTime end start) * (1000 :: Double)))+ -- | Replace the content vector and stop reason of an assistant -- message, preserving usage, errorMessage, and timestamp. overrideBlocksAndReason ::@@ -251,10 +370,10 @@   _ ->     AssistantPayload       { Msg.content = blocks,-        Msg.usage = _Usage,+        Msg.usage = zeroUsage,         Msg.stopReason = sr,         Msg.errorMessage = Just "stream terminated with a non-assistant message",-        Msg.timestamp = fallbackTs+        Msg.timestamp = Just fallbackTs       }  -- | Build a placeholder 'AssistantMessage' for streams that ended@@ -264,10 +383,10 @@   AssistantMessage     AssistantPayload       { Msg.content = blocks,-        Msg.usage = _Usage,+        Msg.usage = zeroUsage,         Msg.stopReason = Stop,         Msg.errorMessage = Just "stream ended without terminal event",-        Msg.timestamp = now+        Msg.timestamp = Just now       }  -- | Wrap a synchronous @complete@ handler in a one-shot event@@ -283,11 +402,14 @@ --   matching @_Start@ / @_Delta@ / @_End@ trio. Text blocks emit --   one 'TextDelta' with the whole text; tool calls emit one --   'ToolCallDelta' with the JSON-encoded arguments.--- * 'EventDone' carrying the fully assembled assistant message.+-- * 'EventDone' carrying the fully assembled assistant message, or+--   'EventError' when the response is error-shaped according to+--   'responseError'. ----- An exception from @complete@ becomes a single-'EventError' stream--- with @stopReason = ErrorReason@ and 'errorMessage' set from the--- exception's display.+-- A synchronous exception from @complete@ becomes a synthetic+-- 'EventStart' followed by 'EventError' with @stopReason = ErrorReason@+-- and 'errorMessage' set from the exception's display. Asynchronous+-- exceptions are rethrown so cancellation works. liftCompleteToStream ::   (Model -> Context -> Options -> IO Response) ->   Model ->@@ -297,14 +419,26 @@ liftCompleteToStream f m ctx opts =   Stream.concatEffect $ do     startTs <- getCurrentTime-    er <- tryAny (f m ctx opts)+    er <- trySync (f m ctx opts)     case er of       Right resp -> pure (Stream.fromList (eventsFor startTs resp))-      Left e -> pure (Stream.fromEffect (errorEvent e))+      Left e -> Stream.fromList <$> errorEvents e --- | A typed 'try' over any synchronous exception.-tryAny :: IO a -> IO (Either Control.Exception.SomeException a)-tryAny = Control.Exception.try+-- | 'try' for synchronous exceptions only. Anything delivered+-- asynchronously (wrapped in 'Control.Exception.SomeAsyncException' by+-- 'Control.Exception.throwTo', 'System.Timeout.timeout', Ctrl-C) is+-- rethrown so cancellation works; converting it into an 'EventError'+-- would defeat it.+trySync :: IO a -> IO (Either Control.Exception.SomeException a)+trySync action = do+  r <- Control.Exception.try action+  case r of+    Left e+      | Just (Control.Exception.SomeAsyncException _) <-+          (fromException e :: Maybe Control.Exception.SomeAsyncException) ->+          Control.Exception.throwIO e+      | otherwise -> pure (Left e)+    Right a -> pure (Right a)  -- | Build the synthetic event list for a fully resolved 'Response'. -- The 'EventStart' carries the supplied @startTs@ on its message@@ -321,7 +455,7 @@               Msg.usage = payload ^. #usage,               Msg.stopReason = payload ^. #stopReason,               Msg.errorMessage = payload ^. #errorMessage,-              Msg.timestamp = startTs+              Msg.timestamp = Just startTs             }       blocks = Vector.toList (payload ^. #content)       blockEvents =@@ -330,9 +464,13 @@           | (i, b) <- zip [0 ..] blocks           ]       reason = payload ^. #stopReason-   in [EventStart StartPayload {partial = skeleton}]+      rid = resp ^. #responseId+      terminalEvent = case responseError resp of+        Just be -> EventError (errorTerminal rid reason msg be)+        Nothing -> EventDone (doneTerminal rid reason msg)+   in [EventStart StartPayload {partial = skeleton, responseId = rid}]         <> blockEvents-        <> [EventDone (doneTerminal reason msg)]+        <> [terminalEvent]  blockEvent :: Int -> AssistantContent -> [AssistantMessageEvent] blockEvent i = \case@@ -341,10 +479,10 @@       TextDelta DeltaPayload {contentIndex = i, delta = t},       TextEnd BlockEndPayload {contentIndex = i, content = t}     ]-  AssistantThinking ThinkingContent {thinking = t} ->+  AssistantThinking th@ThinkingContent {thinking = t} ->     [ ThinkingStart IndexPayload {contentIndex = i},       ThinkingDelta DeltaPayload {contentIndex = i, delta = t},-      ThinkingEnd BlockEndPayload {contentIndex = i, content = t}+      ThinkingEnd ThinkingEndPayload {contentIndex = i, content = th}     ]   AssistantToolCall tc ->     let argsText =@@ -354,8 +492,8 @@           ToolCallEnd ToolCallEndPayload {contentIndex = i, toolCall = tc}         ] -errorEvent :: Control.Exception.SomeException -> IO AssistantMessageEvent-errorEvent e = do+errorEvents :: Control.Exception.SomeException -> IO [AssistantMessageEvent]+errorEvents e = do   now <- getCurrentTime   -- When a @complete@ handler threw a typed 'BaikaiError' (the CLI,   -- 'Baikai.Auth', and registry paths do), preserve it structurally so a@@ -369,17 +507,21 @@         AssistantMessage           AssistantPayload             { Msg.content = Vector.empty,-              Msg.usage = _Usage,+              Msg.usage = zeroUsage,               Msg.stopReason = ErrorReason,               Msg.errorMessage = Just errText,-              Msg.timestamp = now+              Msg.timestamp = Just now             }-  pure (EventError (errorTerminal ErrorReason msg mErr))+      err = maybe (providerError errText) id mErr+  pure+    [ EventStart StartPayload {partial = msg, responseId = Nothing},+      EventError (errorTerminal Nothing ErrorReason msg err)+    ] --- | The single 'EventError' value used when no provider is--- registered for the model's API tag.-noProviderEvent :: Model -> IO AssistantMessageEvent-noProviderEvent m = do+-- | The synthetic error stream used when no provider is registered for+-- the model's API tag.+noProviderEvents :: Model -> IO [AssistantMessageEvent]+noProviderEvents m = do   now <- getCurrentTime   let detail = "No provider registered for API: " <> renderApi (m ^. #api)       be = providerUnavailable detail@@ -387,9 +529,12 @@         AssistantMessage           AssistantPayload             { Msg.content = Vector.empty,-              Msg.usage = _Usage,+              Msg.usage = zeroUsage,               Msg.stopReason = ErrorReason,               Msg.errorMessage = Just detail,-              Msg.timestamp = now+              Msg.timestamp = Just now             }-  pure (EventError (errorTerminal ErrorReason msg (Just be)))+  pure+    [ EventStart StartPayload {partial = msg, responseId = Nothing},+      EventError (errorTerminal Nothing ErrorReason msg be)+    ]
src/Baikai/Stream/Event.hs view
@@ -8,27 +8,32 @@ -- provider, model id), interleaves per-content-block lifecycle events -- (@_Start@ / @_Delta@ / @_End@) keyed by 'contentIndex', and -- terminates with exactly one 'EventDone' (success) or 'EventError'--- (any failure that bubbled out of the producer). The terminal event--- carries the fully assembled 'AssistantMessage' so a consumer that--- only pattern-matches on the terminal event still gets a correct--- response without folding deltas.+-- (any failure that bubbled out of the producer). This EventStart-first+-- invariant includes error-only streams produced by core dispatch and+-- request-preparation failures; they emit a synthetic skeleton before+-- the terminal error. One temporary provider-side gap remains: a Claude+-- mid-call failure before @message_start@ can still terminate without a+-- start event until the EP-7 Claude streaming rewrite pre-seeds its+-- skeleton. The terminal event carries the fully assembled+-- 'AssistantMessage' so a consumer that only pattern-matches on the+-- terminal event still gets a correct response without folding deltas. ----- The algebra is closed and shared by every provider in baikai 0.1.--- Adding a new variant is a breaking change to baikai's public--- surface. Consumers that prefer source resilience over exhaustiveness--- can include a final wildcard branch in their pattern match, but--- providers MUST NOT emit untyped provider-specific events through--- this algebra. Providers MUST emit @_Start@, then zero or more--- @_Delta@, then @_End@ for each content block in increasing--- @contentIndex@ order; the reassembler defends against missing--- @_End@ events but no built-in provider should rely on the recovery--- path.+-- The algebra is closed and shared by every provider. Adding a new+-- variant is a breaking change to baikai's public surface. Consumers+-- that prefer source resilience over exhaustiveness can include a+-- final wildcard branch in their pattern match, but providers MUST NOT+-- emit untyped provider-specific events through this algebra. Providers+-- MUST emit @_Start@, then zero or more @_Delta@, then @_End@ for each+-- content block in increasing @contentIndex@ order; the reassembler+-- defends against missing @_End@ events but no built-in provider should+-- rely on the recovery path. module Baikai.Stream.Event   ( AssistantMessageEvent (..),     StartPayload (..),     IndexPayload (..),     DeltaPayload (..),     BlockEndPayload (..),+    ThinkingEndPayload (..),     ToolCallEndPayload (..),     TerminalPayload (..),     doneTerminal,@@ -37,7 +42,7 @@   ) where -import Baikai.Content (ToolCall)+import Baikai.Content (ThinkingContent, ToolCall) import Baikai.Error (BaikaiError) import Baikai.Message (Message) import Baikai.StopReason (StopReason)@@ -59,8 +64,9 @@ -- partial (e.g. a @delta@ that throws on 'EventStart'); @-Wpartial-fields@ -- flags that latent runtime hazard. Same-shaped constructors share one -- payload type ('IndexPayload', 'DeltaPayload', 'BlockEndPayload',--- 'TerminalPayload'), so the payloads also collapse the repetition the--- flat encoding spread across twelve constructors.+-- 'ThinkingEndPayload', 'TerminalPayload'), so the payloads also+-- collapse the repetition the flat encoding spread across twelve+-- constructors. data AssistantMessageEvent   = -- | The first event in every stream. The payload's 'partial' is an     -- 'AssistantMessage' with empty content; downstream consumers that@@ -79,8 +85,10 @@     ThinkingStart IndexPayload   | -- | A chunk of reasoning text appended to the thinking block.     ThinkingDelta DeltaPayload-  | -- | The thinking block at @contentIndex@ is closed.-    ThinkingEnd BlockEndPayload+  | -- | The thinking block at @contentIndex@ is closed. The payload+    -- carries the full 'ThinkingContent', including provider+    -- signature and redacted flag when available.+    ThinkingEnd ThinkingEndPayload   | -- | A tool-call content block is about to receive argument-JSON     -- chunks. The tool's @id_@ and @name@ become known on this event     -- when the upstream API delivers them up front (Anthropic), or on@@ -107,9 +115,12 @@   deriving stock (Eq, Show, Generic)   deriving anyclass (ToJSON) --- | Payload of 'EventStart': the message skeleton observed up front.-newtype StartPayload = StartPayload-  { partial :: Message+-- | Payload of 'EventStart': the message skeleton observed up front,+-- plus the provider's message id when the provider learns it this+-- early (Anthropic's @message_start.id@). 'Nothing' otherwise.+data StartPayload = StartPayload+  { partial :: !Message,+    responseId :: !(Maybe Text)   }   deriving stock (Eq, Show, Generic)   deriving anyclass (ToJSON)@@ -130,8 +141,8 @@   deriving stock (Eq, Show, Generic)   deriving anyclass (ToJSON) --- | Payload of the text/thinking @_End@ events: the closed block's--- fully concatenated 'content'.+-- | Payload of the text @_End@ event: the closed block's fully+-- concatenated 'content'. data BlockEndPayload = BlockEndPayload   { contentIndex :: !Int,     content :: !Text@@ -139,6 +150,17 @@   deriving stock (Eq, Show, Generic)   deriving anyclass (ToJSON) +-- | Payload of 'ThinkingEnd': the closed thinking block in full —+-- concatenated reasoning text plus the provider 'signature' and+-- 'redacted' flag. The concatenation of the preceding+-- 'ThinkingDelta's equals the payload's @content.thinking@.+data ThinkingEndPayload = ThinkingEndPayload+  { contentIndex :: !Int,+    content :: !ThinkingContent+  }+  deriving stock (Eq, Show, Generic)+  deriving anyclass (ToJSON)+ -- | Payload of 'ToolCallEnd': the fully parsed 'toolCall'. data ToolCallEndPayload = ToolCallEndPayload   { contentIndex :: !Int,@@ -152,10 +174,12 @@ data TerminalPayload = TerminalPayload   { reason :: !StopReason,     message :: !Message,-    -- | Structured error detail for an 'EventError' terminal: the-    -- category, HTTP status, and any retry-after hint a caller needs to-    -- decide retry policy. 'Nothing' for a successful 'EventDone'-    -- terminal and for error terminals a provider could not classify.+    -- | The provider's message id when known by stream end. Consumers+    -- should prefer this over 'StartPayload.responseId'.+    responseId :: !(Maybe Text),+    -- | Structured error detail. Always 'Nothing' on 'EventDone' and+    -- always 'Just' on 'EventError'; use 'errorTerminal' to enforce the+    -- error-side invariant at construction sites.     errorInfo :: !(Maybe BaikaiError)   }   deriving stock (Eq, Show, Generic)@@ -164,13 +188,16 @@ -- | Build a success terminal payload ('errorInfo' is always 'Nothing'). -- Prefer this over the raw 'TerminalPayload' constructor so a new field -- can never be left uninitialised at a construction site.-doneTerminal :: StopReason -> Message -> TerminalPayload-doneTerminal r m = TerminalPayload {reason = r, message = m, errorInfo = Nothing}+doneTerminal :: Maybe Text -> StopReason -> Message -> TerminalPayload+doneTerminal rid r m =+  TerminalPayload {reason = r, message = m, responseId = rid, errorInfo = Nothing} --- | Build an error terminal payload carrying optional structured error--- detail. Prefer this over the raw 'TerminalPayload' constructor.-errorTerminal :: StopReason -> Message -> Maybe BaikaiError -> TerminalPayload-errorTerminal r m e = TerminalPayload {reason = r, message = m, errorInfo = e}+-- | Build an error terminal payload carrying structured error detail.+-- Prefer this over the raw 'TerminalPayload' constructor so an+-- 'EventError' cannot be constructed without 'errorInfo'.+errorTerminal :: Maybe Text -> StopReason -> Message -> BaikaiError -> TerminalPayload+errorTerminal rid r m e =+  TerminalPayload {reason = r, message = m, responseId = rid, errorInfo = Just e}  -- | 'True' when the event terminates the stream — exactly one -- 'EventDone' or 'EventError' is emitted per call.
src/Baikai/Tool.hs view
@@ -20,6 +20,7 @@ module Baikai.Tool   ( Tool (..),     ToolChoice (..),+    emptyTool,     _Tool,   ) where@@ -76,10 +77,14 @@   toJSON = genericToJSON toolChoiceOptions  -- | An empty tool — useful as a base for record updates.-_Tool :: Tool-_Tool =+emptyTool :: Tool+emptyTool =   Tool     { name = Text.empty,       description = Text.empty,       parameters = Null     }++{-# DEPRECATED _Tool "Use emptyTool instead." #-}+_Tool :: Tool+_Tool = emptyTool
src/Baikai/Trace.hs view
@@ -18,8 +18,10 @@ -- matching 'CallFinished' / 'CallFailed' before yielding the -- terminal event to the consumer. Cleanup ('Nothing' sentinel on -- the channel + 'takeMVar' on the worker) is idempotent and runs--- through 'Stream.finallyIO' so an early-aborting consumer never--- leaks the worker.+-- through 'Stream.finallyIO' so an early-aborting consumer eventually+-- records a synthetic 'CallFailed' and never leaks the worker. Sink+-- exceptions are captured by the worker and reported once on stderr+-- during cleanup; they do not propagate into the provider call. module Baikai.Trace   ( -- * Re-exports     TraceEvent (..),@@ -63,19 +65,21 @@ import Control.Concurrent (forkIO) import Control.Concurrent.Chan (Chan, newChan, readChan, writeChan) import Control.Concurrent.MVar (MVar, newEmptyMVar, putMVar, takeMVar)--- Control.Exception no longer needed; producer errors flow through--- the stream as 'EventError'.-import Control.Monad (unless)+import Control.Exception (SomeException, displayException, try)+import Control.Monad (forM_, unless) import Control.Monad.IO.Unlift (MonadUnliftIO, withRunInIO) import Data.Bits (unsafeShiftL, (.&.), (.|.))-import Data.IORef (IORef, atomicModifyIORef', newIORef)+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef, writeIORef) import Data.Maybe (fromMaybe) import Data.Text qualified as Text import Data.Time (UTCTime, diffUTCTime, getCurrentTime) import Data.Time.Clock.POSIX (getPOSIXTime)+import Data.Word (Word64)+import Foreign.StablePtr (StablePtr, freeStablePtr, newStablePtr) import Numeric (showHex) import Streamly.Data.Stream (Stream) import Streamly.Data.Stream qualified as Stream+import System.IO (hPutStrLn, stderr) import System.IO.Unsafe (unsafePerformIO)  -- ============================================================@@ -119,8 +123,15 @@         let stepDrain () = do               msg <- readChan c               pure (fmap (\e -> (e, ())) msg)-        Stream.unfoldrM stepDrain ()-          & Stream.fold sinkFold+        result <-+          try+            ( Stream.unfoldrM stepDrain ()+                & Stream.fold sinkFold+            ) ::+            IO (Either SomeException ())+        case result of+          Left e -> writeIORef (state ^. #sinkError) (Just e)+          Right () -> pure ()         putMVar d ()     eid <- newEventId     start <- getCurrentTime@@ -136,7 +147,7 @@           }     pure $       Stream.finallyIO-        (cleanupTrace state)+        (finalizeTrace state eid start m)         (Stream.mapM (traceEvent state eid start m) (streamRequestWith reg m ctx opts))  -- | Synchronous trace wrapper. Drains 'withTraceStream' into a@@ -180,7 +191,10 @@ data TraceState = TraceState   { chan :: !(Chan (Maybe TraceEvent)),     done :: !(MVar ()),-    closed :: !(IORef Bool)+    closed :: !(IORef Bool),+    sinkError :: !(IORef (Maybe SomeException)),+    terminalSent :: !(IORef Bool),+    stableRoot :: !(IORef (Maybe (StablePtr TraceState)))   }   deriving stock (Generic) @@ -189,16 +203,50 @@   c <- newChan   d <- newEmptyMVar   r <- newIORef False-  pure TraceState {chan = c, done = d, closed = r}+  e <- newIORef Nothing+  t <- newIORef False+  root <- newIORef Nothing+  let state = TraceState {chan = c, done = d, closed = r, sinkError = e, terminalSent = t, stableRoot = root}+  sp <- newStablePtr state+  writeIORef root (Just sp)+  pure state -cleanupTrace :: TraceState -> IO ()-cleanupTrace s = do+finalizeTrace :: TraceState -> Text -> UTCTime -> Model -> IO ()+finalizeTrace s eid start m = do   alreadyClosed <-     atomicModifyIORef' (s ^. #closed) (\b -> (True, b))   unless alreadyClosed $ do+    sent <- readIORef (s ^. #terminalSent)+    unless sent $ do+      now <- getCurrentTime+      writeChan (s ^. #chan) $+        Just+          CallFailed+            { eventId = eid,+              timestamp = now,+              provider = m ^. #provider,+              model = m ^. #modelId,+              latencyMs = millisBetween start now,+              errorMessage = "aborted: stream consumer stopped before the terminal event"+            }     writeChan (s ^. #chan) Nothing     takeMVar (s ^. #done)+    reportSinkError s+    releaseStableRoot s +releaseStableRoot :: TraceState -> IO ()+releaseStableRoot s = do+  msp <- atomicModifyIORef' (s ^. #stableRoot) (\sp -> (Nothing, sp))+  forM_ msp freeStablePtr++reportSinkError :: TraceState -> IO ()+reportSinkError s = do+  merr <- readIORef (s ^. #sinkError)+  forM_ merr $ \e ->+    hPutStrLn+      stderr+      ("baikai: trace sink failed; trace events for this call were dropped: " <> displayException e)+ traceEvent ::   TraceState ->   Text ->@@ -228,7 +276,8 @@                     else Nothing               }       writeChan (state ^. #chan) (Just finished)-      cleanupTrace state+      writeIORef (state ^. #terminalSent) True+      finalizeTrace state eid start m     EventError TerminalPayload {message = msg} -> do       now <- getCurrentTime       let latency = millisBetween start now@@ -245,7 +294,8 @@                 errorMessage = errMsg               }       writeChan (state ^. #chan) (Just failed)-      cleanupTrace state+      writeIORef (state ^. #terminalSent) True+      finalizeTrace state eid start m     _ -> pure ()   pure ev @@ -324,20 +374,26 @@ resolvedMaxTokens :: Model -> Options -> Natural resolvedMaxTokens m opts = fromMaybe (m ^. #maxOutputTokens) (opts ^. #maxTokens) -millisBetween :: UTCTime -> UTCTime -> Integer+millisBetween :: UTCTime -> UTCTime -> Int millisBetween a b = round (realToFrac (diffUTCTime b a) * (1000 :: Double))  -- ============================================================ -- Event id -- ============================================================ +-- | Generate a 16-character lowercase hexadecimal event id. The high+-- 32 bits are derived from process-start POSIX seconds and the low+-- 32 bits are a process-local counter, so ids are unique within a+-- process for 2^32 calls. newEventId :: IO Text newEventId = do   n <- atomicModifyIORef' eventCounter (\k -> (k + 1, k))-  let raw :: Word-      raw = (eventBase .&. 0xFFFF) `unsafeShiftL` 16 .|. (n .&. 0xFFFF)+  let raw :: Word64+      raw =+        (fromIntegral eventBase .&. 0xFFFFFFFF) `unsafeShiftL` 32+          .|. (fromIntegral n .&. 0xFFFFFFFF)       hex = showHex raw ""-      padded = replicate (8 - length hex) '0' <> hex+      padded = replicate (16 - length hex) '0' <> hex   pure (Text.pack padded)  eventCounter :: IORef Word
src/Baikai/Trace/Event.hs view
@@ -51,7 +51,7 @@         timestamp :: !UTCTime,         provider :: !Text,         model :: !Text,-        latencyMs :: !Integer,+        latencyMs :: !Int,         inputTokens :: !(Maybe Natural),         outputTokens :: !(Maybe Natural),         usd :: !(Maybe Scientific)@@ -61,7 +61,7 @@         timestamp :: !UTCTime,         provider :: !Text,         model :: !Text,-        latencyMs :: !Integer,+        latencyMs :: !Int,         errorMessage :: !Text       }   deriving stock (Eq, Show, Generic)
src/Baikai/Usage.hs view
@@ -1,14 +1,30 @@ -- | Token-usage accounting for a single provider call. ----- The shape splits cache-read tokens (the prompt fragment served from a--- prior cache write) from cache-write tokens (the prompt fragment the--- provider stored for future calls), so callers can reason about both--- billing dimensions. 'cost' is always populated — providers without--- pricing data fill it with '_Cost' (zero across all rates) rather than--- a 'Nothing' that every cost-reading caller would have to handle.-module Baikai.Usage (Usage (..), _Usage, sumUsage) where+-- The prompt-side fields are provider-normalized into disjoint token+-- classes. 'inputTokens' counts only non-cached prompt tokens and+-- excludes both cache counters. 'cacheReadTokens' counts prompt tokens+-- served from a provider-side cache; 'cacheWriteTokens' counts prompt+-- tokens stored for future cache reads. Providers whose wire format is+-- inclusive, such as OpenAI Chat Completions where @prompt_tokens@+-- includes @prompt_tokens_details.cached_tokens@, must normalize by+-- subtraction when constructing 'Usage'.+--+-- 'totalTokens' is the sum of the billed token classes:+-- 'inputTokens' + 'outputTokens' + 'cacheReadTokens' ++-- 'cacheWriteTokens'. Provider mappings compute it from the normalized+-- parts rather than trusting a wire total. 'reasoningTokens', when+-- present, is an informational subset of 'outputTokens'; it is already+-- counted in 'outputTokens' and 'totalTokens' and is not billed+-- separately.+--+-- 'cost' is always populated — providers without pricing data fill it+-- with 'zeroCost' (zero across all rates) rather than a 'Nothing' that+-- every cost-reading caller would have to handle. 'Baikai.Cost.Pricing.computeCost'+-- depends on the token classes being disjoint so each class is billed+-- exactly once.+module Baikai.Usage (Usage (..), zeroUsage, _Usage, sumUsage) where -import Baikai.Cost (Cost, _Cost)+import Baikai.Cost (Cost, zeroCost) import Data.Aeson   ( Options (fieldLabelModifier),     ToJSON (toJSON),@@ -20,13 +36,34 @@ import GHC.Generics (Generic) import Numeric.Natural (Natural) +-- | Provider-normalized token usage for one model call.+--+-- The prompt-side classes are disjoint: 'inputTokens' excludes+-- 'cacheReadTokens' and 'cacheWriteTokens'. 'totalTokens' is the sum+-- of all billed token classes, and 'reasoningTokens' is an optional+-- breakdown already included in 'outputTokens'. data Usage = Usage-  { inputTokens :: !Natural,+  { -- | Non-cached prompt tokens. Excludes 'cacheReadTokens' and+    -- 'cacheWriteTokens'.+    inputTokens :: !Natural,+    -- | Output tokens billed at the model's output rate. Any+    -- 'reasoningTokens' are already included here.     outputTokens :: !Natural,+    -- | Prompt tokens served from a provider-side cache.     cacheReadTokens :: !Natural,+    -- | Prompt tokens written into a provider-side cache for future+    -- reads.     cacheWriteTokens :: !Natural,+    -- | Optional reasoning/thinking-token breakdown. These tokens are+    -- an informational subset of 'outputTokens', not an additional+    -- billed class.     reasoningTokens :: !(Maybe Natural),+    -- | Sum of the billed token classes:+    -- 'inputTokens' + 'outputTokens' + 'cacheReadTokens' ++    -- 'cacheWriteTokens'.     totalTokens :: !Natural,+    -- | Computed cost for this usage. Providers without pricing data+    -- use 'zeroCost'.     cost :: !Cost   }   deriving stock (Eq, Show, Generic)@@ -36,8 +73,9 @@  instance ToJSON Usage where toJSON = genericToJSON usageOptions -_Usage :: Usage-_Usage =+-- | Empty usage with every count and cost set to zero.+zeroUsage :: Usage+zeroUsage =   Usage     { inputTokens = 0,       outputTokens = 0,@@ -45,7 +83,7 @@       cacheWriteTokens = 0,       reasoningTokens = Nothing,       totalTokens = 0,-      cost = _Cost+      cost = zeroCost     }  -- | Combine two optional reasoning-token counts. Presence wins: an@@ -69,8 +107,12 @@       }  instance Monoid Usage where-  mempty = _Usage+  mempty = zeroUsage  -- | Total a collection of per-call usages into one. sumUsage :: (Foldable f) => f Usage -> Usage sumUsage = foldl' (<>) mempty++{-# DEPRECATED _Usage "Use zeroUsage instead." #-}+_Usage :: Usage+_Usage = zeroUsage
+ test/CliInternalSpec.hs view
@@ -0,0 +1,32 @@+module CliInternalSpec (tests) where++import Baikai+import Baikai.Provider.Cli.Internal+import Control.Lens ((&), (.~))+import Data.Vector qualified as Vector+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "CLI internal helpers"+    [ testCase "renderPrompt returns a single user text message verbatim" $ do+        let ctx = emptyContext & #messages .~ Vector.singleton (user "hello")+        renderPrompt ctx @?= "hello",+      testCase "renderPrompt tags multi-message contexts" $ do+        let ctx =+              emptyContext+                & #messages+                  .~ Vector.fromList+                    [ user "hello",+                      assistant "hi"+                    ]+        renderPrompt ctx @?= "[user]: hello\n[assistant]: hi",+      testCase "wrapSystemPrompt leaves missing and blank prompts alone" $ do+        wrapSystemPrompt Nothing "hello" @?= "hello"+        wrapSystemPrompt (Just "  ") "hello" @?= "hello",+      testCase "wrapSystemPrompt prefixes nonblank system instructions" $+        wrapSystemPrompt (Just "Be terse.") "hi"+          @?= "System instructions:\nBe terse.\n\nUser request:\nhi"+    ]
+ test/ContextSpec.hs view
@@ -0,0 +1,133 @@+module ContextSpec (tests) where++import Baikai+import Control.Lens ((&), (.~), (^.))+import Data.Aeson qualified as Aeson+import Data.Time (UTCTime)+import Data.Vector qualified as V+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "Context helpers"+    [ monoidTests,+      constructorTests,+      timestampTests,+      flattenTextTests+    ]++monoidTests :: TestTree+monoidTests =+  testGroup+    "Monoid"+    [ testCase "has left and right identity" $ do+        let ctx =+              emptyContext+                { systemPrompt = Just "sys",+                  messages = V.fromList [user "one"],+                  tools = V.singleton sampleTool+                }+        mempty <> ctx @?= ctx+        ctx <> mempty @?= ctx,+      testCase "is associative and keeps the first system prompt" $ do+        let a = systemUser "a-system" "a"+            b = addUser "b" emptyContext+            c =+              emptyContext+                { systemPrompt = Just "c-system",+                  messages = V.fromList [assistant "c"],+                  tools = V.singleton sampleTool+                }+        (a <> b) <> c @?= a <> (b <> c)+        (a <> b <> c) ^. #systemPrompt @?= Just "a-system"+        (b <> c) ^. #systemPrompt @?= Just "c-system"+    ]++constructorTests :: TestTree+constructorTests =+  testGroup+    "constructors"+    [ testCase "contextOf preserves message order" $ do+        contextOf [user "one", assistant "two"] ^. #messages+          @?= V.fromList [user "one", assistant "two"],+      testCase "systemUser creates a system prompt and one user message" $ do+        let ctx = systemUser "system" "prompt"+        ctx ^. #systemPrompt @?= Just "system"+        ctx ^. #messages @?= V.singleton (user "prompt"),+      testCase "addMessage, addUser, and addResponse append in order" $ do+        let resp =+              emptyResponse+                & #message+                  .~ AssistantPayload+                    { content = V.singleton (AssistantText (TextContent "response")),+                      usage = zeroUsage,+                      stopReason = Stop,+                      errorMessage = Nothing,+                      timestamp = Nothing+                    }+            ctx =+              emptyContext+                |> addUser "first"+                |> addMessage (assistant "second")+                |> addResponse resp+        ctx ^. #messages+          @?= V.fromList+            [ user "first",+              assistant "second",+              responseMessage resp+            ]+    ]++timestampTests :: TestTree+timestampTests =+  testGroup+    "timestamps"+    [ testCase "pure constructors do not invent timestamps" $ do+        payloadTimestamp (user "plain") @?= Nothing+        payloadTimestamp (assistant "plain") @?= Nothing+        payloadTimestamp (toolResult "call" "tool" "ok" False) @?= Nothing,+      testCase "explicit constructors preserve timestamps" $ do+        let ts = read "2026-06-05 01:02:03 UTC"+        payloadTimestamp (userAt ts "plain") @?= Just ts+        payloadTimestamp (assistantAt ts "plain") @?= Just ts+        payloadTimestamp (toolResultAt ts "call" "tool" "ok" False) @?= Just ts+    ]++flattenTextTests :: TestTree+flattenTextTests =+  testGroup+    "flattenAssistantText"+    [ testCase "concatenates only text blocks" $ do+        flattenAssistantText+          ( V.fromList+              [ AssistantText (TextContent "hello"),+                AssistantThinking+                  ThinkingContent+                    { thinking = "hidden",+                      signature = Nothing,+                      redacted = False+                    },+                AssistantToolCall emptyToolCall {name = "lookup", arguments = Aeson.object []},+                AssistantText (TextContent " world")+              ]+          )+          @?= "hello world"+    ]++sampleTool :: Tool+sampleTool =+  emptyTool+    { name = "lookup",+      description = "Lookup a value",+      parameters = Aeson.object []+    }++payloadTimestamp :: Message -> Maybe UTCTime+payloadTimestamp (UserMessage UserPayload {timestamp = ts}) = ts+payloadTimestamp (AssistantMessage AssistantPayload {timestamp = ts}) = ts+payloadTimestamp (ToolResultMessage ToolResultPayload {timestamp = ts}) = ts++(|>) :: a -> (a -> b) -> b+(|>) x f = f x
test/CostSpec.hs view
@@ -2,18 +2,19 @@  import Baikai.Api (Api (..)) import Baikai.Content (AssistantContent (..), TextContent (..))-import Baikai.Context (Context (..), _Context)+import Baikai.Context (Context (..), emptyContext) import Baikai.Cost qualified as Cost import Baikai.Cost.Log   ( CallLogConfig (..),-    CallLogEntry,+    CallLogEntry (..),+    appendEntry,     runRequestWithLog,     withCallLog,   ) import Baikai.Cost.Pricing (attachCost, computeCost) import Baikai.Message (AssistantPayload (..), user)-import Baikai.Model (Model (..), ModelCost (..), _Model)-import Baikai.Options (Options, _Options)+import Baikai.Model (Model (..), ModelCost (..), emptyModel)+import Baikai.Options (Options, emptyOptions) import Baikai.Prelude import Baikai.Provider   ( ApiProvider (..),@@ -22,14 +23,16 @@ import Baikai.Response (Response (..), flattenAssistantBlocks) import Baikai.StopReason (StopReason (..)) import Baikai.Stream (liftCompleteToStream)-import Baikai.Usage (Usage, _Usage)+import Baikai.Usage (Usage, zeroUsage) import Data.Aeson qualified as Aeson import Data.ByteString.Lazy.Char8 qualified as BSL import Data.List.NonEmpty (NonEmpty ((:|)), nonEmpty) import Data.Maybe (fromJust, isJust)+import Data.Time (getCurrentTime) import Data.Vector qualified as V import System.Directory (getTemporaryDirectory, removeFile) import System.FilePath ((</>))+import System.Timeout (timeout) import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (testCase, (@?=)) @@ -47,7 +50,7 @@ -- 1000*(1/1_000_000) + 500*(5/1_000_000) = 7/2000 USD. sampleUsage :: Usage sampleUsage =-  _Usage+  zeroUsage     & #inputTokens     .~ 1000     & #outputTokens@@ -58,7 +61,7 @@ -- cache-read $0.10/M, cache-write $1.25/M. knownModel :: Model knownModel =-  _Model+  emptyModel     & #modelId     .~ "claude-haiku-4-5-20251001"     & #api@@ -75,7 +78,7 @@  unknownModel :: Model unknownModel =-  _Model+  emptyModel     & #modelId     .~ "totally-fake-model"     & #api@@ -94,14 +97,14 @@       testCase "cacheReadTokens contribute when present" $ do         let u :: Usage             u =-              _Usage+              zeroUsage                 & #cacheReadTokens                 .~ 1000         Cost.usd (computeCost knownModel u) @?= 1 / 10000,       testCase "cacheWriteTokens contribute against the known model" $ do         let u :: Usage             u =-              _Usage+              zeroUsage                 & #cacheWriteTokens                 .~ 1000         Cost.usd (computeCost knownModel u) @?= 1 / 800@@ -132,7 +135,7 @@                 usage = sampleUsage,                 stopReason = Stop,                 errorMessage = Nothing,-                timestamp = read "2026-05-14 00:00:00 UTC"+                timestamp = Just (read "2026-05-14 00:00:00 UTC")               },           model = m,           api = Custom "test",@@ -157,7 +160,7 @@                 usage = u,                 stopReason = Stop,                 errorMessage = Nothing,-                timestamp = read "2026-05-14 00:00:00 UTC"+                timestamp = Just (read "2026-05-14 00:00:00 UTC")               },           model = knownModel & #api .~ cannedApi,           api = cannedApi,@@ -181,10 +184,10 @@ cannedModel = knownModel & #api .~ cannedApi  ctxHello :: Context-ctxHello = _Context & #messages .~ V.fromList [user "Hello world"]+ctxHello = emptyContext & #messages .~ V.fromList [user "Hello world"]  optsZero :: Options-optsZero = _Options+optsZero = emptyOptions  callLogTests :: TestTree callLogTests =@@ -223,5 +226,25 @@         entry ^. #latencyMs @?= 7         entry ^. #promptSummary @?= "Hello world"         isJust (entry ^. #usd) @?= True-        removeFile path'+        removeFile path',+      testCase "closeCallLog returns even when the log path is unwritable" $ do+        tmp <- getTemporaryDirectory+        let missing = tmp </> "baikai-costspec-no-such-dir" </> "entries.jsonl"+            cfg = CallLogConfig {path = missing, enabled = True}+        now <- getCurrentTime+        let entry =+              CallLogEntry+                { timestamp = now,+                  provider = "test",+                  model = "m",+                  inputTokens = Nothing,+                  outputTokens = Nothing,+                  cachedInputTokens = Nothing,+                  reasoningTokens = Nothing,+                  usd = Nothing,+                  latencyMs = 0,+                  promptSummary = ""+                }+        result <- timeout 5000000 (withCallLog cfg (\h -> appendEntry h entry))+        result @?= Just ()     ]
test/EmbeddingSpec.hs view
@@ -7,7 +7,8 @@ -- default run stays offline. module EmbeddingSpec (tests) where -import Baikai.Embedding (embedOne, mkEmbeddingRequest, openAIEmbeddingModel)+import Baikai.Embedding (embedOne, firstEmbedding, mkEmbeddingRequest, openAIEmbeddingModel)+import Baikai.Error (decodeError) import Data.Vector qualified as V import OpenAI.V1.Embeddings qualified as Emb import OpenAI.V1.Models qualified as OpenAIModels@@ -24,6 +25,17 @@         Emb.input req @?= "hello"         OpenAIModels.text (Emb.model req) @?= "text-embedding-3-small"         Emb.dimensions req @?= Nothing,+      testCase "firstEmbedding reports an empty data array as a typed decode error" $ do+        firstEmbedding V.empty @?= Left (decodeError "embeddings response contained no data"),+      testCase "firstEmbedding extracts the first vector" $ do+        let vec = V.fromList [0.1, 0.2, 0.3]+            obj =+              Emb.EmbbeddingObject+                { Emb.index = 0,+                  Emb.embedding = vec,+                  Emb.object = "embedding"+                }+        firstEmbedding (V.singleton obj) @?= Right vec,       testCase "live embedding returns a 1536-length vector" $ do         live <- lookupEnv "BAIKAI_EMBEDDING_LIVE"         case live of
test/ErrorInfoSpec.hs view
@@ -9,14 +9,14 @@ import Baikai.Prelude import Streamly.Data.Stream qualified as Stream import Test.Tasty (TestTree, testGroup)-import Test.Tasty.HUnit (assertFailure, testCase, (@?=))+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))  errApi :: Api errApi = Custom "baikai-errinfo"  errModel :: Model errModel =-  _Model+  emptyModel     & #modelId     .~ "err-model"     & #api@@ -29,18 +29,24 @@ errStream :: Model -> Context -> Options -> Stream.Stream IO AssistantMessageEvent errStream _ _ _ =   let payload =-        (_Response ^. #message)+        (emptyResponse ^. #message)           & #stopReason           .~ ErrorReason           & #errorMessage           .~ Just "rate limited, slow down"       term =         errorTerminal+          Nothing           ErrorReason           (AssistantMessage payload)-          (Just (rateLimited (Just 5) "rate limited, slow down"))-   in Stream.fromList [EventError term]+          (rateLimited (Just 5) "rate limited, slow down")+      start = EventStart StartPayload {partial = AssistantMessage payload, responseId = Nothing}+   in Stream.fromList [start, EventError term] +errResponse :: Model -> Context -> Options -> IO Response+errResponse m _ _ =+  pure (errorResponse m (read "2026-06-05 00:00:00 UTC") 0 (rateLimited (Just 5) "rate limited, slow down"))+ registerErr :: IO () registerErr =   registerApiProvider@@ -56,10 +62,47 @@     "Response.errorInfo threading"     [ testCase "completeRequest surfaces structured errorInfo" $ do         registerErr-        resp <- completeRequest errModel _Context _Options-        case resp ^. #errorInfo of+        resp <- completeRequest errModel emptyContext emptyOptions+        case responseError resp of           Just be -> do             be ^. #category @?= RateLimited             be ^. #retryAfterSeconds @?= Just 5-          Nothing -> assertFailure "expected Response.errorInfo to be populated"+          Nothing -> assertFailure "expected Response.errorInfo to be populated",+      testCase "completeRequestWith reports missing provider in-band" $ do+        reg <- newProviderRegistry+        resp <- completeRequestWith reg errModel emptyContext emptyOptions+        case responseError resp of+          Just be -> be ^. #category @?= ProviderUnavailable+          Nothing -> assertFailure "expected ProviderUnavailable response",+      testCase "liftCompleteToStream preserves error-shaped responses as EventError" $ do+        let stream = liftCompleteToStream errResponse+        events <- Stream.toList (stream errModel emptyContext emptyOptions)+        assertBool "expected exactly one terminal" (length (filter isTerminal events) == 1)+        case last events of+          EventError TerminalPayload {errorInfo = Just be} -> do+            be ^. #category @?= RateLimited+            be ^. #retryAfterSeconds @?= Just 5+          other -> assertFailure ("expected terminal EventError, got: " <> show other),+      testCase "reassembly normalizes ErrorReason terminals without errorInfo" $ do+        let payload =+              (emptyResponse ^. #message)+                & #stopReason+                .~ ErrorReason+                & #errorMessage+                .~ Just "legacy unclassified failure"+            terminal =+              doneTerminal+                Nothing+                ErrorReason+                (AssistantMessage payload)+            events =+              [ EventStart StartPayload {partial = AssistantMessage payload, responseId = Nothing},+                EventDone terminal+              ]+        resp <- Stream.fold (reassembleResponse errModel) (Stream.fromList events)+        case responseError resp of+          Just be -> do+            be ^. #category @?= OtherError+            be ^. #message @?= "legacy unclassified failure"+          Nothing -> assertFailure "expected synthesized errorInfo"     ]
test/ErrorSpec.hs view
@@ -6,8 +6,10 @@     classifyHttpStatus,     classifyHttpStatusWithBody,     decodeError,+    httpError,     invalidRequest,     isRetryable,+    parseRetryAfterSeconds,     processError,     rateLimited,   )@@ -20,8 +22,27 @@     "Baikai.Error"     [ classifyTests,       bodyClassifyTests,+      httpHelperTests,       retryTests,       constructorTests+    ]++httpHelperTests :: TestTree+httpHelperTests =+  testGroup+    "httpError / parseRetryAfterSeconds"+    [ testCase "429 + retry-after -> RateLimited with hint" $ do+        let e = httpError 429 (Just 12) "slow down"+        category e @?= RateLimited+        httpStatus e @?= Just 429+        retryAfterSeconds e @?= Just 12,+      testCase "400 + overflow body -> ContextOverflow" $+        category (httpError 400 Nothing "maximum context length exceeded")+          @?= ContextOverflow,+      testCase "integer Retry-After parses as seconds" $+        parseRetryAfterSeconds "12" @?= Just 12,+      testCase "HTTP-date Retry-After is ignored" $+        parseRetryAfterSeconds "Wed, 21 Oct 2026 07:28:00 GMT" @?= Nothing     ]  bodyClassifyTests :: TestTree
test/FetchModelsSpec.hs view
@@ -1,5 +1,3 @@-{-# LANGUAGE OverloadedStrings #-}- -- | Unit tests for the pure core of @baikai-fetch-models@ -- ('FetchModelsCore'): filtering, curation, modality mapping, cost -- defaulting, and rendering, exercised against a checked-in@@ -7,15 +5,21 @@ module FetchModelsSpec (tests) where  import Baikai.Model (InputModality (..))+import Baikai.Prelude+import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KeyMap import Data.ByteString.Lazy qualified as BSL+import Data.Generics.Labels () import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map-import Data.Text (Text)+import Data.Maybe (listToMaybe)+import Data.Scientific (Scientific) import Data.Text qualified as Text import Data.Text.Encoding (decodeUtf8)+import Data.Vector qualified as V import FetchModelsCore import Test.Tasty (TestTree, testGroup)-import Test.Tasty.HUnit (assertBool, testCase, (@?=))+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))  -- | Path to the fixture, relative to the @baikai@ package directory -- (tests run with that as the working directory, as 'CatalogSpec'@@ -33,7 +37,7 @@ -- | Build a provider catalog from the fixture. catalogFor :: Map Text (Map Text UpstreamModel) -> ProviderSpec -> Catalog catalogFor upstream spec =-  normalizeProvider spec (Map.findWithDefault Map.empty (psProvider spec) upstream)+  normalizeProvider spec (Map.findWithDefault Map.empty (spec ^. #provider) upstream)  -- | Expected OpenAI catalog after normalization. @gpt-5-pro@ is -- excluded by curation (Responses-API-only) and @gpt-legacy-nontool@@@ -42,27 +46,27 @@ expectedOpenAI :: Catalog expectedOpenAI =   Catalog-    { cProvider = "openai",-      cBaseUrl = "https://api.openai.com",-      cApi = "openai-chat-completions",-      cModels =+    { provider = "openai",+      baseUrl = "https://api.openai.com",+      api = "openai-chat-completions",+      models =         [ CatalogModel-            { cmId = "gpt-5-nano",-              cmName = "GPT-5 Nano",-              cmReasoning = True,-              cmInput = [InputText, InputImage],-              cmCost = CatalogCost 0.05 0.4 0 0,-              cmContextWindow = 400000,-              cmMaxOutputTokens = 128000+            { modelId = "gpt-5-nano",+              name = "GPT-5 Nano",+              reasoning = True,+              input = [InputText, InputImage],+              cost = CatalogCost 0.05 0.4 0 0,+              contextWindow = 400000,+              maxOutputTokens = 128000             },           CatalogModel-            { cmId = "gpt-5.4",-              cmName = "GPT-5.4",-              cmReasoning = True,-              cmInput = [InputText, InputImage],-              cmCost = CatalogCost 2.5 15 0.25 0,-              cmContextWindow = 1050000,-              cmMaxOutputTokens = 128000+            { modelId = "gpt-5.4",+              name = "GPT-5.4",+              reasoning = True,+              input = [InputText, InputImage],+              cost = CatalogCost 2.5 15 0.25 0,+              contextWindow = 1050000,+              maxOutputTokens = 128000             }         ]     }@@ -76,11 +80,11 @@         catalogFor upstream openaiSpec @?= expectedOpenAI,       testCase "tool_call: false model is excluded" $ do         upstream <- loadUpstream-        let ids = map cmId (cModels (catalogFor upstream openaiSpec))+        let ids = map (^. #modelId) (catalogFor upstream openaiSpec ^. #models)         assertBool "gpt-legacy-nontool must be filtered" ("gpt-legacy-nontool" `notElem` ids),       testCase "Responses-API-only id is excluded by curation" $ do         upstream <- loadUpstream-        let ids = map cmId (cModels (catalogFor upstream openaiSpec))+        let ids = map (^. #modelId) (catalogFor upstream openaiSpec ^. #models)         assertBool "gpt-5-pro must be excluded" ("gpt-5-pro" `notElem` ids),       testCase "pdf modality is dropped" $ do         upstream <- loadUpstream@@ -104,10 +108,67 @@         assertBool           "output 15 renders as 15.0"           ("\"output\": 15.0" `Text.isInfixOf` rendered),+      testCase "renderCatalog escapes control characters as valid JSON strings" $ do+        let modelIdWithControls = "model-\n-tab\t-quote\"-slash\\"+            nameWithControls = "Name with \r carriage return"+            catalog =+              Catalog+                { provider = "openai",+                  baseUrl = "https://api.openai.com",+                  api = "openai-chat-completions",+                  models =+                    [ CatalogModel+                        { modelId = modelIdWithControls,+                          name = nameWithControls,+                          reasoning = False,+                          input = [InputText],+                          cost = CatalogCost 0 0 0 0,+                          contextWindow = 1,+                          maxOutputTokens = 1+                        }+                    ]+                }+            rendered = renderText catalog+            raw = BSL.fromStrict (renderCatalog catalog)+            decoded = Aeson.eitherDecode raw :: Either String Aeson.Value+        case decoded of+          Left err -> assertFailure ("decode failed: " <> err)+          Right (Aeson.Object root) ->+            case KeyMap.lookup "models" root of+              Just (Aeson.Array models)+                | Just (Aeson.Object model) <- models V.!? 0 -> do+                    KeyMap.lookup "id" model @?= Just (Aeson.String modelIdWithControls)+                    KeyMap.lookup "name" model @?= Just (Aeson.String nameWithControls)+              _ -> assertFailure "decoded catalog did not contain a model object"+          Right other -> assertFailure ("decoded catalog was not an object: " <> show other)+        assertBool "newline escaped" ("\\n" `Text.isInfixOf` rendered)+        assertBool "tab escaped" ("\\t" `Text.isInfixOf` rendered),       testCase "Anthropic curation keeps exactly the one fixture model" $ do         upstream <- loadUpstream-        let ids = map cmId (cModels (catalogFor upstream anthropicSpec))-        ids @?= ["claude-opus-4-5"]+        let ids = map (^. #modelId) (catalogFor upstream anthropicSpec ^. #models)+        ids @?= ["claude-opus-4-5"],+      testCase "\" (latest)\" suffix is stripped from display names" $ do+        upstream <- loadUpstream+        let cat = catalogFor upstream anthropicSpec+        map (^. #name) (cat ^. #models) @?= ["Claude Opus 4.5"],+      testCase "override corrects a deliberately-wrong upstream cache price" $ do+        upstream <- loadUpstream+        -- Fixture upstream reports cache_read 1.5 for claude-opus-4-5+        -- (the 3x wart); normalization alone preserves it, the override+        -- pins it to the published 0.5.+        let normalized = catalogFor upstream anthropicSpec+            corrected = applyOverrides overrides normalized+        opusCacheRead normalized @?= Just 1.5+        opusCacheRead corrected @?= Just 0.5+    ]++-- | The @claude-opus-4-5@ cacheRead cost in an Anthropic catalog.+opusCacheRead :: Catalog -> Maybe Scientific+opusCacheRead cat =+  listToMaybe+    [ m ^. #cost . #cacheReadCost+    | m <- cat ^. #models,+      m ^. #modelId == "claude-opus-4-5"     ]  renderText :: Catalog -> Text
+ test/GenModelsSpec.hs view
@@ -0,0 +1,56 @@+module GenModelsSpec (tests) where++import Baikai.Api (Api (OpenAIChatCompletions))+import Baikai.Model (InputModality (InputText))+import Data.Text (Text)+import Data.Text qualified as Text+import GenModelsCore+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase)++tests :: TestTree+tests =+  testGroup+    "Baikai.GenModels"+    [ testCase "checkIdentifierCollisions rejects sanitized binding duplicates" $ do+        let entries = flattenEntries collisionCatalog+        case checkIdentifierCollisions entries of+          Right () -> assertFailure "expected duplicate generated identifier to be rejected"+          Left err -> do+            assertBool "mentions duplicate identifier" ("openai_a_b" `Text.isInfixOf` err)+            assertBool "mentions first origin" ("openai/a-b" `Text.isInfixOf` err)+            assertBool "mentions second origin" ("openai/a_b" `Text.isInfixOf` err)+    ]++collisionCatalog :: CatalogFile+collisionCatalog =+  CatalogFile+    { provider = "openai",+      baseUrl = "https://api.openai.com",+      api = OpenAIChatCompletions,+      compat = CatalogCompatAuto,+      models =+        [ model "a-b",+          model "a_b"+        ]+    }++model :: Text -> ModelEntry+model mid =+  ModelEntry+    { entryId = mid,+      entryName = mid,+      entryReasoning = False,+      entryInput = [InputText],+      entryCost =+        CostEntry+          { costInput = 0,+            costOutput = 0,+            costCacheRead = 0,+            costCacheWrite = 0+          },+      entryContextWindow = 1,+      entryMaxOutputTokens = 1,+      entryEnabled = True,+      entryCompatOverride = Nothing+    }
+ test/HelpersSpec.hs view
@@ -0,0 +1,339 @@+module HelpersSpec (tests) where++import Baikai+import Baikai.Prelude+import Control.Exception qualified as Exception+import Data.Aeson qualified as Aeson+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)+import Data.Text qualified as Text+import Data.Time (UTCTime)+import Data.Vector qualified as Vector+import Streamly.Data.Stream qualified as Stream+import System.Environment qualified as Environment+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))++helpersApi :: Api+helpersApi = Custom "baikai-helpers-test"++helpersModel :: Model+helpersModel =+  emptyModel+    & #modelId+    .~ "helpers-model"+    & #api+    .~ helpersApi+    & #provider+    .~ "helpers"++epoch :: UTCTime+epoch = read "2026-06-05 00:00:00 UTC"++tests :: TestTree+tests =+  testGroup+    "Baikai helpers"+    [ testCase "runToolLoop resolves repeated tool turns and leaves final response separate" $ do+        scripted <- newScripted [toolUseResponse "call_1" "get_time", toolUseResponse "call_2" "get_time", textResponse "done"] []+        let ctx0 = addUser "start" emptyContext+            dispatcher _ = pure (toolResultText "2026-06-05T00:00:00Z")+        (ctx1, resp) <- runToolLoopWith (scriptRegistry scripted) 5 dispatcher helpersModel ctx0 emptyOptions+        calls <- scriptCalls scripted+        calls @?= 3+        responseStop resp @?= Stop+        Vector.length (ctx1 ^. #messages) @?= 5+        assertResolvedExchange "call_1" "2026-06-05T00:00:00Z" (Vector.toList (ctx1 ^. #messages) !! 1) (Vector.toList (ctx1 ^. #messages) !! 2)+        assertResolvedExchange "call_2" "2026-06-05T00:00:00Z" (Vector.toList (ctx1 ^. #messages) !! 3) (Vector.toList (ctx1 ^. #messages) !! 4),+      testCase "runToolLoop returns a replay-valid context on budget exhaustion" $ do+        scripted <- newScripted [toolUseResponse "call_1" "get_time", toolUseResponse "call_2" "get_time", textResponse "done"] []+        (ctx1, resp) <- runToolLoopWith (scriptRegistry scripted) 2 (const (pure (toolResultText "ok"))) helpersModel (addUser "start" emptyContext) emptyOptions+        calls <- scriptCalls scripted+        calls @?= 2+        responseStop resp @?= ToolUse+        Vector.length (ctx1 ^. #messages) @?= 3+        assertResolvedExchange "call_1" "ok" (Vector.toList (ctx1 ^. #messages) !! 1) (Vector.toList (ctx1 ^. #messages) !! 2),+      testCase "runToolLoop returns an error response without appending it" $ do+        scripted <- newScripted [errorResponse helpersModel epoch 0 (providerError "broken")] []+        let ctx0 = addUser "start" emptyContext+        (ctx1, resp) <- runToolLoopWith (scriptRegistry scripted) 3 (const (pure (toolResultText "unused"))) helpersModel ctx0 emptyOptions+        calls <- scriptCalls scripted+        calls @?= 1+        ctx1 @?= ctx0+        case responseError resp of+          Just err -> err ^. #message @?= "broken"+          Nothing -> assertFailure "expected error response",+      testCase "runToolLoop converts synchronous dispatcher exceptions into error tool results" $ do+        scripted <- newScripted [toolUseResponse "call_1" "explode", textResponse "recovered"] []+        (ctx1, resp) <-+          runToolLoopWith+            (scriptRegistry scripted)+            3+            (\_ -> Exception.throwIO (userError "boom"))+            helpersModel+            (addUser "start" emptyContext)+            emptyOptions+        responseStop resp @?= Stop+        case Vector.toList (ctx1 ^. #messages) of+          [_, _, ToolResultMessage ToolResultPayload {content = blocks, isError = err}] -> do+            err @?= True+            assertBool "tool error should include exception text" ("boom" `Text.isInfixOf` toolText blocks)+          msgs -> assertFailure ("unexpected messages: " <> show msgs),+      testCase "runToolLoop terminates a ToolUse response with no tool calls" $ do+        scripted <- newScripted [toolUseNoCallsResponse] []+        let ctx0 = addUser "start" emptyContext+        (ctx1, resp) <- runToolLoopWith (scriptRegistry scripted) 3 (const (pure (toolResultText "unused"))) helpersModel ctx0 emptyOptions+        calls <- scriptCalls scripted+        calls @?= 1+        responseStop resp @?= ToolUse+        ctx1 @?= ctx0,+      testCase "completeText returns text and throws BaikaiError on failures" $ do+        registerOneShot (Custom "baikai-helpers-text-ok") (textResponse "plain text")+        ok <-+          completeText+            (helpersModel & #api .~ Custom "baikai-helpers-text-ok")+            "prompt"+        ok @?= "plain text"+        registerApiProvider+          (errorProvider (Custom "baikai-helpers-text-error") (providerError "text failed"))+        thrown <-+          Exception.try+            ( completeText+                (helpersModel & #api .~ Custom "baikai-helpers-text-error")+                "prompt"+            ) ::+            IO (Either BaikaiError Text)+        case thrown of+          Left err -> err ^. #message @?= "text failed"+          Right txt -> assertFailure ("expected BaikaiError, got text: " <> Text.unpack txt),+      testCase "streamRequestEachWith and streamRequestListWith expose streamRequest without streamly imports" $ do+        let events = textEvents "stream-id" "streamed"+        scripted <- newScripted [] events+        seenRef <- newIORef []+        resp <- streamRequestEachWith (scriptRegistry scripted) (\event -> atomicModifyIORef' seenRef (\xs -> (xs <> [event], ()))) helpersModel emptyContext emptyOptions+        seen <- readIORef seenRef+        expected <- streamingComplete (\_ _ _ -> Stream.fromList events) helpersModel emptyContext emptyOptions+        listed <- streamRequestListWith (scriptRegistry scripted) helpersModel emptyContext emptyOptions+        seen @?= events+        listed @?= events+        resp @?= expected,+      testCase "ApiKeyEnvChain resolves the first set environment variable" $ do+        let first = "BAIKAI_HELPERS_CHAIN_FIRST"+            second = "BAIKAI_HELPERS_CHAIN_SECOND"+        withUnsetEnv first $+          withUnsetEnv second $ do+            Environment.setEnv second "second-key"+            resolved <- resolveApiKey (ApiKeyEnvChain [first, second])+            resolved @?= "second-key"+            Environment.setEnv first "first-key"+            resolvedFirst <- resolveApiKey (ApiKeyEnvChain [first, second])+            resolvedFirst @?= "first-key",+      testCase "ApiKeyEnvChain reports all probed names when unset" $ do+        let first = "BAIKAI_HELPERS_CHAIN_MISSING_A"+            second = "BAIKAI_HELPERS_CHAIN_MISSING_B"+        withUnsetEnv first $+          withUnsetEnv second $ do+            thrown <- Exception.try (resolveApiKey (ApiKeyEnvChain [first, second])) :: IO (Either BaikaiError Text)+            case thrown of+              Left err -> do+                err ^. #category @?= AuthError+                assertBool "message should include first name" (Text.pack first `Text.isInfixOf` (err ^. #message))+                assertBool "message should include second name" (Text.pack second `Text.isInfixOf` (err ^. #message))+              Right key -> assertFailure ("expected auth error, got key: " <> Text.unpack key),+      testCase "mkModel fills dispatch discriminators and defaults" $ do+        let model = mkModel OpenAIChatCompletions "gpt-test" "https://example.test"+        model ^. #api @?= OpenAIChatCompletions+        model ^. #modelId @?= "gpt-test"+        model ^. #baseUrl @?= "https://example.test"+        model ^. #name @?= "gpt-test"+        model ^. #provider @?= renderApi OpenAIChatCompletions,+      testCase "newProviderRegistryFrom uses last provider for duplicate tags" $ do+        reg <-+          newProviderRegistryFrom+            [oneShotProvider helpersApi "first", oneShotProvider helpersApi "second"]+        resp <- completeRequestWith reg helpersModel emptyContext emptyOptions+        flattenAssistantText (flattenAssistantBlocks resp) @?= "second",+      testCase "assertRegistered passes when all tags exist and throws for missing tags" $ do+        reg <- newProviderRegistryFrom [oneShotProvider helpersApi "ok"]+        assertRegistered reg [helpersApi]+        thrown <-+          Exception.try (assertRegistered reg [helpersApi, Custom "missing-one", Custom "missing-two"]) ::+            IO (Either BaikaiError ())+        case thrown of+          Left err -> do+            err ^. #category @?= ProviderUnavailable+            assertBool "message should include first missing tag" ("missing-one" `Text.isInfixOf` (err ^. #message))+            assertBool "message should include second missing tag" ("missing-two" `Text.isInfixOf` (err ^. #message))+          Right () -> assertFailure "expected ProviderUnavailable"+    ]++data Scripted = Scripted+  { scriptRegistry :: !ProviderRegistry,+    scriptCallRef :: !(IORef Int)+  }++newScripted :: [Response] -> [AssistantMessageEvent] -> IO Scripted+newScripted responses events = do+  reg <- newProviderRegistry+  responsesRef <- newIORef responses+  callsRef <- newIORef 0+  registerApiProviderWith reg $+    ApiProvider+      { apiTag = helpersApi,+        complete = scriptedComplete responsesRef callsRef,+        stream = \_ _ _ -> Stream.fromList events+      }+  pure Scripted {scriptRegistry = reg, scriptCallRef = callsRef}++scriptedComplete :: IORef [Response] -> IORef Int -> Model -> Context -> Options -> IO Response+scriptedComplete responsesRef callsRef model _ctx _opts = do+  atomicModifyIORef' callsRef (\n -> (n + 1, ()))+  mResp <-+    atomicModifyIORef' responsesRef $ \case+      [] -> ([], Nothing)+      r : rs -> (rs, Just r)+  pure $+    maybe+      (errorResponse model epoch 0 (providerError "script exhausted"))+      (stampModel model)+      mResp++scriptCalls :: Scripted -> IO Int+scriptCalls scripted = readIORef (scriptCallRef scripted)++registerOneShot :: Api -> Response -> IO ()+registerOneShot apiTag resp =+  registerApiProvider+    ApiProvider+      { apiTag,+        complete = \model _ctx _opts -> pure (stampModel model resp),+        stream = \_ _ _ -> Stream.fromList []+      }++oneShotProvider :: Api -> Text -> ApiProvider+oneShotProvider apiTag body =+  ApiProvider+    { apiTag,+      complete = \model _ctx _opts -> pure (stampModel model (textResponse body)),+      stream = \_ _ _ -> Stream.fromList []+    }++errorProvider :: Api -> BaikaiError -> ApiProvider+errorProvider apiTag err =+  ApiProvider+    { apiTag,+      complete = \model _ctx _opts -> pure (errorResponse model epoch 0 err),+      stream = \_ _ _ -> Stream.fromList []+    }++stampModel :: Model -> Response -> Response+stampModel model resp =+  resp+    & #model+    .~ model+    & #api+    .~ (model ^. #api)+    & #provider+    .~ (model ^. #provider)++toolUseResponse :: Text -> Text -> Response+toolUseResponse callId toolName =+  responseWith+    ToolUse+    (Vector.singleton (AssistantToolCall (emptyToolCall {id_ = callId, name = toolName, arguments = Aeson.object []})))+    Nothing++toolUseNoCallsResponse :: Response+toolUseNoCallsResponse =+  responseWith ToolUse (Vector.singleton (AssistantText (TextContent "no calls"))) Nothing++textResponse :: Text -> Response+textResponse body =+  responseWith Stop (Vector.singleton (AssistantText (TextContent body))) Nothing++responseWith :: StopReason -> Vector AssistantContent -> Maybe Text -> Response+responseWith sr blocks err =+  emptyResponse+    & #message+    .~ AssistantPayload+      { content = blocks,+        usage = zeroUsage,+        stopReason = sr,+        errorMessage = err,+        timestamp = Just epoch+      }+    & #model+    .~ helpersModel+    & #api+    .~ helpersApi+    & #provider+    .~ "helpers"++responseStop :: Response -> StopReason+responseStop Response {message = AssistantPayload {stopReason = sr}} = sr++assertResolvedExchange :: Text -> Text -> Message -> Message -> IO ()+assertResolvedExchange callId expectedText assistantMsg resultMsg = do+  case assistantMsg of+    AssistantMessage AssistantPayload {content = blocks, stopReason = sr} -> do+      sr @?= ToolUse+      case [tc | AssistantToolCall tc <- Vector.toList blocks] of+        [tc] -> tc ^. #id_ @?= callId+        calls -> assertFailure ("expected one tool call, got: " <> show calls)+    _ -> assertFailure ("expected assistant tool-use message, got: " <> show assistantMsg)+  case resultMsg of+    ToolResultMessage ToolResultPayload {toolCallId = actual, content = blocks, isError = err} -> do+      actual @?= callId+      err @?= False+      toolText blocks @?= expectedText+    _ -> assertFailure ("expected tool result message, got: " <> show resultMsg)++toolText :: Vector ToolResultContent -> Text+toolText =+  Text.concat+    . Vector.toList+    . Vector.mapMaybe+      ( \case+          ToolResultText (TextContent t) -> Just t+          _ -> Nothing+      )++textEvents :: Text -> Text -> [AssistantMessageEvent]+textEvents rid body =+  let msg =+        AssistantMessage+          AssistantPayload+            { content = Vector.singleton (AssistantText (TextContent body)),+              usage = zeroUsage,+              stopReason = Stop,+              errorMessage = Nothing,+              timestamp = Just epoch+            }+      start =+        AssistantMessage+          AssistantPayload+            { content = Vector.empty,+              usage = zeroUsage,+              stopReason = Stop,+              errorMessage = Nothing,+              timestamp = Just epoch+            }+   in [ EventStart StartPayload {partial = start, responseId = Just rid},+        TextStart IndexPayload {contentIndex = 0},+        TextDelta DeltaPayload {contentIndex = 0, delta = body},+        TextEnd BlockEndPayload {contentIndex = 0, content = body},+        EventDone (doneTerminal (Just rid) Stop msg)+      ]++withUnsetEnv :: String -> IO a -> IO a+withUnsetEnv name action =+  Exception.bracket+    ( do+        old <- Environment.lookupEnv name+        Environment.unsetEnv name+        pure old+    )+    restore+    (const action)+  where+    restore Nothing = Environment.unsetEnv name+    restore (Just value) = Environment.setEnv name value
test/InteractiveSpec.hs view
@@ -18,11 +18,11 @@  requestDefaultTest :: TestTree requestDefaultTest =-  testCase "_InteractiveLaunchRequest keeps optional launch settings empty" $ do-    let req = _InteractiveLaunchRequest "start here"+  testCase "interactiveLaunchRequest keeps optional launch settings empty" $ do+    let req = interactiveLaunchRequest "start here"     req ^. #systemPrompt @?= Nothing     req ^. #userPrompt @?= "start here"-    req ^. #model @?= Nothing+    req ^. #modelId @?= Nothing     req ^. #workingDir @?= Nothing     req ^. #extraDirs @?= []     req ^. #safety @?= DefaultSafety@@ -49,7 +49,7 @@  resultConstructorTest :: TestTree resultConstructorTest =-  testCase "_InteractiveLaunchResult records provider identity and process status" $ do-    let result = _InteractiveLaunchResult InteractiveCodex ExitSuccess+  testCase "interactiveLaunchResult records provider identity and process status" $ do+    let result = interactiveLaunchResult InteractiveCodex ExitSuccess     result ^. #provider @?= InteractiveCodex     result ^. #exitCode @?= ExitSuccess
test/Main.hs view
@@ -2,8 +2,11 @@  import AgentAssetsSpec qualified import Baikai+import Baikai.Models.Generated import Baikai.Prelude import CatalogSpec qualified+import CliInternalSpec qualified+import ContextSpec qualified import CostSpec qualified import Data.Aeson qualified as Aeson import Data.ByteString.Char8 qualified as BS8@@ -14,10 +17,16 @@ import ErrorInfoSpec qualified import ErrorSpec qualified import FetchModelsSpec qualified+import GenModelsSpec qualified+import HelpersSpec qualified import InteractiveSpec qualified+import StreamSpec qualified import Streamly.Data.Stream qualified as Stream+import SurfaceSpec qualified import Test.Tasty (TestTree, defaultMain, testGroup) import Test.Tasty.HUnit (assertBool, testCase, (@?=))+import Test.Tasty.QuickCheck (Gen)+import Test.Tasty.QuickCheck qualified as QC import TraceSpec qualified import UsageSpec qualified @@ -29,7 +38,7 @@  testModel :: Model testModel =-  _Model+  emptyModel     & #modelId     .~ "test-model"     & #name@@ -50,14 +59,14 @@ testProvider providerName canned =   let handler m _ctx _opts =         pure $-          _Response+          emptyResponse             & #message             .~ AssistantPayload               { content = V.singleton (AssistantText (TextContent canned)),-                usage = _Usage,+                usage = zeroUsage,                 stopReason = Stop,                 errorMessage = Nothing,-                timestamp = read "2026-06-05 00:00:00 UTC"+                timestamp = Just (read "2026-06-05 00:00:00 UTC")               }             & #model             .~ m@@ -80,12 +89,18 @@       [ tests,         AgentAssetsSpec.tests,         CatalogSpec.tests,+        CliInternalSpec.tests,+        ContextSpec.tests,         CostSpec.tests,         EmbeddingSpec.tests,         ErrorInfoSpec.tests,         ErrorSpec.tests,         FetchModelsSpec.tests,+        GenModelsSpec.tests,+        HelpersSpec.tests,         InteractiveSpec.tests,+        StreamSpec.tests,+        SurfaceSpec.tests,         TraceSpec.tests,         UsageSpec.tests       ]@@ -94,39 +109,39 @@ tests =   testGroup     "baikai EP-2"-    [ testCase "_Context defaults are zero-y" $ do-        _Context ^. #systemPrompt @?= Nothing-        V.length (_Context ^. #messages) @?= 0,-      testCase "_Options defaults are zero-y" $ do-        _Options ^. #maxTokens @?= Nothing-        _Options ^. #temperature @?= Nothing-        _Options ^. #apiKey @?= Nothing-        _Options ^. #responseFormat @?= Nothing,+    [ testCase "emptyContext defaults are zero-y" $ do+        emptyContext ^. #systemPrompt @?= Nothing+        V.length (emptyContext ^. #messages) @?= 0,+      testCase "emptyOptions defaults are zero-y" $ do+        emptyOptions ^. #maxTokens @?= Nothing+        emptyOptions ^. #temperature @?= Nothing+        emptyOptions ^. #apiKey @?= Nothing+        emptyOptions ^. #responseFormat @?= Nothing,       testCase "responseFormat round-trips through Options" $ do-        responseFormat (_Options & #responseFormat .~ Just JsonObject)+        responseFormat (emptyOptions & #responseFormat .~ Just JsonObject)           @?= Just JsonObject         let person =               Aeson.object                 [ "type" Aeson..= ("object" :: Text)                 ]             schemaFmt = JsonSchema {name = "person", schema = person, strict = True}-        responseFormat (_Options & #responseFormat .~ Just schemaFmt)+        responseFormat (emptyOptions & #responseFormat .~ Just schemaFmt)           @?= Just schemaFmt,       testCase "Options Show redacts literal API keys" $ do         let secret = "sk-baikai-secret-never-print"-            opts = _Options & #apiKey .~ Just (ApiKeyLiteral secret)+            opts = emptyOptions & #apiKey .~ Just (ApiKeyLiteral secret)         assertBool           "show opts must not contain the raw API key"           (not (secret `Text.isInfixOf` Text.pack (show opts))),       testCase "Options JSON redacts literal API keys" $ do         let secret = "sk-baikai-secret-never-print"-            opts = _Options & #apiKey .~ Just (ApiKeyLiteral secret)+            opts = emptyOptions & #apiKey .~ Just (ApiKeyLiteral secret)         assertBool           "Aeson.encode opts must not contain the raw API key"           (not (secret `Text.isInfixOf` Text.pack (LBS8.unpack (Aeson.encode opts)))),       testCase "completeRequest dispatches through the registered handler" $ do-        let ctx = _Context & #messages .~ V.fromList [user "ping"]-        resp <- completeRequest testModel ctx _Options+        let ctx = emptyContext & #messages .~ V.fromList [user "ping"]+        resp <- completeRequest testModel ctx emptyOptions         flattenAssistantBlocks resp           @?= V.singleton (AssistantText (TextContent "hello from the test provider"))         (resp ^. #model) ^. #modelId @?= "test-model"@@ -136,9 +151,9 @@         regB <- newProviderRegistry         registerApiProviderWith regA (testProvider "provider-a" "hello from A")         registerApiProviderWith regB (testProvider "provider-b" "hello from B")-        let ctx = _Context & #messages .~ V.fromList [user "ping"]-        respA <- completeRequestWith regA testModel ctx _Options-        respB <- completeRequestWith regB testModel ctx _Options+        let ctx = emptyContext & #messages .~ V.fromList [user "ping"]+        respA <- completeRequestWith regA testModel ctx emptyOptions+        respB <- completeRequestWith regB testModel ctx emptyOptions         flattenAssistantBlocks respA           @?= V.singleton (AssistantText (TextContent "hello from A"))         flattenAssistantBlocks respB@@ -148,14 +163,14 @@       testCase "streamRequestWith dispatches through an explicit registry" $ do         reg <- newProviderRegistry         registerApiProviderWith reg (testProvider "stream-provider" "hello from stream")-        let ctx = _Context & #messages .~ V.fromList [user "ping"]-        events <- Stream.toList (streamRequestWith reg testModel ctx _Options)+        let ctx = emptyContext & #messages .~ V.fromList [user "ping"]+        events <- Stream.toList (streamRequestWith reg testModel ctx emptyOptions)         resp <- Stream.fold (reassembleResponse testModel) (Stream.fromList events)         flattenAssistantBlocks resp           @?= V.singleton (AssistantText (TextContent "hello from stream")),       testCase "OpenAI compat auto-detection drives provider request policy" $ do         let deepseek =-              _Model+              emptyModel                 & #api                 .~ OpenAIChatCompletions                 & #baseUrl@@ -163,8 +178,40 @@             compat = openaiCompletionsCompatFor deepseek         compat ^. #thinkingFormat @?= ThinkingFormatDeepseek         compat ^. #maxTokensField @?= MaxTokensField-        compat ^. #supportsStrictMode @?= False-        compat ^. #supportsDeveloperRole @?= False,+        compat ^. #supportsStrictMode @?= False,+      testCase "host auto-detection is suffix-bounded" $ do+        urlHost "http://user@openrouter.ai:8443/api" @?= Just "openrouter.ai"+        autoDetectOpenAICompletions "https://api.xyz.ai"+          @?= defaultOpenAICompletionsCompat+        autoDetectOpenAICompletions "https://evil-z.ai.example.com"+          @?= defaultOpenAICompletionsCompat+        autoDetectOpenAICompletions "https://api.z.ai/v1"+          ^. #thinkingFormat+          @?= ThinkingFormatZai+        autoDetectOpenAICompletions "https://API.DEEPSEEK.COM"+          ^. #thinkingFormat+          @?= ThinkingFormatDeepseek+        autoDetectOpenAICompletions "http://user@openrouter.ai:8443/api"+          ^. #thinkingFormat+          @?= ThinkingFormatOpenRouter+        autoDetectOpenAICompletions ""+          @?= defaultOpenAICompletionsCompat,+      QC.testProperty "unknown OpenAI host suffixes use defaults" $+        QC.forAll unknownHostGen $ \host ->+          QC.property $+            autoDetectOpenAICompletions ("https://" <> Text.pack host)+              == defaultOpenAICompletionsCompat,+      testCase "default API-key env table matches known hosts" $ do+        defaultApiKeyEnvForBaseUrl "https://api.deepseek.com/v1"+          @?= Just "DEEPSEEK_API_KEY"+        defaultApiKeyEnvForBaseUrl "http://user@openrouter.ai:8443/api"+          @?= Just "OPENROUTER_API_KEY"+        defaultApiKeyEnvForBaseUrl "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"+          @?= Just "DASHSCOPE_API_KEY"+        defaultApiKeyEnvForBaseUrl "https://api.xyz.ai"+          @?= Nothing+        defaultApiKeyEnvForBaseUrl ""+          @?= Nothing,       testCase "explicit OpenAI compat overrides baseUrl auto-detection" $ do         let explicit =               defaultOpenAICompletionsCompat@@ -172,7 +219,7 @@                   thinkingFormat = ThinkingFormatNone                 }             model =-              _Model+              emptyModel                 & #api                 .~ OpenAIChatCompletions                 & #baseUrl@@ -184,7 +231,7 @@         compat ^. #thinkingFormat @?= ThinkingFormatNone,       testCase "Anthropic compat auto-detection drives cache request policy" $ do         let fireworks =-              _Model+              emptyModel                 & #api                 .~ AnthropicMessages                 & #baseUrl@@ -192,13 +239,39 @@             compat = anthropicMessagesCompatFor fireworks         compat ^. #supportsCacheControlOnTools @?= False         compat ^. #sendSessionAffinityHeaders @?= True-        compat ^. #supportsLongCacheRetention @?= False,+        compat ^. #supportsLongCacheRetention @?= False+        compat ^. #thinkingStyle @?= AnthropicThinkingBudget,+      testCase "Anthropic compat defaults thinking style by model generation" $ do+        anthropicMessagesCompatFor anthropic_claude_opus_4_6+          ^. #thinkingStyle+          @?= AnthropicThinkingAdaptive+        anthropicMessagesCompatFor anthropic_claude_opus_4_7+          ^. #thinkingStyle+          @?= AnthropicThinkingAdaptive+        anthropicMessagesCompatFor anthropic_claude_opus_4_8+          ^. #thinkingStyle+          @?= AnthropicThinkingAdaptive+        anthropicMessagesCompatFor anthropic_claude_fable_5+          ^. #thinkingStyle+          @?= AnthropicThinkingAdaptive+        anthropicMessagesCompatFor anthropic_claude_haiku_4_5+          ^. #thinkingStyle+          @?= AnthropicThinkingBudget+        anthropicMessagesCompatFor anthropic_claude_opus_4_5+          ^. #thinkingStyle+          @?= AnthropicThinkingBudget+        anthropicMessagesCompatFor anthropic_claude_sonnet_4_5+          ^. #thinkingStyle+          @?= AnthropicThinkingBudget+        anthropicMessagesCompatFor anthropic_claude_sonnet_4_6+          ^. #thinkingStyle+          @?= AnthropicThinkingBudget,       testCase "user smart constructor produces a UserMessage" $ do         let ts = read "2026-06-05 01:02:03 UTC"         case userAt ts "hello" of           UserMessage UserPayload {content = uc, timestamp = actualTs} -> do             uc @?= V.singleton (UserText (TextContent "hello"))-            actualTs @?= ts+            actualTs @?= Just ts           _ -> error "expected UserMessage",       testCase "assistant smart constructor produces an AssistantMessage" $ do         let ts = read "2026-06-05 01:02:03 UTC"@@ -206,7 +279,7 @@           AssistantMessage AssistantPayload {content = ac, stopReason = sr, timestamp = actualTs} -> do             ac @?= V.singleton (AssistantText (TextContent "world"))             sr @?= Stop-            actualTs @?= ts+            actualTs @?= Just ts           _ -> error "expected AssistantMessage",       testCase "effectful user constructor produces a UserMessage in IO" $ do         msg <- userNow "hello now"@@ -216,19 +289,19 @@           _ -> error "expected UserMessage",       testCase "appendToolResult carries text, image, and error payloads" $ do         let textCall =-              _ToolCall+              emptyToolCall                 { id_ = "call_text",                   name = "text_tool",                   arguments = Aeson.object []                 }             imageCall =-              _ToolCall+              emptyToolCall                 { id_ = "call_image",                   name = "image_tool",                   arguments = Aeson.object []                 }             errorCall =-              _ToolCall+              emptyToolCall                 { id_ = "call_error",                   name = "error_tool",                   arguments = Aeson.object []@@ -242,16 +315,16 @@                           AssistantToolCall imageCall,                           AssistantToolCall errorCall                         ],-                    usage = _Usage,+                    usage = zeroUsage,                     stopReason = ToolUse,                     errorMessage = Nothing,-                    timestamp = read "2026-06-05 00:00:00 UTC"+                    timestamp = Just (read "2026-06-05 00:00:00 UTC")                   }-            resp = _Response & #message .~ assistantPayload+            resp = emptyResponse & #message .~ assistantPayload             assistantPayload = case assistantTurn of               AssistantMessage p -> p               _ -> error "expected assistant fixture"-            ctx0 = _Context & #messages .~ V.singleton (user "use tools")+            ctx0 = emptyContext & #messages .~ V.singleton (user "use tools")             image = ImageContent {imageData = BS8.pack "png-bytes", mimeType = "image/png"}             dispatcher tc = case tc ^. #name of               "text_tool" -> pure (toolResultText "text result")@@ -279,3 +352,8 @@                 err @?= True           msgs -> error ("unexpected context messages: " <> show msgs)     ]++unknownHostGen :: Gen String+unknownHostGen = do+  label <- QC.listOf1 (QC.elements (['a' .. 'z'] <> ['0' .. '9']))+  pure (label <> ".example.invalid")
+ test/StreamSpec.hs view
@@ -0,0 +1,186 @@+module StreamSpec (tests) where++import Baikai+import Baikai.Prelude+import Control.Concurrent (forkIO, newEmptyMVar, putMVar, takeMVar, threadDelay, throwTo)+import Control.Exception qualified as Exception+import Data.Aeson qualified as Aeson+import Data.Time (UTCTime)+import Data.Vector qualified as Vector+import Streamly.Data.Stream qualified as Stream+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))++streamApi :: Api+streamApi = Custom "baikai-stream-spec"++streamModel :: Model+streamModel =+  emptyModel+    & #modelId+    .~ "stream-spec-model"+    & #api+    .~ streamApi+    & #provider+    .~ "stream-spec"++streamContext :: Context+streamContext = emptyContext++streamOptions :: Options+streamOptions = emptyOptions++epoch :: UTCTime+epoch = read "2000-01-01 00:00:00 UTC"++assistantPayload :: Vector AssistantContent -> StopReason -> Maybe Text -> UTCTime -> AssistantPayload+assistantPayload blocks sr err ts =+  AssistantPayload+    { content = blocks,+      usage = zeroUsage,+      stopReason = sr,+      errorMessage = err,+      timestamp = Just ts+    }++assistantMessage :: [AssistantContent] -> Message+assistantMessage blocks =+  AssistantMessage (assistantPayload (Vector.fromList blocks) Stop Nothing epoch)++responseWith :: Maybe Text -> [AssistantContent] -> Response+responseWith rid blocks =+  emptyResponse+    & #message+    .~ assistantPayload (Vector.fromList blocks) Stop Nothing epoch+    & #model+    .~ streamModel+    & #api+    .~ streamApi+    & #provider+    .~ "stream-spec"+    & #responseId+    .~ rid++runEvents :: [AssistantMessageEvent] -> IO Response+runEvents events =+  Stream.fold (reassembleResponse streamModel) (Stream.fromList events)++startEvent :: Maybe Text -> AssistantMessageEvent+startEvent rid =+  EventStart StartPayload {partial = assistantMessage [], responseId = rid}++doneEvent :: Maybe Text -> [AssistantContent] -> AssistantMessageEvent+doneEvent rid blocks =+  EventDone (doneTerminal rid Stop (assistantMessage blocks))++signedThinking :: ThinkingContent+signedThinking =+  ThinkingContent {thinking = "t", signature = Just "sig-abc", redacted = True}++tests :: TestTree+tests =+  testGroup+    "Baikai.Stream reassembly"+    [ testCase "thinking signature and redacted flag survive lift + reassembly" $ do+        let blocks =+              [ AssistantThinking signedThinking,+                AssistantText (TextContent "answer")+              ]+            handler _ _ _ = pure (responseWith (Just "lifted-id") blocks)+        resp <- streamingComplete (liftCompleteToStream handler) streamModel streamContext streamOptions+        resp ^. #message ^. #content @?= Vector.fromList blocks,+      testCase "ThinkingEnd carries the full ThinkingContent" $ do+        resp <-+          runEvents+            [ startEvent Nothing,+              ThinkingStart IndexPayload {contentIndex = 0},+              ThinkingDelta DeltaPayload {contentIndex = 0, delta = "t"},+              ThinkingEnd ThinkingEndPayload {contentIndex = 0, content = signedThinking},+              doneEvent Nothing []+            ]+        resp ^. #message ^. #content @?= Vector.singleton (AssistantThinking signedThinking),+      testCase "terminal message content is authoritative" $ do+        resp <-+          runEvents+            [ startEvent Nothing,+              TextStart IndexPayload {contentIndex = 0},+              TextDelta DeltaPayload {contentIndex = 0, delta = "partial"},+              TextEnd BlockEndPayload {contentIndex = 0, content = "partial"},+              doneEvent Nothing [AssistantText (TextContent "the real full text")]+            ]+        resp ^. #message ^. #content @?= Vector.singleton (AssistantText (TextContent "the real full text")),+      testCase "responseId flows from events to Response" $ do+        fromStart <- runEvents [startEvent (Just "msg_123"), doneEvent Nothing []]+        fromStart ^. #responseId @?= Just "msg_123"+        fromTerminal <- runEvents [startEvent (Just "msg_123"), doneEvent (Just "msg_456") []]+        fromTerminal ^. #responseId @?= Just "msg_456",+      testCase "dangling buffers keep contentIndex order; tool args flushed" $ do+        resp <-+          runEvents+            [ startEvent Nothing,+              TextStart IndexPayload {contentIndex = 0},+              TextDelta DeltaPayload {contentIndex = 0, delta = "first"},+              TextEnd BlockEndPayload {contentIndex = 0, content = "first"},+              ThinkingStart IndexPayload {contentIndex = 1},+              ThinkingDelta DeltaPayload {contentIndex = 1, delta = "partial-think"},+              TextStart IndexPayload {contentIndex = 2},+              TextDelta DeltaPayload {contentIndex = 2, delta = "last"},+              TextEnd BlockEndPayload {contentIndex = 2, content = "last"},+              ToolCallStart IndexPayload {contentIndex = 3},+              ToolCallDelta DeltaPayload {contentIndex = 3, delta = "{\"a\":1"}+            ]+        let expected =+              Vector.fromList+                [ AssistantText (TextContent "first"),+                  AssistantThinking ThinkingContent {thinking = "partial-think", signature = Nothing, redacted = False},+                  AssistantText (TextContent "last"),+                  AssistantToolCall ToolCall {id_ = "", name = "", arguments = Aeson.String "{\"a\":1"}+                ]+        resp ^. #message ^. #content @?= expected+        resp ^. #message ^. #stopReason @?= Stop+        resp ^. #message ^. #errorMessage @?= Just "stream ended without terminal event",+      testCase "latencyMs is clamped at zero" $ do+        let oldResponse =+              responseWith Nothing [AssistantText (TextContent "old")]+                & #message+                %~ (#timestamp .~ Just epoch)+            handler _ _ _ = pure oldResponse+        resp <- streamingComplete (liftCompleteToStream handler) streamModel streamContext streamOptions+        assertBool "latencyMs should be non-negative" (resp ^. #latencyMs >= 0),+      testCase "async exceptions pass through liftCompleteToStream" $ do+        done <- newEmptyMVar+        let blocked _ _ _ = threadDelay (10 * 1000 * 1000) *> pure (responseWith Nothing [])+        tid <-+          forkIO $ do+            outcome <- Exception.try (Stream.toList (liftCompleteToStream blocked streamModel streamContext streamOptions))+            putMVar done (outcome :: Either Exception.SomeException [AssistantMessageEvent])+        threadDelay 100000+        throwTo tid Exception.ThreadKilled+        outcome <- takeMVar done+        case outcome of+          Left e ->+            (Exception.fromException e :: Maybe Exception.AsyncException)+              @?= Just Exception.ThreadKilled+          Right events -> assertFailure ("expected ThreadKilled, got events: " <> show events),+      testCase "error-only streams begin with EventStart" $ do+        reg <- newProviderRegistry+        noProviderEvents <- Stream.toList (streamRequestWith reg streamModel streamContext streamOptions)+        case noProviderEvents of+          [ EventStart StartPayload {},+            EventError TerminalPayload {errorInfo = Just be}+            ] ->+              be ^. #category @?= ProviderUnavailable+          other -> assertFailure ("expected EventStart then provider-unavailable EventError, got: " <> show other)++        let throwing _ _ _ = Exception.throwIO (rateLimited (Just 5) "slow down")+        liftedEvents <- Stream.toList (liftCompleteToStream throwing streamModel streamContext streamOptions)+        case liftedEvents of+          [EventStart StartPayload {}, EventError {}] -> pure ()+          other -> assertFailure ("expected EventStart then EventError, got: " <> show other)+        resp <- runEvents liftedEvents+        case resp ^. #errorInfo of+          Just be -> do+            be ^. #category @?= RateLimited+            be ^. #retryAfterSeconds @?= Just 5+          Nothing -> assertFailure "expected lifted BaikaiError to survive reassembly"+    ]
+ test/SurfaceSpec.hs view
@@ -0,0 +1,53 @@+module SurfaceSpec (tests) where++import Baikai+import Baikai.Embedding qualified as Embedding+import Baikai.Prelude+import Data.Aeson qualified as Aeson+import Data.Map.Strict qualified as Map+import Data.Vector qualified as V+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "surface"+    [ testCase "abstract records build from public bases and selectors" $ do+        let opts =+              emptyOptions+                & #maxTokens+                .~ Just 32+                & #topP+                .~ Just 0.9+            ctx =+              emptyContext+                & #systemPrompt+                .~ Just "Be brief."+                & #messages+                .~ V.singleton (user "hello")+            model =+              mkModel OpenAIChatCompletions "gpt-test" "https://api.example.test"+                & #headers+                .~ Map.singleton "x-test" "1"+            response =+              emptyResponse+                & #model+                .~ model+                & #message+                . #content+                .~ V.singleton (AssistantText emptyTextContent {text = "ok"})+            req = interactiveLaunchRequest "inspect" & #modelId .~ Just "gpt-test"+        opts ^. #maxTokens @?= Just 32+        ctx ^. #systemPrompt @?= Just "Be brief."+        (response ^. #model) ^. #modelId @?= "gpt-test"+        req ^. #modelId @?= Just "gpt-test",+      testCase "zero and empty bases cover the public rename map" $ do+        zeroUsage ^. #totalTokens @?= 0+        zeroCost ^. #usd @?= 0+        zeroCostBreakdown ^. #inputUsd @?= 0+        zeroModelCost ^. #inputCost @?= 0+        emptyTool ^. #parameters @?= Aeson.Null+        emptyToolCall ^. #arguments @?= Aeson.Null+        Embedding.modelId Embedding.emptyEmbeddingModel @?= ""+    ]
test/TraceSpec.hs view
@@ -2,25 +2,31 @@  import Baikai.Api (Api (..)) import Baikai.Content (AssistantContent (..), TextContent (..))-import Baikai.Context (Context (..), _Context)+import Baikai.Context (Context (..), emptyContext) import Baikai.Error (BaikaiError, providerError) import Baikai.Message (AssistantPayload (..), user)-import Baikai.Model (Model (..), _Model)-import Baikai.Options (Options, _Options)+import Baikai.Model (Model (..), emptyModel)+import Baikai.Options (Options, emptyOptions) import Baikai.Prelude import Baikai.Provider (ApiProvider (..), registerApiProvider) import Baikai.Response (Response (..)) import Baikai.StopReason (StopReason (..)) import Baikai.Stream (liftCompleteToStream)-import Baikai.Trace (withTrace)+import Baikai.Trace (newEventId, withTrace, withTraceStream) import Baikai.Trace.Event (TraceEvent (..)) import Baikai.Trace.Sink (TraceSink (..), silent)-import Baikai.Usage (_Usage)+import Baikai.Usage (zeroUsage)+import Control.Concurrent (threadDelay) import Control.Concurrent.STM (TVar, atomically, modifyTVar', newTVarIO, readTVarIO) import Control.Exception (throwIO)+import Control.Monad (replicateM)+import Data.Set qualified as Set import Data.Text qualified as Text import Data.Vector qualified as V import Streamly.Data.Fold qualified as Fold+import Streamly.Data.Stream qualified as Stream+import System.Mem (performMajorGC)+import System.Timeout (timeout) import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=)) @@ -30,7 +36,10 @@     "Baikai.Trace"     [ silentTest,       memoryFinishTest,-      memoryFailTest+      memoryFailTest,+      throwingSinkTest,+      eventIdUniquenessTest,+      earlyAbortTest     ]  -- | Each test uses its own private 'Api' tag so tasty's parallel@@ -38,7 +47,7 @@ -- tests. stubModel :: Api -> Model stubModel a =-  _Model+  emptyModel     & #modelId     .~ "stub-1"     & #api@@ -49,10 +58,10 @@     .~ 16  stubContext :: Context-stubContext = _Context & #messages .~ V.fromList [user "hello"]+stubContext = emptyContext & #messages .~ V.fromList [user "hello"]  stubOptions :: Options-stubOptions = _Options & #maxTokens .~ Just 16+stubOptions = emptyOptions & #maxTokens .~ Just 16  stubResponse :: Api -> Response stubResponse a =@@ -60,10 +69,10 @@     { message =         AssistantPayload           { content = V.singleton (AssistantText (TextContent "hi")),-            usage = _Usage,+            usage = zeroUsage,             stopReason = Stop,             errorMessage = Nothing,-            timestamp = read "2026-05-14 00:00:00 UTC"+            timestamp = Just (read "2026-05-14 00:00:00 UTC")           },       model = stubModel a,       api = a,@@ -160,3 +169,59 @@           ("expected error to mention stub-failure, got: " <> show msg)           ("stub-failure" `Text.isInfixOf` msg)       _ -> assertFailure ("unexpected event sequence: " <> show events)++throwingSink :: TraceSink+throwingSink =+  TraceSink (Fold.drainMapM (\_ -> throwIO (providerError "sink exploded")))++throwingSinkTest :: TestTree+throwingSinkTest =+  testCase "a throwing sink cannot hang withTrace" $ do+    let a = Custom "baikai-trace-throwing-sink"+    registerOk a+    result <- timeout 5000000 (withTrace throwingSink (stubModel a) stubContext stubOptions)+    case result of+      Nothing -> assertFailure "withTrace hung on a throwing sink"+      Just resp -> do+        let AssistantPayload {stopReason = sr} = resp ^. #message+        sr @?= Stop++eventIdUniquenessTest :: TestTree+eventIdUniquenessTest =+  testCase "newEventId yields 70000 distinct 16-char ids" $ do+    ids <- replicateM 70000 newEventId+    Set.size (Set.fromList ids) @?= 70000+    assertBool "every id is 16 chars" (all ((== 16) . Text.length) ids)++earlyAbortTest :: TestTree+earlyAbortTest =+  testCase "early abort pushes a synthetic CallFailed" $ do+    let a = Custom "baikai-trace-abort"+    registerOk a+    (ref, sink) <- memorySink+    emitted <-+      Stream.toList+        (Stream.take 1 (withTraceStream sink (stubModel a) stubContext stubOptions))+    length emitted @?= 1+    events <- awaitEvents ref 2+    case events of+      [s@CallStarted {}, f@CallFailed {errorMessage = msg}] -> do+        (s ^. #eventId :: Text) @?= (f ^. #eventId :: Text)+        assertBool+          ("expected abort message, got: " <> show msg)+          ("aborted" `Text.isInfixOf` msg)+      _ -> assertFailure ("unexpected event sequence: " <> show events)++-- The trace finalizer on an abandoned stream runs from streamly's GC hook.+awaitEvents :: TVar [TraceEvent] -> Int -> IO [TraceEvent]+awaitEvents ref n = go (100 :: Int)+  where+    go 0 = do+      evs <- readTVarIO ref+      assertFailure ("timed out waiting for trace events; got: " <> show (reverse evs))+    go k = do+      performMajorGC+      evs <- readTVarIO ref+      if length evs >= n+        then pure (reverse evs)+        else threadDelay 50000 >> go (k - 1)
test/UsageSpec.hs view
@@ -1,7 +1,7 @@ module UsageSpec (tests) where -import Baikai.Cost (Cost (..), CostBreakdown (..), _Cost, _CostBreakdown)-import Baikai.Usage (Usage (..), sumUsage, _Usage)+import Baikai.Cost (Cost (..), CostBreakdown (..), zeroCost, zeroCostBreakdown)+import Baikai.Usage (Usage (..), sumUsage, zeroUsage) import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (testCase, (@?=)) @@ -12,6 +12,7 @@     [ identityTests,       additionTests,       reasoningTests,+      documentedSemanticsTests,       costTests,       sumUsageTests     ]@@ -19,7 +20,7 @@ -- | A non-trivial usage value for identity checks. u0 :: Usage u0 =-  _Usage+  zeroUsage     { inputTokens = 10,       outputTokens = 5,       cacheReadTokens = 2,@@ -50,7 +51,7 @@     "monoid identity"     [ testCase "mempty <> u == u" $ (mempty <> u0) @?= u0,       testCase "u <> mempty == u" $ (u0 <> mempty) @?= u0,-      testCase "mempty == _Usage" $ (mempty :: Usage) @?= _Usage+      testCase "mempty == zeroUsage" $ (mempty :: Usage) @?= zeroUsage     ]  additionTests :: TestTree@@ -58,8 +59,8 @@   testGroup     "field-wise addition"     [ testCase "numeric fields add" $ do-        let a = _Usage {inputTokens = 10, outputTokens = 5, cacheReadTokens = 2, cacheWriteTokens = 1, totalTokens = 15}-            b = _Usage {inputTokens = 3, outputTokens = 1, cacheReadTokens = 4, cacheWriteTokens = 5, totalTokens = 4}+        let a = zeroUsage {inputTokens = 10, outputTokens = 5, cacheReadTokens = 2, cacheWriteTokens = 1, totalTokens = 15}+            b = zeroUsage {inputTokens = 3, outputTokens = 1, cacheReadTokens = 4, cacheWriteTokens = 5, totalTokens = 4}             s = a <> b         inputTokens s @?= 13         outputTokens s @?= 6@@ -82,16 +83,37 @@         reasoningTokens (rJust 2 <> rJust 3) @?= Just 5     ]   where-    rJust n = _Usage {reasoningTokens = Just n}-    rNothing = _Usage {reasoningTokens = Nothing}+    rJust n = zeroUsage {reasoningTokens = Just n}+    rNothing = zeroUsage {reasoningTokens = Nothing} +-- Provider mappings must uphold the documented field semantics:+-- 'anthroUsageToBaikai' in baikai-claude/src/Baikai/Provider/Claude/Api.hs+-- and 'rawUsageToUsage' in baikai-openai/src/Baikai/Provider/OpenAI/Api.hs.+documentedSemanticsTests :: TestTree+documentedSemanticsTests =+  testGroup+    "documented field semantics"+    [ testCase "totalTokens sums disjoint billed classes and excludes reasoning double-count" $ do+        let u =+              zeroUsage+                { inputTokens = 20,+                  outputTokens = 50,+                  cacheReadTokens = 80,+                  cacheWriteTokens = 7,+                  reasoningTokens = Just 20,+                  totalTokens = 157+                }+        totalTokens u+          @?= inputTokens u + outputTokens u + cacheReadTokens u + cacheWriteTokens u+    ]+ costTests :: TestTree costTests =   testGroup     "cost totals add"     [ testCase "usd and breakdown add" $ do-        let a = _Usage {cost = costOf 1 0 0 0}-            b = _Usage {cost = costOf 2 0 0 0}+        let a = zeroUsage {cost = costOf 1 0 0 0}+            b = zeroUsage {cost = costOf 2 0 0 0}             c = cost (a <> b)         usd c @?= 3 / 100         inputUsd (breakdown c) @?= 3 / 100,@@ -102,8 +124,8 @@         cachedInputUsd s @?= 10 / 100         cachedWriteUsd s @?= 12 / 100,       testCase "mempty cost is zero" $ do-        (mempty :: Cost) @?= _Cost-        (mempty :: CostBreakdown) @?= _CostBreakdown+        (mempty :: Cost) @?= zeroCost+        (mempty :: CostBreakdown) @?= zeroCostBreakdown     ]  sumUsageTests :: TestTree@@ -111,9 +133,9 @@   testGroup     "sumUsage"     [ testCase "sumUsage equals chained <>" $ do-        let u1 = _Usage {inputTokens = 1, totalTokens = 1}-            u2 = _Usage {inputTokens = 2, totalTokens = 2}-            u3 = _Usage {inputTokens = 3, totalTokens = 3}+        let u1 = zeroUsage {inputTokens = 1, totalTokens = 1}+            u2 = zeroUsage {inputTokens = 2, totalTokens = 2}+            u3 = zeroUsage {inputTokens = 3, totalTokens = 3}         sumUsage [u1, u2, u3] @?= (u1 <> u2 <> u3),       testCase "sumUsage [] == mempty" $         sumUsage [] @?= (mempty :: Usage)