packages feed

kioku-core-0.1.0.0: src/Kioku/Memory/Embedding.hs

module Kioku.Memory.Embedding
  ( EmbeddingConfig (..),
    EmbedError (..),
    resolveEmbeddingConfig,
    toEmbeddingModel,
    embedWithRetry,
    sha256Hex,
  )
where

import Baikai.Auth (ApiKeySource (..))
import Baikai.Embedding (EmbeddingModel (..), embedOne)
import Control.Concurrent (threadDelay)
import Control.Exception (SomeException, try)
import Crypto.Hash (Digest, SHA256)
import Crypto.Hash qualified as Hash
import Data.Text qualified as Text
import Data.Text.Encoding qualified as TE
import Data.Vector (Vector)
import Data.Vector qualified as Vector
import Kioku.Prelude
import Numeric.Natural (Natural)
import System.Environment (lookupEnv)
import Text.Read (readMaybe)

data EmbeddingConfig = EmbeddingConfig
  { baseUrl :: !Text,
    model :: !Text,
    dimensions :: !Int,
    apiKey :: !Text
  }
  deriving stock (Generic, Eq, Show)

data EmbedError
  = EmbedTransport !Text
  | EmbedEmpty
  deriving stock (Generic, Eq, Show)

resolveEmbeddingConfig :: IO EmbeddingConfig
resolveEmbeddingConfig = do
  baseUrl <- envText "KIOKU_EMBEDDING_BASE_URL" "https://api.openai.com"
  model <- envText "KIOKU_EMBEDDING_MODEL" "text-embedding-3-small"
  dimensions <- envInt "KIOKU_EMBEDDING_DIMENSIONS" 1536
  apiKey <- envTextFallback ["KIOKU_EMBEDDING_API_KEY", "OPENAI_API_KEY"] ""
  pure EmbeddingConfig {baseUrl, model, dimensions, apiKey}

toEmbeddingModel :: EmbeddingConfig -> EmbeddingModel
toEmbeddingModel cfg =
  EmbeddingModel
    { modelId = cfg.model,
      baseUrl = cfg.baseUrl,
      dimensions = Just (fromIntegral @Int @Natural cfg.dimensions),
      apiKey = ApiKeyLiteral cfg.apiKey
    }

embedWithRetry :: EmbeddingModel -> Int -> Text -> IO (Either EmbedError (Vector Double))
embedWithRetry model maxAttempts input = go 1
  where
    attempts = max 1 maxAttempts

    go attempt = do
      result <- try (embedOne model input)
      case result of
        Right vec
          | Vector.null vec -> pure (Left EmbedEmpty)
          | otherwise -> pure (Right vec)
        Left (err :: SomeException)
          | attempt >= attempts -> pure (Left (EmbedTransport (Text.pack (show err))))
          | otherwise -> do
              threadDelay (attemptDelayMicros attempt)
              go (attempt + 1)

sha256Hex :: Text -> Text
sha256Hex content =
  Text.pack (show (Hash.hash (TE.encodeUtf8 content) :: Digest SHA256))

attemptDelayMicros :: Int -> Int
attemptDelayMicros attempt = 200000 * (2 ^ max 0 (attempt - 1))

envText :: String -> Text -> IO Text
envText name fallback =
  maybe fallback Text.pack <$> lookupEnv name

envInt :: String -> Int -> IO Int
envInt name fallback = do
  found <- lookupEnv name
  pure $ fromMaybe fallback (found >>= readMaybe)

envTextFallback :: [String] -> Text -> IO Text
envTextFallback [] fallback = pure fallback
envTextFallback (name : rest) fallback = do
  found <- lookupEnv name
  case found of
    Just value -> pure (Text.pack value)
    Nothing -> envTextFallback rest fallback