packages feed

baikai-typesafe-0.1.0.0: src/Baikai/Provider/TypeSafe/Api.hs

-- | Explicitly registered, non-streaming HTTP judgments with atomic data events.
module Baikai.Provider.TypeSafe.Api (register, typesafeProvider, typesafeStream) where

import Baikai
import Baikai.Evidence qualified as E
import Baikai.Evidence.Build qualified as Build
import Baikai.Http qualified as Http
import Baikai.Provider.Transport.Classify (classifyTransportException)
import Baikai.Provider.TypeSafe.Internal.Request qualified as R
import Baikai.Provider.TypeSafe.Internal.Response qualified as D
import Baikai.Url qualified as Url
import Control.Exception (SomeAsyncException, SomeException, displayException, fromException, throwIO, try)
import Control.Lens ((&), (.~), (^.))
import Data.Aeson qualified as A
import Data.ByteString.Lazy qualified as BL
import Data.CaseInsensitive qualified as CI
import Data.Generics.Labels ()
import Data.IORef
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Text.Encoding.Error (lenientDecode)
import Data.Time (UTCTime, diffUTCTime, getCurrentTime)
import Data.Vector qualified as V
import Data.Version (showVersion)
import Network.HTTP.Client qualified as H
import Network.HTTP.Types.Header (ResponseHeaders)
import Network.HTTP.Types.Status (statusCode)
import Paths_baikai_typesafe qualified as Paths
import Servant.Client qualified as Client
import Streamly.Data.Stream (Stream)
import System.Timeout (timeout)
import TypeSafe.Wire qualified as W

register :: IO ()
register = registerApiProvider typesafeProvider

typesafeProvider :: ApiProvider
typesafeProvider =
  apiProvider TypeSafeSystemOne typesafeStream
    & #describeThinking .~ (\_ opts -> Build.requestedTranslation opts)
    & #strengthCeiling .~ E.declaredStrength TypeSafeSystemOne
    & #structuredOutput .~ declaredStructuredOutput TypeSafeSystemOne

typesafeStream :: Model -> Context -> Options -> Stream IO AssistantMessageEvent
typesafeStream = liftCompleteToStream completeTypeSafe

completeTypeSafe :: Model -> Context -> Options -> IO Response
completeTypeSafe m ctx opts = do
  started <- getCurrentTime
  headerRef <- newIORef []
  bodyRef <- newIORef Nothing
  envelopeRef <- newIORef (Build.dispatchEnvelope m opts)
  let base = if T.null (m ^. #baseUrl) then W.defaultBaseUrl else m ^. #baseUrl
      action = do
        (js, wire) <- either throwIO pure (R.prepareRequest m ctx opts)
        let bytes = R.requestBytes wire
            envelope = fromMaybe A.Null (A.decode bytes)
        writeIORef envelopeRef envelope
        case opts ^. #timeoutMs of
          Just ms | ms <= 0 -> throwIO (unsupportedFeature "Options.timeoutMs" "timeoutMs must be positive")
          _ -> pure ()
        case Url.baseUrlProblem base of
          Just why -> throwIO (invalidRequest ("Model.baseUrl is not usable: " <> why))
          Nothing -> pure ()
        canonical <- either (throwIO . invalidRequest) pure (Http.canonicalBaseUrl base)
        key <- case opts ^. #apiKey of
          Just source -> resolveApiKey source
          Nothing -> case defaultApiKeyEnvForBaseUrl base of
            Just env -> resolveApiKey (ApiKeyEnv env)
            Nothing -> throwIO (authError "Custom TypeSafe hosts require an explicit Options.apiKey source")
        env <- Http.getClientEnvCached base
        request <- H.parseRequest (Client.showBaseUrl canonical <> "/v1/systemone")
        let headers = Map.unions [opts ^. #headers, m ^. #headers, Map.fromList [("Authorization", "Bearer " <> key), ("Content-Type", "application/json")]]
            req =
              request
                { H.method = "POST",
                  H.requestBody = H.RequestBodyLBS bytes,
                  H.requestHeaders = [(CI.mk (TE.encodeUtf8 (renderHeaderName k)), TE.encodeUtf8 v) | (k, v) <- Map.toList headers],
                  H.redirectCount = 0,
                  H.checkResponse = \_ _ -> pure (),
                  H.responseTimeout = H.responseTimeoutNone
                }
        H.withResponse req (Client.manager env) $ \response -> do
          writeIORef headerRef (H.responseHeaders response)
          body <- BL.fromChunks <$> H.brConsume (H.responseBody response)
          writeIORef bodyRef (Just body)
          let status = statusCode (H.responseStatus response)
          if status >= 200 && status < 300
            then either throwIO pure (D.decodeResponse m js body)
            else do
              now <- getCurrentTime
              throwIO (statusError m now status (H.responseHeaders response) body)
      bounded = case opts ^. #timeoutMs of
        Just ms | ms > 0 -> do
          result <- timeout (fromInteger (min (toInteger (maxBound :: Int)) (toInteger ms * 1000))) action
          maybe (throwIO ((providerError ("TypeSafe call exceeded timeoutMs=" <> T.pack (show ms))) & #category .~ TransientError)) pure result
        _ -> action
  result <- trySync bounded
  ended <- getCurrentTime
  headers <- readIORef headerRef
  body <- readIORef bodyRef
  envelope <- readIORef envelopeRef
  let latency = max 0 (round (diffUTCTime ended started * 1000))
      err = either Just (const Nothing) result
      response = case result of
        Left e -> errorResponse m ended latency e
        Right (dat, _, u) ->
          emptyResponse
            & #model .~ m
            & #api .~ (m ^. #api)
            & #provider .~ (m ^. #provider)
            & #latencyMs .~ latency
            & #message .~ AssistantPayload (V.singleton (AssistantData dat)) u Stop Nothing (Just ended)
      (observedModel, observedUsage) = maybe (Unobserved, Unobserved) (D.responseObservations m) body
      requestId = maybe Unobserved (Observed . TE.decodeUtf8With lenientDecode) (lookup W.requestIdHeader headers)
      responseCommitment = case result of
        Right _ -> Observed (commitmentDigest (A.object ["content" A..= (response ^. #message . #content), "stop_reason" A..= Stop, "usage" A..= E.usageEnvelope (response ^. #message . #usage)]))
        Left _ -> maybe Unobserved (\bytes -> Observed (commitmentDigest (fromMaybe (A.String (TE.decodeUtf8With lenientDecode (BL.toStrict bytes))) (A.decode bytes)))) body
  evidence <- Build.minimalEvidenceAt base m opts TransportHttpApi (Build.requestedTranslation opts) envelope started ended (if isJustError err then E.CallFailed else CallSucceeded) err
  let enrich e =
        e
          & #observedModel .~ observedModel
          & #usage .~ observedUsage
          & #providerRequestId .~ requestId
          & #responseCommitment .~ responseCommitment
          & #strength .~ E.deriveStrength observedModel requestId Unobserved
          & #endpoint . #implementationVersion .~ Just (T.pack (showVersion Paths.version))
  pure (response & #evidence .~ fmap enrich evidence)
  where
    isJustError Nothing = False; isJustError (Just _) = True

trySync :: IO a -> IO (Either BaikaiError a)
trySync action = do
  result <- try action
  case result of
    Right x -> pure (Right x)
    Left e
      | Just async <- (fromException e :: Maybe SomeAsyncException) -> throwIO async
      | otherwise -> pure (Left (exceptionError e))

exceptionError :: SomeException -> BaikaiError
exceptionError e = case fromException e of
  Just be -> be
  Nothing -> fromMaybe (providerError (T.pack (displayException e))) (classifyTransportException e)

statusError :: Model -> UTCTime -> Int -> ResponseHeaders -> BL.ByteString -> BaikaiError
statusError m now status headers bytes =
  let reference = fromMaybe now (lookup "Date" headers >>= parseHttpDate . TE.decodeUtf8With lenientDecode)
      retry = lookup "Retry-After" headers >>= retryAfterSecondsAt reference . TE.decodeUtf8With lenientDecode
      wire = W.parseErrorBody bytes
      detail = case wire of
        W.ErrorBodyDetail d -> d ^. #message
        W.ErrorBodyDetailText t -> t
        W.ErrorBodyValidation v -> case v ^. #detail >>= (V.!? 0) of
          Just d -> T.intercalate "." [case x of { W.LocationField t -> t; W.LocationIndex i -> T.pack (show i) } | x <- V.toList (d ^. #loc)] <> ": " <> d ^. #msg
          Nothing -> "TypeSafe validation error"
        _ -> TE.decodeUtf8With lenientDecode (BL.toStrict bytes)
      base = httpError status retry detail
   in case wire of
        W.ErrorBodyDetail d | status == 400, d ^. #errorType == Just "api_usage_error", "Unknown model:" `T.isPrefixOf` (d ^. #message) -> base & #requestProblem .~ Just (UnsupportedModel (m ^. #modelId))
        _ -> base