packages feed

baikai-0.6.0.0: src/Baikai/Url.hs

-- | The one place baikai reads a host out of a URL.
--
-- baikai decides which API key to send and which per-host compatibility
-- record to apply by looking at the host name inside a model's
-- @baseUrl@. That decision routes a credential, so it has to be made the
-- same way everywhere: two parsers that disagree about what host a URL
-- names are two different answers to "where does this key go".
--
-- This module is deliberately __not__ a validating URI parser. It knows
-- just enough to name a host, key a cache, render an endpoint for an
-- evidence record, and say why a base URL is unusable. It has no
-- dependencies beyond @text@ and @base@, and every function is total.
--
-- The rule, in full:
--
-- * Leading and trailing whitespace is stripped.
--
-- * If the text before the first @\"://\"@ is a syntactically valid
--   scheme — a letter followed by letters, digits, @+@, @-@ or @.@ —
--   that is the scheme, lower-cased, and it is removed. Otherwise there
--   is no scheme and nothing is removed.
--
-- * The __authority__ is everything up to the first @\/@, @?@ or @#@.
--   This is what RFC 3986 means by the term, and bounding it at all
--   three characters is the point of this module: a URL such as
--   @https:\/\/proxy.example.com\/v1?u=\@api.openai.com@ names the host
--   @proxy.example.com@, and anything that reads the text after the last
--   @\@@ anywhere in the URL will send that proxy another host's key.
--
-- * Userinfo is everything up to the last @\@@ __inside the authority__,
--   and is dropped. Its presence is recorded; its text never is.
--
-- * What remains is the host and an optional port. A bracketed IPv6
--   literal keeps its brackets and its port follows the closing
--   bracket; otherwise the host is the text before the first @:@. A
--   non-numeric port is ignored and the host is still the text before
--   the colon. The host is lower-cased, because DNS names are
--   case-insensitive.
--
-- * The path is everything from the first @\/@ up to the first @?@ or
--   @#@, kept verbatim — case and trailing slash included.
--
-- * An empty host means there is no result at all.
module Baikai.Url
  ( -- * Parsing
    UrlParts (scheme, host, port, path, hasUserInfo, hasQuery, hasFragment),
    parseUrl,
    urlHost,
    hostMatchesSuffix,

    -- * Rendering
    renderEndpoint,
    stripApiVersion,

    -- * Fitness as a base URL
    baseUrlProblem,
  )
where

import Data.Char (isAlpha, isAlphaNum, isDigit)
import Data.Text (Text)
import Data.Text qualified as Text
import GHC.Generics (Generic)

-- | The pieces of a URL that baikai needs.
--
-- Credential-free by construction: userinfo, the query string and the
-- fragment are recorded as /present or absent/ and never as text, so a
-- value of this type cannot carry a secret into a log line. That is why
-- the constructor is not exported — 'parseUrl' is the only producer.
data UrlParts = UrlParts
  { -- | Lower-cased scheme without the @\"://\"@, when one was present.
    scheme :: !(Maybe Text),
    -- | Lower-cased host. An IPv6 literal keeps its brackets: @\"[::1]\"@.
    host :: !Text,
    -- | The port, when one was given as digits.
    port :: !(Maybe Int),
    -- | From the first @\/@ up to (not including) @?@ or @#@; @\"\"@ when
    -- there was no path. Kept verbatim.
    path :: !Text,
    -- | Whether a @user:password\@@ prefix was present and dropped.
    hasUserInfo :: !Bool,
    -- | Whether a @?query@ was present and dropped.
    hasQuery :: !Bool,
    -- | Whether a @#fragment@ was present and dropped.
    hasFragment :: !Bool
  }
  deriving stock (Eq, Show, Generic)

-- | Parse a URL far enough to name its host. 'Nothing' when no host can
-- be found, which includes the empty string and a bare scheme.
parseUrl :: Text -> Maybe UrlParts
parseUrl raw
  | Text.null hostText = Nothing
  | otherwise =
      Just
        UrlParts
          { scheme = parsedScheme,
            host = hostText,
            port = parsedPort,
            path = pathText,
            hasUserInfo = userInfoPresent,
            hasQuery = queryPresent,
            hasFragment = fragmentPresent
          }
  where
    trimmed = Text.strip raw

    -- The scheme is only a scheme when it looks like one. "note://x" has
    -- one; ":://x" does not, and neither does a bare "api.openai.com".
    (parsedScheme, afterScheme) = case Text.breakOn "://" trimmed of
      (candidate, rest)
        | not (Text.null rest),
          validScheme candidate ->
            (Just (Text.toLower candidate), Text.drop 3 rest)
      _ -> (Nothing, trimmed)
    validScheme s = case Text.uncons s of
      Just (c, cs) -> isAlpha c && Text.all schemeChar cs
      Nothing -> False
    schemeChar c = isAlphaNum c || c == '+' || c == '-' || c == '.'

    -- The authority ends at the first '/', '?' or '#'. Everything this
    -- module exists for depends on that boundary.
    (authority, afterAuthority) =
      Text.break (\c -> c == '/' || c == '?' || c == '#') afterScheme

    -- Userinfo is the last '@' inside the authority, never one later in
    -- the path or query.
    (userInfoPresent, hostAndPort) = case Text.breakOnEnd "@" authority of
      (before, after) | not (Text.null before) -> (True, after)
      _ -> (False, authority)

    (hostText, parsedPort) = splitHostPort hostAndPort

    (pathText, afterPath) =
      Text.break (\c -> c == '?' || c == '#') afterAuthority
    queryPresent = "?" `Text.isPrefixOf` afterPath
    fragmentPresent = "#" `Text.isInfixOf` afterPath

-- | Split @host:port@, keeping an IPv6 literal's brackets together.
splitHostPort :: Text -> (Text, Maybe Int)
splitHostPort raw
  | "[" `Text.isPrefixOf` raw =
      case Text.breakOn "]" raw of
        (literal, rest)
          | not (Text.null rest) ->
              (Text.toLower (literal <> "]"), portOf (Text.drop 1 rest))
        _ -> (Text.toLower raw, Nothing)
  | otherwise =
      let (h, rest) = Text.breakOn ":" raw
       in (Text.toLower h, portOf rest)
  where
    -- ":8080" is a port; ":" alone, ":abc" and "" are not, and in every
    -- one of those cases the host is still what came before the colon.
    portOf rest = case Text.stripPrefix ":" rest of
      Just digits
        | not (Text.null digits),
          Text.all isDigit digits ->
            Just (read (Text.unpack digits))
      _ -> Nothing

-- | The host a URL names, or 'Nothing' when it names none.
urlHost :: Text -> Maybe Text
urlHost = fmap host . parseUrl

-- | Match a hostname against a suffix at a label boundary, so that
-- @evil-api.openai.com.attacker.test@ does not match @api.openai.com@.
hostMatchesSuffix :: Text -> Text -> Bool
hostMatchesSuffix h suffix =
  let lowerHost = Text.toLower (Text.strip h)
      lowerSuffix = Text.toLower (Text.strip suffix)
   in not (Text.null lowerHost)
        && not (Text.null lowerSuffix)
        && (lowerHost == lowerSuffix || ("." <> lowerSuffix) `Text.isSuffixOf` lowerHost)

-- | Render the parts back as an endpoint: scheme, host, port and path,
-- and nothing else. Userinfo, the query and the fragment are gone
-- because 'UrlParts' never held them.
renderEndpoint :: UrlParts -> Text
renderEndpoint parts =
  maybe "" (<> "://") (scheme parts)
    <> host parts
    <> maybe "" (\p -> ":" <> Text.pack (show p)) (port parts)
    <> path parts

-- | Remove one trailing @\/v1@ segment from a path, along with any
-- trailing slashes.
--
-- Segment-wise, so @\/v10@ and @\/v1beta@ are left alone. The result is
-- either @\"\"@ or a path beginning with @\/@. This is what makes
-- @https:\/\/api.deepseek.com\/v1@ — the base URL every OpenAI SDK
-- teaches — compose to one @\/v1\/chat\/completions@ rather than two.
stripApiVersion :: Text -> Text
stripApiVersion raw
  | Text.null trimmed = ""
  | otherwise = case Text.stripSuffix "/v1" withLeadingSlash of
      Just kept -> kept
      Nothing -> withLeadingSlash
  where
    trimmed = Text.dropWhileEnd (== '/') raw
    withLeadingSlash
      | "/" `Text.isPrefixOf` trimmed = trimmed
      | otherwise = "/" <> trimmed

-- | Why this text cannot be used as a model's @baseUrl@, or 'Nothing'
-- when it can.
--
-- Every message names the offending URL with its userinfo and query
-- removed — rendered through 'renderEndpoint', never echoed raw — so an
-- error that reaches a log cannot carry a key someone put in a query
-- parameter.
baseUrlProblem :: Text -> Maybe Text
baseUrlProblem raw = case parseUrl raw of
  Nothing -> Just "no host could be found in it"
  Just parts
    | Nothing <- scheme parts ->
        Just (safe parts <> " has no scheme; start it with https:// or http://")
    | Just s <- scheme parts,
      s /= "http",
      s /= "https" ->
        Just (safe parts <> " uses the scheme " <> s <> "; only http and https are sent")
    | hasUserInfo parts ->
        Just
          ( safe parts
              <> " carries credentials before the host, which are never sent; \
                 \use Options.apiKey for the API key or Options.headers for a \
                 \gateway header"
          )
    | hasQuery parts ->
        Just
          ( safe parts
              <> " has a query string; baikai composes the request path itself \
                 \and does not support per-host query parameters such as \
                 \?api-version=. Remove it, or front the host with a gateway \
                 \that adds it"
          )
    | hasFragment parts ->
        Just (safe parts <> " has a fragment, which is not part of a request")
    | Just ending <- endpointSuffix (path parts) ->
        Just
          ( safe parts
              <> " already ends in the endpoint path "
              <> ending
              <> "; Model.baseUrl is the API root, and baikai appends the \
                 \endpoint path itself"
          )
    | otherwise -> Nothing
  where
    safe = renderEndpoint
    endpointSuffix p =
      case filter (`Text.isSuffixOf` Text.dropWhileEnd (== '/') p) endpointPaths of
        (found : _) -> Just found
        [] -> Nothing
    endpointPaths = ["/chat/completions", "/messages", "/embeddings"]