packages feed

baikai-0.1.0.0: src/Baikai/Compat.hs

{-# LANGUAGE LambdaCase #-}

-- | Per-API compatibility records — feature flags that capture how a
-- specific host implements an OpenAI- or Anthropic-style endpoint.
-- The shape mirrors pi-mono's @OpenAICompletionsCompat@ and
-- @AnthropicMessagesCompat@ records.
--
-- The driving observation is that two hosts can share an API tag
-- (both DeepSeek and OpenAI implement the Chat Completions wire
-- protocol) yet differ in small details: where to put the max-output
-- token cap, whether @strict: true@ is accepted on tool definitions,
-- which JSON key carries reasoning effort, whether thinking is
-- delivered in-band as @\<thinking\>@ tags, etc. Carrying those flags
-- on a per-'Baikai.Model.Model' record means the same provider
-- handler can serve both hosts without any per-host code branching.
--
-- These record constructors are intentionally public in baikai 0.1.
-- They are the supported escape hatch for hand-rolled models and
-- generated catalog overrides when a host speaks a familiar protocol
-- but rejects or requires a small request-shaping difference. New
-- fields or enum constructors may be added in later minor versions as
-- new provider quirks are discovered; callers that want fewer upgrade
-- edits should start from the @default*@ values and use record updates
-- rather than constructing every field from scratch.
--
-- Auto-detection from a 'Baikai.Model.Model' @baseUrl@ provides
-- reasonable defaults so callers rarely need to spell out a full
-- compat record.
module Baikai.Compat
  ( -- * OpenAI Chat Completions compat
    OpenAICompletionsCompat (..),
    defaultOpenAICompletionsCompat,
    MaxTokensField (..),
    ThinkingFormat (..),
    CacheControlFormat (..),

    -- * Anthropic Messages compat
    AnthropicMessagesCompat (..),
    defaultAnthropicMessagesCompat,

    -- * Auto-detection from baseUrl
    autoDetectOpenAICompletions,
    autoDetectAnthropicMessages,
  )
where

import Data.Aeson (FromJSON, ToJSON)
import Data.Text (Text)
import Data.Text qualified as Text
import GHC.Generics (Generic)

-- | Where the OpenAI-compatible host expects the max-output-tokens
-- cap. OpenAI's o-series models require @max_completion_tokens@; many
-- compatible hosts (DeepSeek, some OpenRouter routes) keep the
-- pre-o-series @max_tokens@ name.
data MaxTokensField
  = MaxCompletionTokensField
  | MaxTokensField
  deriving stock (Eq, Show, Generic)
  deriving anyclass (FromJSON, ToJSON)

-- | How the OpenAI-compatible host receives reasoning-effort
-- preferences. Each constructor names a wire shape we know about; new
-- shapes land as new constructors.
data ThinkingFormat
  = -- | OpenAI-native: top-level @reasoning_effort: "minimal" | "low"
    --   | "medium" | "high"@.
    ThinkingFormatOpenAI
  | -- | OpenRouter: nested @reasoning: { effort: "..." }@.
    ThinkingFormatOpenRouter
  | -- | DeepSeek: top-level @thinking: { type: "enabled" }@ plus
    --   @reasoning_effort@.
    ThinkingFormatDeepseek
  | -- | Together AI: nested @reasoning: { enabled: true }@ plus
    --   @reasoning_effort@.
    ThinkingFormatTogether
  | -- | Z.ai / Qwen: top-level @enable_thinking: true@.
    ThinkingFormatZai
  | -- | Qwen chat-template: top-level @enable_thinking: true@.
    ThinkingFormatQwen
  | -- | Host does not expose reasoning controls; the option is
    --   silently dropped.
    ThinkingFormatNone
  deriving stock (Eq, Show, Generic)
  deriving anyclass (FromJSON, ToJSON)

-- | The wire shape an OpenAI-compatible host accepts for prompt-cache
-- markers. Only Anthropic's @cache_control@ markers are known; the
-- field is 'Nothing' on hosts that do not advertise prompt caching.
data CacheControlFormat = CacheControlFormatAnthropic
  deriving stock (Eq, Show, Generic)
  deriving anyclass (FromJSON, ToJSON)

-- | Feature flags for one OpenAI-compatible host.
--
-- Every flag has a default that reproduces OpenAI's own behaviour —
-- @defaultOpenAICompletionsCompat@. Hosts that diverge from OpenAI
-- override the differing fields only.
data OpenAICompletionsCompat = OpenAICompletionsCompat
  { -- | Where the max-output-tokens cap goes in the request body.
    maxTokensField :: !MaxTokensField,
    -- | Whether the host accepts the @developer@ message role
    --   (OpenAI's o-series) or only @system@.
    supportsDeveloperRole :: !Bool,
    -- | Whether the host accepts @strict: true@ on function tool
    --   definitions. Set 'False' to drop the field on hosts that
    --   reject it.
    supportsStrictMode :: !Bool,
    -- | Whether the host smuggles thinking into the assistant text as
    --   @\<thinking\>...\</thinking\>@ markers. When 'True', the
    --   provider's stream transformer extracts those markers into
    --   typed thinking deltas on the way out. (The transformer itself
    --   is wired in by EP-3; EP-5 only flips the switch.)
    requiresThinkingAsText :: !Bool,
    -- | The wire shape the host accepts for reasoning-effort
    --   preferences.
    thinkingFormat :: !ThinkingFormat,
    -- | Whether the host accepts Anthropic-style @cache_control@
    --   markers (some OpenRouter routes pass them through to an
    --   Anthropic backend).
    cacheControlFormat :: !(Maybe CacheControlFormat),
    -- | Whether the host emits a final @usage@ chunk in streaming
    --   responses. OpenAI does; older OpenAI-compatible servers may
    --   not. Currently advisory.
    supportsUsageInStreaming :: !Bool,
    -- | Whether the host honours long (1h) cache TTLs through the
    --   Anthropic-style cache_control marker. Currently only relevant
    --   when 'cacheControlFormat' is 'Just CacheControlFormatAnthropic'.
    supportsLongCacheRetention :: !Bool
  }
  deriving stock (Eq, Show, Generic)
  deriving anyclass (FromJSON, ToJSON)

-- | OpenAI's own host: every flag at the OpenAI default.
defaultOpenAICompletionsCompat :: OpenAICompletionsCompat
defaultOpenAICompletionsCompat =
  OpenAICompletionsCompat
    { maxTokensField = MaxCompletionTokensField,
      supportsDeveloperRole = True,
      supportsStrictMode = True,
      requiresThinkingAsText = False,
      thinkingFormat = ThinkingFormatOpenAI,
      cacheControlFormat = Nothing,
      supportsUsageInStreaming = True,
      supportsLongCacheRetention = True
    }

-- | Feature flags for one Anthropic Messages-compatible host.
data AnthropicMessagesCompat = AnthropicMessagesCompat
  { -- | Whether the host honours Anthropic's
    --   @cache_control.ttl: "1h"@ long-retention marker. When 'False',
    --   long-retention preferences silently downgrade to ephemeral.
    supportsLongCacheRetention :: !Bool,
    -- | Whether tool definitions accept @cache_control@ markers.
    --   Anthropic's own host does; some compatible hosts do not.
    supportsCacheControlOnTools :: !Bool,
    -- | Whether the host supports per-tool eager input streaming
    --   (Anthropic's @fine-grained-tool-streaming@ beta). Currently
    --   advisory; the SDK does not expose the field directly.
    supportsEagerToolInputStreaming :: !Bool,
    -- | Whether to add session-affinity headers on every request
    --   (Fireworks-style routing).
    sendSessionAffinityHeaders :: !Bool
  }
  deriving stock (Eq, Show, Generic)
  deriving anyclass (FromJSON, ToJSON)

-- | Anthropic's own host: every flag at its default.
defaultAnthropicMessagesCompat :: AnthropicMessagesCompat
defaultAnthropicMessagesCompat =
  AnthropicMessagesCompat
    { supportsLongCacheRetention = True,
      supportsCacheControlOnTools = True,
      supportsEagerToolInputStreaming = True,
      sendSessionAffinityHeaders = False
    }

-- | Pick a sensible compat record for an unknown OpenAI-compatible
-- host based on its @baseUrl@. Falls back to
-- 'defaultOpenAICompletionsCompat' for hosts the table does not
-- recognise.
autoDetectOpenAICompletions :: Text -> OpenAICompletionsCompat
autoDetectOpenAICompletions url
  | hasInfix "api.openai.com" = defaultOpenAICompletionsCompat
  | hasInfix "api.deepseek.com" =
      defaultOpenAICompletionsCompat
        { thinkingFormat = ThinkingFormatDeepseek,
          requiresThinkingAsText = True,
          maxTokensField = MaxTokensField,
          supportsStrictMode = False,
          supportsDeveloperRole = False
        }
  | hasInfix "openrouter.ai" =
      defaultOpenAICompletionsCompat
        { thinkingFormat = ThinkingFormatOpenRouter,
          supportsStrictMode = False,
          supportsDeveloperRole = False,
          cacheControlFormat = Just CacheControlFormatAnthropic
        }
  | hasInfix "together.xyz" || hasInfix "api.together.ai" =
      defaultOpenAICompletionsCompat
        { thinkingFormat = ThinkingFormatTogether,
          supportsStrictMode = False,
          supportsDeveloperRole = False
        }
  | hasInfix "z.ai" =
      defaultOpenAICompletionsCompat
        { thinkingFormat = ThinkingFormatZai,
          supportsStrictMode = False
        }
  | hasInfix "dashscope" || hasInfix "qwen" =
      defaultOpenAICompletionsCompat
        { thinkingFormat = ThinkingFormatQwen,
          supportsStrictMode = False
        }
  | otherwise = defaultOpenAICompletionsCompat
  where
    hasInfix :: Text -> Bool
    hasInfix needle = needle `Text.isInfixOf` url

-- | Pick a sensible compat record for an unknown
-- Anthropic-compatible host based on its @baseUrl@. Falls back to
-- 'defaultAnthropicMessagesCompat' for hosts the table does not
-- recognise.
autoDetectAnthropicMessages :: Text -> AnthropicMessagesCompat
autoDetectAnthropicMessages url
  | hasInfix "api.anthropic.com" = defaultAnthropicMessagesCompat
  | hasInfix "fireworks.ai" =
      defaultAnthropicMessagesCompat
        { supportsCacheControlOnTools = False,
          sendSessionAffinityHeaders = True,
          supportsLongCacheRetention = False
        }
  | otherwise = defaultAnthropicMessagesCompat
  where
    hasInfix :: Text -> Bool
    hasInfix needle = needle `Text.isInfixOf` url