baikai-0.2.0.0: fetch/FetchModels.hs
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Executable @baikai-fetch-models@: refresh baikai's catalog JSON
-- from upstream sources (primarily @https:\/\/models.dev\/api.json@).
--
-- Pipeline role: this tool does @network -> catalog JSON@. The
-- existing @baikai-gen-models@ does @catalog JSON -> Generated.hs@.
-- The two stay separate so the network never runs at build time and
-- the catalog JSON stays a human-reviewable @git diff@ artifact.
--
-- Typical use, from the repo root:
--
-- > cabal run baikai-fetch-models -- fetch live, rewrite data/models/{anthropic,openai}.json
-- > cabal run baikai-gen-models -- regenerate Generated.hs
-- > git --no-pager diff baikai/data/models -- review before committing
--
-- Flags:
--
-- * @--from-url URL@ — upstream document (default
-- @https:\/\/models.dev\/api.json@).
-- * @--from-file PATH@ — read a local document instead of the network
-- (used by tests and for offline work).
-- * @--out-dir DIR@ — directory to write @\<provider\>.json@ into
-- (default @\<baikai-pkg-dir\>\/data\/models@).
-- * @--provider {openai|anthropic|all}@ — which catalogs to emit
-- (default @all@).
-- * @--stdout@ — print to stdout instead of writing files.
--
-- All normalization, curation, and rendering logic lives in the pure
-- 'FetchModelsCore' module so it can be unit-tested without IO; this
-- module is only the impure shell (arg parsing, network fetch, file
-- writing).
module FetchModels (main, fetchUpstream) where
import Control.Exception (catch)
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as BSL
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)
import Network.HTTP.Client.TLS (newTlsManager)
import System.Directory (doesFileExist, getCurrentDirectory)
import System.Environment (getArgs)
import System.Exit (die)
import System.FilePath (takeDirectory, (</>))
-- | Which provider(s) to emit.
data ProviderSel = SelOpenAI | SelAnthropic | SelAll
deriving stock (Eq, Show)
-- | Parsed command-line options.
data Options = Options
{ optFromUrl :: !String,
optFromFile :: !(Maybe FilePath),
optOutDir :: !(Maybe FilePath),
optProvider :: !ProviderSel,
optStdout :: !Bool
}
defaultOptions :: Options
defaultOptions =
Options
{ optFromUrl = "https://models.dev/api.json",
optFromFile = Nothing,
optOutDir = Nothing,
optProvider = SelAll,
optStdout = False
}
main :: IO ()
main = do
args <- getArgs
opts <- parseArgs args
raw <- loadUpstreamBytes opts
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
then mapM_ (BS.putStr . renderCatalog) catalogs
else do
outDir <- resolveOutDir (optOutDir opts)
mapM_ (writeCatalog outDir) 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
Just path -> BSL.readFile path
Nothing ->
fetchUpstream (optFromUrl opts)
`catch` \e ->
die $
"baikai-fetch-models: failed to fetch "
<> optFromUrl opts
<> ": "
<> show (e :: HttpException)
-- | GET a URL over TLS and return the body. 'parseUrlThrow' makes any
-- non-2xx status raise an 'HttpException' (handled by the caller), so
-- a 404 or server error never reaches the parser or the filesystem.
fetchUpstream :: String -> IO BSL.ByteString
fetchUpstream url = do
manager <- newTlsManager
req <- parseUrlThrow url
responseBody <$> httpLbs req manager
-- | 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)
-- | 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")
BS.writeFile path (renderCatalog cat)
putStrLn $
"Wrote " <> path <> " (" <> show (length (cModels cat)) <> " models)"
-- | The provider specs selected by @--provider@.
selectedSpecs :: ProviderSel -> [ProviderSpec]
selectedSpecs = \case
SelOpenAI -> [openaiSpec]
SelAnthropic -> [anthropicSpec]
SelAll -> [anthropicSpec, openaiSpec]
-- | Resolve the output directory, anchoring the default against the
-- @baikai@ package directory the same way @baikai-gen-models@ does.
resolveOutDir :: Maybe FilePath -> IO FilePath
resolveOutDir (Just d) = pure d
resolveOutDir Nothing = do
mPkg <- locatePackageDir
case mPkg of
Nothing ->
die
"baikai-fetch-models: could not locate baikai.cabal in any \
\ancestor directory; pass --out-dir explicitly"
Just pkg -> pure (pkg </> "data/models")
-- | Locate the directory containing @baikai.cabal@ by walking up from
-- the current working directory, also checking a @baikai@ subdir at
-- each level so invocation from the repo root works. Mirrors
-- @baikai-gen-models@.
locatePackageDir :: IO (Maybe FilePath)
locatePackageDir = getCurrentDirectory >>= walk
where
walk dir = do
here <- doesFileExist (dir </> "baikai.cabal")
if here
then pure (Just dir)
else do
sub <- doesFileExist (dir </> "baikai" </> "baikai.cabal")
if sub
then pure (Just (dir </> "baikai"))
else
let parent = takeDirectory dir
in if parent == dir then pure Nothing else walk parent
-- | Parse the flags; anything else is a hard error, mirroring
-- @baikai-gen-models@'s strict arg handling.
parseArgs :: [String] -> IO Options
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 ("--provider" : v : rest) = do
sel <- parseProvider v
go o {optProvider = sel} rest
go o ("--stdout" : rest) = go o {optStdout = True} rest
go _ (a : _) = die $ "baikai-fetch-models: unknown argument " <> show a
parseProvider :: String -> IO ProviderSel
parseProvider = \case
"openai" -> pure SelOpenAI
"anthropic" -> pure SelAnthropic
"all" -> pure SelAll
other ->
die $
"baikai-fetch-models: unknown --provider "
<> show other
<> " (expected openai, anthropic, or all)"