baikai-0.6.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.
--
-- The records export their field selectors but not their data
-- constructors. They are expected to grow as provider quirks are
-- discovered, so callers should start from the @default*@ values and
-- use record updates for the fields they need to override.
--
-- Auto-detection from a 'Baikai.Model.Model' @baseUrl@ provides
-- reasonable defaults so callers rarely need to spell out a full
-- compat record. The host it detects on comes from "Baikai.Url", the
-- only place in baikai that turns a URL into a host name; 'urlHost' and
-- 'hostMatchesSuffix' are re-exported from here so a caller reasoning
-- about auto-detection has them to hand.
module Baikai.Compat
( -- * OpenAI Chat Completions compat
OpenAICompletionsCompat
( maxTokensField,
supportsStrictMode,
requiresThinkingAsText,
thinkingFormat,
cacheControlFormat,
supportsUsageInStreaming,
supportsLongCacheRetention
),
defaultOpenAICompletionsCompat,
MaxTokensField (..),
ThinkingFormat (..),
CacheControlFormat (..),
-- * Anthropic Messages compat
AnthropicMessagesCompat
( supportsLongCacheRetention,
supportsCacheControlOnTools,
sendSessionAffinityHeaders,
thinkingStyle,
supportsSamplingParameters
),
AnthropicThinkingStyle (..),
defaultAnthropicMessagesCompat,
-- * Auto-detection from baseUrl
urlHost,
hostMatchesSuffix,
autoDetectOpenAICompletions,
autoDetectAnthropicMessages,
)
where
import Baikai.Url (hostMatchesSuffix, urlHost)
import Data.Aeson (FromJSON, ToJSON)
import Data.Text (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" | "xhigh" | "max"@.
--
-- This shape sends the canonical baikai level verbatim. Three of
-- the other six — OpenRouter, DeepSeek and Together — route
-- through @Baikai.Provider.OpenAI.Shape.compatibleEffort@, which
-- clamps @minimal@ to @low@ and both @xhigh@ and @max@ to @high@ —
-- a lowest-common-denominator vocabulary for hosts that do not
-- accept the full one. Z.ai and Qwen send a bare toggle with no
-- depth, and 'ThinkingFormatNone' drops the control. Excluding
-- this shape from the clamp is deliberate and is guarded by
-- @nativeHigherEffortTests@ in
-- @baikai-openai/test/ShapeSpec.hs@: clamping here would silently
-- weaken every high-effort request against a current OpenAI model.
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, so the option is
-- dropped from the request. Nothing about the wire says so — the
-- drop is recorded in the call's evidence as
-- @thinking_dropped_unsupported_host@ rather than left invisible.
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)
-- | Which request shape an Anthropic-compatible host/model accepts
-- for extended thinking. Budget-era models take
-- @{"type":"enabled","budget_tokens":N}@; adaptive-era models take
-- @{"type":"adaptive"}@ with depth guided by @output_config.effort@.
data AnthropicThinkingStyle
= AnthropicThinkingBudget
| AnthropicThinkingAdaptive
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.
-- Consumed by @Baikai.Provider.OpenAI.Shape.renameMaxTokens@,
-- which rewrites @max_completion_tokens@ to @max_tokens@
-- for hosts that require the legacy key.
maxTokensField :: !MaxTokensField,
-- | Whether the host accepts @strict: true@ on function tool
-- definitions. Consumed by
-- @Baikai.Provider.OpenAI.Internal.Request.mkOpenAIResponseFormat@ and
-- @Baikai.Provider.OpenAI.Shape.dropUnsupportedStrict@ to
-- omit JSON-schema @strict@ on hosts that reject it.
supportsStrictMode :: !Bool,
-- | Whether the host smuggles thinking into the assistant text as
-- @\<think\>...\</think\>@ or
-- @\<thinking\>...\</thinking\>@ markers. Field-based reasoning
-- extraction (for @reasoning_content@ / @reasoning@ deltas) is
-- unconditional; this flag enables the incremental tag scanner
-- in @Baikai.Provider.OpenAI.Internal.Stream.scanThinkTags@ for
-- hosts that do not split reasoning into a separate field.
requiresThinkingAsText :: !Bool,
-- | The wire shape the host accepts for reasoning-effort
-- preferences. Consumed by
-- @Baikai.Provider.OpenAI.Internal.Request.applyThinkingFormat@ for the
-- OpenAI-native field and by
-- @Baikai.Provider.OpenAI.Shape.injectThinkingShape@ for
-- OpenAI-compatible host-specific JSON keys.
thinkingFormat :: !ThinkingFormat,
-- | Whether the host accepts Anthropic-style @cache_control@
-- markers (some OpenRouter routes pass them through to an
-- Anthropic backend). Consumed by
-- @Baikai.Provider.OpenAI.Shape.injectCacheControl@.
cacheControlFormat :: !(Maybe CacheControlFormat),
-- | Whether the host emits a final @usage@ chunk in streaming
-- responses. Consumed by
-- @Baikai.Provider.OpenAI.Shape.streamRequestBody@ to include
-- or omit @stream_options.include_usage@.
supportsUsageInStreaming :: !Bool,
-- | Whether the host honours long (1h) cache TTLs through the
-- Anthropic-style cache_control marker. Consumed by
-- @Baikai.Provider.OpenAI.Shape.injectCacheControl@ 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,
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.
-- Consumed by @Baikai.Provider.Claude.Internal.Request.computeCacheControl@
-- for top-level cache markers and by
-- @Baikai.Provider.Claude.Shape.injectToolCacheControl@ for
-- tool cache markers.
supportsLongCacheRetention :: !Bool,
-- | Whether tool definitions accept @cache_control@ markers.
-- Consumed by
-- @Baikai.Provider.Claude.Shape.injectToolCacheControl@.
-- Anthropic's own host does; some compatible hosts do not.
supportsCacheControlOnTools :: !Bool,
-- | Whether to add session-affinity headers on every request
-- (Fireworks-style routing). Consumed by
-- @Baikai.Provider.Claude.Transport.requestHeaders@.
sendSessionAffinityHeaders :: !Bool,
-- | Which extended-thinking request shape to send for the
-- selected model generation. Which shape a generation accepts
-- is a fact of the generated catalog record
-- ("Baikai.Models.Generated"), not something to be guessed from
-- the model id. Consumed by
-- @Baikai.Provider.Claude.Internal.Request.computeThinking@.
thinkingStyle :: !AnthropicThinkingStyle,
-- | Whether the model generation accepts the sampling parameters
-- @temperature@, @top_p@ and @top_k@. Adaptive-era generations
-- from Opus 4.7 and Sonnet 5 onward reject them with a 400, so
-- the Anthropic adapter drops them and records
-- 'Baikai.Evidence.SamplingDroppedUnsupportedModel'. Which
-- generations accept them is a fact of the generated catalog
-- record, not of this type. Consumed by
-- @Baikai.Provider.Claude.Internal.Request.planRequest@.
supportsSamplingParameters :: !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,
sendSessionAffinityHeaders = False,
thinkingStyle = AnthropicThinkingBudget,
supportsSamplingParameters = True
}
-- | 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
| matches "api.openai.com" = defaultOpenAICompletionsCompat
| matches "api.deepseek.com" =
defaultOpenAICompletionsCompat
{ thinkingFormat = ThinkingFormatDeepseek,
requiresThinkingAsText = True,
maxTokensField = MaxTokensField,
supportsStrictMode = False
}
| matches "openrouter.ai" =
defaultOpenAICompletionsCompat
{ thinkingFormat = ThinkingFormatOpenRouter,
supportsStrictMode = False,
cacheControlFormat = Just CacheControlFormatAnthropic
}
| matches "together.xyz" || matches "together.ai" =
defaultOpenAICompletionsCompat
{ thinkingFormat = ThinkingFormatTogether,
supportsStrictMode = False
}
| matches "z.ai" =
defaultOpenAICompletionsCompat
{ thinkingFormat = ThinkingFormatZai,
supportsStrictMode = False
}
| matches "dashscope.aliyuncs.com"
|| matches "dashscope-intl.aliyuncs.com"
|| matches "qwen.ai" =
defaultOpenAICompletionsCompat
{ thinkingFormat = ThinkingFormatQwen,
supportsStrictMode = False
}
| otherwise = defaultOpenAICompletionsCompat
where
host = urlHost url
matches suffix = maybe False (`hostMatchesSuffix` suffix) host
-- | 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
| matches "api.anthropic.com" = defaultAnthropicMessagesCompat
| matches "fireworks.ai" =
defaultAnthropicMessagesCompat
{ supportsCacheControlOnTools = False,
sendSessionAffinityHeaders = True,
supportsLongCacheRetention = False
}
| otherwise = defaultAnthropicMessagesCompat
where
host = urlHost url
matches suffix = maybe False (`hostMatchesSuffix` suffix) host