packages feed

agentic-openai-0.2.0.0: src/Agentic/OpenAI.hs

-- | OpenAI as a runtime's System Two (and, through 'viaLLM', System One), over
-- the Responses API.
--
-- > rt <- pure runtime >>= withSystemTwo openai
-- > rt <- pure runtime >>= withSystemTwo (openai & model "gpt-6.1-sol" & effort Low)
module Agentic.OpenAI
  ( OpenAI (..)
  , openai
  , OpenAIError (..)
    -- * Wire format
  , requestBody
  , decodeTurn
  ) where

import Agentic.Aeson (fromAeson)
import Agentic.Core (Instruction (..))
import Agentic.JsonSchema (objectSchema, schemaName, unwrap)
import Agentic.Runtime
import Agentic.Schema (Schema)
import Agentic.Settings
import qualified Agentic.Value as A
import Agentic.ViaLLM (viaLLM)
import Control.Exception (Exception (..), throwIO)
import Data.Aeson ((.:), (.:?))
import qualified Data.Aeson as J
import qualified Data.Aeson.Types as J
import qualified Data.ByteString.Lazy as LBS
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.Encoding as TL
import qualified Network.HTTP.Client as Http
import Network.HTTP.Client.TLS (newTlsManager)
import Network.HTTP.Types.Status (statusCode)
import System.Environment (lookupEnv)

-- | OpenAI's settings. Start from 'openai' and change them with the setters
-- from "Agentic.Settings": 'Agentic.Settings.model', 'Agentic.Settings.system',
-- 'Agentic.Settings.effort', 'Agentic.Settings.maxTokens', 'Agentic.Settings.key',
-- 'Agentic.Settings.endpoint' and 'Agentic.Settings.timeout'.
data OpenAI = OpenAI
  { model :: Text
  , system :: Maybe Text
    -- ^ Sent as the request's instructions.
  , maxTokens :: Maybe Int
  , effort :: Maybe Effort
    -- ^ The model's default if unset.
  , key :: Maybe Text
    -- ^ Defaults to the @OPENAI_API_KEY@ environment variable.
  , endpoint :: String
  , timeout :: Int
    -- ^ Seconds.
  }

openai :: OpenAI
openai =
  OpenAI
    { model = "gpt-6-astra"
    , system = Nothing
    , maxTokens = Nothing
    , effort = Nothing
    , key = Nothing
    , endpoint = "https://api.openai.com/v1/responses"
    , timeout = 600
    }

instance HasModel OpenAI where model m c = c {model = m}
instance HasSystem OpenAI where system t c = c {system = Just t}
instance HasMaxTokens OpenAI where maxTokens n c = c {maxTokens = Just n}
instance HasEffort OpenAI where effort e c = c {effort = Just e}
instance HasKey OpenAI where key k c = c {key = Just k}
instance HasEndpoint OpenAI where endpoint e c = c {endpoint = e}
instance HasTimeout OpenAI where timeout t c = c {timeout = t}

effortName :: Effort -> Text
effortName = \case
  Low -> "low"
  Medium -> "medium"
  High -> "high"
  XHigh -> "xhigh"
  Max -> "max"

-- | What can go wrong talking to the Responses API. Thrown in IO.
data OpenAIError
  = MissingKey
  | HttpError Int Text
    -- ^ A non-200 status, and the API's error body.
  | Refused Text
    -- ^ The model refused, with its explanation.
  | Truncated
    -- ^ The reply hit the token limit before it was complete.
  | Incomplete Text
    -- ^ The response stopped early for another reason.
  | UnexpectedResponse Text
  deriving (Show)

instance Exception OpenAIError where
  displayException = \case
    MissingKey -> "OpenAI: no API key. Set OPENAI_API_KEY, or use (openai & key ...)."
    HttpError status body -> "OpenAI rejected the request (HTTP " <> show status <> "): " <> T.unpack body
    Refused why -> "The model refused: " <> T.unpack why
    Truncated -> "The reply hit the token limit; raise it with (openai & maxTokens ...)"
    Incomplete reason -> "The response stopped early: " <> T.unpack reason
    UnexpectedResponse problem -> "OpenAI sent a response agentic can't read: " <> T.unpack problem

instance ProvidesSystemTwo OpenAI where
  toSystemTwo cfg = do
    key' <- maybe (fmap T.pack <$> lookupEnv "OPENAI_API_KEY") (pure . Just) cfg.key >>= maybe (throwIO MissingKey) pure
    manager <- newTlsManager
    base <- Http.parseRequest cfg.endpoint
    pure $ SystemTwo $ \conversation -> do
      let http =
            base
              { Http.method = "POST"
              , Http.requestHeaders =
                  [ ("Authorization", "Bearer " <> T.encodeUtf8 key')
                  , ("Content-Type", "application/json")
                  ]
              , Http.requestBody = Http.RequestBodyLBS (TL.encodeUtf8 (TL.fromStrict (A.renderJson (requestBody cfg conversation))))
              , Http.responseTimeout = Http.responseTimeoutMicro (cfg.timeout * 1000000)
              }
      response <- Http.httpLbs http manager
      let status = statusCode (Http.responseStatus response)
          body = Http.responseBody response
      if status /= 200
        then throwIO (HttpError status (T.decodeUtf8Lenient (LBS.toStrict body)))
        else case J.eitherDecode body of
          Left problem -> throwIO (UnexpectedResponse (T.pack problem))
          Right value -> either throwIO pure (decodeTurn conversation value)

-- | The model answers judgements too, with uncalibrated probabilities.
instance ProvidesSystemOne OpenAI where
  toSystemOne cfg = viaLLM <$> toSystemTwo cfg

-- | The Responses API request for one turn of a step. Nothing is stored on
-- OpenAI's side: each turn sends the whole step so far, and reasoning comes
-- back encrypted so it can be sent back unchanged.
requestBody :: OpenAI -> Conversation -> A.Value
requestBody cfg c =
  A.Object $
    [("model", A.String cfg.model)]
      <> maybe [] (\s -> [("instructions", A.String s)]) cfg.system
      <> [("tools", A.Array (map tool (tools c))) | not (null (tools c))]
      <> [ ("input", A.Array (task : concatMap exchange (history c)))
         , ( "text"
           , A.Object
               [ ( "format"
                 , A.Object
                     [ ("type", A.String "json_schema")
                     , ("name", A.String (schemaName (output c)))
                     , ("schema", objectSchema (output c))
                     , ("strict", A.Bool True)
                     ]
                 )
               ]
           )
         , ("store", A.Bool False)
         , ("include", A.Array [A.String "reasoning.encrypted_content"])
         ]
      <> maybe [] (\e -> [("reasoning", A.Object [("effort", A.String (effortName e))])]) cfg.effort
      <> maybe [] (\n -> [("max_output_tokens", A.Integer (toInteger n))]) cfg.maxTokens
  where
    task = message "user" (instructionText (instruction c) <> input)
    input = case state c of
      A.Null -> ""
      s -> "\n\nInput:\n" <> A.renderJson s
    tool spec =
      A.Object
        [ ("type", A.String "function")
        , ("name", A.String (specName spec))
        , ("description", A.String (specDescription spec))
        , ("parameters", objectSchema (specInput spec))
        , ("strict", A.Bool True)
        ]
    -- A turn's raw value is its list of output items, which go back as input.
    items (Raw raw) = case raw of
      A.Array xs -> xs
      other -> [other]
    exchange = \case
      Called raw results -> items raw <> map result results
      Rejected raw problem -> items raw <> [message "user" ("That answer was rejected: " <> problem <> ". Please answer again.")]
    result (callId', r) =
      A.Object
        [ ("type", A.String "function_call_output")
        , ("call_id", A.String callId')
        , ("output", A.String (case r of ToolOk v -> asText v; ToolFailed problem -> "Error: " <> problem))
        ]
    asText = \case
      A.String t -> t
      v -> A.renderJson v
    message :: Text -> Text -> A.Value
    message role content = A.Object [("role", A.String role), ("content", A.String content)]

-- | Read one turn from a Responses API response.
decodeTurn :: Conversation -> J.Value -> Either OpenAIError Turn
decodeTurn c = either (Left . UnexpectedResponse . T.pack) id . J.parseEither parse
  where
    parse = J.withObject "response" $ \r -> do
      status <- r .: "status"
      details <- r .:? "incomplete_details"
      reason <- maybe (pure Nothing) (J.withObject "incomplete_details" (.:? "reason")) details
      outputs <- r .: "output" :: J.Parser [J.Value]
      -- Message parts are flattened into the item list.
      parsed <- concatMap (\case Message parts -> parts; x -> [x]) <$> traverse item outputs
      let raw = Raw (fromAeson (J.toJSON outputs))
          calls = [ToolCall i n (unwrapInput n v) | Call i n v <- parsed]
          text = T.concat [t | Text t <- parsed]
          refusals = [t | Refusal t <- parsed]
      pure $ case (status :: Text, reason :: Maybe Text) of
        (_, _) | why : _ <- refusals -> Left (Refused why)
        ("incomplete", Just "max_output_tokens") -> Left Truncated
        ("incomplete", Just "content_filter") -> Left (Refused "content filter")
        ("incomplete", other) -> Left (Incomplete (maybe "unknown" id other))
        ("completed", _)
          | not (null calls) -> Right (Turn raw (CallTools calls))
          | otherwise -> Right (Turn raw (Respond (final text)))
        (other, _) -> Left (Incomplete other)
    item = J.withObject "item" $ \o -> do
      kind <- o .: "type"
      case kind :: Text of
        "function_call" -> do
          arguments <- o .: "arguments"
          Call <$> o .: "call_id" <*> o .: "name" <*> pure (json arguments)
        "message" -> do
          content <- o .: "content"
          Message <$> traverse part content
        _ -> pure Other
    part = J.withObject "part" $ \p -> do
      kind <- p .: "type"
      case kind :: Text of
        "output_text" -> Text <$> p .: "text"
        "refusal" -> Refusal <$> p .: "refusal"
        _ -> pure Other
    -- A reply that isn't JSON goes back to the core as text; the output
    -- contract then rejects it and the model gets another go.
    final text = case decodeJson text of
      Just v -> unwrap (output c) v
      Nothing -> A.String text
    json t = maybe (A.String t) id (decodeJson t)
    decodeJson t = fromAeson <$> J.decode (TL.encodeUtf8 (TL.fromStrict t))
    unwrapInput name v = maybe v (`unwrap` v) (inputSchema name)
    inputSchema :: Text -> Maybe Schema
    inputSchema name = case [specInput s | s <- tools c, specName s == name] of
      s : _ -> Just s
      [] -> Nothing

data Item = Call Text Text A.Value | Message [Item] | Text Text | Refusal Text | Other