packages feed

baikai-0.3.1.0: fetch/FetchModelsCore.hs

-- | 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
-- hand-maintained files under @baikai\/data\/models\/@.
--
-- This module is deliberately free of @IO@ and of any network
-- dependency so it can be unit-tested directly (see
-- @baikai\/test\/FetchModelsSpec.hs@) and so the network fetch lives
-- only in the thin executable wrapper @baikai\/fetch\/FetchModels.hs@.
--
-- == Curation policy
--
-- baikai's catalog is an intentionally curated short list, not an
-- exhaustive mirror of models.dev. Only models whose ids appear in the
-- per-provider include set ('openaiInclude', 'anthropicInclude') are
-- emitted, and only if upstream marks them @tool_call: true@. For
-- @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 (..),
    parseUpstream,

    -- * Output catalog shape
    CatalogModel (..),
    CatalogCost (..),
    Catalog (..),

    -- * Provider specs and normalization
    ProviderSpec (..),
    openaiSpec,
    anthropicSpec,
    openaiInclude,
    anthropicInclude,
    normalizeProvider,

    -- * Override layer
    Override (..),
    emptyOverride,
    overrides,
    applyOverrides,
    staleOverrides,

    -- * Rendering
    renderCatalog,
  )
where

import Baikai.Model (InputModality (..))
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
import Data.Maybe (fromMaybe)
import Data.Scientific (FPFormat (Fixed), Scientific, formatScientific)
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text qualified as Text
import Data.Text.Encoding (decodeUtf8, encodeUtf8)

-- * Upstream shape ----------------------------------------------------

-- | The subset of a models.dev model object that we consume. Every
-- optional field defaults the way pi-mono's scraper does: missing
-- numbers become 0 at normalization time, missing arrays become empty.
data UpstreamModel = UpstreamModel
  { 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, Generic)

instance FromJSON UpstreamModel where
  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
        { 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
  { context :: !(Maybe Integer),
    output :: !(Maybe Integer)
  }
  deriving stock (Show, Generic)

instance FromJSON Limit where
  parseJSON = withObject "limit" $ \o ->
    Limit <$> o .:? "context" <*> o .:? "output"

-- | @cost@ sub-object (US dollars per million tokens).
data Cost = Cost
  { input :: !(Maybe Scientific),
    output :: !(Maybe Scientific),
    cacheRead :: !(Maybe Scientific),
    cacheWrite :: !(Maybe Scientific)
  }
  deriving stock (Show, Generic)

instance FromJSON Cost where
  parseJSON = withObject "cost" $ \o ->
    Cost
      <$> o .:? "input"
      <*> o .:? "output"
      <*> o .:? "cache_read"
      <*> o .:? "cache_write"

-- | @modalities@ sub-object; we only read @input@.
newtype Modalities = Modalities {input :: [Text]}
  deriving stock (Show, Generic)

instance FromJSON Modalities where
  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 {models :: Map Text UpstreamModel}
  deriving stock (Generic)

instance FromJSON UpstreamProvider where
  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 (^. #models) <$> (eitherDecode bs :: Either String (Map Text UpstreamProvider))

-- * Output catalog shape ---------------------------------------------

-- | Per-million-token cost rates, named to mirror
-- 'Baikai.Model.ModelCost'.
data CatalogCost = CatalogCost
  { inputCost :: !Scientific,
    outputCost :: !Scientific,
    cacheReadCost :: !Scientific,
    cacheWriteCost :: !Scientific
  }
  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
  { modelId :: !Text,
    name :: !Text,
    reasoning :: !Bool,
    input :: ![InputModality],
    cost :: !CatalogCost,
    contextWindow :: !Integer,
    maxOutputTokens :: !Integer
  }
  deriving stock (Eq, Show, Generic)

-- | A whole catalog file: one provider and its curated models.
data Catalog = Catalog
  { provider :: !Text,
    baseUrl :: !Text,
    api :: !Text,
    models :: ![CatalogModel]
  }
  deriving stock (Eq, Show, Generic)

-- * Provider specs and normalization ---------------------------------

-- | What a provider needs to be normalized: its identity in the
-- catalog and the curation predicate selecting which upstream model
-- ids to keep.
data ProviderSpec = ProviderSpec
  { 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@,
-- @*-deep-research@) are deliberately absent.
openaiInclude :: Set Text
openaiInclude =
  Set.fromList
    [ "gpt-5.6",
      "gpt-5.6-luna",
      "gpt-5.6-sol",
      "gpt-5.6-terra",
      "gpt-5.5",
      "gpt-5.4",
      "gpt-5.4-mini",
      "gpt-5.4-nano",
      "gpt-5.2",
      "gpt-5.1",
      "gpt-5",
      "gpt-5-mini",
      "gpt-5-nano",
      "gpt-4.1",
      "gpt-4.1-mini",
      "gpt-4.1-nano",
      "gpt-4o",
      "gpt-4o-mini",
      "o3",
      "o3-mini",
      "o4-mini",
      "o1"
    ]

-- | Curation include set for Anthropic: the current generations.
anthropicInclude :: Set Text
anthropicInclude =
  Set.fromList
    [ "claude-opus-4-8",
      "claude-opus-4-7",
      "claude-opus-4-6",
      "claude-opus-4-5",
      "claude-sonnet-5",
      "claude-sonnet-4-6",
      "claude-sonnet-4-5",
      "claude-haiku-4-5",
      "claude-fable-5"
    ]

-- | Provider spec for OpenAI's first-party chat-completions endpoint.
openaiSpec :: ProviderSpec
openaiSpec =
  ProviderSpec
    { 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
    { provider = "anthropic",
      baseUrl = "https://api.anthropic.com",
      api = "anthropic-messages",
      include = (`Set.member` anthropicInclude)
    }

-- | Normalize one provider's upstream models into a 'Catalog'. Keeps
-- only @tool_call == True@ models that pass the curation predicate,
-- maps each upstream model to a 'CatalogModel' (dropping @pdf@ and any
-- non-text/image modality; missing costs and limits default to 0 so a
-- human notices), and sorts by model id for deterministic output.
normalizeProvider :: ProviderSpec -> Map Text UpstreamModel -> Catalog
normalizeProvider spec upstream =
  Catalog
    { provider = spec ^. #provider,
      baseUrl = spec ^. #baseUrl,
      api = spec ^. #api,
      models = sortOn (^. #modelId) (map toCatalogModel kept)
    }
  where
    kept =
      [ m
      | m <- Map.elems upstream,
        m ^. #toolCall,
        (spec ^. #include) (m ^. #modelId)
      ]
    toCatalogModel m =
      CatalogModel
        { modelId = m ^. #modelId,
          name = normalizeName (fromMaybe (m ^. #modelId) (m ^. #name)),
          reasoning = m ^. #reasoning,
          input =
            if "image" `elem` (m ^. #inputModalities)
              then [InputText, InputImage]
              else [InputText],
          cost =
            CatalogCost
              { inputCost = fromMaybe 0 (m ^. #inputCost),
                outputCost = fromMaybe 0 (m ^. #outputCost),
                cacheReadCost = fromMaybe 0 (m ^. #cacheReadCost),
                cacheWriteCost = fromMaybe 0 (m ^. #cacheWriteCost)
              },
          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
-- the hand-maintained files: 2-space indent, an inline @input@ array,
-- costs as decimal numbers with a trailing @.0@ on whole values, and a
-- trailing newline.
renderCatalog :: Catalog -> ByteString
renderCatalog cat = encodeUtf8 (Text.unlines (renderLines cat))

renderLines :: Catalog -> [Text]
renderLines cat =
  ["{"]
    ++ [ "  \"provider\": " <> jsonString (cat ^. #provider) <> ",",
         "  \"baseUrl\": " <> jsonString (cat ^. #baseUrl) <> ",",
         "  \"api\": " <> jsonString (cat ^. #api) <> ",",
         "  \"compat\": \"auto\","
       ]
    ++ modelsSection (cat ^. #models)
    ++ ["}"]

modelsSection :: [CatalogModel] -> [Text]
modelsSection [] = ["  \"models\": []"]
modelsSection ms =
  ["  \"models\": ["]
    ++ concat (addBlockCommas (map renderModel ms))
    ++ ["  ]"]

-- | Append a trailing comma to the last line of each block except the
-- final one, so emitted model objects are comma-separated.
addBlockCommas :: [[Text]] -> [[Text]]
addBlockCommas [] = []
addBlockCommas [b] = [b]
addBlockCommas (b : rest) = appendCommaToLast b : addBlockCommas rest
  where
    appendCommaToLast xs = case reverse xs of
      [] -> xs
      (lastLine : front) -> reverse ((lastLine <> ",") : front)

renderModel :: CatalogModel -> [Text]
renderModel m =
  [ "    {",
    "      \"id\": " <> jsonString (m ^. #modelId) <> ",",
    "      \"name\": " <> jsonString (m ^. #name) <> ",",
    "      \"reasoning\": " <> jsonBool (m ^. #reasoning) <> ",",
    "      \"input\": " <> renderInput (m ^. #input) <> ",",
    "      \"cost\": {",
    "        \"input\": " <> renderNum (c ^. #inputCost) <> ",",
    "        \"output\": " <> renderNum (c ^. #outputCost) <> ",",
    "        \"cacheRead\": " <> renderNum (c ^. #cacheReadCost) <> ",",
    "        \"cacheWrite\": " <> renderNum (c ^. #cacheWriteCost),
    "      },",
    "      \"contextWindow\": " <> Text.pack (show (m ^. #contextWindow)) <> ",",
    "      \"maxOutputTokens\": " <> Text.pack (show (m ^. #maxOutputTokens)) <> ",",
    "      \"enabled\": true",
    "    }"
  ]
  where
    c = m ^. #cost

renderInput :: [InputModality] -> Text
renderInput ms = "[" <> Text.intercalate ", " (map one ms) <> "]"
  where
    one InputText = "\"text\""
    one InputImage = "\"image\""

jsonBool :: Bool -> Text
jsonBool True = "true"
jsonBool False = "false"

-- | Render a cost as a fixed-point decimal. Whole values gain a
-- trailing @.0@ (e.g. @15@ → @15.0@) to match the existing files;
-- fractional values keep their natural decimal form (e.g. @0.075@).
renderNum :: Scientific -> Text
renderNum = Text.pack . formatScientific Fixed Nothing

-- | 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 = decodeUtf8 . LBS.toStrict . encode . String