baikai 0.1.0.0 → 0.1.1.0
raw patch · 8 files changed
+240/−5 lines, 8 filesdep +openaiPVP: major bump suggested
API removals or changes: PVP suggests a major version bump
Dependencies added: openai
API changes (from Hackage documentation)
+ Baikai.Embedding: EmbeddingModel :: !Text -> !Text -> !Maybe Natural -> !ApiKeySource -> EmbeddingModel
+ Baikai.Embedding: [apiKey] :: EmbeddingModel -> !ApiKeySource
+ Baikai.Embedding: [baseUrl] :: EmbeddingModel -> !Text
+ Baikai.Embedding: [dimensions] :: EmbeddingModel -> !Maybe Natural
+ Baikai.Embedding: [modelId] :: EmbeddingModel -> !Text
+ Baikai.Embedding: _EmbeddingModel :: EmbeddingModel
+ Baikai.Embedding: data EmbeddingModel
+ Baikai.Embedding: embed :: EmbeddingModel -> [Text] -> IO (Vector (Vector Double))
+ Baikai.Embedding: embedOne :: EmbeddingModel -> Text -> IO (Vector Double)
+ Baikai.Embedding: instance GHC.Internal.Show.Show Baikai.Embedding.EmbeddingModel
+ Baikai.Embedding: mkEmbeddingRequest :: EmbeddingModel -> Text -> CreateEmbeddings
+ Baikai.Embedding: openAIEmbeddingModel :: Text -> EmbeddingModel
+ Baikai.Options: [responseFormat] :: Options -> !Maybe ResponseFormat
+ Baikai.ResponseFormat: JsonObject :: ResponseFormat
+ Baikai.ResponseFormat: JsonSchema :: !Text -> !Value -> !Bool -> ResponseFormat
+ Baikai.ResponseFormat: [name] :: ResponseFormat -> !Text
+ Baikai.ResponseFormat: [schema] :: ResponseFormat -> !Value
+ Baikai.ResponseFormat: [strict] :: ResponseFormat -> !Bool
+ Baikai.ResponseFormat: data ResponseFormat
+ Baikai.ResponseFormat: instance Data.Aeson.Types.FromJSON.FromJSON Baikai.ResponseFormat.ResponseFormat
+ Baikai.ResponseFormat: instance Data.Aeson.Types.ToJSON.ToJSON Baikai.ResponseFormat.ResponseFormat
+ Baikai.ResponseFormat: instance GHC.Classes.Eq Baikai.ResponseFormat.ResponseFormat
+ Baikai.ResponseFormat: instance GHC.Internal.Generics.Generic Baikai.ResponseFormat.ResponseFormat
+ Baikai.ResponseFormat: instance GHC.Internal.Show.Show Baikai.ResponseFormat.ResponseFormat
- Baikai.Options: Options :: !Maybe Natural -> !Maybe Double -> !Maybe ApiKeySource -> !Maybe Int -> !Map Text Text -> !Map Text Value -> !Maybe ToolChoice -> !Maybe CacheRetention -> !Maybe ThinkingLevel -> Options
+ Baikai.Options: Options :: !Maybe Natural -> !Maybe Double -> !Maybe ApiKeySource -> !Maybe Int -> !Map Text Text -> !Map Text Value -> !Maybe ToolChoice -> !Maybe CacheRetention -> !Maybe ThinkingLevel -> !Maybe ResponseFormat -> Options
Files
- CHANGELOG.md +33/−0
- baikai.cabal +6/−1
- src/Baikai.hs +2/−0
- src/Baikai/Embedding.hs +105/−0
- src/Baikai/Options.hs +8/−3
- src/Baikai/ResponseFormat.hs +38/−0
- test/EmbeddingSpec.hs +34/−0
- test/Main.hs +14/−1
CHANGELOG.md view
@@ -7,6 +7,39 @@ ## [Unreleased] +## [baikai 0.1.1.0] - 2026-06-12++### Added++- Added provider-agnostic `ResponseFormat` support on `Options`, including+ plain JSON-object mode and named JSON-schema mode.+- Added `Baikai.Embedding`, an OpenAI `/v1/embeddings` client for text+ embeddings.++## [baikai-claude 0.1.1.0] - 2026-06-12++### Added++- Mapped baikai `ResponseFormat` options onto Anthropic `output_config` for+ Claude API requests.+- Exported `mapRequest` for request-mapping tests and downstream inspection.++## [baikai-openai 0.1.1.0] - 2026-06-12++### Added++- Mapped baikai `ResponseFormat` options onto OpenAI Chat Completions+ `response_format`.+- Exported `mapRequest` for request-mapping tests and downstream inspection.++## [baikai-effectful 0.1.0.0] - 2026-06-12++### Added++- Initial release: effectful binding for baikai with the `Baikai` dynamic+ effect, `complete`, `streamCollect`, `streamEach`, and registry-backed+ interpreters.+ ## [baikai 0.1.0.0] - 2026-06-04 ### Added
baikai.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.4 name: baikai-version: 0.1.0.0+version: 0.1.1.0 synopsis: Unified Haskell interface for multiple AI providers description: baikai provides a unified, provider-agnostic Haskell interface for working@@ -47,6 +47,7 @@ Baikai.Cost Baikai.Cost.Log Baikai.Cost.Pricing+ Baikai.Embedding Baikai.Error Baikai.Interactive Baikai.Message@@ -58,6 +59,7 @@ Baikai.Provider.Cli.Internal Baikai.Provider.Registry Baikai.Response+ Baikai.ResponseFormat Baikai.StopReason Baikai.Stream Baikai.Stream.Event@@ -76,6 +78,7 @@ , containers , generic-lens , lens ^>=5.3+ , openai , scientific , streamly >=0.11 && <0.13 , streamly-core >=0.3 && <0.5@@ -108,6 +111,7 @@ AgentAssetsSpec CatalogSpec CostSpec+ EmbeddingSpec InteractiveSpec TraceSpec @@ -119,6 +123,7 @@ , bytestring , directory , filepath+ , openai , process , stm , streamly-core >=0.3 && <0.5
src/Baikai.hs view
@@ -23,6 +23,7 @@ -- * Per-API compat shims and call-time options module Baikai.Compat, module Baikai.CacheRetention,+ module Baikai.ResponseFormat, module Baikai.ThinkingLevel, -- * Provider registry@@ -49,6 +50,7 @@ import Baikai.Options import Baikai.Provider import Baikai.Response+import Baikai.ResponseFormat import Baikai.StopReason import Baikai.Stream import Baikai.Stream.Event
+ src/Baikai/Embedding.hs view
@@ -0,0 +1,105 @@+-- | A small, provider-neutral embeddings client over an OpenAI-compatible+-- @\/v1\/embeddings@ endpoint (EP-15).+--+-- baikai shipped no embeddings client; this is the first. It reuses the same+-- @openai@ SDK path the OpenAI /chat/ provider already uses+-- ('OpenAI.V1.getClientEnv' + 'OpenAI.V1.makeMethods') and the sibling+-- 'OpenAI.V1.createEmbeddings' method, plus baikai's own 'Baikai.Auth' for key+-- resolution. It is policy-free (a plain @IO@ client, no effect binding) — the+-- effect interpreter lives one layer up in shikumi, exactly as @baikai-effectful@+-- relates to the transport.+--+-- An embedding model is named by a bare provider model-id string (e.g.+-- @\"text-embedding-3-small\"@) plus a base URL inside 'EmbeddingModel'; there is+-- no @Api@ tag and no chat-catalog entry, because none of the chat 'Baikai.Model'+-- fields (context window, output tokens, chat pricing, modalities) are meaningful+-- for embeddings.+module Baikai.Embedding+ ( EmbeddingModel (..),+ _EmbeddingModel,+ openAIEmbeddingModel,+ mkEmbeddingRequest,+ embed,+ embedOne,+ )+where++import Baikai.Auth (ApiKeySource (..), resolveApiKey)+import Data.Text (Text)+import Data.Vector (Vector)+import Data.Vector qualified as V+import Numeric.Natural (Natural)+import OpenAI.V1 qualified as OpenAI+import OpenAI.V1.Embeddings qualified as Emb+import OpenAI.V1.Models qualified as OpenAIModels++-- | How to reach an embeddings endpoint and which model to ask for.+data EmbeddingModel = EmbeddingModel+ { -- | e.g. @\"text-embedding-3-small\"@+ modelId :: !Text,+ -- | e.g. @\"https:\/\/api.openai.com\"@ (empty = the OpenAI default)+ baseUrl :: !Text,+ -- | request a reduced dimensionality, or 'Nothing' for the model default+ dimensions :: !(Maybe Natural),+ -- | how to resolve the API key (from "Baikai.Auth")+ apiKey :: !ApiKeySource+ }+ deriving stock (Show)++-- | A blank embedding model; a record-update target for hand-built models. Keyed+-- on @OPENAI_API_KEY@ by default.+_EmbeddingModel :: EmbeddingModel+_EmbeddingModel =+ EmbeddingModel+ { modelId = "",+ baseUrl = "",+ dimensions = Nothing,+ apiKey = ApiKeyEnv "OPENAI_API_KEY"+ }++-- | The OpenAI default: @api.openai.com@, key from @OPENAI_API_KEY@, model-default+-- dimensionality.+openAIEmbeddingModel :: Text -> EmbeddingModel+openAIEmbeddingModel mid =+ _EmbeddingModel+ { modelId = mid,+ baseUrl = "https://api.openai.com",+ dimensions = Nothing,+ apiKey = ApiKeyEnv "OPENAI_API_KEY"+ }++-- | Build the OpenAI @\/v1\/embeddings@ request for a single input text. Pure and+-- exported so it can be unit-tested without a network. (@embed@ calls it.)+mkEmbeddingRequest :: EmbeddingModel -> Text -> Emb.CreateEmbeddings+mkEmbeddingRequest m t =+ Emb._CreateEmbeddings+ { Emb.input = t,+ Emb.model = OpenAIModels.Model (modelId m),+ Emb.dimensions = dimensions m+ }++-- | Embed a batch of texts: one vector per input text, in input order. The SDK's+-- @CreateEmbeddings.input@ is a single 'Text', so this loops one call per text. The+-- transport exception (a Servant client error) is let propagate — error remapping+-- is the consumer's job (in shikumi the interpreter wraps it as a typed error).+embed :: EmbeddingModel -> [Text] -> IO (Vector (Vector Double))+embed _ [] = pure V.empty+embed m texts = do+ key <- resolveApiKey (apiKey m)+ env <- OpenAI.getClientEnv (urlOf m)+ let create = OpenAI.createEmbeddings (OpenAI.makeMethods env key Nothing Nothing)+ V.fromList <$> traverse (embedText create) texts+ where+ embedText create t = do+ objs <- create (mkEmbeddingRequest m t)+ pure (Emb.embedding (V.head objs))++-- | Embed a single text.+embedOne :: EmbeddingModel -> Text -> IO (Vector Double)+embedOne m t = V.head <$> embed m [t]++-- | Substitute the OpenAI default for an empty base URL (as the chat provider does).+urlOf :: EmbeddingModel -> Text+urlOf m = case baseUrl m of+ "" -> "https://api.openai.com"+ u -> u
src/Baikai/Options.hs view
@@ -10,7 +10,9 @@ -- EP-4 added @toolChoice@. EP-5 adds @cacheRetention@ and @thinking@ -- (provider-agnostic preferences that each provider maps to its own -- primitive — see 'Baikai.CacheRetention' and 'Baikai.ThinkingLevel'--- for the mappings).+-- for the mappings). EP-2 (shikumi) adds @responseFormat@, the+-- provider-agnostic structured-output preference — see+-- 'Baikai.ResponseFormat'. module Baikai.Options ( Options (..), _Options,@@ -19,6 +21,7 @@ import Baikai.Auth (ApiKeySource) import Baikai.CacheRetention (CacheRetention)+import Baikai.ResponseFormat (ResponseFormat) import Baikai.ThinkingLevel (ThinkingLevel) import Baikai.Tool (ToolChoice) import Data.Aeson (ToJSON, Value)@@ -37,7 +40,8 @@ metadata :: !(Map Text Value), toolChoice :: !(Maybe ToolChoice), cacheRetention :: !(Maybe CacheRetention),- thinking :: !(Maybe ThinkingLevel)+ thinking :: !(Maybe ThinkingLevel),+ responseFormat :: !(Maybe ResponseFormat) } deriving stock (Eq, Show, Generic) deriving anyclass (ToJSON)@@ -53,5 +57,6 @@ metadata = Map.empty, toolChoice = Nothing, cacheRetention = Nothing,- thinking = Nothing+ thinking = Nothing,+ responseFormat = Nothing }
+ src/Baikai/ResponseFormat.hs view
@@ -0,0 +1,38 @@+{-# OPTIONS_GHC -Wno-partial-fields #-}++-- | Provider-agnostic structured-output preference.+--+-- Each provider maps the value to its own native mechanism:+-- OpenAI's @response_format@ and Anthropic's @output_config@.+-- 'Nothing' on 'Baikai.Options.responseFormat' means no+-- structured-output constraint (today's behaviour).+module Baikai.ResponseFormat+ ( ResponseFormat (..),+ )+where++import Data.Aeson (FromJSON, ToJSON, Value)+import Data.Text (Text)+import GHC.Generics (Generic)++-- | How to constrain the model's output.+data ResponseFormat+ = -- | Enforce a named JSON Schema. The 'schema' is a raw JSON+ -- Schema document (an aeson 'Value'), passed through verbatim;+ -- baikai never inspects or validates it. 'strict' requests the+ -- provider's strict schema-enforcement mode where available+ -- (OpenAI honours it; Anthropic structured outputs are always+ -- schema-enforcing and ignore it).+ JsonSchema+ { name :: !Text,+ schema :: !Value,+ strict :: !Bool+ }+ | -- | Plain-JSON mode: the model must emit syntactically valid JSON+ -- but is not constrained to a specific shape. Maps to OpenAI's+ -- @{"type":"json_object"}@; on Anthropic (whose structured+ -- outputs require a schema) it maps to a permissive+ -- @{"type":"object"}@ schema.+ JsonObject+ deriving stock (Eq, Show, Generic)+ deriving anyclass (FromJSON, ToJSON)
+ test/EmbeddingSpec.hs view
@@ -0,0 +1,34 @@+-- | Tests for the embeddings client (EP-15, M1).+--+-- The request-mapping test is hermetic: it asserts on the pure+-- 'mkEmbeddingRequest' (no network), proving the input text, model id, and+-- dimensions land where the OpenAI @\/v1\/embeddings@ wire expects them. The live+-- test is gated on @BAIKAI_EMBEDDING_LIVE=1@ (and a real @OPENAI_API_KEY@) so the+-- default run stays offline.+module EmbeddingSpec (tests) where++import Baikai.Embedding (embedOne, mkEmbeddingRequest, openAIEmbeddingModel)+import Data.Vector qualified as V+import OpenAI.V1.Embeddings qualified as Emb+import OpenAI.V1.Models qualified as OpenAIModels+import System.Environment (lookupEnv)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=))++tests :: TestTree+tests =+ testGroup+ "Baikai.Embedding"+ [ testCase "mkEmbeddingRequest maps input, model id, and dimensions" $ do+ let req = mkEmbeddingRequest (openAIEmbeddingModel "text-embedding-3-small") "hello"+ Emb.input req @?= "hello"+ OpenAIModels.text (Emb.model req) @?= "text-embedding-3-small"+ Emb.dimensions req @?= Nothing,+ testCase "live embedding returns a 1536-length vector" $ do+ live <- lookupEnv "BAIKAI_EMBEDDING_LIVE"+ case live of+ Just "1" -> do+ v <- embedOne (openAIEmbeddingModel "text-embedding-3-small") "hello"+ V.length v @?= 1536+ _ -> putStrLn "BAIKAI_EMBEDDING_LIVE not set; skipping live test"+ ]
test/Main.hs view
@@ -10,6 +10,7 @@ import Data.ByteString.Lazy.Char8 qualified as LBS8 import Data.Text qualified as Text import Data.Vector qualified as V+import EmbeddingSpec qualified import InteractiveSpec qualified import Streamly.Data.Stream qualified as Stream import Test.Tasty (TestTree, defaultMain, testGroup)@@ -76,6 +77,7 @@ AgentAssetsSpec.tests, CatalogSpec.tests, CostSpec.tests,+ EmbeddingSpec.tests, InteractiveSpec.tests, TraceSpec.tests ]@@ -90,7 +92,18 @@ testCase "_Options defaults are zero-y" $ do _Options ^. #maxTokens @?= Nothing _Options ^. #temperature @?= Nothing- _Options ^. #apiKey @?= Nothing,+ _Options ^. #apiKey @?= Nothing+ _Options ^. #responseFormat @?= Nothing,+ testCase "responseFormat round-trips through Options" $ do+ responseFormat (_Options & #responseFormat .~ Just JsonObject)+ @?= Just JsonObject+ let person =+ Aeson.object+ [ "type" Aeson..= ("object" :: Text)+ ]+ schemaFmt = JsonSchema {name = "person", schema = person, strict = True}+ responseFormat (_Options & #responseFormat .~ Just schemaFmt)+ @?= Just schemaFmt, testCase "Options Show redacts literal API keys" $ do let secret = "sk-baikai-secret-never-print" opts = _Options & #apiKey .~ Just (ApiKeyLiteral secret)