packages feed

baikai 0.5.0.0 → 0.6.0.0

raw patch · 67 files changed

+7475/−1023 lines, 67 filesdep +case-insensitivedep +http-typesdep +servant-clientdep ~http-clientdep ~stmPVP ok

version bump matches the API change (PVP)

Dependencies added: case-insensitive, http-types, servant-client, tls

Dependency ranges changed: http-client, stm

API changes (from Hackage documentation)

- Baikai.Agent: OutputMalformed :: !Text -> AgentRunFailure
- Baikai.Compat: defaultAnthropicThinkingStyle :: Text -> AnthropicThinkingStyle
- Baikai.Content: _ImageContent :: ImageContent
- Baikai.Content: _TextContent :: TextContent
- Baikai.Content: _ThinkingContent :: ThinkingContent
- Baikai.Content: _ToolCall :: ToolCall
- Baikai.Context: _Context :: Context
- Baikai.Cost: _Cost :: Cost
- Baikai.Cost: _CostBreakdown :: CostBreakdown
- Baikai.Cost.Log: CallLogConfig :: !FilePath -> !Bool -> CallLogConfig
- Baikai.Cost.Log: [enabled] :: CallLogConfig -> !Bool
- Baikai.Cost.Log: [path] :: CallLogConfig -> !FilePath
- 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.Evidence: EvidenceRequest :: !Text -> !EvidenceStrictness -> !Natural -> !Maybe Text -> EvidenceRequest
- Baikai.Evidence: ModelCallEvidence :: !Text -> !Text -> !Text -> !Natural -> !Maybe Text -> !EndpointIdentity -> !Text -> !ThinkingTranslation -> !Observed Text -> !Observed Text -> !Observed Text -> !Observed Text -> !Maybe Text -> !UTCTime -> !UTCTime -> !Int -> !CallStatus -> !Maybe BaikaiError -> !Observed Usage -> !EvidenceStrength -> !Text -> !Text -> !Observed Text -> ModelCallEvidence
- Baikai.Evidence: [attempt] :: EvidenceRequest -> !Natural
- Baikai.Evidence: [callId] :: ModelCallEvidence -> !Text
- Baikai.Evidence: [clientRequestId] :: ModelCallEvidence -> !Maybe Text
- Baikai.Evidence: [endedAt] :: ModelCallEvidence -> !UTCTime
- Baikai.Evidence: [errorInfo] :: ModelCallEvidence -> !Maybe BaikaiError
- Baikai.Evidence: [latencyMs] :: ModelCallEvidence -> !Int
- Baikai.Evidence: [observedModel] :: ModelCallEvidence -> !Observed Text
- Baikai.Evidence: [observedThinking] :: ModelCallEvidence -> !Observed Text
- Baikai.Evidence: [providerRequestId] :: ModelCallEvidence -> !Observed Text
- Baikai.Evidence: [requestCommitment] :: ModelCallEvidence -> !Text
- Baikai.Evidence: [requestConfiguration] :: ModelCallEvidence -> !Text
- Baikai.Evidence: [requestedModel] :: ModelCallEvidence -> !Text
- Baikai.Evidence: [responseCommitment] :: ModelCallEvidence -> !Observed Text
- Baikai.Evidence: [responseId] :: ModelCallEvidence -> !Observed Text
- Baikai.Evidence: [runId] :: EvidenceRequest -> !Text
- Baikai.Evidence: [schemaVersion] :: ModelCallEvidence -> !Text
- Baikai.Evidence: [startedAt] :: ModelCallEvidence -> !UTCTime
- Baikai.Evidence: [status] :: ModelCallEvidence -> !CallStatus
- Baikai.Evidence: [strength] :: ModelCallEvidence -> !EvidenceStrength
- Baikai.Evidence: [strictness] :: EvidenceRequest -> !EvidenceStrictness
- Baikai.Evidence: [supersedes] :: EvidenceRequest -> !Maybe Text
- Baikai.Evidence: [thinking] :: ModelCallEvidence -> !ThinkingTranslation
- Baikai.Evidence: [usage] :: ModelCallEvidence -> !Observed Usage
- Baikai.Interactive: _InteractiveLaunchRequest :: Text -> InteractiveLaunchRequest
- Baikai.Interactive: _InteractiveLaunchResult :: InteractiveProvider -> ExitCode -> InteractiveLaunchResult
- Baikai.Model: _Model :: Model
- Baikai.Model: _ModelCost :: ModelCost
- Baikai.Options: _Options :: Options
- Baikai.Provider: ApiProvider :: !Api -> !Model -> Context -> Options -> Stream IO AssistantMessageEvent -> !Model -> Context -> Options -> IO Response -> !Model -> Options -> ThinkingTranslation -> ApiProvider
- Baikai.Provider: [apiTag] :: ApiProvider -> !Api
- Baikai.Provider: [complete] :: ApiProvider -> !Model -> Context -> Options -> IO Response
- Baikai.Provider: [describeThinking] :: ApiProvider -> !Model -> Options -> ThinkingTranslation
- Baikai.Provider: [stream] :: ApiProvider -> !Model -> Context -> Options -> Stream IO AssistantMessageEvent
- Baikai.Provider.Registry: ApiProvider :: !Api -> !Model -> Context -> Options -> Stream IO AssistantMessageEvent -> !Model -> Context -> Options -> IO Response -> !Model -> Options -> ThinkingTranslation -> ApiProvider
- Baikai.Provider.Registry: [apiTag] :: ApiProvider -> !Api
- Baikai.Provider.Registry: [complete] :: ApiProvider -> !Model -> Context -> Options -> IO Response
- Baikai.Provider.Registry: [describeThinking] :: ApiProvider -> !Model -> Options -> ThinkingTranslation
- Baikai.Provider.Registry: [stream] :: ApiProvider -> !Model -> Context -> Options -> Stream IO AssistantMessageEvent
- Baikai.Response: _Response :: Response
- Baikai.ResponseFormat: [name] :: ResponseFormat -> !Text
- Baikai.ResponseFormat: [schema] :: ResponseFormat -> !Value
- Baikai.ResponseFormat: [strict] :: ResponseFormat -> !Bool
- Baikai.StopReason: Aborted :: StopReason
- Baikai.Tool: Tool :: !Text -> !Text -> !Value -> Tool
- Baikai.Tool: [description] :: Tool -> !Text
- Baikai.Tool: [name] :: Tool -> !Text
- Baikai.Tool: [parameters] :: Tool -> !Value
- Baikai.Tool: _Tool :: Tool
- Baikai.Trace: newEventId :: IO Text
- Baikai.Usage: _Usage :: Usage
+ Baikai.Agent: AgentTimedOut :: !NominalDiffTime -> !AgentCapturedOutput -> !AgentCapturedOutput -> AgentTimedOut
+ Baikai.Agent: JsonFormat :: AgentOutputFormat
+ Baikai.Agent: OutputLimitExceeded :: !Maybe Int -> !Int -> CeilingViolation
+ Baikai.Agent: RepositoryScopeForbidden :: !Text -> CeilingViolation
+ Baikai.Agent: TextFormat :: AgentOutputFormat
+ Baikai.Agent: TimeoutExceeded :: !Maybe NominalDiffTime -> !NominalDiffTime -> CeilingViolation
+ Baikai.Agent: ToolGrantForbidden :: ![Text] -> !AgentCapability -> CeilingViolation
+ Baikai.Agent: WorkingDirOutsideRepository :: !FilePath -> !FilePath -> CeilingViolation
+ Baikai.Agent: [limit] :: AgentTimedOut -> !NominalDiffTime
+ Baikai.Agent: [stderr] :: AgentTimedOut -> !AgentCapturedOutput
+ Baikai.Agent: [stdout] :: AgentTimedOut -> !AgentCapturedOutput
+ Baikai.Agent: ceilingViolations :: AgentCeiling -> AgentRunRequest -> [CeilingViolation]
+ Baikai.Agent: data AgentOutputFormat
+ Baikai.Agent: data AgentTimedOut
+ Baikai.Agent: defaultMaxOutputLimit :: Int
+ Baikai.Agent: instance GHC.Classes.Eq Baikai.Agent.AgentOutputFormat
+ Baikai.Agent: instance GHC.Classes.Eq Baikai.Agent.AgentTimedOut
+ Baikai.Agent: instance GHC.Classes.Ord Baikai.Agent.AgentOutputFormat
+ Baikai.Agent: instance GHC.Internal.Generics.Generic Baikai.Agent.AgentOutputFormat
+ Baikai.Agent: instance GHC.Internal.Generics.Generic Baikai.Agent.AgentTimedOut
+ Baikai.Agent: instance GHC.Internal.Show.Show Baikai.Agent.AgentOutputFormat
+ Baikai.Agent: instance GHC.Internal.Show.Show Baikai.Agent.AgentTimedOut
+ Baikai.Agent: parseAgentOutputFormat :: Text -> Maybe AgentOutputFormat
+ Baikai.Agent: renderAgentOutputFormat :: AgentOutputFormat -> Text
+ Baikai.Agent: toolGrantsImpliedBy :: AgentCapability -> Maybe [Text]
+ Baikai.Api: normaliseApi :: Api -> Api
+ Baikai.Auth: isCredentialHeader :: Text -> Bool
+ Baikai.Auth: redactHeaderValues :: Map HeaderName Text -> Map HeaderName Text
+ Baikai.Auth: redactedMarker :: Text
+ Baikai.Content: isCutOffToolCall :: ToolCall -> Bool
+ Baikai.Content: toolArgumentsFromText :: Text -> Value
+ Baikai.Cost.Log: callLogConfig :: FilePath -> CallLogConfig
+ Baikai.Embedding: embeddingClientEnv :: EmbeddingModel -> IO ClientEnv
+ Baikai.Embedding: instance GHC.Classes.Eq Baikai.Embedding.EmbeddingModel
+ Baikai.Embedding: instance GHC.Internal.Generics.Generic Baikai.Embedding.EmbeddingModel
+ Baikai.Embedding: resolveEmbeddingKey :: EmbeddingModel -> IO Text
+ Baikai.Error: ContentFiltered :: ErrorCategory
+ Baikai.Error: contentFiltered :: Text -> BaikaiError
+ Baikai.Error: parseHttpDate :: Text -> Maybe UTCTime
+ Baikai.Error: retryAfterSecondsAt :: UTCTime -> Text -> Maybe Int
+ Baikai.Evidence: SamplingDroppedUnsupportedApi :: ![Text] -> ThinkingAdjustment
+ Baikai.Evidence: SamplingDroppedUnsupportedModel :: ![Text] -> ThinkingAdjustment
+ Baikai.Evidence: ThinkingModeNotTranslated :: ThinkingMode
+ Baikai.Evidence: deriveStrength :: Observed Text -> Observed Text -> Observed Text -> EvidenceStrength
+ Baikai.Evidence: parseEvidenceStrength :: Text -> Maybe EvidenceStrength
+ Baikai.Evidence: untranslatedThinking :: Maybe ThinkingLevel -> ThinkingTranslation
+ Baikai.Evidence: usageEnvelope :: Usage -> Value
+ Baikai.Evidence: weakensThinking :: ThinkingAdjustment -> Bool
+ Baikai.Evidence.Build: endpointIdentityAt :: Text -> Model -> TransportKind -> EndpointIdentity
+ Baikai.Evidence.Build: minimalEvidenceAt :: Text -> Model -> Options -> TransportKind -> ThinkingTranslation -> Value -> UTCTime -> UTCTime -> CallStatus -> Maybe BaikaiError -> IO (Maybe ModelCallEvidence)
+ Baikai.Evidence.Build: missingEvidenceError :: BaikaiError
+ Baikai.Evidence.Build: prepareEvidenceAt :: Text -> Model -> Options -> TransportKind -> ThinkingTranslation -> Value -> UTCTime -> IO (Maybe (UTCTime -> CallStatus -> Maybe BaikaiError -> ModelCallEvidence))
+ Baikai.Evidence.Build: requestedTranslation :: Options -> ThinkingTranslation
+ Baikai.Evidence.Build: strictnessOf :: Options -> EvidenceStrictness
+ Baikai.Header: data HeaderName
+ Baikai.Header: headerName :: Text -> HeaderName
+ Baikai.Header: instance Data.Aeson.Types.FromJSON.FromJSON Baikai.Header.HeaderName
+ Baikai.Header: instance Data.Aeson.Types.FromJSON.FromJSONKey Baikai.Header.HeaderName
+ Baikai.Header: instance Data.Aeson.Types.ToJSON.ToJSON Baikai.Header.HeaderName
+ Baikai.Header: instance Data.Aeson.Types.ToJSON.ToJSONKey Baikai.Header.HeaderName
+ Baikai.Header: instance GHC.Classes.Eq Baikai.Header.HeaderName
+ Baikai.Header: instance GHC.Classes.Ord Baikai.Header.HeaderName
+ Baikai.Header: instance GHC.Internal.Data.String.IsString Baikai.Header.HeaderName
+ Baikai.Header: instance GHC.Internal.Generics.Generic Baikai.Header.HeaderName
+ Baikai.Header: instance GHC.Internal.Show.Show Baikai.Header.HeaderName
+ Baikai.Header: renderHeaderName :: HeaderName -> Text
+ Baikai.Http: cachedClientEnvCount :: IO Int
+ Baikai.Http: canonicalBaseUrl :: Text -> Either Text BaseUrl
+ Baikai.Http: getClientEnvCached :: Text -> IO ClientEnv
+ Baikai.Models.Generated: anthropic_claude_opus_5 :: Model
+ Baikai.Provider: apiProvider :: Api -> (Model -> Context -> Options -> Stream IO AssistantMessageEvent) -> ApiProvider
+ Baikai.Provider: apiProviderWith :: Api -> (Model -> Context -> Options -> Stream IO AssistantMessageEvent) -> (Model -> Context -> Options -> IO Response) -> ApiProvider
+ Baikai.Provider.Internal.StreamWorker: closeFrames :: FrameQueue a -> IO ()
+ Baikai.Provider.Internal.StreamWorker: data FrameQueue a
+ Baikai.Provider.Internal.StreamWorker: forkFrameWorker :: FrameQueue a -> IO () -> IO ThreadId
+ Baikai.Provider.Internal.StreamWorker: frameQueueCapacity :: Natural
+ Baikai.Provider.Internal.StreamWorker: instance GHC.Internal.Generics.Generic (Baikai.Provider.Internal.StreamWorker.FrameQueue a)
+ Baikai.Provider.Internal.StreamWorker: newFrameQueue :: IO (FrameQueue a)
+ Baikai.Provider.Internal.StreamWorker: pullFrame :: FrameQueue a -> IO (Maybe a)
+ Baikai.Provider.Internal.StreamWorker: pushFrame :: FrameQueue a -> a -> IO ()
+ Baikai.Provider.Internal.StreamWorker: withFrameWorker :: FrameQueue a -> IO () -> Stream IO b -> Stream IO b
+ Baikai.Provider.Registry: apiProviderWith :: Api -> (Model -> Context -> Options -> Stream IO AssistantMessageEvent) -> (Model -> Context -> Options -> IO Response) -> ApiProvider
+ Baikai.Provider.Registry: describeApi :: Api -> Text
+ Baikai.Provider.Registry: instance GHC.Internal.Generics.Generic Baikai.Provider.Registry.ApiProvider
+ Baikai.Provider.Registry: requireEvidenceOnResponse :: Options -> Response -> Response
+ Baikai.Provider.Transport.Classify: classifyHttpException :: HttpException -> BaikaiError
+ Baikai.Provider.Transport.Classify: classifyHttpExceptionContent :: HttpExceptionContent -> BaikaiError
+ Baikai.Provider.Transport.Classify: classifyIOException :: IOException -> Maybe BaikaiError
+ Baikai.Provider.Transport.Classify: classifyTlsException :: TLSException -> BaikaiError
+ Baikai.Provider.Transport.Classify: classifyTransportException :: SomeException -> Maybe BaikaiError
+ Baikai.ResponseFormat: data JsonSchemaFormat
+ Baikai.ResponseFormat: instance GHC.Classes.Eq Baikai.ResponseFormat.JsonSchemaFormat
+ Baikai.ResponseFormat: instance GHC.Internal.Generics.Generic Baikai.ResponseFormat.JsonSchemaFormat
+ Baikai.ResponseFormat: instance GHC.Internal.Show.Show Baikai.ResponseFormat.JsonSchemaFormat
+ Baikai.ResponseFormat: jsonSchemaFormat :: Text -> Value -> JsonSchemaFormat
+ Baikai.Stream: requireEvidenceOnTerminal :: Options -> AssistantMessageEvent -> AssistantMessageEvent
+ Baikai.ThinkingLevel: parseThinkingLevel :: Text -> Maybe ThinkingLevel
+ Baikai.Tool: mkTool :: Text -> Text -> Value -> Tool
+ Baikai.Trace: instance GHC.Internal.Exception.Type.Exception Baikai.Trace.TraceSinkStalled
+ Baikai.Trace: instance GHC.Internal.Show.Show Baikai.Trace.TraceSinkStalled
+ Baikai.Trace.Sink: instance GHC.Internal.Exception.Type.Exception Baikai.Trace.Sink.TraceSinkFailure
+ Baikai.Trace.Sink: instance GHC.Internal.Show.Show Baikai.Trace.Sink.TraceSinkFailure
+ Baikai.Url: baseUrlProblem :: Text -> Maybe Text
+ Baikai.Url: data UrlParts
+ Baikai.Url: hostMatchesSuffix :: Text -> Text -> Bool
+ Baikai.Url: instance GHC.Classes.Eq Baikai.Url.UrlParts
+ Baikai.Url: instance GHC.Internal.Generics.Generic Baikai.Url.UrlParts
+ Baikai.Url: instance GHC.Internal.Show.Show Baikai.Url.UrlParts
+ Baikai.Url: parseUrl :: Text -> Maybe UrlParts
+ Baikai.Url: renderEndpoint :: UrlParts -> Text
+ Baikai.Url: stripApiVersion :: Text -> Text
+ Baikai.Url: urlHost :: Text -> Maybe Text
- Baikai.Agent: RunTimedOut :: !NominalDiffTime -> AgentRunFailure
+ Baikai.Agent: RunTimedOut :: !AgentTimedOut -> AgentRunFailure
- Baikai.Evidence.Build: checkEvidenceRequirements :: EvidenceStrictness -> Api -> ThinkingTranslation -> [EvidenceRefusal]
+ Baikai.Evidence.Build: checkEvidenceRequirements :: EvidenceStrictness -> EvidenceStrength -> ThinkingTranslation -> [EvidenceRefusal]
- Baikai.Model: headers :: Model -> Map Text Text
+ Baikai.Model: headers :: Model -> Map HeaderName Text
- Baikai.Options: headers :: Options -> Map Text Text
+ Baikai.Options: headers :: Options -> Map HeaderName Text
- Baikai.Options: seed :: Options -> Maybe Integer
+ Baikai.Options: seed :: Options -> Maybe Int
- Baikai.Options: stopSequences :: Options -> Maybe (Vector Text)
+ Baikai.Options: stopSequences :: Options -> [Text]
- Baikai.ResponseFormat: JsonSchema :: !Text -> !Value -> !Bool -> ResponseFormat
+ Baikai.ResponseFormat: JsonSchema :: !JsonSchemaFormat -> ResponseFormat

Files

CHANGELOG.md view
@@ -7,125 +7,1352 @@  ## [Unreleased] -## [baikai 0.5.0.0] - 2026-08-05--### Added--- `baikai`: new exposed module `Baikai.Agent`, the provider-neutral vocabulary-  for an **unattended coding-agent run** — a run with no terminal and no human,-  which owns its own tool loop, may change files inside directories the caller-  authorized, and returns a process result rather than a `Response`. It defines-  `AgentRunRequest` (with a required `workingDir`), `AgentRunResult`, the-  `AgentCapability` profile (`read-only`, `edit-workspace`, `full-access`),-  `AgentSafety`, the `AgentOutputMode` and `AgentCapturedOutput` output-  discipline, the `AgentCommand` renderer/runner boundary with an explicit-  prompt transport, and the `AgentRenderError` / `AgentRunFailure` taxonomies.--- `baikai`: the operator policy ceiling — `AgentCeiling`,-  `defaultAgentCeiling`, `CeilingViolation`, and the pure `applyAgentCeiling`.-  It returns a request unchanged when it is within the ceiling and reports-  every violation when it is not; it never clamps an over-broad request to the-  permitted value. The default ceiling permits read-only and edit-workspace-  authority and refuses full access and raw provider arguments.--  `Baikai.Agent` itself is vocabulary and pure policy algebra only: it spawns no-  process and renders no command-line flags. Those live in the vendor packages-  and in `baikai-agent`, below. The module is deliberately not re-exported from-  the umbrella `Baikai` module, because its field accessors share names with-  `Baikai.Interactive`, so `import Baikai` continues to compile unchanged.--- `baikai`: new exposed module `Baikai.Evidence`, the vocabulary for-  **verifiable model-call evidence** — a record of what actually crossed the-  boundary to a provider, as opposed to what the process was configured to ask-  for. It defines `ModelCallEvidence` and the `evidenceSchemaVersion` string-  consumers pin against, `Observed` (a deliberate non-`Maybe` for a value the-  provider either did or did not report, with no function that supplies a-  default), `ThinkingTranslation` with its `ThinkingMode` and-  `ThinkingAdjustment` enumerations describing what a requested-  reasoning-effort level actually became on the wire and every clamp, collapse,-  or drop applied on the way, `EndpointIdentity` and `TransportKind`,-  `CallStatus`, and the ascending `EvidenceStrength` scale.--  It also provides the canonical hashing core: `canonicalEncode` gives a JSON-  value exactly one byte representation (object keys sorted, no insignificant-  whitespace, numbers normalised so `1`, `1.0`, `1.00`, and `1e0` all encode as-  `1`, and a hand-written string escaper so an aeson upgrade cannot silently-  invalidate a recorded digest); `commitmentDigest` hashes a full request-  envelope, and `configurationDigest` hashes an allow-list projection-  (`configurationProjection`) that keeps configuration and replaces content with-  structural summaries, so two calls that ask the same model the same way about-  different subjects agree. The two digests are separate on purpose: the first-  binds a record to a particular request, the second is safe to compare across-  runs that legitimately differ in content.--  Nothing constructs a `ModelCallEvidence` from a real call yet, and no existing-  behaviour changed. New dependencies: `cryptohash-sha256` and-  `base16-bytestring`, both single-purpose packages chosen over a full-  cryptographic framework.--- `baikai`: `Options` gains an `evidence` field carrying an optional-  `EvidenceRequest` — the caller's run identifier, retry provenance, and how-  strictly they need evidence. A call whose `evidence` is `Nothing`, which is-  every call that does not opt in, behaves exactly as it did before: no digest-  is computed and no evidence is emitted.--- `baikai`: model-call evidence is now **produced and emitted**. A caller who-  sets `Options.evidence` gets exactly one `call_evidence` line per call from-  their trace sink, under every way a call can end: success, provider failure, a-  consumer that abandons the stream (status `aborted`, not `failed` — an abort-  is the consumer's doing and reporting it as a provider failure would-  misattribute it), and dispatch that found no registered handler.--  New exposed module `Baikai.Evidence.Build` bridges the vocabulary to the-  `Model` and `Options` records: `minimalEvidence` and `prepareEvidence` build a-  record, `dispatchEnvelope` supplies the request envelope for the paths where-  no adapter ran, `sanitizeEndpoint` reduces a base URL to scheme/host/port/path-  with the query string and any userinfo dropped wholesale, and `onSinkFailure`-  is the hook a future release replaces to make a strict caller's call fail when-  the trace sink does.--  Every record this release produces has `strength` `requested_only` and every-  provider-observed field set to `"unobserved"`. That is not a placeholder: it-  is a truthful record for a transport that has not yet been taught to observe-  anything. Later releases teach each transport to observe more.--  **A caller who does not opt in pays nothing.** With `Options.evidence` absent-  no digest is computed, no call identifier is generated, no evidence event is-  emitted, and the request envelope is never even forced — the gate lives inside-  the shared builder rather than at each adapter's call site, and the envelope-  parameter is deliberately lazy. Both facts are guarded by tests.--- `baikai`: `TraceEvent` gains a `CallEvidence` constructor, encoded as-  `{"kind":"call_evidence", …}`. A consumer whose pattern match over `TraceEvent`-  is exhaustive must add a branch; one with a wildcard is unaffected. Filter for-  it with `jq 'select(.kind == "call_evidence") | .evidence'`. Note that a trace-  line carries its fields alongside the `kind` discriminator rather than nested-  under a `data` key, and that the evidence record inside spells its own fields-  in snake_case — the two encodings differ deliberately, because an evidence-  record must render an absent field as explicit `null` while a trace line drops-  it to stay small.--- `baikai`: `Baikai.Provider.Cli.Internal` — the module the two subprocess-  providers share — gains the vocabulary for reading what a coding-agent CLI-  reported about its own run. `CodexRunReport` and the new-  `parseCodexJsonlStream :: Stream IO ByteString -> IO CodexRunReport` fold the-  `codex exec --json` event stream into its assistant text, its thread-  identifier, and its token counts, instead of concatenating agent-message text-  and discarding everything else. `ClaudeCliReport` and-  `decodeClaudeCliResult` do the same for `claude -p --output-format json`.-  Every field but the message text is optional, because both tools' event-  schemas have changed across versions and an absent field is a genuine absence-  rather than a parse failure. **Breaking** for anyone calling-  `parseCodexJsonlStream` directly: its result type is no longer `Text`. This is-  an internal module and is documented as outside the PVP guarantee.--- `baikai`: `Baikai.Provider.Cli.Internal` also gains `ExecutableIdentity` and-  `executableIdentity`, which resolve a configured executable name to an-  absolute path and read the tool's own `--version` line. The probe is cached-  per resolved name for the lifetime of the process, because spawning it per-  model call would roughly double the process cost of the cheapest possible-  call, and it is bounded by a two-second timeout so a tool that hangs on-  `--version` cannot wedge a model call. A probe that fails records the version+## [baikai 0.6.0.0] - 2026-08-28++### Added++- `baikai`: `Baikai.ThinkingLevel.parseThinkingLevel :: Text -> Maybe+  ThinkingLevel` and `Baikai.Evidence.parseEvidenceStrength :: Text -> Maybe+  EvidenceStrength`, each beside its renderer. Three hand-copied tables — the+  evidence schema's level parser, `baikai-agent`'s KDL `effort` decoder, and its+  `--require-evidence` parser — now read them instead, so a level or strength+  added later cannot be added in one place and missed in three. (REV-2 G.6.)++- `baikai`: `Baikai.Agent.AgentRunResult` exports its selectors (`provider`,+  `exitCode`, `stdout`, `stderr`, `duration`). It exported neither them nor its+  constructor, so a consumer without generic-lens could not read a run's exit+  code at all. (REV-2 G.6.)++- `baikai`: `Baikai.Api.normaliseApi :: Api -> Api`, which collapses a `Custom`+  tag that spells a built-in API onto that constructor. The registry applies it+  to the key it stores and to the tag it is asked for, so a handler registered+  under `Custom "anthropic-messages"` answers a model tagged `AnthropicMessages`+  and the reverse; the two used to be separate entries and dispatch depended on+  which spelling the model happened to carry. Derived `Eq`/`Ord` on `Api` are+  deliberately unchanged: altering them would silently rearrange every+  `Map Api` a consumer holds. (REV-2 G.4.)++- `baikai`: `Baikai.Header`, a new module exporting `HeaderName` with+  `headerName` and `renderHeaderName`. See the `headers` retype under Changed.++- `baikai`: `Baikai.Error.ErrorCategory` gains `ContentFiltered` (wire tag+  `content_filtered`, never retryable) with the smart constructor+  `contentFiltered`. OpenAI's `finish_reason: "content_filter"` and Anthropic's+  `refusal` stop now carry it. Both used to be `OtherError`, so the only way to+  tell a filtered response from any other non-retryable failure was to match on+  the message text. __Breaking__ for a consumer whose `case` over+  `ErrorCategory` is exhaustive without a wildcard. (REV-1 1.7 residual.)++- `baikai` (breaking to construct, not to read): every record that can still+  grow a field is now built from an exported base value and refined by record+  update, and its constructor is no longer exported —+  `Baikai.Provider.Registry.ApiProvider` (`apiProvider` /`apiProviderWith`),+  `Baikai.Evidence.ModelCallEvidence` (`baseEvidence`),+  `Baikai.Evidence.EvidenceRequest` (`evidenceRequest`), `Baikai.Tool.Tool`+  (`mkTool`, with `emptyTool` kept for fixtures),+  `Baikai.Embedding.EmbeddingModel` (`emptyEmbeddingModel`),+  `Baikai.Cost.Log.CallLogConfig` (`callLogConfig`),+  `baikai-trace-otel`'s `OtelSinkOptions` (`defaultOtelSinkOptions`), and+  `baikai-agent`'s `AgentCliOptions` (`agentCliOptions`), `AgentCliRun`+  (`agentCliRun`), `AgentJob` (`agentJob`) and `AgentConfigPaths`+  (`emptyAgentConfigPaths`). Selectors, record update, `OverloadedRecordDot`+  reads and generic-lens labels all keep working; only construction from the+  constructor stops. Adding `describeThinking` to `ApiProvider` in 0.5.0.0 broke+  every third-party registration site, and `strengthCeiling` would have broken+  them again; from this release such an addition is a minor bump. (REV-2 G.1.)++- `baikai`: `Baikai.Provider.apiProvider`, which builds an `ApiProvider` from an+  `Api` tag and a streaming producer, deriving `complete` with+  `streamingComplete`; and `Baikai.Provider.Registry.apiProviderWith`, which+  takes the completer explicitly. Both default `describeThinking` to+  "nothing requested, nothing translated" and `strengthCeiling` to+  `EvidenceRequestedOnly`, matching `declaredStrength (Custom _)`.++- `baikai`: `Baikai.Tool.mkTool` — a tool from its name, description and JSON+  Schema. A tool built from `emptyTool` and sent unchanged reaches the wire with+  `input_schema: null`; `mkTool` has no such shape.++- `baikai`: `Baikai.Agent.AgentOutputFormat` (`TextFormat`, `JsonFormat`) with+  `renderAgentOutputFormat` and `parseAgentOutputFormat`, and+  `AgentRunRequest.outputFormat`, defaulting to `TextFormat`. `baikai-claude`+  renders `--output-format json` and `baikai-openai` renders `--json`, both+  right after the effort flags; `baikai-agent` reads it from+  `jobs.<name>.output-format`. This is the one setting an evidence record needs+  in order to observe a run's session, model and usage, and asking for it used+  to require the `provider-args` channel that an operator ceiling closes by+  default — an operator should not have to open a privileged channel to get a+  record. (REV-2 F.14.)++- `baikai`: `Baikai.Agent.AgentCeiling` gains three fields and the module gains+  the vocabulary they need. `allowedTools :: [Text]` names tool grants the+  operator permits beyond the ones `toolGrantsImpliedBy` (also new) says a+  capability implies on its own; `maxTimeout :: Maybe NominalDiffTime` and+  `maxOutputLimit :: Maybe Int` bound what any job may request, the second+  defaulting to the new `defaultMaxOutputLimit` (67108864, sixty-four+  mebibytes). `Baikai.Agent.ceilingViolations` is `applyAgentCeiling`'s violation+  list on its own, so a caller can concatenate it with violations of its own.+  (REV-2 F.3.)++- `baikai`: `Baikai.Content.toolArgumentsFromText` and+  `Baikai.Content.isCutOffToolCall`. The first is the single rule that turns a+  tool call's accumulated argument text into its `arguments` value — empty text+  is an empty object, non-empty text that does not decode is kept verbatim as a+  `String` — and both provider assemblers and core's stream-recovery path now+  use it, so the second means the same thing at every layer.++- `baikai`: new exposed module `Baikai.Provider.Internal.StreamWorker` — the+  bounded hand-off both HTTP providers now use between their SSE worker thread+  and the consumer draining the stream. `FrameQueue` is a 64-slot `TBQueue` plus+  a closed flag; `forkFrameWorker` closes the queue however the body ends, and+  `withFrameWorker` runs the consumer under `Stream.bracketIO` so the worker is+  killed when the stream stops. The module is exposed like+  `Baikai.Provider.Cli.Internal`, outside the PVP promise. See+  [docs/adr/0010](docs/adr/0010-a-stream-consumer-that-stops-owns-cancelling-the-producer.md).++- `baikai`: every Anthropic model in the generated catalog now carries an+  explicit `CompatAnthropicMessages` record stating the two request-shaping+  facts of its generation: `AnthropicMessagesCompat.thinkingStyle` (which+  extended-thinking wire shape it accepts) and the new+  `AnthropicMessagesCompat.supportsSamplingParameters` (whether it accepts+  `temperature`, `top_p` and `top_k`). Both are sourced from+  `baikai/data/models/anthropic.json`, which the fetcher writes from its+  curated `anthropicInclude` table, and `baikai-gen-models` now refuses an+  `anthropic-messages` entry that reaches it without a `compat` block rather+  than falling back to host auto-detection, which cannot know a generation.+  This is what fixes `claude-sonnet-5`, whose thinking requests were shaped by+  a prefix table that did not know the id. See+  [docs/adr/0009](docs/adr/0009-provider-capability-facts-live-in-the-generated-catalog-record.md).++- `baikai`: two new `Baikai.Evidence.ThinkingAdjustment` constructors,+  `SamplingDroppedUnsupportedModel` and `SamplingDroppedUnsupportedApi`, encoding as+  `{"kind":"sampling_dropped_unsupported_model","fields":["temperature","top_p"]}` and+  `{"kind":"sampling_dropped_unsupported_api","fields":["seed"]}`. They record sampling+  parameters removed because the model generation rejects them, or because the API has no+  such field on any generation. Both carry a `fields` array and no `requested` level, so+  they can appear on a call whose thinking mode is `absent`.++- `baikai`: `Baikai.Evidence.weakensThinking`, which says whether an adjustment weakens the+  thinking the caller asked for. Strict evidence mode filters through it, so a dropped+  sampling parameter is recorded without refusing the call — the documented contract is+  refusing a call that would weaken the requested *thinking level*.++- `baikai`: new exposed module `Baikai.Url` — the one place baikai turns a URL+  into a host name. `parseUrl` yields a `UrlParts` record with the scheme, host,+  port and path, plus flags saying whether userinfo, a query string or a+  fragment were present; it never holds their text, so the value cannot carry a+  secret into a log line. Alongside it: `urlHost`, `hostMatchesSuffix` (moved+  from `Baikai.Compat`, which now re-exports both), `renderEndpoint`,+  `stripApiVersion`, and `baseUrlProblem`, which says why a URL is unusable as a+  `Model.baseUrl` and what to do instead. See+  [docs/adr/0008](docs/adr/0008-one-url-host-parser-and-every-consumer-uses-it.md).++- `baikai`: new exposed module `Baikai.Provider.Transport.Classify` — the one+  rule every HTTP provider uses to classify a transport failure, exporting+  `classifyTransportException` plus the per-type functions it composes. The rule+  is *where* the failure happened, not what type it is: anything that breaks or+  ends the connection after the request went out is `TransientError`, anything+  that says the request or the configuration is wrong is not retryable, and a+  programming error stays `OtherError`. It understands all three shapes+  `http-client` can deliver — an `HttpException` of any constructor, a raw socket+  `IOException`, and a raw or wrapped `TLSException` — because the manager wraps+  the connect phase but not the body reader. Core gains direct `build-depends` on+  `http-types` and `tls`, both already in its install plan. Written for+  third-party `Custom` providers built on `http-client` as much as for baikai's+  own two. See+  [docs/adr/0011](docs/adr/0011-core-owns-transport-failure-classification.md).++- `baikai`: `Baikai.Error.parseHttpDate` and `Baikai.Error.retryAfterSecondsAt`.+  The first parses an HTTP-date in the IMF-fixdate form servers must send plus+  the two obsolete forms a recipient must accept; the second converts a+  `Retry-After` header in either of its forms to seconds against a reference+  instant, clamping a date already in the past to `0`.+  `parseRetryAfterSeconds` keeps its integer-only contract, now a deliberate+  division of labour rather than a limitation.++- `baikai`: new exposed module `Baikai.Http` — `canonicalBaseUrl`,+  `getClientEnvCached` and `cachedClientEnvCount`, the process-global+  `ClientEnv` cache that both HTTP provider packages now share instead of each+  keeping its own. Core gains direct `build-depends` on `servant-client`,+  `http-client` and `http-client-tls`, which were already in its install plan+  through the `openai` SDK.++- `baikai`: `Baikai.Evidence.ThinkingModeNotTranslated`, encoded as+  `"not_translated"`, and `Baikai.Evidence.untranslatedThinking`; and+  `Baikai.Evidence.Build.requestedTranslation`. A path where no adapter ran to+  translate the caller's level now records the level and says the translation is+  unknown, instead of saying nothing was asked. (REV-2 D.2.)++- `baikai`: `Baikai.Evidence.Build.missingEvidenceError`,+  `Baikai.Evidence.Build.strictnessOf` (moved here from `Baikai.Trace`, where it+  was private), `Baikai.Stream.requireEvidenceOnTerminal` and+  `Baikai.Provider.Registry.requireEvidenceOnResponse`. (REV-2 D.3.)++- `baikai`: `Baikai.Evidence.usageEnvelope`, and+  `Baikai.Evidence.Build.endpointIdentityAt`, `prepareEvidenceAt` and+  `minimalEvidenceAt`, which take the base URL the adapter actually resolved.+  The three unsuffixed functions remain and pass the model's own field.+  (REV-2 D.8, D.11.)++- `baikai`: `Baikai.Evidence.deriveStrength`, the single rule that turns an+  observed model, a provider request id and a response id into an+  `EvidenceStrength`. (REV-2 D.10.)++### Changed++- `baikai`: catalog refresh. `claude-opus-5` joins the curated Anthropic include+  set (adaptive thinking, sampling parameters rejected — the facts+  `docs/plans/60-make-anthropic-thinking-style-and-sampling-support-catalog-driven.md`+  said whoever curated it in would have to state), and the `gpt-5.6` family+  picks up its price cut: `gpt-5.6` and `gpt-5.6-sol` to $4.00/$20.00,+  `gpt-5.6-terra` to $2.00/$12.00, `gpt-5.6-luna` to $0.20/$1.20 per Mtok, cache+  rates in step. `Baikai.Models.Generated` gains `anthropic_claude_opus_5` and+  now carries 36 enabled models. No OpenAI id was added: the `gpt-5.6` family is+  still the newest one models.dev reports that speaks+  `openai-chat-completions`.++- `baikai` (breaking): `ResponseFormat`'s `JsonSchema` carries a+  `JsonSchemaFormat` record — `name`, `schema`, `strict`, exported+  selector-only with the base `jsonSchemaFormat name schema` — instead of+  holding the three fields directly. As fields of a sum they were partial+  selectors: `name f` on a `JsonObject` crashed at runtime rather than failing to+  typecheck, which contradicted the module's own documentation.+  `-Wno-partial-fields` is dropped from the module. The JSON encoding is+  deliberately unchanged (`{"tag":"JsonSchema","name":…,"schema":…,"strict":…}`)+  and is now pinned by a test, because `Options` derives `ToJSON` through it and+  at least one consumer keys a cache on the result. (REV-2 G.2.)++- `baikai`: `Baikai.Context.appendToolResult` returns its input context+  unchanged, and runs no dispatcher, when the response is error-shaped. A failed+  call has no assistant turn worth replaying and no tool calls to answer;+  appending its empty message put a turn into the transcript the model never+  took. `runToolLoop` has always stopped on such a response — the documented+  direct round trip in `docs/user/tools.md` reaches `appendToolResult` instead,+  and now behaves the same way. Its Haddock also stops claiming multi-call+  concurrency lives in the dispatcher: the calls are traversed in order.+  (REV-2 G.7.)++- Release metadata (REV-2 G.8): every publishable package now declares+  `tested-with: GHC ==9.12.4` and ships its `CHANGELOG.md` (a symlink to the+  root one, as `baikai` already did) via `extra-doc-files`, so Hackage shows a+  changelog and a tested compiler for all seven. `baikai-claude` and+  `baikai-openai` describe what they actually contain — four surfaces each, not+  "wraps package X" — and `baikai-trace-otel`'s `streamly-core` bound is+  `>=0.3 && <0.5`, matching every other package in the workspace rather than+  excluding the 0.4 series the others accept.++- `baikai` (breaking): `Options.headers` and `Model.headers` are keyed on+  `Baikai.Header.HeaderName` — a newtype over a case-insensitive `CI Text` that+  keeps the original spelling — instead of `Text`. A header name is+  case-insensitive on the wire, so a `Map Text Text` holding both+  `Authorization` and `authorization` sent whichever the assembling fold reached+  last; the map now holds one entry per header and the last write wins, as a+  caller writing two spellings would expect. `HeaderName` has an `IsString`+  instance, so `Map.singleton "x-test" "1"` and `#headers` updates keep+  compiling; the spelling given is what goes out on the wire and into JSON.+  (REV-2 G.5.)++- `baikai` (breaking): `Options.stopSequences` is `[Text]`, where empty means+  "send nothing", instead of `Maybe (Vector Text)` — `Nothing` and `Just []`+  were indistinguishable on the wire and only one of them could be right. Plan+  43's rule is lists for caller-side configuration and `Vector` for+  provider-bound sequences; this was the one field breaking it. `Options.seed`+  is `Maybe Int` rather than `Maybe Integer`: a seed is a machine integer at+  every provider that accepts one, and it now sits beside+  `timeoutMs :: Maybe Int`. (REV-2 G.5, R14.)++- `baikai` (breaking): `StopReason.Aborted` is removed. Nothing produced it —+  timeouts are `ErrorReason`/`TransientError`, and a consumer abort is recorded+  as evidence `CallAborted` — while `responseError`, `eventsFor` and+  `runToolLoop` all treated it as a *success*, so a value that reached any of+  them would have been silently mishandled. Since 0.6.0.0 a stream consumer that+  stops cancels the producer, so no consumer is left to receive such a terminal+  either. (REV-2 B.6.)++- `baikai`: dispatching a model whose `api` is still `emptyModel`'s+  `Custom ""` says so — `No provider registered for API: <blank Custom tag —+  emptyModel.api was never set>` — where the message used to end after the+  colon. `emptyModel`'s Haddock says the same thing. (REV-2 G.4.)++- `baikai`: `withTrace` and `withTraceStream` wait at most one second for the+  trace sink after writing the shutdown sentinel. On expiry the worker is+  abandoned — not killed, which would abort the sink's fold mid-step and lose+  its end-of-stream action — the call proceeds, and one stderr line reports+  `the trace sink did not confirm delivery within 1000 ms; its worker was+  abandoned, and events already queued may still be delivered later`. A sink+  that blocked forever used to hold the call forever and swallow the first+  attempt to cancel it. A caller under `EvidenceRequired` whose sink did not+  confirm delivery gets a failed call, through the same path a throwing sink+  takes; `Baikai.Evidence.Build.sinkFailureError` now says "its record was not+  confirmed written" rather than "not written", which is the honest claim for+  an abandoned worker whose events are still queued. The synthetic terminal a+  consumer's abort produces is delivered from a garbage-collection hook and is+  not guaranteed before process exit; that was always true and is now stated in+  `docs/user/model-call-evidence.md`, `docs/capabilities/call-tracing.md` and+  the `Baikai.Trace` module documentation, with the pattern for callers who need+  the record. See+  [docs/adr/0015](docs/adr/0015-trace-cleanup-is-bounded-and-abort-cleanup-is-gc-eventual.md).+  (REV-2 D.5, Theme 7.3.)++- `baikai`: `Baikai.Trace.Sink.multiSink` runs each member on its own drain+  thread behind its own unbounded channel, instead of folding `Fold.tee` across+  the list. `Fold.tee` runs one member then the other and lets either's+  exception escape, so a single throwing member stopped delivery to every+  sibling for the rest of the call and skipped their end-of-stream actions — an+  OpenTelemetry span paired with an unwritable file sink was opened and never+  ended, and nothing was exported. The step never blocks; the final action sends+  every member the sentinel, waits for every member, and reports one aggregate+  failure naming each failed member by zero-based index+  (`1 of 2 member sinks failed: member 0: …`). (REV-2 D.6.)++- `baikai`: `AgentSafety.allowedTools` is documented as the __grant__ it is.+  On Claude Code it renders `--allowedTools`, whose help reads "list of tool+  names to allow": it pre-approves tools the permission mode would otherwise+  raise a request for, and in an unattended run a request nobody answers is+  denied. The old Haddock called it "optional narrowing of the provider's tool+  set", which was the opposite, and `applyAgentCeiling` never looked at it. It+  is now bounded: a grant passes when the maximum capability implies it+  (`read-only` implies `Read`, `Glob`, `Grep`, `NotebookRead`, `TodoWrite`;+  `edit-workspace` adds `Edit`, `MultiEdit`, `Write`, `NotebookEdit`;+  `full-access` implies every grant) or when the operator named it in+  `policy.allowed-tools`. Matching is exact, so `Bash(git *)` is not `Bash`.+  A repository job that grants itself `Bash` under `edit-workspace` — which+  passed unexamined before — is now refused with exit 77 before any process is+  created. (REV-2 F.3.)++- `baikai` (breaking): `Baikai.Agent.CeilingViolation` gains five constructors:+  `ToolGrantForbidden`, `TimeoutExceeded`, `OutputLimitExceeded`,+  `RepositoryScopeForbidden` and `WorkingDirOutsideRepository`. A `case` over+  the type that was exhaustive is no longer.++- `baikai` (behaviour): the default ceiling has a finite `maxOutputLimit`, so+  `applyAgentCeiling defaultAgentCeiling` now refuses a request whose+  `outputLimit` is `Nothing` — capture without bound is exactly what the+  maximum exists to refuse. Jobs resolved through `baikai-agent` are unaffected:+  that layer's own default supplies a finite limit, and only an explicit+  `output-limit "unlimited"` reaches the ceiling as `Nothing`.++- `baikai`: a tool call cut off by the output cap is no longer executed.+  `runToolLoop` stops with the response and its tool calls intact when any call+  is cut off, and `appendToolResult` appends a `ToolResultMessage` with+  `isError = True` explaining why instead of calling the dispatcher. Previously+  both assemblers replaced truncated arguments with `{}` and a tool loop+  happily ran the call with no arguments at all. (REV-2 B.2.)++- `baikai`: `Baikai.Model.anthropicMessagesCompatFor` no longer overlays a+  thinking style guessed from the model id onto a model whose `compat` is+  `CompatNone`. `CompatNone` now means host auto-detection alone — the budget+  thinking shape, sampling parameters supported. Every catalog model carries an+  explicit record, so this changes nothing for them; a **hand-rolled** model+  naming an adaptive-era id (`claude-sonnet-5`, `claude-opus-4-7`,+  `claude-opus-4-8`, `claude-fable-5`) must now carry+  `CompatAnthropicMessages (defaultAnthropicMessagesCompat {thinkingStyle = AnthropicThinkingAdaptive, supportsSamplingParameters = False})`+  or start from the catalog value.++- `baikai`: `Baikai.Evidence.evidenceSchemaVersion` is now+  `baikai.model-call-evidence/1.1`. A minor bump: the two sampling adjustment kinds are a+  compatible addition, and no previously recorded digest changes.++- `baikai`: HTTP 413 classifies as `ContextOverflow` rather than `OtherError`,+  from the status alone and whatever the body says. 413 *is* the size-limit+  status and the caller's remedy — shrink the input — is the same either way;+  making the category depend on body wording would recreate for 413 the+  inconsistency this release fixes for connection resets. (REV-2 A.7.)++- `baikai`, `baikai-claude`, `baikai-openai`: an HTTP-date `Retry-After` is+  converted to seconds instead of ignored. Both transports use the response's own+  `Date` header as the reference instant, falling back to the local clock, so a+  CDN-fronted `429` — the common case for a date-valued `Retry-After` — now+  carries a hint rather than leaving the caller to guess. (REV-2 A.9.)++- `baikai`: **breaking.** `Baikai.Embedding.EmbeddingModel.apiKey` is now+  `Maybe ApiKeySource` rather than `ApiKeySource`. `Nothing` means the+  conventional environment variable for the model's host, from+  `defaultApiKeyEnvForBaseUrl` — the same table the chat providers use — and a+  host that table does not know refuses with an `AuthError` naming+  `EmbeddingModel.apiKey`. Migration: `apiKey = source` becomes+  `apiKey = Just source`. `EmbeddingModel` also derives `Eq` and `Generic`, so+  the `#field .~ value` idiom works on it as it does on every other record.+  (REV-2 E.3.)++- `baikai`: **breaking.** `AgentRunFailure`'s `RunTimedOut` constructor now+  carries a new record `AgentTimedOut` — the configured `limit` plus the+  `stdout` and `stderr` a timed-out run drained before its process group was+  killed — instead of a bare `NominalDiffTime`. A caller matching+  `RunTimedOut limit` becomes `RunTimedOut timedOut` and reads `timedOut ^.+  #limit`; `renderAgentRunFailure` is unchanged in what it says. The bytes were+  always there, drained from the moment the child was spawned, and were simply+  dropped on the timeout path — which is the run an operator most wants an+  account of, because the tool started, may have consumed tokens, and may+  already have changed the working tree.++- `baikai`: under `EvidenceRequired`, a successful terminal that carries no+  evidence record fails the call with `missingEvidenceError` rather than+  returning a silent success with zero `call_evidence` lines. Strict mode+  guaranteed that a record which was built and then lost fails the call; it did+  not guarantee that one was built. The rule is applied at both dispatch points,+  so `completeRequest` with no sink gets the same guarantee as a streaming call;+  a failed call keeps the provider's own error, and best effort is unchanged.+  See `docs/adr/0014-strict-evidence-means-a-record-exists.md`. (REV-2 D.3.)++- `baikai`: a caller's thinking level is recorded on every evidence path — the+  consumer abort, an unregistered provider, a `complete` handler that threw, and+  each provider's `immediateError`. The abort path asks the registered adapter's+  own `describeThinking`; the others record `not_translated`. All four used to+  record the caller's request as `absent`, which+  `docs/adr/0002-requested-translated-observed-are-never-collapsed.md` forbids.+  (REV-2 D.2.)++- **`baikai.model-call-evidence/2.0`.** Two digests cover different bytes, so a+  verifier must now select its rules by `schema_version`. `response_commitment`+  covers the provider-reported token counts and never baikai's computed cost:+  the cost comes from the caller's catalog rates rather than from the response,+  so the digest used to change whenever a price was edited and a verifier+  holding only the response could not recompute it. `request_configuration`+  summarises `output_config` and `response_format` as it already summarised+  `tools`, because a structured-output JSON schema carries author-written+  `description` strings and is content wherever it appears — the same schema was+  stripped from `tools[].input_schema` and survived verbatim through the other+  two keys. `thinking.mode` may also now be `"not_translated"`, which is a+  compatible addition. (REV-2 D.7, D.11.)++- **Breaking.** `baikai`: `Baikai.Provider.Registry.ApiProvider` gains a fifth+  field, `strengthCeiling :: EvidenceStrength`, and+  `Baikai.Evidence.Build.checkEvidenceRequirements` takes that ceiling where it+  took an `Api`. The gate compared against `declaredStrength`, a table keyed by+  the API tag, which necessarily answered `EvidenceRequestedOnly` for every+  `Custom` transport — so a gateway that genuinely observes a model could never+  satisfy a strict caller who required that it did. Only a provider knows what+  its evidence reaches. `EvidenceRequestedOnly` reproduces the old behaviour for+  any custom provider; the four built-in providers fill the field from+  `declaredStrength`, which is unchanged in value and still used by the+  unattended-agent surface. (REV-2 D.10, G.1.)++- `baikai`, `baikai-claude`, `baikai-openai`: one strength derivation replaces+  three. An observed **response id** now counts as correlation alongside a+  captured request-id header, so a host that names its model and its response id+  on every chunk but sends no header reaches `model_observed` instead of+  `requested_only` — which had put it *below* a host that sent only a header and+  named nothing. `anthropicStrength` and `openaiStrength` are removed;+  `Baikai.Provider.Cli.Internal.subprocessStrength` keeps its signature and+  delegates. (REV-2 D.10.)++### Removed++- `baikai` **0.6.0.0** (breaking): the sixteen `_Type` base-value aliases deprecated in+  0.3.0.0 — `_Options`, `_Context`, `_Model`, `_ModelCost`, `_Response`,+  `_Usage`, `_Cost`, `_CostBreakdown`, `_Tool`, `_TextContent`,+  `_ThinkingContent`, `_ToolCall`, `_ImageContent`, `_EmbeddingModel`,+  `_InteractiveLaunchRequest` and `_InteractiveLaunchResult`. Each has an+  `empty…` or `zero…` replacement of the same value, named in the pragma that+  has been on it since 0.3.0.0. The 0.3.0.0 entry said they remained "for this+  release"; 0.4.0.0 and 0.5.0.0 shipped without removing them because no entry+  named a version.+  `docs/adr/0016-deprecated-names-are-removed-at-the-next-major.md` now fixes+  the rule: a name deprecated in `A.B.0.0` is removed in `A.(B+1).0.0`, and+  every pragma says so. (REV-2 G.3.)++- `baikai` **0.6.0.0** (breaking): `Baikai.Trace.newEventId`. It has delegated to+  `Baikai.Evidence.newCallId` since 0.5.0.0; call that. (REV-2 G.3.)++- `baikai` **0.6.0.0** (breaking): `Baikai.Compat.defaultAnthropicThinkingStyle`, deprecated+  earlier in this cycle. Nothing in baikai consults it — the thinking style of a+  first-party Anthropic model is a field of its generated catalog record+  (`Baikai.Models.Generated`); start from that value, or set+  `CompatAnthropicMessages` explicitly.++- `baikai` (breaking): `AgentRunRequest.envPassthrough` is renamed `envRequires`.+  The field is a list of variables the job declares it requires, checked as a+  precondition; it has never passed anything through, and the KDL key has said+  `env-requires` since the setting existed.++- `baikai` (breaking): `AgentRunFailure.OutputMalformed`, and with it+  `baikai-agent`'s exit code 70 and its `internalExitCode` export. Nothing ever+  constructed the constructor, and giving it a producer would have been wrong:+  the runner treats the tool's output as best-effort observation and its+  deliverable is the changed working tree, so a run that edited files correctly+  and then printed an unparseable final line would have been reported as a+  failure with its exit code and output discarded. A record's `strength` and+  `unobserved` fields already say when output could not be read. (REV-2 F.13.)++### Fixed++- `baikai`: the terminal event and its evidence record are pushed to the trace+  sink exactly once under asynchronous exceptions. The terminal path pushed the+  evidence record, pushed the terminal event and only then set the+  already-sent flag; an exception delivered between the last two made the+  stream finaliser read the flag as unset and push a second `CallEvidence` and+  an `aborted` `CallFailed` after the real `CallFinished`, so a sink saw two+  records and two contradictory terminals for one call. All three writes now+  run inside one `uninterruptibleMask_` with the flag first. (REV-2 D.4.)++- `baikai`: `Baikai.Cost.Log.closeCallLog` is idempotent. The first caller+  claims the handle and waits for the worker; a second returns at once instead+  of blocking forever on an `MVar` the worker had already emptied — a shape+  `withCallLog` makes easy to reach, since its bracket closes a handle the body+  may also have closed. An `appendEntry` after the close enqueues nothing.++- `baikai`: `reassembleResponse` is total under duplicated, late and+  timestamp-less input. The first `EventStart` wins the skeleton and+  `responseId` merges with `<|>`, so a later `Nothing` cannot erase an id an+  earlier event supplied; events after the first terminal are ignored, so a+  producer that keeps talking cannot rewrite the answer; and `latencyMs` falls+  back to the reassembler's own wall clock when neither the skeleton nor the+  terminal carries a provider timestamp, instead of reporting a zero that reads+  as "instant". (REV-2 B.7.)++- `baikai`: an `EmbeddingModel` pointed at a non-OpenAI host no longer sends+  `OPENAI_API_KEY` to it. The default key source was that variable whatever the+  base URL said, so pointing the client at DeepSeek handed DeepSeek an OpenAI+  credential. It now resolves per host, and refuses an unknown one. New+  `resolveEmbeddingKey` and `embeddingClientEnv` expose both decisions without+  making a request. (REV-2 E.3.)++- `baikai`: `Baikai.Embedding.embed` no longer allocates a TLS manager per call.+  It used the `openai` SDK's own `getClientEnv`, which builds a fresh manager+  every time; it now takes one from `Baikai.Http`'s process-global cache, the+  same one the chat providers use, so an embedding call and a chat call to one+  host share a connection pool.++- `baikai`: **a credential in a header is no longer printed.** `Options.headers`+  and `Model.headers` went through derived `Show` and `ToJSON` instances that+  rendered every value verbatim — while `Baikai.Options`' own documentation+  invites callers to put a gateway's `Authorization` header there and the+  getting-started guide tells them to `print resp`, which renders the embedded+  `Model`. Both types now have hand-written instances that render exactly what+  the derived ones did, except that the value of a header whose name looks+  credential-carrying (`authorization`, `api-key`, `apikey`, `token`, `secret`,+  `cookie`, `password`, or any name ending in `-key`, case-insensitively) prints+  as `<redacted>`. `Baikai.Auth` exports the three pieces — `redactedMarker`,+  `isCredentialHeader`, `redactHeaderValues` — so a caller can apply the same+  rule to its own logging. Only the rendering changes: the field is untouched,+  `Eq` is untouched, and the header is still sent as written. A JSON round trip+  of a `Model` is deliberately lossy, since a serialised `Model` is exactly the+  thing that should not carry a key. (REV-2 E.2.)++- `baikai`: an API-key environment variable set to the empty string, or to+  nothing but whitespace, now counts as **unset**. `ApiKeyEnv` fails with an+  `AuthError` naming the variable and saying it is not set or is empty;+  `ApiKeyEnvChain` skips it and continues, and reports every name when none+  yields a key. Previously an empty variable resolved to an empty key, which+  short-circuited a chain and produced `Authorization: Bearer ` and a provider+  401 that said nothing about the cause. A key with real content is still passed+  through untrimmed. (REV-2 E.6.)++- `baikai`: **the host parse no longer lets a base URL choose which key baikai+  sends.** `urlHost` took the text after the *last* `@` anywhere in a URL, so+  `https://proxy.example.com/v1?u=@api.openai.com` named the host+  `api.openai.com`: `defaultApiKeyEnvForBaseUrl` resolved `OPENAI_API_KEY`,+  `autoDetectOpenAICompletions` returned OpenAI's own compatibility record, and+  the bearer token went to `proxy.example.com`. Anyone who could set `baseUrl` —+  a `Model` decoded from JSON, a proxy override — could pick which provider's+  credential to be handed. The same defect broke the benign direction:+  `https://api.openai.com/v1/@x` named the host `x` and resolved no key at all.+  The authority now ends at the first `/`, `?` or `#`, and userinfo is only ever+  the last `@` inside it. (REV-2 A.1 / E.1.)++- `baikai`: `Baikai.Evidence.Build.sanitizeEndpoint` was a second, separately+  written parser that bounded the authority at the first `/` only, so a URL with+  a query and no path recorded the wrong host. It is now `renderEndpoint <$>+  parseUrl`, which also means a recorded endpoint has a lower-cased scheme and+  host; the path keeps its case and trailing slash.++- `baikai`: `parseCodexJsonlStream` assembles lines in **linear time**. It+  previously unpacked every chunk into a stream of bytes and appended them one+  at a time with `BS.snoc`, copying the whole accumulator per byte — quadratic+  in line length, so one codex event carrying a two-million-character message+  cost on the order of a trillion byte moves and in practice never finished.+  Lines are now cut out of each chunk with `BS.elemIndex` and `BS.splitAt`, and+  the pieces of a line that spans a chunk boundary are joined once. Behaviour is+  unchanged: a non-JSON line is still skipped, and a last line without a+  trailing newline is still parsed.++- `baikai`: a Codex custom agent's instructions body renders as a TOML+  **literal** multi-line string (`'''`), which interprets nothing, instead of a+  basic one (`"""`), which interprets backslash escapes. As a basic string an+  instruction as ordinary as "match `\d+`" made Codex refuse to load the file;+  `tomllib` rejects the old output with `Unescaped '\' in a string`. A body a+  literal string cannot hold — one containing three apostrophes, a bare carriage+  return, or a control character other than tab and newline — falls back to a+  fully escaped basic string. `tomlString`, which renders `name` and+  `description`, now escapes every control character as TOML 1.0 requires+  instead of only the five it happened to name.++- Documentation: `baikai`'s Haddock no longer describes behaviour the code left+  behind. The trace event's token counts are `Maybe` because a non-assistant+  terminal has no usage, not because the CLI providers report nothing — since+  0.5.0.0 both carry what the tool reported. `EventStart`'s `partial` is a+  message skeleton with empty content, zero usage and no stop reason; the api,+  provider and model id live on the `Response`. A lifted stream's `EventStart`+  carries the final usage and stop reason already filled in, because the+  response is complete before the stream begins. `Baikai.CacheRetention` no+  longer mentions an OpenAI Responses 24-hour bucket no code emits. System+  prompts are documented as living on `Context.systemPrompt` rather than on a+  `Baikai.Request` module that no longer exists, `emptyModel`'s `compat` is+  described as auto-detection rather than a placeholder, tool dispatch says+  calls run one at a time in order, and every reference to a plan number is+  gone. (REV-2 H.4.)++## [baikai-claude 0.6.0.0] - 2026-08-28++### Added++- `baikai-claude`: `Baikai.Provider.Claude.Internal.Request` exports `planRequest`,+  `SamplingPlan`, `uncappedMaxTokensFloor` and `normalizeToolCallId` as test seams.+  `planThinking` and `describeThinkingFor` are now projections of `planRequest`, so the+  strict gate, the request builder and the evidence record read one answer.++### Changed++- `baikai-claude`, `baikai-openai` (breaking): each provider's streaming+  machinery moved from `Baikai.Provider.<P>.Api` to+  `Baikai.Provider.<P>.Internal.Stream` — the `SseDriver` seam, `liveSseDriver`,+  `<p>StreamWith`, `Assembler`, `emptyAssembler`, `translate`, and on the OpenAI+  side `RawChunk`, `RawToolDelta`, `parseChunk`, `parseFrame`, `TagScanState`,+  `scanThinkTags`, `closeOpenStream`, `RawUsage`, `parseUsage` and+  `rawUsageToUsage`. `Api` now exports exactly `register`, the provider value+  and the live stream function. The `.Internal` module is exposed for the test+  suites and sibling packages and, like every `.Internal` module, may change in+  any release without a major bump — so changing the assembler stops being a+  documented break. `Shape`, `Sse` and `Transport` keep their names and gain the+  same no-guarantees header. `_TagScanState` is renamed `emptyTagScanState`.+  (REV-2 G.1.)++- `baikai-claude`, `baikai-openai`: a consumer that stops reading now stops the+  provider. Both packages fork their SSE worker under `Stream.bracketIO` and+  hand frames through the bounded `FrameQueue` above instead of an unbounded+  `Chan`. A consumer that cancels — `Ctrl-C`, `System.Timeout.timeout`,+  `cancel` — releases the HTTP connection immediately; a consumer that abandons+  the stream (`Stream.take 3`) stops the socket read within 64 further frames+  and releases the connection at the next major garbage collection. Previously+  the worker read the entire generation into memory for a consumer that would+  never look at it, and the provider billed all of it. The three cleanup+  strengths are stated in+  [docs/adr/0010](docs/adr/0010-a-stream-consumer-that-stops-owns-cancelling-the-producer.md)+  and in caller terms in `docs/user/streaming.md`.++- `baikai-claude`: `anthropic_claude_sonnet_4_6` now sends the adaptive+  thinking shape rather than `budget_tokens`. The budget shape is deprecated+  for that generation; baikai sends the shape Anthropic documents as current.++- `baikai-claude`, `baikai-openai`: **behaviour change.** `Options.timeoutMs` of+  `Just n` with `n <= 0` is refused as `InvalidRequest` before the action runs, so+  no connection is opened. `System.Timeout.timeout` returns immediately at zero+  and runs unbounded below it, and the previous `max 0` clamp made both spellings+  fail instantly as a *retryable* `TransientError` — a classification a caller's+  retry loop re-issues forever for what is a configuration mistake. `Nothing`+  remains the only spelling of "no bound". (REV-2 A.10.)++- `baikai-claude`, `baikai-openai`: an evidence record's `endpoint` names the+  host the call actually went to. Both adapters substitute a vendor default for+  an empty `Model.baseUrl` inside `prepareCall`, so a call with a perfectly+  definite destination recorded `endpoint: null`. Where no adapter ran, `null`+  remains the truthful answer. (REV-2 D.8.)++- `baikai-claude`: the `claude` dependency moves from `^>=1.4` to `^>=1.5`.+  1.5.0 adds a `Pause_Turn` constructor to `Claude.V1.Messages.StopReason`, and+  `mapStopReason` matches that type with no wildcard under+  `-Werror=incomplete-patterns`, so the bump forced a decision. A paused turn+  maps to `Stop`: Anthropic suspends the turn mid-flight for a long-running+  server-side tool and expects the caller to send the message back to continue+  it, so nothing failed, and `Baikai.StopReason` has no constructor that says+  "resume me". Widening that public sum is a breaking change for every consumer+  who matches on it exhaustively, and it is not this bump's to make. The general+  rule is+  [ADR 0018](docs/adr/0018-a-provider-stop-reason-with-no-baikai-equivalent-maps-to-the-nearest-truthful-one.md):+  a provider stop reason with no baikai equivalent maps to the constructor that+  is truthful about whether the call failed, and the sum widens only when baikai+  would behave differently for it.++- `baikai-claude`: `Messages.StreamUsage` lost its `Generic` instance in `claude`+  1.5.0, so the `message_delta` usage is read through `OverloadedRecordDot`+  rather than a generic-lens label. `Messages.max_tokens` and+  `Messages.output_config` became ambiguous selectors — `Messages.Fallback`+  carries both names — so the provider's tests read them through `^. #max_tokens`+  and `^. #output_config` instead.++### Removed++- `baikai-claude`, `baikai-openai` **0.6.0.0** (breaking): the eight registration shims —+  `registerWith`, `registerWithRegistry` and `registerWithRegistryAndConfig` in+  both `Cli` modules, and `registerWithRegistry` in both `Api` modules. Register+  the exported provider value instead:+  `registerApiProvider (claudeCliProvider cfg)`,+  `registerApiProviderWith reg (codexCliProvider cfg)`,+  `registerApiProviderWith reg claudeMessagesProvider`. The batch-mode note that+  had accumulated on `registerWith` — why `complete` stays on the direct path+  rather than going through `streamingComplete` — moves to the provider value it+  describes. (REV-2 G.3.)++- `baikai-claude`, `baikai-openai`: `responseToError` and `classifyErrorText`+  (and its private `classifySdkHttpText` half) from both+  `.Internal.ErrorClass` modules. Neither package runs a `servant-client` client+  on the chat path any more, so the `ClientError` branch was unreachable, and the+  text classifiers parsed a string shape the local SSE transports stopped+  producing in July. The phrase table `classifyErrorText` held survives as the+  message fallback inside `classifyErrorFrame`, pinned through the entry point the+  runtime actually uses. Both modules are documented as outside the PVP-stable+  surface, so this is not a major bump; version bumps are recorded once, later.++- **Breaking.** `baikai-claude`: `Baikai.Provider.Claude.Api.anthropicStrength`+  and `baikai-openai`: `Baikai.Provider.OpenAI.Api.openaiStrength`, both replaced+  by `Baikai.Evidence.deriveStrength`.++### Fixed++- `baikai-claude`, `baikai-openai`: a failure that lands while the response body+  is streaming is classified as the transient failure it is. A connection reset,+  a server closing the socket mid-chunk, a body shorter than its declared length+  and a TLS session torn down after the handshake all now terminate the stream+  with `TransientError` and `isRetryable = True`, carrying whatever text had+  already been drained. Every one of them used to be `OtherError` with+  `isRetryable = False`, while the identical failure at connect time was+  transient — because `http-client` wraps the connect phase with the manager's+  exception wrapper and the body reader with nothing that converts a socket+  `IOException` or a `TLSException`, so those reached the worker raw and missed+  the `HttpException` branch entirely. (REV-2 A.2.)++- `baikai-claude`, `baikai-openai`: a transport failure mid-stream now closes+  the blocks that were open when it arrived, on both providers, so a consumer+  reading raw events and a consumer reassembling them see the same partial+  output. Both providers built their terminal from the closed blocks alone and+  silently dropped open text, thinking and tool arguments. On the Claude side+  this covers `translate (Left …)`, the in-band `error` frame, and the+  unexpected end of stream. (REV-2 B.3.)++- `baikai-claude`: an SSE frame whose event `type` — or whose+  `content_block_delta` `delta.type` — the SDK has no constructor for is now+  skipped instead of ending the stream with a decode error. The SDK decodes both+  with no unknown-tag fallback, so a new frame type from Anthropic used to be a+  terminal fault. A frame of a *known* type that still fails to decode remains+  one. `Baikai.Provider.Claude.Sse` exports the new `decodeFrame`. (REV-2 B.5.)++- `baikai-claude`, `baikai-openai`: an empty `data:` heartbeat is ignored, and+  on the OpenAI side `[DONE]` is compared after trailing whitespace is trimmed,+  so `data: [DONE] ` and `data: [DONE]\r` end the stream rather than failing to+  decode. (REV-2 A.8.)++- `baikai-claude`: every failing stream now begins with `EventStart`. The+  producer pre-seeds the start event before the first wire read, exactly as the+  OpenAI producer already did, and `message_start` updates the assembler without+  emitting a second one. Previously a 401, a rate limit, an in-band `error`+  frame or an EOF arriving before `message_start` produced a lone `EventError`,+  breaking the protocol `Baikai.Stream.Event` documents. `StartPayload.responseId`+  is consequently `Nothing` on both HTTP providers; the provider's message id+  rides `TerminalPayload.responseId`, which `reassembleResponse` already prefers.+  (REV-2 A.4, REV-1 Theme 1.1.)++- `baikai-claude`, `baikai-openai`: an asynchronous exception delivered to the+  stream worker can no longer strand its consumer. End-of-frames is a flag set+  by the worker fork's own `finally` rather than a sentinel value pushed onto+  the channel, so a worker that dies without running its normal exit path still+  ends the stream in an `EventError`. Previously the consumer blocked until the+  runtime's deadlock detector noticed.++- `baikai-smoke`: two keyed cases against `claude-sonnet-5` — one asking for+  thinking (which is a 400 before this release) and one setting `temperature` — plus+  `deepseek-chat` and `openrouter/openai/gpt-4o-mini` in `apiCases`, so the tool and+  structured-output smokes run against a compatible host that is not OpenAI.+  `CompatSmoke` now asserts DeepSeek honoured the output cap rather than only that it+  answered, and `CacheSmoke` asserts the cached token classes cost something.++- `baikai-claude`: a thinking request on `claude-sonnet-5` no longer 400s. It sends+  `"thinking":{"type":"adaptive"}` and no `budget_tokens`, because the shape is read off+  the model's catalog record rather than guessed from its id. (REV-2 C.1.)++- `baikai-claude`: `temperature` and `top_p` are no longer sent to a model generation that+  rejects them with a 400. They are omitted and the omission is recorded as+  `sampling_dropped_unsupported_model` in the call's evidence. `seed`, `frequencyPenalty`+  and `presencePenalty`, which the Anthropic Messages API has no field for on any+  generation, are recorded as `sampling_dropped_unsupported_api`. (REV-2 C.1, C.5.)++- `baikai-claude`: a model whose `maxOutputTokens` is `0` no longer sends+  `"max_tokens":0`, which Anthropic rejects — and, with thinking set, no longer had its+  whole thinking plan discarded for not fitting inside a ceiling of zero. It sends+  `uncappedMaxTokensFloor` (1024, the SDK's own default) instead. An explicit+  `maxTokens = Just 0` is still forwarded as written. (REV-2 C.2.)++- `baikai-claude`: replay no longer sends an empty text block or an empty `content` array,+  both of which Anthropic rejects. An empty text block is dropped; an assistant turn left+  with nothing is dropped whole (it is baikai's own artifact — a block that closed with no+  deltas, or only unsigned thinking, which replay already omits); a user turn left with+  nothing is refused locally with a message naming the turn. (REV-2 C.3.)++- `baikai-claude`: tool-call ids that differ only in characters the alphabet forbids, or+  only past character 64, no longer normalise onto the same id and misroute a tool result.+  A conforming id passes through unchanged — every id Anthropic and OpenAI actually mint+  does — and any other is truncated to 51 characters and suffixed with twelve hex+  characters of its SHA-256. Two `tool_use` blocks in one turn that still collide are+  refused rather than sent. (REV-2 C.7.)++- Documentation: `baikai-claude`'s and `baikai-openai`'s Haddock point at the+  functions that exist. `Baikai.Compat` named+  `Baikai.Provider.OpenAI.Api.mkOpenAIResponseFormat`,+  `…Api.applyThinkingFormat` and `…Api.translateTextLikeDelta`; the first two+  moved to `…Internal.Request` and the third is+  `…Internal.Stream.scanThinkTags`. `ThinkingFormat`'s note said the six+  non-native shapes all clamp through `compatibleEffort`; three do, Z.ai and+  Qwen send a bare toggle, and `ThinkingFormatNone` drops the control.+  `immediateError` carried two `-- |` headers where one was intended.+  (REV-2 H.4.)++- `baikai-claude`: an Anthropic call reports its thinking tokens. `Usage.reasoningTokens`+  was hard-coded to `Nothing` on this provider because `claude` 1.4.0's+  `Messages.Usage` had no breakdown to read; 1.5.0 adds+  `output_tokens_details.thinking_tokens`, and both `message_start` and+  `message_delta` now fill the field from it. `reasoningTokens` is an+  informational subset of `outputTokens`, so no total and no cost moves.++- `baikai-claude`: the prompt-side token counts survive a server-side tool run.+  The final `message_delta` used to contribute only `output_tokens`, and+  `inputTokens`, `cacheReadTokens` and `cacheWriteTokens` kept whatever+  `message_start` had reported — which is wrong for a call whose prompt grew+  mid-stream. `claude` 1.5.0 exposes those three on `Messages.StreamUsage`, and+  each is now taken when present. An absent field still keeps the+  `message_start` figure rather than zeroing it, so a model that sends only+  `output_tokens` is accounted for exactly as before.++## [baikai-openai 0.6.0.0] - 2026-08-28++### Added++- `baikai-openai`: `Baikai.Provider.OpenAI.Internal.ErrorClass.classifyErrorFrame`+  and `Baikai.Provider.OpenAI.Api.parseFrame`, which sort a decoded SSE payload+  into a classified in-band error or a completion chunk.++### Changed++- `baikai-openai`: **breaking.** `Baikai.Provider.OpenAI.Shape`'s+  `injectThinkingShape`, `describeThinkingShape`, `shapeRequestBody` and+  `streamRequestBody` take a `Bool` after the compat record — whether the model+  advertises reasoning support (`Model.reasoning`). A level on a `reasoning = False`+  model now sends no `reasoning_effort`, `reasoning`, `thinking` or `enable_thinking`+  key on any host, and records `thinking_dropped_unsupported_model` instead. The model+  check runs before the host-format check. This is what stops `gpt-4o-mini` plus a+  level from 400ing. (REV-2 C.4.)++### Fixed++- `baikai-openai`: an in-band `{"error": …}` frame on a `2xx` stream terminates+  the call with the frame's own classification, status and message. Compatible+  hosts (OpenRouter, DeepSeek, Together) report an upstream failure they only+  learned about after committing to a `200` this way, and `parseChunk` never+  looked at `error`. The pre-fix behaviour was worse than a bad category:+  OpenRouter's frame carries `choices[0].finish_reason = "error"`, which mapped+  to `Stop`, so the call ended as `EventDone` with `errorInfo = Nothing` — a+  consumer switching on the terminal saw a *completed* call. A frame with no+  `choices` beside the error ended as+  `OtherError "openai stream ended without finish_reason"`. (REV-2 A.3.)++- `baikai-openai`: reasoning that arrives after visible text closes the open+  text block before opening the thinking block, so at most one of the two is+  open at a time, every `_End` precedes the next `_Start`, and no `contentIndex`+  is revisited after a later one. (REV-2 B.4.)++- `baikai-openai`, `baikai-claude`: **a provider POST no longer follows+  redirects.** `http-client`'s default is to follow up to ten with every header+  intact, so a 3xx would have re-sent the bearer token (or `x-api-key`) to+  whatever host the `Location` header named. `redirectCount` is now zero and the+  3xx is delivered as the one in-band terminal error carrying its status. Each+  transport's request builder is exported as `buildRequest`, so the method, the+  composed path and the redirect policy are assertable without a connection.+  (REV-2 A.5 / E.4.)++- `baikai-openai`, `baikai-claude`, `baikai`: **the base-URL convention is+  stated and enforced.** `Model.baseUrl` and `EmbeddingModel.baseUrl` are the+  API *root* — the host, or the prefix a host mounts the API under — because+  baikai appends `/v1/chat/completions`, `/v1/messages` or `/v1/embeddings`+  itself. A trailing `/v1` is accepted and removed rather than doubled, so+  `https://api.deepseek.com/v1` now requests `/v1/chat/completions` instead of+  `/v1/v1/chat/completions`. A base URL with no scheme, a scheme other than+  `http`/`https`, credentials, a query string, a fragment, or a path that is+  already an endpoint is refused as an `InvalidRequest` naming the problem —+  and refused *before* a key is read, so an unusable base URL never causes a+  credential to be looked up. The message renders the URL without its userinfo+  or query, so it is safe to log. `docs/user/models-and-providers.md` gains a+  **Base URLs** section stating all of it. (REV-2 A.6.)++- `baikai-openai`, `baikai-claude`: the `ClientEnv` cache was duplicated in each+  package and keyed on the raw base-URL text, so `https://h` and `https://h/`+  were two TLS managers and two connection pools to one host. There is now one+  cache, in `Baikai.Http`, keyed on the canonical rendering of the parsed base+  URL. `Transport.getClientEnvCached` and `Transport.cachedClientEnvCount` are+  re-exports of the core functions and keep their signatures.++- `baikai-openai`: the Codex interactive launcher now **refuses the two approval+  policies the installed CLI rejects**. `codex --help` at `codex-cli 0.149.1`+  lists exactly `on-request` and `never` for `--ask-for-approval`;+  `CodexApprovalUntrusted` and `CodexApprovalOnFailure` are older spellings the+  CLI answers with `error: invalid value 'untrusted' for+  '--ask-for-approval'`. Rendering them made a launch return `Right` carrying a+  non-zero exit code — a session that ran and failed — instead of the `Left+  SafetyNotExpressible` this module promises for a policy that cannot be+  honoured. They are refused before any process is created, and refused rather+  than quietly mapped onto `on-request`, because substituting a different+  approval policy would change what the caller asked for. The constructors and+  their spellings are unchanged, so code that matches on `CodexApprovalPolicy`+  keeps compiling.++## [baikai-trace-otel 0.4.0.0] - 2026-08-28++### Added++- `baikai-trace-otel`: `OtelSinkOptions` derives `Generic`, so `#spanName`+  resolves on it. No `Eq` or `Show`: `OpenTelemetry.Context.Context` has neither,+  and an instance that ignored `parentContext` would be a lie. (REV-2 G.6.)++- `baikai-trace-otel`: `OtelSinkOptions.parentContext :: Maybe Context`, default+  `Nothing`. When set, every span the sink opens becomes a child of the span in+  that context instead of a root, so a call can be nested under the caller's own+  request span. It is a value fixed when the sink is built rather than an action+  run per call, because the fold runs on baikai's trace worker thread where the+  caller's thread-local context is invisible: capture the context on your own+  thread (`ctx <- getContext`, or `Context.insertSpan mySpan Context.empty`) and+  build the sink for that request. __Breaking for positional construction__ of+  `OtelSinkOptions`; the documented path is a record update on+  `defaultOtelSinkOptions`. (REV-2 D.9.)++### Changed++- `baikai-trace-otel`: the `baikai.evidence.strength` span attribute is rendered+  by `Baikai.Evidence.renderEvidenceStrength`, the function the JSON encoding+  uses, instead of a second spelling local to the sink that could drift from it.++- `baikai-trace-otel`: `gen_ai.response.model` is set only by the evidence+  branch, from the model the provider reported. The terminal branch set it from+  the *requested* id, and since evidence is pushed before the terminal and+  `addAttributes` replaces a key, that both labelled a request as an observation+  on every call without evidence and overwrote the genuinely observed value on+  every call with one. (REV-2 D.1.)++## [baikai-effectful 0.4.0.0] - 2026-08-28++### Changed++- `baikai-effectful` (breaking): the version is a **major** bump although this+  package's own exports are unchanged. Its `baikai` bound moves to `^>=0.6.0`,+  and the `Baikai` effect's three operations are typed in `Model`, `Context`,+  `Options` and `Response` — every one of which baikai 0.6.0.0 changes+  breakingly. A consumer therefore meets a break through this package even+  though nothing in it was renamed, so the number says so rather than making+  `0.3.0.4` look like a safe upgrade.++- `baikai-effectful`: no longer depends on `streamly`. Both stanzas listed it+  while every module imports only `Streamly.Data.Fold` and+  `Streamly.Data.Stream`, which are `streamly-core`. (REV-2 minor.)++## [baikai-kit 0.2.0.0] - 2026-08-28++### Added++- `baikai-kit`: `Baikai.Kit.Error` with the closed `KitError` sum, its+  `Exception` instance and `renderKitError`; `Baikai.Kit.Path.safeSourcePath`,+  which resolves an untrusted relative source below the kit checkout and refuses+  a symbolic link in any component or a canonical path outside the checkout;+  `Baikai.Kit.Manifest.itemSources`/`ItemSources`, the one pure derivation of an+  item's source list, and `supportedManifestVersions`;+  `Baikai.Kit.Sidecar.hashEntries`; `Baikai.Kit.Repo.KitRepo`/`RepoRefresh`;+  `Baikai.Kit.Install.installFrom`, `renderAvailable` and `UpdateReport`;+  `Baikai.Kit.Status.StatusReport`, `UpstreamAvailability` and the now-pure+  `renderStatusTable`; `Baikai.Kit.Command.runKitCommand`. `KitState` gains+  `KitUpstreamRefused`, rendered `refused`. (REV-2 E.5, F.10, F.11.)++- `baikai-kit`: `Baikai.Kit.Install.OverwritePolicy` (`KeepLocalEdits`,+  `OverwriteLocalEdits`), `reinstallPresent` (the network-free half of+  `updateKit`), and `PlannedWrite`/`WriteContent`/`executePlan`/`executePlanWith`+  as a test seam. `SidecarMeta` gains `installedFiles` and `installedHash`,+  which record what this tool wrote for one provider and the hash of exactly+  those bytes; `newSidecarMeta` takes both. `kit update` gains `--force`.+  (REV-2 F.12, Theme 8.2.)++### Changed++- **Breaking.** `baikai-kit`: every library function returns+  `Either KitError a` and prints nothing; only+  `Baikai.Kit.Command.runKit` prints `Error: …` and exits 1. `loadManifest`,+  `loadManifestMaybe`, `installItem`, `listAvailable`, `uninstallItem`,+  `updateKit` and `ensureKitRepo` change shape accordingly, `computeKitHash`+  takes the kit root, a base and relative file names, `kitStatus` returns a+  `StatusReport` instead of printing, and `KitUpdate`'s report is rendered by+  the caller. See `docs/adr/0013-library-code-never-calls-exitfailure.md`. A+  consumer that only calls `runKit` and `kitCommandParser` needs no change; one+  that calls the library directly binds `Right`. (REV-2 F.11.)++- `baikai-kit`: a kit is plain files. Install, the content hash and `kit status`+  resolve every listed source through `safeSourcePath`, so a kit repository that+  commits a symbolic link can no longer have a file read through it and copied+  into a provider directory. `kit status` shows such an item as `refused`.+  (REV-2 E.5 = F.10.)++- `baikai-kit`: a manifest whose `version` is not 1 or 2 is refused with+  `KitManifestVersionUnsupported` instead of being decoded and installed.+  (REV-2 F.12.)++- `baikai-kit`: an agent that lists several `files` installs all of them. The+  first becomes the provider's agent file as before, and each remaining file+  goes into a resource directory named after the agent beside it+  (`<agents dir>/<name>/<file>`), which uninstall removes with the agent. Only+  the first file used to be installed. (REV-2 F.12.)++- `baikai-kit`: `kit update` skips an item whose installed files no longer hash+  to what its sidecar recorded, printing the `--force` invocation that would+  overwrite them; `kit update --force` reinstalls anyway. Sidecars written+  before this release carry no such hash and are updated without the check.+  (REV-2 Theme 8.2.)++### Removed++- **Breaking.** `baikai-kit`: `Baikai.Kit.Path.safeUnder` (exported and unused),+  `Baikai.Kit.Manifest.agentSources` (replaced by `itemSources`) and+  `Baikai.Kit.Install.uninstallOutcomes` (absorbed by `uninstallItem`, which now+  returns the outcomes for the caller to render). The internal `requireSafe` and+  `Baikai.Kit.Status.resolveCacheOrEmpty` are gone with the exits they wrapped.++### Fixed++- `baikai-kit`: `kit status` with no cache and no network prints+  `No kit items installed.` and exits 0. It used to exit 1: the guard around+  `ensureKitRepo` caught `IOException`, which is not what `exitFailure` throws.+  (REV-2 F.11.)++- `baikai-kit`: `Baikai.Kit.Status.upstreamHash` joined the manifest `path`+  without validating it, a second unsanitised join that grew after the July+  hardening pass validated the first. Both now go through `itemSources` and+  `safeSourcePath`. (REV-2 Theme 8.1.)++- `baikai-kit`: an install that fails while renaming files into place now+  restores what was there before, or names the paths it could not restore.+  Phase two was a bare loop of renames, so a failure part-way left earlier+  renames in place while the message said "no changes were made". Temporary+  files are also created with `openTempFile`, so two concurrent installs of one+  item no longer clobber each other's staging file, and a destination that is a+  directory is refused before anything is written. (REV-2 F.12.)++- `baikai-kit`: `Baikai.Kit.Install.stripYamlFrontmatter` normalises line+  endings to LF on every branch. Input without frontmatter, and input whose+  frontmatter is never closed, used to keep their `\r` characters and leak them+  into the Codex agent TOML. (REV-2 Theme 8.7.)++- `baikai-kit`: an `IOException` raised while reinstalling during `kit update`+  is returned as `KitWriteFailed` instead of escaping as an uncaught exception.+  (REV-2 Theme 8.4.)++## [baikai-agent 0.2.0.0] - 2026-08-28++### Added++- `baikai-agent`: three operator-only `policy` keys — `policy.allowed-tools`,+  `policy.max-timeout` (a duration or `"unlimited"`) and+  `policy.max-output-limit` (a byte count or `"unlimited"`) — each defaulting+  from `defaultAgentCeiling`, and all six ceiling fields now printed by+  `agent show` and carried in its `--json` object.++- `baikai-agent`: `Baikai.Agent.Config.repositoryScopeViolations`, which reads+  the resolution report to say which values the untrusted repository file was+  not allowed to supply at all. `Baikai.Agent.Cli` concatenates its answer with+  the pure ceiling's, so an operator sees one refusal naming every problem.++### Changed++- `baikai-agent` (breaking): `AgentConfigScope`'s constructors are+  `AgentUserScope` and `AgentRepositoryScope`. `UserScope` collided with+  `baikai-kit`'s `KitScope` constructor of the same name, the one clash between+  two baikai-family packages. (REV-2 G.5.)++- `baikai-agent` (breaking): a relative `working-dir` resolves against the+  repository root rather than the process's own directory, so `working-dir "."`+  means the checkout whichever file declared it. Resolving against the process+  directory made `"."` mean two places when two documents defined one job, since+  which one it was depended on which layer won. An absolute path is unchanged.+  (REV-2 F.14.)++- `baikai-agent` (breaking): every `--json` output is now built with `aeson`+  rather than a hand-rolled writer, and `agent show --json` always emits one+  object with the same seven keys — `job`, `outcome` (`shown`, `refused` or+  `failed`), `exitCode`, `message`, `configuration`, `ceiling`, `command` —+  with `null` for the parts that do not apply. Previously a refusal emitted a+  different shape from a success and a document that would not parse emitted a+  bare resolution report or nothing at all, so a reader had to know which+  failure mode it was looking at before it could find the exit code. `run --json`+  keeps its `outcome` values and `list --json` is unchanged. (REV-2 F.14.)++- `baikai-agent` (breaking): `--run-id` or `--require-evidence` without either+  `--evidence-file` or `--json` is now a usage error (64) naming both fixes.+  Before, the record was built — a `--version` probe of the tool and two digests+  — and then dropped. Under `--json` the record now travels in the envelope as+  `evidence`, encoded by the same `ToJSON` `--evidence-file` writes.++- `baikai-agent`: `agent show` and `agent run` no longer print another job's+  unknown-key warnings, or the operator file's `policy` keys. The declaration+  describes one job and the ceiling is a separate declaration, so `settei` warns+  about both; neither is a mistake and a document with four jobs printed three+  jobs' worth of noise on every run. A misspelled key inside the selected job+  still warns, and a `policy` node in the *repository* document earns exactly one+  notice saying it has no effect. `Baikai.Agent.Config` exports the two filters,+  `relevantWarnings` and `repositoryPolicyNotice`. (REV-2 F.13.)++- `baikai-agent`: an evidence record's `endpoint` resolves a relative executable+  against the job's working directory before probing it, because that is what+  the child execs. A job whose `executable` is `./bin/agent` previously reported+  a path resolved against the parent's own directory, which does not exist.+  `Baikai.Agent.Run` exports `executableForEvidence`. (REV-2 F.13.)++- `baikai-agent`: a failed run's `error_info.message` keeps the last+  `errorInfoStderrTailBytes` (4096) bytes of standard error, prefixed with how+  many earlier bytes were dropped, instead of the whole captured stream — which+  the output limit allows to reach four mebibytes by default. `Baikai.Agent.Run`+  exports the constant. (REV-2 F.13.)++- `baikai-agent`: `--evidence-file` stages through a uniquely named temporary+  file created with `O_EXCL` beside the destination, instead of the destination+  plus `.partial`. A symbolic link planted at the old, guessable name was+  followed, which let an unattended run overwrite a file of the planter's+  choosing. (REV-2 F.13.)++- `baikai-agent` (breaking): an operator configuration file that lies inside the+  repository root is refused with exit 78, naming the file and the root, and no+  ceiling is established. The source list already refused the repository+  *document*; this closes the shape where the repository supplies the *operator*+  document, which both `--user-config .baikai/policy.kdl` and+  `XDG_CONFIG_HOME=$PWD/.baikai` produce. `--user-config`, `XDG_CONFIG_HOME` and+  `HOME` remain the operator's own inputs: the ceiling is exactly as trustworthy+  as the process environment that selects it, and the guide now says so.+  (REV-2 F.4.)++- `baikai-agent` (breaking): an unrecognised key under the operator file's+  `policy` node is an error rather than a warning, naming the file and every+  such key. Everywhere else a forward-compatible file should not stop an older+  binary; under `policy` a misspelling would silently leave the default ceiling+  in force, which for the one node whose purpose is limiting authority is+  indefensible. Two `AgentConfigError` constructors are added,+  `CeilingFileInsideRepository` and `UnknownPolicySetting`.++- `baikai-agent` (breaking): `AgentConfigPaths` gains `repositoryRoot`, the+  directory the process runs in. `--config PATH` chooses which file supplies+  repository-scope settings and does not move the root, because the root is what+  confines a repository-supplied `working-dir`.++- `baikai-agent` (breaking): a repository configuration file may no longer set+  `executable` or a non-empty `extra-dirs`, and its `working-dir` must resolve —+  after following symbolic links — inside the repository root. Each is refused+  with exit 77 naming the setting, or naming both directories. The operator's+  own file and `--set` may still set all three. `executable` turns configuration+  into code execution with the operator's environment and the prompt on standard+  input; `extra-dirs` inside the root adds nothing the working directory does not+  already give, so the only ones a checkout would ask for are outside it.+  (REV-2 F.3.)++### Removed++- `baikai-agent` (breaking): the `BAIKAI_AGENT_EXECUTABLE` environment binding.+  An environment variable is inherited by every child process and is easy to set+  by accident, and naming the program to run is the widest widening there is.+  An operator whose installation is not on `PATH` writes `executable` in their+  own configuration file or passes `--set`.++### Fixed++- `baikai-agent`: a timed-out run now **escalates to `SIGKILL`**. The runner+  interrupts the child's whole process group, then terminates it, then kills it,+  each of the first two stages bounded by the grace period and ended early once+  the leader has been reaped and no member of the group is left. Previously the+  last resort was `terminateProcess` followed by an *unbounded* wait, so a+  coding agent that ignored `SIGTERM` — or a grandchild holding the output pipe+  — hung the run for as long as it chose to live, with the deadline already+  past. Polling the group rather than waiting on the leader alone is also what+  gives a grandchild the same grace the agent gets.++- `baikai-agent`: a timed-out run **reports the output it drained**. `baikai+  agent run` prints it under the same stream discipline a finished run gets, so+  `response=$(baikai agent run job)` under `capture` receives the partial answer+  with `$?` set to 75, and `--json`'s failure envelope carries the same+  `stdout`, `stdoutTruncated`, `stderr` and `stderrTruncated` fields. A drain+  interrupted because something outside the process group still held the pipe+  open keeps its bytes too, reported as truncated.++- `baikai-agent`: the `baikai` command writes its output as **UTF-8 bytes**+  rather than through the locale encoding. Where an unattended run actually+  happens — cron, a systemd unit, a container — the environment says `LANG=C`,+  and on a platform whose locale encoding follows it a single accented character+  in the agent's answer made the write throw after the run had already finished:+  exit 1, answer lost. This mirrors what the prompt read and the prompt write+  have always done.++- `baikai-agent`: the `baikai` executable now links the **threaded runtime**+  (`ghc-options: -threaded` on the `executable baikai` stanza). Without it a+  blocking operating-system call — the `waitpid` inside+  `System.Process.waitForProcess` — stopped every Haskell thread in the+  installed binary, so a job's configured `timeout` could never fire and a+  coding agent that wrote more than one pipe buffer deadlocked against the+  runner's drain threads. Both defects existed only in the shipped executable:+  the test suite was already compiled `-threaded`, so every runner test passed+  under a runtime the binary did not have.++  The suite now proves the runtime the binary ships with rather than its own.+  `baikai-agent/test/BinaryTests.hs` spawns the built executable — cabal builds+  it first and puts it on the suite's `PATH` through+  `build-tool-depends: baikai-agent:baikai` — asserts that `baikai +RTS --info`+  reports `rts_thr`, and runs `baikai agent run` against a stub agent that+  outlives its deadline, requiring exit 75 within seconds and the whole process+  group gone. See+  [docs/adr/0006](docs/adr/0006-a-process-spawning-executable-ships-on-the-threaded-runtime.md).++## [baikai 0.5.0.0] - 2026-08-05++### Added++- `baikai`: new exposed module `Baikai.Agent`, the provider-neutral vocabulary+  for an **unattended coding-agent run** — a run with no terminal and no human,+  which owns its own tool loop, may change files inside directories the caller+  authorized, and returns a process result rather than a `Response`. It defines+  `AgentRunRequest` (with a required `workingDir`), `AgentRunResult`, the+  `AgentCapability` profile (`read-only`, `edit-workspace`, `full-access`),+  `AgentSafety`, the `AgentOutputMode` and `AgentCapturedOutput` output+  discipline, the `AgentCommand` renderer/runner boundary with an explicit+  prompt transport, and the `AgentRenderError` / `AgentRunFailure` taxonomies.++- `baikai`: the operator policy ceiling — `AgentCeiling`,+  `defaultAgentCeiling`, `CeilingViolation`, and the pure `applyAgentCeiling`.+  It returns a request unchanged when it is within the ceiling and reports+  every violation when it is not; it never clamps an over-broad request to the+  permitted value. The default ceiling permits read-only and edit-workspace+  authority and refuses full access and raw provider arguments.++  `Baikai.Agent` itself is vocabulary and pure policy algebra only: it spawns no+  process and renders no command-line flags. Those live in the vendor packages+  and in `baikai-agent`, below. The module is deliberately not re-exported from+  the umbrella `Baikai` module, because its field accessors share names with+  `Baikai.Interactive`, so `import Baikai` continues to compile unchanged.++- `baikai`: new exposed module `Baikai.Evidence`, the vocabulary for+  **verifiable model-call evidence** — a record of what actually crossed the+  boundary to a provider, as opposed to what the process was configured to ask+  for. It defines `ModelCallEvidence` and the `evidenceSchemaVersion` string+  consumers pin against, `Observed` (a deliberate non-`Maybe` for a value the+  provider either did or did not report, with no function that supplies a+  default), `ThinkingTranslation` with its `ThinkingMode` and+  `ThinkingAdjustment` enumerations describing what a requested+  reasoning-effort level actually became on the wire and every clamp, collapse,+  or drop applied on the way, `EndpointIdentity` and `TransportKind`,+  `CallStatus`, and the ascending `EvidenceStrength` scale.++  It also provides the canonical hashing core: `canonicalEncode` gives a JSON+  value exactly one byte representation (object keys sorted, no insignificant+  whitespace, numbers normalised so `1`, `1.0`, `1.00`, and `1e0` all encode as+  `1`, and a hand-written string escaper so an aeson upgrade cannot silently+  invalidate a recorded digest); `commitmentDigest` hashes a full request+  envelope, and `configurationDigest` hashes an allow-list projection+  (`configurationProjection`) that keeps configuration and replaces content with+  structural summaries, so two calls that ask the same model the same way about+  different subjects agree. The two digests are separate on purpose: the first+  binds a record to a particular request, the second is safe to compare across+  runs that legitimately differ in content.++  Nothing constructs a `ModelCallEvidence` from a real call yet, and no existing+  behaviour changed. New dependencies: `cryptohash-sha256` and+  `base16-bytestring`, both single-purpose packages chosen over a full+  cryptographic framework.++- `baikai`: `Options` gains an `evidence` field carrying an optional+  `EvidenceRequest` — the caller's run identifier, retry provenance, and how+  strictly they need evidence. A call whose `evidence` is `Nothing`, which is+  every call that does not opt in, behaves exactly as it did before: no digest+  is computed and no evidence is emitted.++- (Entry added 2026-08-27; the behaviour shipped in 0.5.0.0.) `baikai`: **strict+  evidence mode**. `EvidenceStrictness` is `EvidenceBestEffort` or+  `EvidenceRequired !EvidenceStrength`, and a caller who asks for the second+  gets a call that **refuses to start** — before any request is built or any+  connection opened — when the configuration cannot reach the strength asked+  for: `Baikai.Evidence.Build.checkEvidenceRequirements` compares the+  requirement against what the provider can deliver and against the thinking+  translation, and `completeRequest` / `streamRequest` return an error-shaped+  response or a terminal `EventError` instead of dispatching. The gate is+  pre-dispatch by design; that is the only point at which refusing is still+  free.++- (Entry added 2026-08-27; the behaviour shipped in 0.5.0.0.) `baikai`:+  **sink-failure semantics under strict mode**. `Baikai.Evidence.Build`+  exports `onSinkFailure`, `sinkFailureIsFatal` and `sinkFailureError`: a trace+  sink that throws fails an `EvidenceRequired` caller's call, because a record+  the sink did not confirm written is not a record, while a best-effort caller's+  call succeeds with the failure reported on stderr.++- (Entry added 2026-08-27; the behaviour shipped in 0.5.0.0.) **Breaking.**+  `baikai`: `Baikai.Provider.Registry.ApiProvider` gained a fourth field,+  `describeThinking :: Model -> Options -> ThinkingTranslation`, which the+  pre-dispatch strictness gate calls to learn what a provider would do with the+  caller's reasoning-effort request without sending anything. Every third-party+  provider constructed with the `ApiProvider` constructor stopped compiling.+  This was not recorded at the time; it is the defect that made 0.6.0.0 hide the+  constructor behind `apiProvider` so that the next field addition is a minor+  release.++- `baikai`: model-call evidence is now **produced and emitted**. A caller who+  sets `Options.evidence` gets exactly one `call_evidence` line per call from+  their trace sink, under every way a call can end: success, provider failure, a+  consumer that abandons the stream (status `aborted`, not `failed` — an abort+  is the consumer's doing and reporting it as a provider failure would+  misattribute it), and dispatch that found no registered handler.++  New exposed module `Baikai.Evidence.Build` bridges the vocabulary to the+  `Model` and `Options` records: `minimalEvidence` and `prepareEvidence` build a+  record, `dispatchEnvelope` supplies the request envelope for the paths where+  no adapter ran, `sanitizeEndpoint` reduces a base URL to scheme/host/port/path+  with the query string and any userinfo dropped wholesale, and `onSinkFailure`+  is the hook a future release replaces to make a strict caller's call fail when+  the trace sink does.++  Every record this release produces has `strength` `requested_only` and every+  provider-observed field set to `"unobserved"`. That is not a placeholder: it+  is a truthful record for a transport that has not yet been taught to observe+  anything. Later releases teach each transport to observe more.++  (Correction added 2026-08-27: the two paragraphs above describe the release+  inaccurately and are kept as shipped rather than rewritten. `onSinkFailure`+  did not await a future release — it shipped in 0.5.0.0 together with+  `sinkFailureIsFatal` and `sinkFailureError`, which already fail a strict+  caller's call when the sink throws. And not every 0.5.0.0 record has `strength`+  `requested_only`: the provider entries below describe what each transport+  reports, and the HTTP adapters reach `correlated` and `model_observed`.)++  **A caller who does not opt in pays nothing.** With `Options.evidence` absent+  no digest is computed, no call identifier is generated, no evidence event is+  emitted, and the request envelope is never even forced — the gate lives inside+  the shared builder rather than at each adapter's call site, and the envelope+  parameter is deliberately lazy. Both facts are guarded by tests.++- `baikai`: `TraceEvent` gains a `CallEvidence` constructor, encoded as+  `{"kind":"call_evidence", …}`. A consumer whose pattern match over `TraceEvent`+  is exhaustive must add a branch; one with a wildcard is unaffected. Filter for+  it with `jq 'select(.kind == "call_evidence") | .evidence'`. Note that a trace+  line carries its fields alongside the `kind` discriminator rather than nested+  under a `data` key, and that the evidence record inside spells its own fields+  in snake_case — the two encodings differ deliberately, because an evidence+  record must render an absent field as explicit `null` while a trace line drops+  it to stay small.++- `baikai`: `Baikai.Provider.Cli.Internal` — the module the two subprocess+  providers share — gains the vocabulary for reading what a coding-agent CLI+  reported about its own run. `CodexRunReport` and the new+  `parseCodexJsonlStream :: Stream IO ByteString -> IO CodexRunReport` fold the+  `codex exec --json` event stream into its assistant text, its thread+  identifier, and its token counts, instead of concatenating agent-message text+  and discarding everything else. `ClaudeCliReport` and+  `decodeClaudeCliResult` do the same for `claude -p --output-format json`.+  Every field but the message text is optional, because both tools' event+  schemas have changed across versions and an absent field is a genuine absence+  rather than a parse failure. **Breaking** for anyone calling+  `parseCodexJsonlStream` directly: its result type is no longer `Text`. This is+  an internal module and is documented as outside the PVP guarantee.++- `baikai`: `Baikai.Provider.Cli.Internal` also gains `ExecutableIdentity` and+  `executableIdentity`, which resolve a configured executable name to an+  absolute path and read the tool's own `--version` line. The probe is cached+  per resolved name for the lifetime of the process, because spawning it per+  model call would roughly double the process cost of the cheapest possible+  call, and it is bounded by a five-second timeout so a tool that hangs on+  `--version` cannot wedge a model call. (Corrected 2026-08-27: the entry said+  two seconds; `versionProbeMicros` has always been five.) A probe that fails records the version   as absent rather than failing the call. It is only ever called from inside   the evidence branch: a caller who asked for no evidence must not pay for a   process whose only purpose is to describe a tool they were about to run
baikai.cabal view
@@ -1,6 +1,6 @@ cabal-version:   3.4 name:            baikai-version:         0.5.0.0+version:         0.6.0.0 synopsis:        Unified Haskell interface for multiple AI providers description:   baikai provides a unified, provider-agnostic Haskell interface for working@@ -16,6 +16,7 @@ maintainer:      nadeem@gmail.com copyright:       (c) 2026 Nadeem Bitar build-type:      Simple+tested-with:     GHC ==9.12.4 extra-doc-files: CHANGELOG.md  common common-options@@ -67,6 +68,8 @@     Baikai.Error     Baikai.Evidence     Baikai.Evidence.Build+    Baikai.Header+    Baikai.Http     Baikai.Interactive     Baikai.Message     Baikai.Model@@ -75,7 +78,9 @@     Baikai.Prelude     Baikai.Provider     Baikai.Provider.Cli.Internal+    Baikai.Provider.Internal.StreamWorker     Baikai.Provider.Registry+    Baikai.Provider.Transport.Classify     Baikai.Response     Baikai.ResponseFormat     Baikai.StopReason@@ -86,6 +91,7 @@     Baikai.Trace     Baikai.Trace.Event     Baikai.Trace.Sink+    Baikai.Url     Baikai.Usage    -- The cabal-generated version module. 'Baikai.Evidence.Build' reads@@ -100,19 +106,26 @@     , base16-bytestring  ^>=1.0     , base64-bytestring  ^>=1.2     , bytestring         ^>=0.12+    , case-insensitive   ^>=1.2     , containers         ^>=0.7     , cryptohash-sha256  ^>=0.11     , directory          ^>=1.3     , filepath           ^>=1.5     , generic-lens       ^>=2.3+    , http-client        ^>=0.7+    , http-client-tls    ^>=0.3+    , http-types         ^>=0.12     , lens               ^>=5.3     , openai             ^>=2.5     , process            ^>=1.6     , scientific         ^>=0.3+    , servant-client     ^>=0.20+    , stm                ^>=2.5     , streamly           >=0.11  && <0.13     , streamly-core      >=0.3   && <0.5     , text               ^>=2.1     , time               ^>=1.14+    , tls                >=2.2   && <2.5     , unliftio-core      ^>=0.2     , vector             ^>=0.13 @@ -180,11 +193,15 @@     GenModelsSpec     HelpersSpec     InteractiveSpec+    PublicSurfaceSpec     StreamSpec+    StreamWorkerSpec     StrictEvidenceSpec     SurfaceSpec     ThinkingLevelSpec     TraceSpec+    TransportClassifySpec+    UrlSpec     UsageSpec    build-tool-depends: baikai:baikai-gen-models@@ -193,14 +210,18 @@     , baikai     , base     , bytestring+    , case-insensitive     , containers     , directory     , filepath     , generic-lens+    , http-client+    , http-types     , lens     , openai     , process     , scientific+    , servant-client     , stm     , streamly-core     >=0.3 && <0.5     , tasty@@ -209,4 +230,5 @@     , temporary     , text     , time+    , tls     , vector
fetch/FetchModelsCore.hs view
@@ -40,6 +40,8 @@     -- * Output catalog shape     CatalogModel (..),     CatalogCost (..),+    CatalogModelCompat (..),+    AnthropicGenerationFacts (..),     Catalog (..),      -- * Provider specs and normalization@@ -62,6 +64,7 @@   ) where +import Baikai.Compat (AnthropicThinkingStyle (..)) import Baikai.Model (InputModality (..)) import Baikai.Prelude import Data.Aeson (Value (String), eitherDecode, encode, withObject, (.!=), (.:), (.:?))@@ -188,6 +191,26 @@   }   deriving stock (Eq, Show, Generic) +-- | The two request-shaping facts every curated Anthropic model must+-- state before it can enter the catalog. Which extended-thinking wire+-- shape a generation accepts, and whether it accepts the sampling+-- parameters @temperature@, @top_p@ and @top_k@, are facts about the+-- generation that no amount of inspecting the model id or the base URL+-- can recover; they are curated here and travel through the catalog+-- JSON into the generated 'Baikai.Compat.AnthropicMessagesCompat'.+data AnthropicGenerationFacts = AnthropicGenerationFacts+  { thinkingStyle :: !AnthropicThinkingStyle,+    supportsSamplingParameters :: !Bool+  }+  deriving stock (Eq, Show, Generic)++-- | A per-model @compat@ block in the catalog JSON. Only+-- @anthropic-messages@ needs one today; the OpenAI-compatible side is+-- fully covered by the file-level @"compat": "auto"@ directive and+-- 'Baikai.Compat.autoDetectOpenAICompletions'.+data CatalogModelCompat = CatalogAnthropicCompat !AnthropicGenerationFacts+  deriving stock (Eq, Show, Generic)+ -- | One emitted catalog model. @enabled@ is always @true@ for emitted -- models, so it is not stored here; the renderer writes it literally. data CatalogModel = CatalogModel@@ -197,7 +220,8 @@     input :: ![InputModality],     cost :: !CatalogCost,     contextWindow :: !Integer,-    maxOutputTokens :: !Integer+    maxOutputTokens :: !Integer,+    compat :: !(Maybe CatalogModelCompat)   }   deriving stock (Eq, Show, Generic) @@ -219,7 +243,11 @@   { provider :: !Text,     baseUrl :: !Text,     api :: !Text,-    include :: !(Text -> Bool)+    include :: !(Text -> Bool),+    -- | The per-model @compat@ block to render, if this provider needs+    -- one. 'const Nothing' for a provider whose file-level+    -- @"compat": "auto"@ directive says everything.+    compatFor :: !(Text -> Maybe CatalogModelCompat)   }   deriving stock (Generic) @@ -253,20 +281,56 @@       "o1"     ] --- | Curation include set for Anthropic: the current generations.-anthropicInclude :: Set Text+-- | Curation include set for Anthropic: the current generations, each+-- keyed to the request-shaping facts of its generation.+--+-- This is the one place a human vets an Anthropic id, so it is also the+-- one place the facts are stated: no id can be curated in without them,+-- and a wholesale refresh cannot lose them. Each entry MUST carry a+-- dated comment naming its source, exactly as 'overrides' does. The+-- generator refuses an @anthropic-messages@ entry that reaches it+-- without a @compat@ block, so a hand edit cannot quietly drop one+-- back to host auto-detection.+anthropicInclude :: Map Text AnthropicGenerationFacts anthropicInclude =-  Set.fromList-    [ "claude-opus-4-8",-      "claude-opus-4-7",-      "claude-opus-4-6",-      "claude-opus-4-5",-      "claude-sonnet-5",-      "claude-sonnet-4-6",-      "claude-sonnet-4-5",-      "claude-haiku-4-5",-      "claude-fable-5"+  Map.fromList+    [ -- 2026-08-27: adaptive-only, sampling parameters rejected with a+      -- 400 — Anthropic API reference cached 2026-06-24, as consulted+      -- by REV-2 C.1 (docs/reviews/correctness-and-api-review-follow-up.md).+      -- docs/plans/60-... named this id as the one the include set did not+      -- yet carry, and stated the facts it would have to arrive with.+      ("claude-opus-5", adaptiveNoSampling),+      -- 2026-08-27: adaptive-only, sampling parameters rejected with a+      -- 400 — same source.+      ("claude-opus-4-8", adaptiveNoSampling),+      -- 2026-08-27: adaptive-only, sampling parameters rejected — same source.+      ("claude-opus-4-7", adaptiveNoSampling),+      -- 2026-08-27: accepts both thinking shapes, but the budget shape is+      -- deprecated for this generation, so baikai sends the adaptive one;+      -- sampling parameters still accepted — same source.+      ("claude-opus-4-6", adaptiveWithSampling),+      -- 2026-08-27: budget shape, sampling parameters accepted — same source.+      ("claude-opus-4-5", budgetWithSampling),+      -- 2026-08-27: adaptive-only, sampling parameters rejected with a 400.+      -- This is the finding: the retired prefix table did not know this id+      -- and sent it budget_tokens — same source.+      ("claude-sonnet-5", adaptiveNoSampling),+      -- 2026-08-27: as claude-opus-4-6 — budget deprecated but functional,+      -- sampling accepted; baikai prefers the non-deprecated shape — same+      -- source. Plan 40 left this membership to a live check that never+      -- happened; docs/plans/60-... M4 is where it meets a real key.+      ("claude-sonnet-4-6", adaptiveWithSampling),+      -- 2026-08-27: budget shape, sampling parameters accepted — same source.+      ("claude-sonnet-4-5", budgetWithSampling),+      -- 2026-08-27: budget shape, sampling parameters accepted — same source.+      ("claude-haiku-4-5", budgetWithSampling),+      -- 2026-08-27: adaptive-only, sampling parameters rejected — same source.+      ("claude-fable-5", adaptiveNoSampling)     ]+  where+    adaptiveNoSampling = AnthropicGenerationFacts AnthropicThinkingAdaptive False+    adaptiveWithSampling = AnthropicGenerationFacts AnthropicThinkingAdaptive True+    budgetWithSampling = AnthropicGenerationFacts AnthropicThinkingBudget True  -- | Provider spec for OpenAI's first-party chat-completions endpoint. openaiSpec :: ProviderSpec@@ -275,7 +339,8 @@     { provider = "openai",       baseUrl = "https://api.openai.com",       api = "openai-chat-completions",-      include = (`Set.member` openaiInclude)+      include = (`Set.member` openaiInclude),+      compatFor = const Nothing     }  -- | Provider spec for Anthropic's first-party messages endpoint.@@ -285,7 +350,8 @@     { provider = "anthropic",       baseUrl = "https://api.anthropic.com",       api = "anthropic-messages",-      include = (`Set.member` anthropicInclude)+      include = (`Map.member` anthropicInclude),+      compatFor = fmap CatalogAnthropicCompat . (`Map.lookup` anthropicInclude)     }  -- | Normalize one provider's upstream models into a 'Catalog'. Keeps@@ -325,7 +391,8 @@                 cacheWriteCost = fromMaybe 0 (m ^. #cacheWriteCost)               },           contextWindow = fromMaybe 0 (m ^. #contextWindow),-          maxOutputTokens = fromMaybe 0 (m ^. #maxOutputTokens)+          maxOutputTokens = fromMaybe 0 (m ^. #maxOutputTokens),+          compat = (spec ^. #compatFor) (m ^. #modelId)         }  -- | Strip a trailing @" (latest)"@ display-name suffix that models.dev@@ -500,12 +567,39 @@     "        \"cacheWrite\": " <> renderNum (c ^. #cacheWriteCost),     "      },",     "      \"contextWindow\": " <> Text.pack (show (m ^. #contextWindow)) <> ",",-    "      \"maxOutputTokens\": " <> Text.pack (show (m ^. #maxOutputTokens)) <> ",",-    "      \"enabled\": true",-    "    }"+    "      \"maxOutputTokens\": " <> Text.pack (show (m ^. #maxOutputTokens)) <> ","   ]+    ++ renderModelCompat (m ^. #compat)+    ++ [ "      \"enabled\": true",+         "    }"+       ]   where     c = m ^. #cost++-- | Render the per-model @compat@ block, if the provider spec supplied+-- one. The block sits between @maxOutputTokens@ and @enabled@ so a+-- @git diff@ over the catalog shows a generation's wire facts next to+-- its limits.+renderModelCompat :: Maybe CatalogModelCompat -> [Text]+renderModelCompat Nothing = []+renderModelCompat (Just (CatalogAnthropicCompat facts)) =+  [ "      \"compat\": {",+    "        \"kind\": \"anthropic-messages\",",+    "        \"thinkingStyle\": "+      <> jsonString (renderThinkingStyle (facts ^. #thinkingStyle))+      <> ",",+    "        \"supportsSamplingParameters\": "+      <> jsonBool (facts ^. #supportsSamplingParameters),+    "      },"+  ]++-- | The catalog dialect spells the thinking style as a word, as every+-- other catalog enum does. The derived JSON instance on+-- 'Baikai.Compat.AnthropicThinkingStyle' is part of 'Baikai.Model.Model'\'s+-- pinned round trip and is deliberately not reused here.+renderThinkingStyle :: AnthropicThinkingStyle -> Text+renderThinkingStyle AnthropicThinkingBudget = "budget"+renderThinkingStyle AnthropicThinkingAdaptive = "adaptive"  renderInput :: [InputModality] -> Text renderInput ms = "[" <> Text.intercalate ", " (map one ms) <> "]"
gen/GenModels.hs view
@@ -34,7 +34,8 @@ import Data.Text qualified as Text import Data.Text.IO qualified as TIO import GenModelsCore-  ( checkIdentifierCollisions,+  ( checkAnthropicCompat,+    checkIdentifierCollisions,     flattenEntries,     renderModule,   )@@ -56,6 +57,9 @@       Right c -> pure c   let allEntries = concatMap flattenEntries catalogs   case checkIdentifierCollisions allEntries of+    Left err -> die (Text.unpack err)+    Right () -> pure ()+  case checkAnthropicCompat allEntries of     Left err -> die (Text.unpack err)     Right () -> pure ()   let sorted = sortOn fst allEntries
gen/GenModelsCore.hs view
@@ -14,6 +14,8 @@     GeneratedEntry (..),     flattenEntries,     checkIdentifierCollisions,+    checkAnthropicCompat,+    parseAnthropicThinkingStyle,     sanitizeIdentifier,     renderModule,   )@@ -25,6 +27,7 @@       ( sendSessionAffinityHeaders,         supportsCacheControlOnTools,         supportsLongCacheRetention,+        supportsSamplingParameters,         thinkingStyle       ),     AnthropicThinkingStyle (..),@@ -74,8 +77,9 @@       <*> o .: "compat"       <*> o .: "models" --- | A compat directive in the catalog. @"auto"@ defers to EP-5's--- @baseUrl@-driven auto-detection (rendered as 'CompatNone'). The two+-- | A compat directive in the catalog. @"auto"@ defers to the+-- provider's @baseUrl@-driven auto-detection (rendered as+-- 'CompatNone'). The two -- structured constructors carry a full override record. data CatalogCompat   = CatalogCompatAuto@@ -124,15 +128,28 @@   slcr <- o .:? "supportsLongCacheRetention" .!= d.supportsLongCacheRetention   scot <- o .:? "supportsCacheControlOnTools" .!= d.supportsCacheControlOnTools   ssah <- o .:? "sendSessionAffinityHeaders" .!= d.sendSessionAffinityHeaders-  ts <- o .:? "thinkingStyle" .!= d.thinkingStyle+  ts <- optionalField o "thinkingStyle" parseAnthropicThinkingStyle d.thinkingStyle+  ssp <- o .:? "supportsSamplingParameters" .!= d.supportsSamplingParameters   pure     d       { supportsLongCacheRetention = slcr,         supportsCacheControlOnTools = scot,         sendSessionAffinityHeaders = ssah,-        thinkingStyle = ts+        thinkingStyle = ts,+        supportsSamplingParameters = ssp       } +-- | The catalog dialect spells the extended-thinking wire shape as a+-- word, as every other catalog enum does, rather than through the+-- derived instance on 'AnthropicThinkingStyle' (which is part of+-- 'Baikai.Model.Model'\'s pinned JSON round trip and names the Haskell+-- constructor).+parseAnthropicThinkingStyle :: Text -> Parser AnthropicThinkingStyle+parseAnthropicThinkingStyle = \case+  "budget" -> pure AnthropicThinkingBudget+  "adaptive" -> pure AnthropicThinkingAdaptive+  t -> fail $ "unknown thinkingStyle: " <> Text.unpack t+ parseMaxTokensField :: Text -> Parser MaxTokensField parseMaxTokensField = \case   "max_tokens" -> pure MaxTokensField@@ -299,6 +316,32 @@         <> Text.intercalate ", " (map origin (reverse es))     origin e = e.provider <> "/" <> e.modelId +-- | Every @anthropic-messages@ entry must state its thinking style and+-- sampling support explicitly. An entry left at the file-level+-- @"compat": "auto"@ directive would fall through to host+-- auto-detection, which knows the host but cannot know the model+-- generation — the drift that sent @claude-sonnet-5@ a @budget_tokens@+-- request the generation rejects. The generator refuses rather than+-- guessing, so a hand edit to @baikai/data/models/anthropic.json@ that+-- drops a block fails the build instead of shipping.+checkAnthropicCompat :: [(Text, GeneratedEntry)] -> Either Text ()+checkAnthropicCompat entries =+  case [e | (_, e) <- entries, e.api == AnthropicMessages, not (stated e.compat)] of+    [] -> Right ()+    missing -> Left (Text.intercalate "; " (map complain missing))+  where+    stated = \case+      CatalogCompatAnthropic _ -> True+      _ -> False+    complain e =+      "anthropic-messages entry "+        <> e.provider+        <> "/"+        <> e.modelId+        <> " has no compat block; add {\"kind\":\"anthropic-messages\""+        <> ",\"thinkingStyle\":\"budget\"|\"adaptive\""+        <> ",\"supportsSamplingParameters\":true|false}"+ -- | Replace any non-identifier character with @_@. Haskell allows -- letters, digits, underscore, and apostrophe; everything else -- (slash, dash, dot, colon, ...) becomes an underscore.@@ -342,9 +385,25 @@         "",         "import Baikai.Api (Api (..))",         "import Baikai.Compat",-        "  ( AnthropicThinkingStyle (..),",+        "  ( AnthropicMessagesCompat",+        "      ( sendSessionAffinityHeaders,",+        "        supportsCacheControlOnTools,",+        "        supportsLongCacheRetention,",+        "        supportsSamplingParameters,",+        "        thinkingStyle",+        "      ),",+        "    AnthropicThinkingStyle (..),",         "    CacheControlFormat (..),",         "    MaxTokensField (..),",+        "    OpenAICompletionsCompat",+        "      ( cacheControlFormat,",+        "        maxTokensField,",+        "        requiresThinkingAsText,",+        "        supportsLongCacheRetention,",+        "        supportsStrictMode,",+        "        supportsUsageInStreaming,",+        "        thinkingFormat",+        "      ),",         "    ThinkingFormat (..),",         "    defaultAnthropicMessagesCompat,",         "    defaultOpenAICompletionsCompat,",@@ -408,11 +467,12 @@     renderCost g.cost <> ",",     "      contextWindow = " <> Text.pack (show g.contextWindow) <> ",",     "      maxOutputTokens = " <> Text.pack (show g.maxOutputTokens) <> ",",-    "      headers = Map.empty,",-    "      compat = " <> renderCompat g.compat,-    "    }",-    ""+    "      headers = Map.empty,"   ]+    ++ renderCompat g.compat+    ++ [ "    }",+         ""+       ]  renderText :: Text -> Text renderText t =@@ -455,34 +515,40 @@ renderRational r =   Text.pack (show (numerator r)) <> " % " <> Text.pack (show (denominator r)) -renderCompat :: CatalogCompat -> Text+-- | The @compat@ field of one rendered entry, as source lines.+--+-- The layout is the one @ormolu@ produces, because the repository+-- formatter runs over the generated module and @CatalogSpec@ demands+-- the generator's output be byte-identical to the committed file: a+-- layout the formatter would rewrite makes those two checks+-- contradict each other.+renderCompat :: CatalogCompat -> [Text] renderCompat = \case-  CatalogCompatAuto -> "CompatNone"+  CatalogCompatAuto -> ["      compat = CompatNone"]   CatalogCompatOpenAI c ->-    Text.intercalate-      "\n"-      [ "CompatOpenAICompletions",-        "        defaultOpenAICompletionsCompat",-        "          { maxTokensField = " <> renderMaxTokensField c.maxTokensField <> ",",-        "            supportsStrictMode = " <> renderBool c.supportsStrictMode <> ",",-        "            requiresThinkingAsText = " <> renderBool c.requiresThinkingAsText <> ",",-        "            thinkingFormat = " <> renderThinkingFormat c.thinkingFormat <> ",",-        "            cacheControlFormat = " <> renderMaybeCacheControl c.cacheControlFormat <> ",",-        "            supportsUsageInStreaming = " <> renderBool c.supportsUsageInStreaming <> ",",-        "            supportsLongCacheRetention = " <> renderBool c.supportsLongCacheRetention,-        "          }"-      ]+    [ "      compat =",+      "        CompatOpenAICompletions",+      "          defaultOpenAICompletionsCompat",+      "            { maxTokensField = " <> renderMaxTokensField c.maxTokensField <> ",",+      "              supportsStrictMode = " <> renderBool c.supportsStrictMode <> ",",+      "              requiresThinkingAsText = " <> renderBool c.requiresThinkingAsText <> ",",+      "              thinkingFormat = " <> renderThinkingFormat c.thinkingFormat <> ",",+      "              cacheControlFormat = " <> renderMaybeCacheControl c.cacheControlFormat <> ",",+      "              supportsUsageInStreaming = " <> renderBool c.supportsUsageInStreaming <> ",",+      "              supportsLongCacheRetention = " <> renderBool c.supportsLongCacheRetention,+      "            }"+    ]   CatalogCompatAnthropic c ->-    Text.intercalate-      "\n"-      [ "CompatAnthropicMessages",-        "        defaultAnthropicMessagesCompat",-        "          { supportsLongCacheRetention = " <> renderBool c.supportsLongCacheRetention <> ",",-        "            supportsCacheControlOnTools = " <> renderBool c.supportsCacheControlOnTools <> ",",-        "            sendSessionAffinityHeaders = " <> renderBool c.sendSessionAffinityHeaders <> ",",-        "            thinkingStyle = " <> renderAnthropicThinkingStyle c.thinkingStyle,-        "          }"-      ]+    [ "      compat =",+      "        CompatAnthropicMessages",+      "          defaultAnthropicMessagesCompat",+      "            { supportsLongCacheRetention = " <> renderBool c.supportsLongCacheRetention <> ",",+      "              supportsCacheControlOnTools = " <> renderBool c.supportsCacheControlOnTools <> ",",+      "              sendSessionAffinityHeaders = " <> renderBool c.sendSessionAffinityHeaders <> ",",+      "              thinkingStyle = " <> renderAnthropicThinkingStyle c.thinkingStyle <> ",",+      "              supportsSamplingParameters = " <> renderBool c.supportsSamplingParameters,+      "            }"+    ]  renderMaxTokensField :: MaxTokensField -> Text renderMaxTokensField = \case
src/Baikai.hs view
@@ -19,6 +19,7 @@     module Baikai.AgentAssets,     module Baikai.Api,     module Baikai.Auth,+    module Baikai.Header,     module Baikai.Model,     module Baikai.Content,     module Baikai.StopReason,@@ -60,6 +61,7 @@ import Baikai.Error import Baikai.Evidence import Baikai.Evidence.Build+import Baikai.Header import Baikai.Interactive import Baikai.Message import Baikai.Model
src/Baikai/Agent.hs view
@@ -36,6 +36,9 @@     AgentOutputMode (..),     renderAgentOutputMode,     parseAgentOutputMode,+    AgentOutputFormat (..),+    renderAgentOutputFormat,+    parseAgentOutputFormat,     AgentCapturedOutput (..),     capturedBytes, @@ -50,24 +53,35 @@         safety,         timeout,         output,+        outputFormat,         outputLimit,-        envPassthrough+        envRequires       ),     agentRunRequest,      -- * The operator policy ceiling-    AgentCeiling (maxCapability, allowProviderArgs, allowedProviders),+    AgentCeiling+      ( maxCapability,+        allowProviderArgs,+        allowedProviders,+        allowedTools,+        maxTimeout,+        maxOutputLimit+      ),     defaultAgentCeiling,+    defaultMaxOutputLimit,+    toolGrantsImpliedBy,     CeilingViolation (..),     renderCeilingViolation,     applyAgentCeiling,+    ceilingViolations,      -- * The rendered command     AgentPromptTransport (..),     AgentCommand (..),      -- * The run result-    AgentRunResult,+    AgentRunResult (provider, exitCode, stdout, stderr, duration),     agentRunResult,     AgentRunOutcome (..),     agentRunOutcome,@@ -76,6 +90,7 @@     AgentRenderError (..),     renderAgentRenderError,     AgentRunFailure (..),+    AgentTimedOut (..),     renderAgentRunFailure,   ) where@@ -142,10 +157,21 @@ data AgentSafety = AgentSafety   { -- | How much filesystem authority the run requests.     capability :: !AgentCapability,-    -- | Optional narrowing of the provider's tool set. An empty list-    -- means \"do not restrict tools beyond what the capability-    -- implies\"; a non-empty list is rendered where the provider-    -- supports a tool allow-list.+    -- | Tools this run is __granted__ — pre-approved — beyond what the+    -- capability's permission mode approves on its own. This is a+    -- widening, not a narrowing: on Claude Code the list renders as+    -- @--allowedTools@, whose help reads \"list of tool names to+    -- allow\", so @allowedTools = [\"Bash\"]@ under an+    -- @edit-workspace@ capability pre-approves shell access that the+    -- permission mode would otherwise have raised a request for, and in+    -- an unattended run a request nobody answers is denied. An empty+    -- list grants nothing beyond the mode, which is the default. Codex+    -- has no equivalent flag and its renderer refuses a non-empty list.+    --+    -- Because a grant is authority, an operator ceiling bounds it: see+    -- 'toolGrantsImpliedBy' and 'AgentCeiling.allowedTools'. The+    -- narrowing flags Claude Code also has, @--tools@ and+    -- @--disallowedTools@, are not modelled here.     allowedTools :: ![Text],     -- | Raw provider arguments Baikai does not model, passed through     -- verbatim. This is a privileged channel: arbitrary vendor flags@@ -158,8 +184,8 @@   }   deriving stock (Eq, Show, Generic) --- | A safety request for the given capability, with no tool narrowing--- and no raw provider arguments.+-- | A safety request for the given capability, with no tool grants+-- beyond what the capability implies and no raw provider arguments. agentSafety :: AgentCapability -> AgentSafety agentSafety cap =   AgentSafety@@ -194,6 +220,38 @@ parseAgentOutputMode "tee" = Just TeeOutput parseAgentOutputMode _ = Nothing +-- | What shape the coding agent should print its final answer in.+--+-- Distinct from 'AgentOutputMode', which says /where/ the bytes go. This+-- says what they are.+--+-- * 'TextFormat': whatever the tool prints by default, meant for a+--   person. Both tools default to it and Baikai renders no flag.+-- * 'JsonFormat': one machine-readable result. Claude Code renders+--   @--output-format json@ and @codex exec@ renders @--json@; both are+--   the shapes Baikai's own output readers already parse, so this is+--   the setting that lets an evidence record observe the session+--   identifier, the model and the token usage of a run.+--+-- Asking for it through the raw-argument channel used to be the only+-- way, which meant an operator had to open a privileged channel to get+-- a record — the opposite of what the ceiling is for.+data AgentOutputFormat+  = TextFormat+  | JsonFormat+  deriving stock (Eq, Ord, Show, Generic)++renderAgentOutputFormat :: AgentOutputFormat -> Text+renderAgentOutputFormat TextFormat = "text"+renderAgentOutputFormat JsonFormat = "json"++-- | Parse a canonical output-format name. Matching is exact and+-- case-sensitive.+parseAgentOutputFormat :: Text -> Maybe AgentOutputFormat+parseAgentOutputFormat "text" = Just TextFormat+parseAgentOutputFormat "json" = Just JsonFormat+parseAgentOutputFormat _ = Nothing+ -- | One captured stream of a finished run. The three states are -- distinct on purpose: under 'InheritOutput' the bytes went to the -- parent's terminal and none exist to report, which an empty@@ -245,6 +303,8 @@     timeout :: !(Maybe NominalDiffTime),     -- | What to do with the child's output streams.     output :: !AgentOutputMode,+    -- | What shape the tool should print its final answer in.+    outputFormat :: !AgentOutputFormat,     -- | Maximum captured bytes per stream, not in total. 'Nothing'     -- means unbounded.     outputLimit :: !(Maybe Int),@@ -258,7 +318,7 @@     -- fails before spawning when a declared variable is unset or     -- empty, so a misconfigured job produces one clear error instead     -- of a coding agent that starts and then flails.-    envPassthrough :: ![Text]+    envRequires :: ![Text]   }   deriving stock (Eq, Show, Generic) @@ -266,8 +326,8 @@ -- working directory, with the given prompt. Everything else defaults -- to the least-authority, least-surprising value: no model or effort -- override, no extra directories, read-only capability, no timeout,--- inherited output, no output limit, and no declared environment--- variables.+-- inherited output in the tool's own text format, no output limit, and+-- no declared environment variables. -- -- The capability default is 'AgentReadOnly': a caller who wants to -- change files must say so. That is independent of an operator@@ -284,8 +344,9 @@       safety = agentSafety AgentReadOnly,       timeout = Nothing,       output = InheritOutput,+      outputFormat = TextFormat,       outputLimit = Nothing,-      envPassthrough = []+      envRequires = []     }  -- | The limit an operator places on what any job may request.@@ -304,7 +365,21 @@     allowProviderArgs :: !Bool,     -- | The providers jobs may select. An empty list permits __no__     -- provider; it does not mean \"all providers\".-    allowedProviders :: ![AgentProvider]+    allowedProviders :: ![AgentProvider],+    -- | Tool grants the operator permits beyond the ones+    -- 'toolGrantsImpliedBy' the maximum capability already allows.+    -- Matching is exact on the whole string, so granting @\"Bash\"@+    -- does not permit @\"Bash(git *)\"@ and vice versa: a job asks for+    -- exactly the spelling the operator wrote, or it is refused.+    allowedTools :: ![Text],+    -- | The longest wall-clock limit any job may request. 'Nothing'+    -- permits an unlimited run, which is the default. A finite maximum+    -- also refuses a job that requests __no__ timeout at all, because a+    -- maximum defeated by omitting the setting is not a maximum.+    maxTimeout :: !(Maybe NominalDiffTime),+    -- | The largest per-stream output capture any job may request.+    -- 'Nothing' permits @output-limit \"unlimited\"@.+    maxOutputLimit :: !(Maybe Int)   }   deriving stock (Eq, Show, Generic) @@ -323,9 +398,53 @@   AgentCeiling     { maxCapability = AgentEditWorkspace,       allowProviderArgs = False,-      allowedProviders = [AgentClaude, AgentCodex]+      allowedProviders = [AgentClaude, AgentCodex],+      allowedTools = [],+      maxTimeout = Nothing,+      maxOutputLimit = Just defaultMaxOutputLimit     } +-- | The largest per-stream output capture the default ceiling permits:+-- sixty-four mebibytes, sixteen times the per-stream default a job gets+-- when it mentions no limit at all.+--+-- Concrete rather than unbounded because the memory belongs to the host+-- the operator owns, not to the repository that wrote the job: a+-- checkout writing @output-limit \"unlimited\"@ is asking to buffer an+-- entire runaway agent in the operator's address space, and it should+-- have to ask the operator rather than help itself. Sixty-four+-- mebibytes is far more than any real run prints, so a job that hits+-- it has gone wrong.+defaultMaxOutputLimit :: Int+defaultMaxOutputLimit = 67108864++-- | The tool grants a capability implies on its own, or 'Nothing' when+-- the capability implies every grant.+--+-- The names are Claude Code's built-in tools at version 2.1.247. The+-- lists are deliberately short and fail closed: a tool name that is not+-- listed here can only ever be refused unless the maximum capability is+-- 'AgentFullAccess' or the operator names it in+-- 'AgentCeiling.allowedTools', so a coding agent that grows a new tool+-- can never widen an existing ceiling by accident.+--+-- @Bash@ is absent from every finite list on purpose. It runs arbitrary+-- commands, which is what 'AgentFullAccess' means; a job that wants it+-- under a lesser capability needs the operator to say so.+toolGrantsImpliedBy :: AgentCapability -> Maybe [Text]+toolGrantsImpliedBy AgentReadOnly = Just readTools+toolGrantsImpliedBy AgentEditWorkspace = Just (readTools <> editTools)+toolGrantsImpliedBy AgentFullAccess = Nothing++-- | Grants that read but change nothing.+readTools :: [Text]+readTools = ["Read", "Glob", "Grep", "NotebookRead", "TodoWrite"]++-- | Grants that change files, which 'AgentEditWorkspace' adds to+-- 'readTools'.+editTools :: [Text]+editTools = ["Edit", "MultiEdit", "Write", "NotebookEdit"]+ -- | One way a request exceeded a ceiling. data CeilingViolation   = -- | The requested capability, then the permitted maximum. The@@ -346,6 +465,31 @@     ProviderArgsForbidden ![Text]   | -- | The requested provider, then the permitted providers.     ProviderForbidden !AgentProvider ![AgentProvider]+  | -- | The requested tool grants that are not permitted, then the+    -- maximum capability in force. A grant is authority, so the+    -- capability is named: it is what decides which grants are implied+    -- without the operator writing anything.+    ToolGrantForbidden ![Text] !AgentCapability+  | -- | The requested wall-clock limit ('Nothing' is \"no limit\"),+    -- then the permitted maximum.+    TimeoutExceeded !(Maybe NominalDiffTime) !NominalDiffTime+  | -- | The requested per-stream capture ('Nothing' is @unlimited@),+    -- then the permitted maximum in bytes.+    OutputLimitExceeded !(Maybe Int) !Int+  | -- | The leaf name of a setting only operator scope may supply, for+    -- example @executable@, that a repository file supplied.+    --+    -- Unlike every other violation this one is about /where/ a value+    -- came from rather than what it was, so 'applyAgentCeiling' cannot+    -- produce it: that function sees a request, not the provenance of+    -- each field. It is produced by the configuration layer, which+    -- reads provenance from the resolution report, and is carried in+    -- the same list so an operator sees one refusal.+    RepositoryScopeForbidden !Text+  | -- | The working directory a repository file asked for, after+    -- resolving symbolic links, then the repository root it must lie+    -- inside.+    WorkingDirOutsideRepository !FilePath !FilePath   deriving stock (Eq, Show, Generic)  -- | One line of plain English naming what was asked for and what is@@ -368,7 +512,56 @@   where     renderPermittedProviders [] = "none"     renderPermittedProviders ps = Text.intercalate ", " (map renderAgentProvider ps)+renderCeilingViolation (ToolGrantForbidden grants permitted) =+  "tool grants "+    <> Text.intercalate ", " grants+    <> " are not permitted under the maximum capability "+    <> renderAgentCapability permitted+    <> "; add them to policy.allowed-tools in the operator file or raise \+       \policy.max-capability"+renderCeilingViolation (TimeoutExceeded Nothing permitted) =+  "the job sets no timeout, and the permitted maximum is "+    <> renderCeilingDuration permitted+renderCeilingViolation (TimeoutExceeded (Just requested) permitted) =+  "the requested timeout "+    <> renderCeilingDuration requested+    <> " exceeds the permitted maximum "+    <> renderCeilingDuration permitted+renderCeilingViolation (OutputLimitExceeded Nothing permitted) =+  "output-limit unlimited exceeds the permitted maximum "+    <> Text.pack (show permitted)+    <> " bytes"+renderCeilingViolation (OutputLimitExceeded (Just requested) permitted) =+  "the requested output-limit "+    <> Text.pack (show requested)+    <> " exceeds the permitted maximum "+    <> Text.pack (show permitted)+    <> " bytes"+renderCeilingViolation (RepositoryScopeForbidden name) =+  "the repository configuration set "+    <> name+    <> ", which only the operator file or the command line may set"+renderCeilingViolation (WorkingDirOutsideRepository resolved root) =+  "the working directory "+    <> Text.pack resolved+    <> " lies outside the repository "+    <> Text.pack root +-- | A duration in one of the spellings the configuration layer's+-- @timeout@ parser accepts, so a refusal names a value an operator can+-- paste straight back into @policy.max-timeout@. @show@ on a+-- 'NominalDiffTime' prints @7200s@, which that parser does accept but+-- which no operator writes.+renderCeilingDuration :: NominalDiffTime -> Text+renderCeilingDuration value+  | seconds > 0, seconds `mod` 3600 == 0 = spell (seconds `div` 3600) "h"+  | seconds > 0, seconds `mod` 60 == 0 = spell (seconds `div` 60) "m"+  | otherwise = spell seconds "s"+  where+    seconds :: Integer+    seconds = truncate value+    spell magnitude unit = Text.pack (show magnitude) <> unit+ -- | Check a request against a ceiling. Returns the request -- __unchanged__ when it is within the ceiling, and every violation -- when it is not.@@ -384,28 +577,68 @@ -- This function does not inspect the contents of the requested -- 'providerArgs'. See that field's documentation for why a denylist of -- dangerous flags would be false confidence rather than a boundary.+--+-- Two violations this function never produces are+-- 'RepositoryScopeForbidden' and 'WorkingDirOutsideRepository'. Both+-- depend on which configuration file supplied a value, and a request+-- carries no provenance; the configuration layer produces them and+-- concatenates them with this function's list, so a caller sees one+-- refusal naming everything at once. applyAgentCeiling :: AgentCeiling -> AgentRunRequest -> Either [CeilingViolation] AgentRunRequest applyAgentCeiling limit request   | null violations = Right request   | otherwise = Left violations   where+    violations = ceilingViolations limit request++-- | Every way a request exceeds a ceiling, as a list a caller can+-- concatenate with the provenance-dependent violations the+-- configuration layer produces. 'applyAgentCeiling' is this function+-- plus the decision to return the request unchanged when the list is+-- empty.+ceilingViolations :: AgentCeiling -> AgentRunRequest -> [CeilingViolation]+ceilingViolations limit request =+  concat+    [ [ ProviderForbidden requestedProvider permittedProviders+      | requestedProvider `notElem` permittedProviders+      ],+      [ CapabilityExceeded requestedCapability permittedCapability+      | requestedCapability > permittedCapability+      ],+      [ ProviderArgsForbidden requestedArgs+      | not (null requestedArgs),+        not (limit ^. #allowProviderArgs)+      ],+      [ ToolGrantForbidden forbiddenGrants permittedCapability+      | not (null forbiddenGrants)+      ],+      [ TimeoutExceeded requestedTimeout permittedTimeout+      | Just permittedTimeout <- [limit ^. #maxTimeout],+        maybe True (> permittedTimeout) requestedTimeout+      ],+      [ OutputLimitExceeded requestedOutputLimit permittedLimit+      | Just permittedLimit <- [limit ^. #maxOutputLimit],+        maybe True (> permittedLimit) requestedOutputLimit+      ]+    ]+  where     requestedProvider = request ^. #provider     permittedProviders = limit ^. #allowedProviders     requestedCapability = request ^. #safety . #capability     permittedCapability = limit ^. #maxCapability     requestedArgs = request ^. #safety . #providerArgs-    violations =-      concat-        [ [ ProviderForbidden requestedProvider permittedProviders-          | requestedProvider `notElem` permittedProviders-          ],-          [ CapabilityExceeded requestedCapability permittedCapability-          | requestedCapability > permittedCapability-          ],-          [ ProviderArgsForbidden requestedArgs-          | not (null requestedArgs),-            not (limit ^. #allowProviderArgs)-          ]+    requestedTimeout = request ^. #timeout+    requestedOutputLimit = request ^. #outputLimit+    -- A capability implying every grant permits the whole list; any+    -- other capability permits its implied names plus whatever the+    -- operator granted, matched exactly.+    forbiddenGrants = case toolGrantsImpliedBy permittedCapability of+      Nothing -> []+      Just implied ->+        [ grant+        | grant <- request ^. #safety . #allowedTools,+          grant `notElem` implied,+          grant `notElem` (limit ^. #allowedTools)         ]  -- | How the prompt reaches the child process.@@ -458,6 +691,8 @@ -- A non-zero exit code is a normal result and lives here rather than -- in a failure type: a coding agent that fails its task and exits 1 -- has still run.+-- Construction: the constructor is deliberately not exported. Start+-- from 'agentRunResult' and override fields by record update. data AgentRunResult = AgentRunResult   { -- | Which coding-agent tool ran.     provider :: !AgentProvider,@@ -563,6 +798,28 @@   "the request exceeds the permitted policy ceiling: "     <> Text.intercalate "; " (map renderCeilingViolation violations) +-- | What a run that hit its deadline left behind.+--+-- The limit is the one that was configured, not the slightly larger+-- elapsed time, because the caller asked for a limit and wants to be+-- told which one was hit.+--+-- The two streams are whatever was drained before the process group was+-- killed. A timed-out run is precisely the run an operator most wants to+-- read: the tool started, may have consumed tokens, and may already have+-- changed the working tree, and the bytes it printed on the way are the+-- only account of that. Under 'InheritOutput' those bytes went to the+-- parent's terminal and both fields are 'OutputNotCaptured'.+data AgentTimedOut = AgentTimedOut+  { -- | The configured limit the run exceeded.+    limit :: !NominalDiffTime,+    -- | Standard output drained before the group was killed.+    stdout :: !AgentCapturedOutput,+    -- | Standard error drained before the group was killed.+    stderr :: !AgentCapturedOutput+  }+  deriving stock (Eq, Show, Generic)+ -- | A failure raised while spawning the child process or waiting for -- it. --@@ -575,16 +832,16 @@     -- not installed\" from \"the tool is installed but the working     -- directory does not exist\".     SpawnFailed !FilePath !Text-  | -- | The run exceeded this limit and was terminated.-    RunTimedOut !NominalDiffTime-  | -- | Every variable named in the request's 'envPassthrough' that is+  | -- | The run exceeded its limit. Its whole process group was+    -- interrupted, then terminated, then killed; what each stream+    -- drained before the kill is carried along.+    RunTimedOut !AgentTimedOut+  | -- | Every variable named in the request's 'envRequires' that is     -- unset or empty, checked as a group so an operator sees all of     -- them at once.     MissingEnvironment ![Text]   | -- | The working directory does not exist or is not a directory.     WorkingDirMissing !FilePath-  | -- | The run produced output the caller could not interpret.-    OutputMalformed !Text   | -- | The caller required evidence this configuration cannot produce,     -- so nothing was started. Carries one rendered explanation per     -- reason, from@@ -601,16 +858,14 @@ renderAgentRunFailure :: AgentRunFailure -> Text renderAgentRunFailure (SpawnFailed path message) =   "could not start " <> Text.pack path <> ": " <> message-renderAgentRunFailure (RunTimedOut limit) =-  "the run exceeded its timeout of " <> Text.pack (show limit)+renderAgentRunFailure (RunTimedOut timedOut) =+  "the run exceeded its timeout of " <> Text.pack (show (timedOut ^. #limit)) renderAgentRunFailure (MissingEnvironment names) =   "required environment variables are unset or empty: "     <> Text.intercalate ", " names renderAgentRunFailure (WorkingDirMissing path) =   "the working directory does not exist or is not a directory: "     <> Text.pack path-renderAgentRunFailure (OutputMalformed why) =-  "the run produced malformed output: " <> why renderAgentRunFailure (EvidenceRefused reasons) =   "refused before starting, because this run cannot produce the evidence it \   \required: "
src/Baikai/AgentAssets.hs view
@@ -26,6 +26,7 @@   ) import Baikai.Prelude import Data.Text qualified as Text+import Text.Printf (printf)  -- | Asset helpers use the same provider vocabulary as interactive -- launchers: Claude Code and Codex are the local provider families.@@ -129,17 +130,56 @@   where     appendSegment acc segment = acc <> "/" <> segment +-- | A TOML /basic/ string: quotation mark, backslash, and every control+-- character escaped, as TOML 1.0 requires. A basic string interprets+-- backslash escapes, so an unescaped control character in one is a+-- parse error rather than a stray byte. tomlString :: Text -> Text-tomlString t =-  "\"" <> Text.concatMap escape t <> "\""-  where-    escape '"' = "\\\""-    escape '\\' = "\\\\"-    escape '\n' = "\\n"-    escape '\r' = "\\r"-    escape '\t' = "\\t"-    escape c = Text.singleton c+tomlString t = "\"" <> Text.concatMap escapeBasic t <> "\"" +-- | One character inside a TOML basic string.+--+-- The six named escapes are the ones TOML spells; everything else below+-- U+0020, and U+007F, takes the @\\uXXXX@ form. Nothing above that is+-- escaped: TOML basic strings are Unicode, and escaping more would only+-- make the file harder to read.+escapeBasic :: Char -> Text+escapeBasic = \case+  '"' -> "\\\""+  '\\' -> "\\\\"+  '\b' -> "\\b"+  '\t' -> "\\t"+  '\n' -> "\\n"+  '\f' -> "\\f"+  '\r' -> "\\r"+  c+    | c < ' ' || c == '\DEL' -> Text.pack (printf "\\u%04X" (fromEnum c))+    | otherwise -> Text.singleton c++-- | The instructions body of a Codex custom agent.+--+-- Rendered as a TOML /literal/ multi-line string — three apostrophes,+-- interpreting nothing — so the Markdown a human opens in+-- @.codex\/agents\/*.toml@ is the Markdown that was written, backslashes+-- intact. Rendered as a /basic/ string instead, every backslash in the+-- body starts an escape sequence, so a body containing @\\d+@ made Codex+-- refuse to load the file.+--+-- A literal string cannot contain three apostrophes, a bare carriage+-- return, or any control character other than tab and newline, so such a+-- body falls back to a fully escaped basic string rather than being+-- refused. Escaping every quotation mark there guarantees the closing+-- delimiter cannot appear inside, and escaping every backslash means no+-- line-ending backslash can silently swallow the next line's+-- indentation. tomlMultilineString :: Text -> Text-tomlMultilineString t =-  "\"\"\"\n" <> Text.replace "\"\"\"" "\\\"\\\"\\\"" t <> "\n\"\"\""+tomlMultilineString t+  | literalSafe = "\'\'\'\n" <> t <> "\n\'\'\'"+  | otherwise = "\"\"\"\n" <> Text.concatMap escapeMultiline t <> "\n\"\"\""+  where+    literalSafe = not ("\'\'\'" `Text.isInfixOf` t) && Text.all literalChar t+    literalChar c = c == '\t' || c == '\n' || (c >= ' ' && c /= '\DEL')+    -- A raw newline is allowed inside a multi-line basic string and+    -- keeps the body readable; everything else follows the basic rules.+    escapeMultiline '\n' = "\n"+    escapeMultiline c = escapeBasic c
src/Baikai/Api.hs view
@@ -17,6 +17,7 @@   ( Api (..),     renderApi,     parseApi,+    normaliseApi,   ) where @@ -51,6 +52,18 @@   "openai-completions-cli" -> OpenAICompletionsCli   "anthropic-messages-cli" -> AnthropicMessagesCli   t -> Custom t++-- | Collapse a 'Custom' tag that spells a built-in API onto that+-- constructor, so @Custom "anthropic-messages"@ and 'AnthropicMessages'+-- are one registry key. Every other value is returned unchanged.+--+-- The registry normalises both the key it stores and the tag it is asked+-- for, so a handler registered under either spelling answers a model+-- tagged with the other. Derived 'Eq' and 'Ord' are deliberately left+-- alone: changing them would silently rearrange every @Map Api@ a+-- consumer holds.+normaliseApi :: Api -> Api+normaliseApi = parseApi . renderApi  instance ToJSON Api where   toJSON = toJSON . renderApi
src/Baikai/Auth.hs view
@@ -11,21 +11,32 @@     defaultApiKeyEnvForBaseUrl,     renderApiKeySourceForDebug,     resolveApiKey,++    -- * Redacting credentials that travel in headers+    redactedMarker,+    isCredentialHeader,+    redactHeaderValues,   ) where -import Baikai.Compat (hostMatchesSuffix, urlHost) import Baikai.Error (authError)+import Baikai.Header (HeaderName, renderHeaderName)+import Baikai.Url (hostMatchesSuffix, urlHost) import Control.Exception (throwIO) import Control.Monad.IO.Class (MonadIO, liftIO) import Data.Aeson (ToJSON (toJSON), object, (.=))+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map import Data.Text (Text) import Data.Text qualified as Text import System.Environment qualified as Environment  data ApiKeySource   = ApiKeyLiteral !Text-  | ApiKeyEnv !String+  | -- | 'String' rather than 'Text' because+    -- 'System.Environment.lookupEnv' takes one; converting here would+    -- only move the conversion to every call site.+    ApiKeyEnv !String   | ApiKeyEnvChain ![String]   deriving stock (Eq) @@ -71,35 +82,95 @@       | hostMatchesSuffix host "fireworks.ai" = Just "FIREWORKS_API_KEY"       | otherwise = Nothing +-- | What baikai prints where a credential would otherwise appear.+--+-- One marker everywhere, so a reader who has seen it once in an+-- @ApiKeyLiteral@ recognises it in a header map.+redactedMarker :: Text+redactedMarker = "<redacted>"++-- | Whether a header name carries a credential, by convention.+--+-- Case-insensitive, and deliberately generous: it matches+-- @authorization@, @api-key@, @apikey@, @token@, @secret@, @cookie@ and+-- @password@ anywhere in the name, and any name ending in @-key@. That+-- over-matches — a header called @x-idempotency-key@ prints as the+-- marker — and over-matching is the safe direction, because this only+-- decides what is /printed/. The header itself is untouched and is still+-- sent exactly as the caller wrote it.+isCredentialHeader :: Text -> Bool+isCredentialHeader name =+  any (`Text.isInfixOf` lowered) needles || "-key" `Text.isSuffixOf` lowered+  where+    lowered = Text.toLower (Text.strip name)+    needles =+      [ "authorization",+        "api-key",+        "apikey",+        "token",+        "secret",+        "cookie",+        "password"+      ]++-- | Replace the value of every credential-carrying header with+-- 'redactedMarker', leaving the names and every other value alone.+redactHeaderValues :: Map HeaderName Text -> Map HeaderName Text+redactHeaderValues =+  Map.mapWithKey+    ( \name value ->+        if isCredentialHeader (renderHeaderName name) then redactedMarker else value+    )+ -- | Render a credential source for logs, test failures, and debugging without -- exposing literal secret material. renderApiKeySourceForDebug :: ApiKeySource -> Text-renderApiKeySourceForDebug (ApiKeyLiteral _) = "ApiKeyLiteral <redacted>"+renderApiKeySourceForDebug (ApiKeyLiteral _) = "ApiKeyLiteral " <> redactedMarker renderApiKeySourceForDebug (ApiKeyEnv name) =   "ApiKeyEnv " <> Text.pack (show name) renderApiKeySourceForDebug (ApiKeyEnvChain names) =   "ApiKeyEnvChain " <> Text.pack (show names)  -- | Resolve a key source to a plain 'Text'. Throws a 'BaikaiError' in the--- 'Baikai.Error.AuthError' category if 'ApiKeyEnv' is used and the named variable--- is unset.+-- 'Baikai.Error.AuthError' category when no variable yields a key.+--+-- A variable whose value is empty, or is only whitespace, counts as+-- __unset__. An empty key can never authenticate, so reporting it here+-- as an error that names the variable is strictly better than sending+-- @Authorization: Bearer @ and reading a provider's 401 back. A+-- non-empty value is passed through exactly as it was set, whitespace+-- and all: trimming a real key would be a different behaviour change,+-- and one that could silently break a key with a meaningful edge+-- character. resolveApiKey :: (MonadIO m) => ApiKeySource -> m Text resolveApiKey (ApiKeyLiteral t) = pure t resolveApiKey (ApiKeyEnv name) =   liftIO $-    Environment.lookupEnv name >>= \case-      Just v -> pure (Text.pack v)-      Nothing -> throwIO (authError ("env var " <> Text.pack name <> " is not set"))+    lookupNonEmptyEnv name >>= \case+      Just v -> pure v+      Nothing ->+        throwIO+          (authError ("env var " <> Text.pack name <> " is not set or is empty")) resolveApiKey (ApiKeyEnvChain names) =   liftIO (go names)   where     go [] =       throwIO-        (authError ("none of the env vars " <> renderedNames <> " are set"))-    go (name : rest) =-      Environment.lookupEnv name >>= \case-        Just v -> pure (Text.pack v)-        Nothing -> go rest+        ( authError+            ( "none of the env vars "+                <> renderedNames+                <> " are set (an empty value counts as unset)"+            )+        )+    go (name : rest) = lookupNonEmptyEnv name >>= maybe (go rest) pure     renderedNames = case names of       [] -> "<empty>"       _ -> Text.intercalate ", " (Text.pack <$> names)++-- | 'Environment.lookupEnv' that treats a blank value as absent.+lookupNonEmptyEnv :: String -> IO (Maybe Text)+lookupNonEmptyEnv name = do+  found <- Environment.lookupEnv name+  pure $ case found of+    Just raw | not (Text.null (Text.strip (Text.pack raw))) -> Just (Text.pack raw)+    _ -> Nothing
src/Baikai/CacheRetention.hs view
@@ -1,10 +1,12 @@ -- | Provider-agnostic prompt-cache retention preference. ----- Each provider maps the value to its own primitive: Anthropic's--- 'long' becomes @cache_control.ttl: "1h"@, 'short' becomes the--- ephemeral marker with no TTL; OpenAI Responses API's 'long' would--- become 24h. Hosts that do not advertise prompt caching ignore the--- preference.+-- Each provider maps the value to its own primitive: on Anthropic,+-- 'CacheRetentionLong' becomes @cache_control.ttl: "1h"@ and+-- 'CacheRetentionShort' the ephemeral marker with no TTL. The+-- OpenAI-compatible provider emits Anthropic-style markers only where+-- the host's compat record sets+-- 'Baikai.Compat.cacheControlFormat'; hosts that do not advertise+-- prompt caching under a marker ignore the preference. module Baikai.CacheRetention   ( CacheRetention (..),   )@@ -19,9 +21,9 @@     CacheRetentionNone   | -- | Provider-default ephemeral retention (Anthropic: 5 minutes).     CacheRetentionShort-  | -- | Long-retention bucket (Anthropic: @ttl: "1h"@; OpenAI-    --   Responses: 24h). Downgrades to short on hosts that report-    --   'supportsLongCacheRetention' as 'False'.+  | -- | Long-retention bucket (Anthropic: @ttl: "1h"@). Downgrades to+    --   short on hosts that report 'supportsLongCacheRetention' as+    --   'False'.     CacheRetentionLong   deriving stock (Eq, Show, Generic)   deriving anyclass (FromJSON, ToJSON)
src/Baikai/Compat.hs view
@@ -21,7 +21,10 @@ -- -- Auto-detection from a 'Baikai.Model.Model' @baseUrl@ provides -- reasonable defaults so callers rarely need to spell out a full--- compat record.+-- 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@@ -43,11 +46,11 @@       ( supportsLongCacheRetention,         supportsCacheControlOnTools,         sendSessionAffinityHeaders,-        thinkingStyle+        thinkingStyle,+        supportsSamplingParameters       ),     AnthropicThinkingStyle (..),     defaultAnthropicMessagesCompat,-    defaultAnthropicThinkingStyle,      -- * Auto-detection from baseUrl     urlHost,@@ -57,9 +60,9 @@   ) where +import Baikai.Url (hostMatchesSuffix, urlHost) 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@@ -79,12 +82,15 @@   = -- | OpenAI-native: top-level @reasoning_effort: "minimal" | "low"     --   | "medium" | "high" | "xhigh" | "max"@.     ---    --   This shape sends the canonical baikai level verbatim. The other-    --   six 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. The exclusion is deliberate and-    --   guarded by @nativeHigherEffortTests@ in+    --   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@@ -138,7 +144,7 @@     maxTokensField :: !MaxTokensField,     -- | Whether the host accepts @strict: true@ on function tool     --   definitions. Consumed by-    --   @Baikai.Provider.OpenAI.Api.mkOpenAIResponseFormat@ and+    --   @Baikai.Provider.OpenAI.Internal.Request.mkOpenAIResponseFormat@ and     --   @Baikai.Provider.OpenAI.Shape.dropUnsupportedStrict@ to     --   omit JSON-schema @strict@ on hosts that reject it.     supportsStrictMode :: !Bool,@@ -147,12 +153,12 @@     --   @\<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.Api.translateTextLikeDelta@ for+    --   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.Api.applyThinkingFormat@ for the+    --   @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.@@ -194,7 +200,7 @@   { -- | 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.Api.computeCacheControl@+    --   Consumed by @Baikai.Provider.Claude.Internal.Request.computeCacheControl@     --   for top-level cache markers and by     --   @Baikai.Provider.Claude.Shape.injectToolCacheControl@ for     --   tool cache markers.@@ -209,9 +215,21 @@     --   @Baikai.Provider.Claude.Transport.requestHeaders@.     sendSessionAffinityHeaders :: !Bool,     -- | Which extended-thinking request shape to send for the-    --   selected model generation. Consumed by-    --   @Baikai.Provider.Claude.Api.computeThinking@.-    thinkingStyle :: !AnthropicThinkingStyle+    --   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)@@ -223,22 +241,10 @@     { supportsLongCacheRetention = True,       supportsCacheControlOnTools = True,       sendSessionAffinityHeaders = False,-      thinkingStyle = AnthropicThinkingBudget+      thinkingStyle = AnthropicThinkingBudget,+      supportsSamplingParameters = True     } --- | The thinking style a first-party Anthropic model id defaults to--- when the model carries no explicit compat record. Unknown ids--- default to the budget style used by earlier model generations.-defaultAnthropicThinkingStyle :: Text -> AnthropicThinkingStyle-defaultAnthropicThinkingStyle modelId-  | adaptive "claude-opus-4-6" = AnthropicThinkingAdaptive-  | adaptive "claude-opus-4-7" = AnthropicThinkingAdaptive-  | adaptive "claude-opus-4-8" = AnthropicThinkingAdaptive-  | adaptive "claude-fable-5" = AnthropicThinkingAdaptive-  | otherwise = AnthropicThinkingBudget-  where-    adaptive prefix = prefix `Text.isPrefixOf` modelId- -- | 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@@ -298,27 +304,3 @@   where     host = urlHost url     matches suffix = maybe False (`hostMatchesSuffix` suffix) host---- | Extract a hostname from a URL-ish value. This is intentionally--- small and total rather than a validating URI parser: it drops an--- optional scheme, optional userinfo, then stops at '/', ':', '?', or--- '#'. Empty results return 'Nothing'.-urlHost :: Text -> Maybe Text-urlHost raw =-  let noScheme = case Text.breakOn "://" raw of-        (_, rest) | not (Text.null rest) -> Text.drop 3 rest-        _ -> raw-      noUser = last (Text.splitOn "@" noScheme)-      host = Text.toLower (Text.strip (Text.takeWhile hostChar noUser))-   in if Text.null host then Nothing else Just host-  where-    hostChar c = c /= '/' && c /= ':' && c /= '?' && c /= '#'---- | Match a hostname against a suffix at a label boundary.-hostMatchesSuffix :: Text -> Text -> Bool-hostMatchesSuffix host suffix =-  let h = Text.toLower (Text.strip host)-      s = Text.toLower (Text.strip suffix)-   in not (Text.null h)-        && not (Text.null s)-        && (h == s || ("." <> s) `Text.isSuffixOf` h)
src/Baikai/Content.hs view
@@ -9,8 +9,7 @@ -- invocation. For tool-result messages (a caller-supplied reply to a -- model-issued tool call) blocks can be text or image. ----- EP-1 introduces the types; EP-3 streams them, and EP-4 wires tool--- round-tripping through the providers. Image content is restricted to+-- Image content is restricted to -- inline base64 with an explicit @mimeType@: the caller is responsible -- for the (small, reversible) work of base64-encoding bytes once, and -- every provider can consume the same shape without a URL-fetch path@@ -27,15 +26,15 @@     AssistantContent (..),     ToolResultContent (..), +    -- * Tool-call arguments+    toolArgumentsFromText,+    isCutOffToolCall,+     -- * Smart defaults     emptyTextContent,     emptyThinkingContent,     emptyToolCall,     emptyImageContent,-    _TextContent,-    _ThinkingContent,-    _ToolCall,-    _ImageContent,   ) where @@ -54,6 +53,7 @@     (.:),     (.=),   )+import Data.Aeson qualified as Aeson import Data.ByteString (ByteString) import Data.ByteString qualified as BS import Data.ByteString.Base64 qualified as Base64@@ -87,6 +87,16 @@ -- | A model-issued tool invocation. @id_@ has a trailing underscore in -- Haskell to dodge a clash with @Prelude.id@; the JSON encoding strips -- it back to @id@.+--+-- @arguments@ is the decoded JSON value the model sent — normally an+-- object. A bare 'Data.Aeson.String' is the __cut-off marker__: the+-- model's argument stream was truncated (by the output cap, or by a+-- transport failure mid-call) and the raw text is kept verbatim rather+-- than replaced by something well-formed that the model never asked+-- for. 'isCutOffToolCall' is the predicate; 'toolArgumentsFromText' is+-- the one rule that produces it. A cut-off call must not be dispatched:+-- 'Baikai.Provider.Registry.runToolLoop' stops on one and+-- 'Baikai.Context.appendToolResult' reports it as a tool-result error. data ToolCall = ToolCall   { id_ :: !Text,     name :: !Text,@@ -94,6 +104,32 @@   }   deriving stock (Eq, Show, Generic) +-- | Turn a tool call's accumulated argument text into its @arguments@+-- value.+--+-- Empty text is an empty object: Anthropic opens a @tool_use@ block with+-- no input and streams no delta, and an empty object is exactly what the+-- model asked for. Non-empty text that does not decode is kept verbatim+-- as a 'Data.Aeson.String' — the call was cut off, and no byte of what+-- the model did send is dropped.+--+-- Both provider assemblers and core's stream-recovery path use this one+-- rule, so 'isCutOffToolCall' means the same thing at every layer.+-- Before it, the assemblers replaced malformed arguments with @{}@ and a+-- tool loop happily executed the call with no arguments at all.+toolArgumentsFromText :: Text -> Value+toolArgumentsFromText raw+  | Text.null (Text.strip raw) = Aeson.Object mempty+  | otherwise = case Aeson.eitherDecodeStrict (Text.encodeUtf8 raw) of+      Right v -> v+      Left _ -> Aeson.String raw++-- | 'True' when the call's argument stream was cut off: @arguments@ is+-- the raw text rather than a decoded value. See 'ToolCall'.+isCutOffToolCall :: ToolCall -> Bool+isCutOffToolCall ToolCall {arguments = Aeson.String _} = True+isCutOffToolCall _ = False+ -- | An inline image block. Bytes are stored decoded; the JSON encoding -- emits base64 under @data@ and the @mimeType@ camel-snakes to -- @mime_type@.@@ -216,19 +252,3 @@  instance ToJSON ToolResultContent where   toJSON = genericToJSON contentSumOptions--{-# DEPRECATED _TextContent "Use emptyTextContent instead." #-}-_TextContent :: TextContent-_TextContent = emptyTextContent--{-# DEPRECATED _ThinkingContent "Use emptyThinkingContent instead." #-}-_ThinkingContent :: ThinkingContent-_ThinkingContent = emptyThinkingContent--{-# DEPRECATED _ToolCall "Use emptyToolCall instead." #-}-_ToolCall :: ToolCall-_ToolCall = emptyToolCall--{-# DEPRECATED _ImageContent "Use emptyImageContent instead." #-}-_ImageContent :: ImageContent-_ImageContent = emptyImageContent
src/Baikai/Context.hs view
@@ -2,14 +2,14 @@ -- conversation: the optional system prompt, the message vector, and -- the declared tools the model may invoke. ----- 'Context' replaces the prior 'Baikai.Request.Request' record's--- conversation-related fields. The per-call knobs that previously--- lived alongside the messages (max tokens, temperature, API key)--- now live on 'Baikai.Options.Options' instead.+-- The per-call knobs — max tokens, temperature, API key — live on+-- 'Baikai.Options.Options' instead, so a conversation and the settings+-- it is dispatched with are separate values. ----- EP-4 adds the @tools@ field and the 'appendToolResult' helper--- that builds the follow-up request after the model invoked one or--- more tools. The helper lives here rather than in 'Baikai.Tool' so+-- The @tools@ field is on the context because the same tool set applies+-- to every turn, and so is 'appendToolResult', which builds the+-- follow-up request after the model invoked one or more tools. The+-- helper lives here rather than in 'Baikai.Tool' so -- that 'Baikai.Tool' can stay imports-light (the 'Tool' type is -- referenced by the @tools@ field, so 'Baikai.Tool' cannot itself -- depend on 'Context').@@ -19,7 +19,6 @@     messages,     tools,     emptyContext,-    _Context,     contextOf,     systemUser,     addUser,@@ -30,9 +29,9 @@   ) where -import Baikai.Content (AssistantContent (..), ToolCall (..))-import Baikai.Message (Message (..), ToolResult, toolResultFromCallNow, toolResultText, user)-import Baikai.Response (Response (..), responseMessage)+import Baikai.Content (AssistantContent (..), ToolCall (..), isCutOffToolCall)+import Baikai.Message (Message (..), ToolResult, toolResultErrorText, toolResultFromCallNow, toolResultText, user)+import Baikai.Response (Response (..), responseError, responseMessage) import Baikai.Tool (Tool) import Control.Applicative ((<|>)) import Control.Lens ((&), (.~), (^.))@@ -102,15 +101,36 @@ -- returned 'Context' is ready to drive the follow-up request that -- gives the model the tool results. ----- The dispatcher receives one 'ToolCall' at a time and returns a rich--- 'ToolResult' carrying text blocks, image blocks, and an error flag.--- Any error handling (timeouts, sandboxing, multi-call concurrency)--- lives in the dispatcher.+-- Calls are dispatched one at a time, in the order they appear, and the+-- dispatcher returns a rich 'ToolResult' carrying text blocks, image+-- blocks, and an error flag. Any timeout or sandboxing lives in the+-- dispatcher.+--+-- An __error-shaped response__ (one whose 'Baikai.Response.responseError'+-- is 'Just') appends nothing and dispatches nothing: the context comes+-- back unchanged. A failed call has no assistant turn worth replaying+-- and no tool calls to answer, and appending its empty message would put+-- a turn into the transcript that the model never took.+-- 'Baikai.Provider.Registry.runToolLoop' has always stopped on such a+-- response; the documented direct round trip in @docs\/user\/tools.md@+-- reaches here instead, and now behaves the same way.+--+-- A tool call cut off by the output cap+-- ('Baikai.Content.isCutOffToolCall') is __never dispatched__: its+-- arguments are the raw text the model got as far as sending, not a+-- request it finished making. It still gets a+-- 'Baikai.Message.ToolResultMessage', with @isError = True@ explaining+-- why, because a caller driving the exchange by hand expects one result+-- per call and must not silently lose the turn.+-- 'Baikai.Provider.Registry.runToolLoop' stops on such a response+-- instead of reaching here. appendToolResult ::   Context ->   Response ->   (ToolCall -> IO ToolResult) ->   IO Context+appendToolResult ctx resp _dispatcher+  | Just _ <- responseError resp = pure ctx appendToolResult ctx resp dispatcher = do   let respPayload = resp ^. #message       respMsg = responseMessage resp@@ -118,7 +138,10 @@   results <-     traverse       ( \tc -> do-          result <- dispatcher tc+          result <-+            if isCutOffToolCall tc+              then pure cutOffToolResult+              else dispatcher tc           toolResultFromCallNow tc result       )       toolCalls@@ -130,6 +153,13 @@                <> V.fromList results            ) +-- | What 'appendToolResult' reports instead of dispatching a call the+-- model never finished asking for.+cutOffToolResult :: ToolResult+cutOffToolResult =+  toolResultErrorText+    "tool call arguments were cut off by the output limit; the call was not dispatched — raise maxTokens and retry"+ -- | Text-only convenience wrapper for the common case where every -- tool call returns one successful text block. appendToolResultText ::@@ -139,7 +169,3 @@   IO Context appendToolResultText ctx resp dispatcher =   appendToolResult ctx resp (fmap toolResultText . dispatcher)--{-# DEPRECATED _Context "Use emptyContext instead." #-}-_Context :: Context-_Context = emptyContext
src/Baikai/Cost.hs view
@@ -3,8 +3,6 @@     CostBreakdown (..),     zeroCost,     zeroCostBreakdown,-    _Cost,-    _CostBreakdown,     usdAsScientific,   ) where@@ -82,11 +80,3 @@  ratToSci :: Rational -> Scientific ratToSci = fst . fromRationalRepetendUnlimited--{-# DEPRECATED _CostBreakdown "Use zeroCostBreakdown instead." #-}-_CostBreakdown :: CostBreakdown-_CostBreakdown = zeroCostBreakdown--{-# DEPRECATED _Cost "Use zeroCost instead." #-}-_Cost :: Cost-_Cost = zeroCost
src/Baikai/Cost/Log.hs view
@@ -8,15 +8,26 @@ -- The usual pattern is 'withCallLog', which opens a handle, runs -- the body, and flushes pending entries on the way out: ----- > withCallLog (CallLogConfig "/tmp/baikai.jsonl" True) $ \h -> do+-- > withCallLog (callLogConfig "/tmp/baikai.jsonl") $ \h -> do -- >   _ <- runRequestWithLog h model context options -- >   pure () -- -- If the worker cannot open or write the log file, the close path -- reports one warning on stderr and returns. Logging failures do not -- mask the request body or hang release actions.+--+-- 'closeCallLog' is idempotent: the first caller claims the handle,+-- writes the sentinel and waits for the worker; a second caller returns+-- at once rather than blocking forever on a worker that has already+-- finished. An 'appendEntry' after the close is a no-op, because the+-- worker that would have drained it is gone. The close wait itself is+-- unbounded, unlike the trace bridge's: the call log's purpose is+-- durability, its close runs once per process rather than once per+-- call, and its writer is a local file the operator chose rather than a+-- third-party fold. module Baikai.Cost.Log-  ( CallLogConfig (..),+  ( CallLogConfig (path, enabled),+    callLogConfig,     CallLogEntry (..),     CallLogHandle,     openCallLog,@@ -48,10 +59,10 @@ import Baikai.Usage qualified as Usage import Control.Concurrent (forkIO) import Control.Concurrent.Chan (Chan, newChan, readChan, writeChan)-import Control.Concurrent.MVar (MVar, newEmptyMVar, putMVar, takeMVar)+import Control.Concurrent.MVar (MVar, newEmptyMVar, putMVar, readMVar) import Control.Exception (SomeException, bracket, displayException, try) import Control.Lens ((^.))-import Control.Monad (forM_)+import Control.Monad (forM_, unless) import Control.Monad.IO.Class (MonadIO, liftIO) import Control.Monad.IO.Unlift (MonadUnliftIO, withRunInIO) import Data.Aeson (FromJSON, ToJSON)@@ -60,7 +71,7 @@ import Data.Foldable (find) import Data.Function ((&)) import Data.Generics.Labels ()-import Data.IORef (IORef, newIORef, readIORef, writeIORef)+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef, writeIORef) import Data.Scientific (Scientific) import Data.Text (Text) import Data.Text qualified as Text@@ -73,15 +84,21 @@ import System.IO (BufferMode (LineBuffering), IOMode (AppendMode), hPutStrLn, hSetBuffering, stderr, withFile)  -- | Where (and whether) to write the call log.+--+-- Construction: the constructor is deliberately not exported. Start+-- from 'callLogConfig' and override fields by record update. data CallLogConfig = CallLogConfig   { path :: !FilePath,     enabled :: !Bool   }   deriving stock (Eq, Show, Generic) --- | One line of the JSONL call log. Wire shape preserved from EP-0:--- @cachedInputTokens@ keeps its name so existing log readers keep--- parsing.+-- | A call log at the given path, enabled.+callLogConfig :: FilePath -> CallLogConfig+callLogConfig logPath = CallLogConfig {path = logPath, enabled = True}++-- | One line of the JSONL call log. @cachedInputTokens@ keeps the name+-- it has always had on the wire, so existing log readers keep parsing. data CallLogEntry = CallLogEntry   { timestamp :: !UTCTime,     provider :: !Text,@@ -102,7 +119,10 @@   { chan :: !(Chan (Maybe CallLogEntry)),     done :: !(MVar ()),     cfg :: !CallLogConfig,-    workerError :: !(IORef (Maybe SomeException))+    workerError :: !(IORef (Maybe SomeException)),+    -- | Claimed by the first 'closeCallLog'. A second close returns+    -- immediately and an 'appendEntry' after it enqueues nothing.+    closed :: !(IORef Bool)   }  -- | Open a handle. When @enabled = True@, fork the worker thread@@ -112,26 +132,36 @@   ch <- newChan   d <- newEmptyMVar   e <- newIORef Nothing+  cl <- newIORef False   case enabled c of     False -> putMVar d ()     True -> do       _ <- forkIO (worker (path c) ch d e)       pure ()-  pure CallLogHandle {chan = ch, done = d, cfg = c, workerError = e}+  pure CallLogHandle {chan = ch, done = d, cfg = c, workerError = e, closed = cl}  -- | Signal shutdown and block until the worker has drained every -- pending entry to disk.+--+-- Idempotent. The first caller claims the handle and does the work; a+-- second returns at once. Before the claim existed, a second close+-- blocked forever on an 'MVar' the worker had already emptied — which+-- 'withCallLog' made easy to hit, since its bracket closes a handle a+-- body may also have closed. 'readMVar' rather than 'takeMVar' for the+-- same reason: the slot stays filled. closeCallLog :: (MonadIO m) => CallLogHandle -> m () closeCallLog h = liftIO $ do-  case enabled (cfg h) of-    True -> writeChan (chan h) Nothing-    False -> pure ()-  takeMVar (done h)-  merr <- readIORef (workerError h)-  forM_ merr $ \e ->-    hPutStrLn-      stderr-      ("baikai: call log worker failed; pending entries were dropped: " <> displayException e)+  alreadyClosed <- atomicModifyIORef' (closed h) (\b -> (True, b))+  unless alreadyClosed $ do+    case enabled (cfg h) of+      True -> writeChan (chan h) Nothing+      False -> pure ()+    readMVar (done h)+    merr <- readIORef (workerError h)+    forM_ merr $ \e ->+      hPutStrLn+        stderr+        ("baikai: call log worker failed; pending entries were dropped: " <> displayException e)  -- | Bracketed lifetime: open the handle, run the body, close -- exactly once on every path (including exceptions).@@ -140,12 +170,16 @@   withRunInIO $ \run ->     bracket (openCallLog c) closeCallLog (run . body) --- | Non-blocking enqueue. When the handle is disabled, returns--- immediately without touching the channel.+-- | Non-blocking enqueue. When the handle is disabled, or has already+-- been closed, returns immediately without touching the channel — the+-- worker that would have drained the entry is gone, so enqueuing it+-- would only grow a channel nobody reads. appendEntry :: (MonadIO m) => CallLogHandle -> CallLogEntry -> m () appendEntry h entry   | not (enabled (cfg h)) = pure ()-  | otherwise = liftIO (writeChan (chan h) (Just entry))+  | otherwise = liftIO $ do+      isClosed <- readIORef (closed h)+      unless isClosed (writeChan (chan h) (Just entry))  -- | Dispatch through the registry, then (if logging is enabled) -- enqueue a single JSONL record summarizing the call.
src/Baikai/Embedding.hs view
@@ -1,12 +1,16 @@ -- | A small, provider-neutral embeddings client over an OpenAI-compatible--- @\/v1\/embeddings@ endpoint (EP-15).+-- @\/v1\/embeddings@ endpoint. ----- 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@+-- baikai shipped no embeddings client; this is the first. It reuses the+-- @openai@ SDK's 'OpenAI.V1.makeMethods' and the sibling+-- 'OpenAI.V1.createEmbeddings' method, baikai's own 'Baikai.Auth' for key+-- resolution — the same per-host table the chat providers use — and+-- baikai's own 'Baikai.Http' connection cache, which the chat providers+-- share, so two calls to one host reuse one TLS manager rather than+-- allocating one per call as the SDK's own @getClientEnv@ does.+--+-- 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.@@ -15,29 +19,39 @@ -- fields (context window, output tokens, chat pricing, modalities) are meaningful -- for embeddings. module Baikai.Embedding-  ( EmbeddingModel (..),+  ( EmbeddingModel (modelId, baseUrl, dimensions, apiKey),     emptyEmbeddingModel,-    _EmbeddingModel,     openAIEmbeddingModel,     mkEmbeddingRequest,     firstEmbedding,+    resolveEmbeddingKey,+    embeddingClientEnv,     embed,     embedOne,   ) where  import Baikai.Auth (ApiKeySource (..), resolveApiKey)-import Baikai.Error (BaikaiError, decodeError)+import Baikai.Auth qualified as Auth+import Baikai.Error (BaikaiError, authError, decodeError, invalidRequest)+import Baikai.Http qualified as Http+import Baikai.Url qualified as Url import Control.Exception (throwIO) import Data.Text (Text) import Data.Vector (Vector) import Data.Vector qualified as V+import GHC.Generics (Generic) import Numeric.Natural (Natural) import OpenAI.V1 qualified as OpenAI import OpenAI.V1.Embeddings qualified as Emb import OpenAI.V1.Models qualified as OpenAIModels+import Servant.Client qualified as Client  -- | How to reach an embeddings endpoint and which model to ask for.+--+-- Construction: the constructor is deliberately not exported. Start+-- from 'emptyEmbeddingModel' (or 'openAIEmbeddingModel') and override+-- fields by record update. data EmbeddingModel = EmbeddingModel   { -- | e.g. @\"text-embedding-3-small\"@     modelId :: !Text,@@ -45,31 +59,38 @@     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+    -- | How to resolve the API key (from "Baikai.Auth"). 'Nothing' means+    -- the conventional variable for this host, from+    -- 'Auth.defaultApiKeyEnvForBaseUrl' — the same table the chat+    -- providers consult — and a host that table does not know refuses+    -- with an 'Baikai.Error.AuthError' rather than falling back to+    -- another provider's credential. This mirrors+    -- 'Baikai.Options.apiKey', which has meant exactly that all along.+    apiKey :: !(Maybe ApiKeySource)   }-  deriving stock (Show)+  deriving stock (Eq, Show, Generic)  -- | A blank embedding model; a record-update target for hand-built models. Keyed--- on @OPENAI_API_KEY@ by default.+-- per host by default, so @api.openai.com@ resolves @OPENAI_API_KEY@ and+-- @api.deepseek.com@ resolves @DEEPSEEK_API_KEY@. emptyEmbeddingModel :: EmbeddingModel emptyEmbeddingModel =   EmbeddingModel     { modelId = "",       baseUrl = "",       dimensions = Nothing,-      apiKey = ApiKeyEnv "OPENAI_API_KEY"+      apiKey = Nothing     } --- | The OpenAI default: @api.openai.com@, key from @OPENAI_API_KEY@, model-default--- dimensionality.+-- | The OpenAI default: @api.openai.com@, whose conventional key variable is+-- @OPENAI_API_KEY@, and model-default dimensionality. openAIEmbeddingModel :: Text -> EmbeddingModel openAIEmbeddingModel mid =   emptyEmbeddingModel     { modelId = mid,       baseUrl = "https://api.openai.com",       dimensions = Nothing,-      apiKey = ApiKeyEnv "OPENAI_API_KEY"+      apiKey = Nothing     }  -- | Build the OpenAI @\/v1\/embeddings@ request for a single input text. Pure and@@ -94,6 +115,40 @@     Just (obj, _) ->       Right (Emb.embedding obj) +-- | The key 'embed' will send: the explicit source when the model names+-- one, otherwise the conventional variable for the model's host.+--+-- A host with no conventional variable is an 'Baikai.Error.AuthError'+-- naming the host and telling the caller to set the field, rather than a+-- silent fallback to @OPENAI_API_KEY@ — which is what this did before,+-- and which sent an OpenAI key to whatever host the base URL named.+--+-- Exported so a caller can see which key a model resolves without+-- making a request.+resolveEmbeddingKey :: EmbeddingModel -> IO Text+resolveEmbeddingKey m = case apiKey m of+  Just source -> resolveApiKey source+  Nothing -> case Auth.defaultApiKeyEnvForBaseUrl url of+    Just name -> resolveApiKey (ApiKeyEnv name)+    Nothing ->+      throwIO+        ( authError+            ( "no default API key env is known for "+                <> url+                <> "; set EmbeddingModel.apiKey explicitly"+            )+        )+  where+    url = urlOf m++-- | The cached connection 'embed' will use, from "Baikai.Http" — the+-- same process-global cache the chat providers use, so an embeddings+-- call and a chat call to one host share a TLS manager.+--+-- Exported so the sharing is observable without a network call.+embeddingClientEnv :: EmbeddingModel -> IO Client.ClientEnv+embeddingClientEnv = Http.getClientEnvCached . urlOf+ -- | 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@@ -101,8 +156,17 @@ embed :: EmbeddingModel -> [Text] -> IO (Vector (Vector Double)) embed _ [] = pure V.empty embed m texts = do-  key <- resolveApiKey (apiKey m)-  env <- OpenAI.getClientEnv (urlOf m)+  -- Checked before the key is resolved, so a base URL baikai will not+  -- send to never causes a credential to be read out of the+  -- environment. The base URL is the API root — baikai appends+  -- @\/v1\/embeddings@ itself, and a trailing @\/v1@ is removed rather+  -- than doubled.+  case Url.baseUrlProblem (urlOf m) of+    Just problem ->+      throwIO (invalidRequest ("EmbeddingModel.baseUrl is not usable: " <> problem))+    Nothing -> pure ()+  key <- resolveEmbeddingKey m+  env <- embeddingClientEnv m   let create = OpenAI.createEmbeddings (OpenAI.makeMethods env key Nothing Nothing)   V.fromList <$> traverse (embedText create) texts   where@@ -123,7 +187,3 @@ urlOf m = case baseUrl m of   "" -> "https://api.openai.com"   u -> u--{-# DEPRECATED _EmbeddingModel "Use emptyEmbeddingModel instead." #-}-_EmbeddingModel :: EmbeddingModel-_EmbeddingModel = emptyEmbeddingModel
src/Baikai/Error.hs view
@@ -5,6 +5,7 @@     -- * Smart constructors     providerError,     invalidRequest,+    contentFiltered,     decodeError,     processError,     rateLimited,@@ -17,12 +18,15 @@     -- * Pure classification helpers for provider packages     httpError,     parseRetryAfterSeconds,+    parseHttpDate,+    retryAfterSecondsAt,     classifyHttpStatus,     classifyHttpStatusWithBody,     bodyIndicatesOverflow,   ) where +import Control.Applicative ((<|>)) import Control.Exception (Exception (displayException)) import Data.Aeson   ( FromJSON (parseJSON),@@ -33,8 +37,10 @@     genericParseJSON,     genericToJSON,   )+import Data.Maybe (listToMaybe, mapMaybe) import Data.Text (Text) import Data.Text qualified as Text+import Data.Time (UTCTime, defaultTimeLocale, diffUTCTime, parseTimeM) import GHC.Generics (Generic) import Text.Read (readMaybe) @@ -50,11 +56,17 @@     -- retryable after a delay; see 'retryAfterSeconds'.     RateLimited   | -- | The request exceeded the model's context window or a related-    -- size limit. Not retryable as-is; the caller must shrink input.+    -- size limit: HTTP 413, or a 400\/422 whose body names the context+    -- window. Not retryable as-is; the caller must shrink input.     ContextOverflow   | -- | The request was malformed or otherwise rejected as invalid     -- (HTTP 400/404/422). Not retryable without changes.     InvalidRequest+  | -- | The provider refused or filtered the content — OpenAI's+    -- @finish_reason: "content_filter"@, Anthropic's @refusal@ stop.+    -- The content, not the transport, is the problem, so it is not+    -- retryable as-is: the caller must change what it sent.+    ContentFiltered   | -- | A transient server-side or network failure (HTTP 408/5xx, or a     -- connection error). Safe to retry, ideally with backoff.     TransientError@@ -91,7 +103,7 @@     -- | The HTTP status code, when the failure came from an HTTP call.     httpStatus :: !(Maybe Int),     -- | Seconds to wait before retrying, parsed from a @Retry-After@-    -- header when present and integer-valued.+    -- header in either its integer or its HTTP-date form.     retryAfterSeconds :: !(Maybe Int),     -- | The subprocess exit code, for 'ProcessFailure'.     exitCode :: !(Maybe Int)@@ -135,6 +147,10 @@ invalidRequest :: Text -> BaikaiError invalidRequest = baseError InvalidRequest +-- | Content the provider refused or filtered.+contentFiltered :: Text -> BaikaiError+contentFiltered = baseError ContentFiltered+ -- | A response that failed to decode. decodeError :: Text -> BaikaiError decodeError = baseError DecodeFailure@@ -166,13 +182,43 @@   TransientError -> True   _ -> False --- | Parse an integer-valued @Retry-After@ header as seconds. HTTP-date--- values and malformed values yield 'Nothing'.+-- | Parse an integer-valued @Retry-After@ header as seconds. The+-- integer form only: an HTTP-date yields 'Nothing' here, deliberately,+-- because converting one needs a reference instant. See+-- 'retryAfterSecondsAt' for the form that accepts either. parseRetryAfterSeconds :: Text -> Maybe Int parseRetryAfterSeconds raw = do   n <- readMaybe (Text.unpack (Text.strip raw))   if n >= 0 then Just n else Nothing +-- | Parse an HTTP-date (RFC 7231 section 7.1.1.1). Accepts the+-- IMF-fixdate form servers must send, plus the obsolete RFC 850 and+-- asctime forms a recipient must still accept.+parseHttpDate :: Text -> Maybe UTCTime+parseHttpDate raw = listToMaybe (mapMaybe attempt formats)+  where+    s = Text.unpack (Text.strip raw)+    attempt fmt = parseTimeM True defaultTimeLocale fmt s+    formats =+      [ "%a, %d %b %Y %H:%M:%S GMT", -- Sun, 06 Nov 1994 08:49:37 GMT+        "%A, %d-%b-%y %H:%M:%S GMT", -- Sunday, 06-Nov-94 08:49:37 GMT+        "%a %b %e %H:%M:%S %Y" -- Sun Nov  6 08:49:37 1994+      ]++-- | Seconds to wait, from a @Retry-After@ value in either of its two+-- forms, relative to a reference instant.+--+-- The reference should be the response's own @Date@ header when it+-- parses, which takes the caller's clock skew out of the computation;+-- the local time is the fallback. A date already in the past yields+-- @Just 0@ — the server is saying "now" — and text in neither form+-- yields 'Nothing'.+retryAfterSecondsAt :: UTCTime -> Text -> Maybe Int+retryAfterSecondsAt reference raw =+  parseRetryAfterSeconds raw <|> (secondsUntil <$> parseHttpDate raw)+  where+    secondsUntil t = max 0 (ceiling (diffUTCTime t reference))+ -- | Build a classified error from an HTTP failure's status, optional -- parsed @Retry-After@ seconds, and response body text. httpError :: Int -> Maybe Int -> Text -> BaikaiError@@ -195,12 +241,15 @@ -- -- The body of a 400 may indicate a context-window overflow, but this -- helper only sees the status code; callers that can inspect the body--- should special-case overflow before falling back here.+-- should special-case overflow before falling back here. 413 needs no+-- such help: it /is/ the size-limit status, and the caller's remedy —+-- shrink the input — is the same whatever the body says. classifyHttpStatus :: Int -> Maybe Int -> ErrorCategory classifyHttpStatus status _retryAfter   | status == 401 || status == 403 = AuthError   | status == 429 = RateLimited   | status == 408 = TransientError+  | status == 413 = ContextOverflow   | status == 400 || status == 404 || status == 422 = InvalidRequest   | status >= 500 = TransientError   | otherwise = OtherError
src/Baikai/Evidence.hs view
@@ -29,7 +29,31 @@     evidenceSchemaVersion,      -- * The evidence record-    ModelCallEvidence (..),+    ModelCallEvidence+      ( schemaVersion,+        runId,+        callId,+        attempt,+        supersedes,+        endpoint,+        requestedModel,+        thinking,+        observedModel,+        observedThinking,+        responseId,+        providerRequestId,+        clientRequestId,+        startedAt,+        endedAt,+        latencyMs,+        status,+        errorInfo,+        usage,+        strength,+        requestCommitment,+        requestConfiguration,+        responseCommitment+      ),     baseEvidence,      -- * Observation@@ -40,7 +64,9 @@     ThinkingTranslation (..),     ThinkingMode (..),     ThinkingAdjustment (..),+    weakensThinking,     noThinkingRequested,+    untranslatedThinking,      -- * Endpoint and transport     EndpointIdentity (..),@@ -50,14 +76,17 @@     CallStatus (..),     EvidenceStrength (..),     renderEvidenceStrength,+    parseEvidenceStrength,     declaredStrength,+    deriveStrength,      -- * The caller's request-    EvidenceRequest (..),+    EvidenceRequest (runId, strictness, attempt, supersedes),     EvidenceStrictness (..),     evidenceRequest,      -- * Canonical encoding and digests+    usageEnvelope,     canonicalEncode,     commitmentDigest,     configurationDigest,@@ -70,8 +99,9 @@  import Baikai.Api (Api (..)) import Baikai.Error (BaikaiError)-import Baikai.ThinkingLevel (ThinkingLevel (..), renderThinkingLevel)+import Baikai.ThinkingLevel (ThinkingLevel (..), parseThinkingLevel, renderThinkingLevel) import Baikai.Usage (Usage)+import Baikai.Usage qualified as Usage import Control.Exception (SomeException, try) import Crypto.Hash.SHA256 qualified as SHA256 import Data.Aeson@@ -91,7 +121,7 @@   ) import Data.Aeson.Key qualified as Key import Data.Aeson.KeyMap qualified as KeyMap-import Data.Aeson.Types (typeMismatch)+import Data.Aeson.Types (Parser, typeMismatch) import Data.Bits (Bits, shiftL, shiftR, (.&.), (.|.)) import Data.ByteString (ByteString) import Data.ByteString qualified as ByteString@@ -172,7 +202,7 @@ -- | Which shape a provider's thinking configuration took on the wire. -- -- Encodes as a lowercase string: @budget@, @adaptive@, @flag@,--- @toggle@, @unsupported@, @absent@.+-- @toggle@, @unsupported@, @not_translated@, @absent@. data ThinkingMode   = -- | The provider took an explicit token budget.     ThinkingModeBudget@@ -185,6 +215,13 @@   | -- | The caller requested a level and this transport cannot express     -- any part of it.     ThinkingModeUnsupported+  | -- | The caller requested a level and no provider adapter ran to+    -- translate it: the call was refused, never dispatched, or+    -- abandoned before the adapter could describe what it did. The+    -- request is recorded; the translation is unknown. Distinct from+    -- 'ThinkingModeAbsent' (nothing requested) and from+    -- 'ThinkingModeUnsupported' (an adapter looked and could not).+    ThinkingModeNotTranslated   | -- | The caller requested no level at all.     ThinkingModeAbsent   deriving stock (Eq, Show, Generic)@@ -196,6 +233,7 @@   ThinkingModeFlag -> "flag"   ThinkingModeToggle -> "toggle"   ThinkingModeUnsupported -> "unsupported"+  ThinkingModeNotTranslated -> "not_translated"   ThinkingModeAbsent -> "absent"  parseThinkingMode :: Text -> Maybe ThinkingMode@@ -205,6 +243,7 @@   "flag" -> Just ThinkingModeFlag   "toggle" -> Just ThinkingModeToggle   "unsupported" -> Just ThinkingModeUnsupported+  "not_translated" -> Just ThinkingModeNotTranslated   "absent" -> Just ThinkingModeAbsent   _ -> Nothing @@ -227,6 +266,12 @@ -- Levels are carried as 'ThinkingLevel' rather than text so that -- strict evidence mode can compare them; they render through -- 'Baikai.ThinkingLevel.renderThinkingLevel' in JSON.+--+-- Two constructors are not about thinking: the sampling drops record+-- that @temperature@, @top_p@, @seed@ and their kind were removed+-- because the model generation or the API rejects them. They carry no+-- requested level and 'weakensThinking' is 'False' for them, so strict+-- evidence mode does not refuse a call over one. data ThinkingAdjustment   = -- | The requested level was replaced by a weaker one the transport     -- accepts. Carries the requested level and the wire text sent.@@ -248,8 +293,38 @@     -- fit inside the resolved output-token ceiling. Carries the     -- requested level, the budget that was computed, and the ceiling.     ThinkingDroppedBudgetExceeded !ThinkingLevel !Natural !Natural+  | -- | Sampling parameters the caller set were removed because the+    -- chosen model generation rejects them. Carries the wire names+    -- removed, in wire order, for example+    -- @["temperature","top_p"]@. Carries no requested level: it is+    -- not about thinking, and it happens on calls that asked for no+    -- thinking at all.+    SamplingDroppedUnsupportedModel ![Text]+  | -- | Sampling parameters the caller set were removed because this+    -- API has no field for them on any generation — the Anthropic+    -- Messages API has no @seed@, @frequency_penalty@ or+    -- @presence_penalty@. Carries the wire names removed, in wire+    -- order.+    SamplingDroppedUnsupportedApi ![Text]   deriving stock (Eq, Show, Generic) +-- | Whether an adjustment weakens the /thinking/ the caller asked for.+--+-- Strict evidence mode refuses a call whose translation would weaken+-- the requested thinking level; it must not refuse one merely because+-- a sampling parameter had nowhere to go. The six level-carrying+-- constructors weaken thinking; the two sampling ones do not.+weakensThinking :: ThinkingAdjustment -> Bool+weakensThinking = \case+  EffortClamped {} -> True+  EffortCollapsedToToggle {} -> True+  EffortOmitted {} -> True+  ThinkingDroppedUnsupportedModel {} -> True+  ThinkingDroppedUnsupportedHost {} -> True+  ThinkingDroppedBudgetExceeded {} -> True+  SamplingDroppedUnsupportedModel {} -> False+  SamplingDroppedUnsupportedApi {} -> False+ -- | Adjustments encode as a tagged object whose @kind@ names the -- constructor in snake_case and whose @requested@ field carries the -- canonical level name.@@ -270,28 +345,43 @@         "thinking_dropped_budget_exceeded"         lvl         ["budget_tokens" .= budget, "max_tokens" .= maxOut]+    SamplingDroppedUnsupportedModel fields ->+      untagged "sampling_dropped_unsupported_model" fields+    SamplingDroppedUnsupportedApi fields ->+      untagged "sampling_dropped_unsupported_api" fields     where       tagged kind lvl extra =         object           ( ["kind" .= (kind :: Text), "requested" .= renderThinkingLevel lvl]               <> extra           )+      untagged kind fields =+        object ["kind" .= (kind :: Text), "fields" .= (fields :: [Text])] +-- | @kind@ is read first, because only the six level-carrying kinds+-- have a @requested@ field to read: the two sampling kinds carry a+-- @fields@ array instead. instance FromJSON ThinkingAdjustment where   parseJSON = \case     Object o -> do       kind <- o .: "kind"-      lvl <- o .: "requested" >>= parseThinkingLevelText+      let withLevel :: (ThinkingLevel -> Parser ThinkingAdjustment) -> Parser ThinkingAdjustment+          withLevel k = o .: "requested" >>= parseThinkingLevelText >>= k       case kind :: Text of-        "effort_clamped" -> EffortClamped lvl <$> o .: "wire"-        "effort_collapsed_to_toggle" -> pure (EffortCollapsedToToggle lvl)-        "effort_omitted" -> pure (EffortOmitted lvl)+        "effort_clamped" -> withLevel $ \lvl -> EffortClamped lvl <$> o .: "wire"+        "effort_collapsed_to_toggle" -> withLevel (pure . EffortCollapsedToToggle)+        "effort_omitted" -> withLevel (pure . EffortOmitted)         "thinking_dropped_unsupported_model" ->-          pure (ThinkingDroppedUnsupportedModel lvl)+          withLevel (pure . ThinkingDroppedUnsupportedModel)         "thinking_dropped_unsupported_host" ->-          pure (ThinkingDroppedUnsupportedHost lvl)+          withLevel (pure . ThinkingDroppedUnsupportedHost)         "thinking_dropped_budget_exceeded" ->-          ThinkingDroppedBudgetExceeded lvl <$> o .: "budget_tokens" <*> o .: "max_tokens"+          withLevel $ \lvl ->+            ThinkingDroppedBudgetExceeded lvl <$> o .: "budget_tokens" <*> o .: "max_tokens"+        "sampling_dropped_unsupported_model" ->+          SamplingDroppedUnsupportedModel <$> o .: "fields"+        "sampling_dropped_unsupported_api" ->+          SamplingDroppedUnsupportedApi <$> o .: "fields"         other -> fail ("unknown thinking adjustment: " <> show other)     v -> typeMismatch "ThinkingAdjustment" v @@ -301,15 +391,13 @@ -- names that 'ThinkingLevel'\'s own derived instance uses, because a -- reader of an evidence record should see the same vocabulary the -- provider documentation uses.+--+-- The table itself lives beside its renderer, in+-- 'Baikai.ThinkingLevel.parseThinkingLevel'; this is the parser-monad+-- wrapper that turns a miss into a decode failure naming the input. parseThinkingLevelText :: (MonadFail m) => Text -> m ThinkingLevel-parseThinkingLevelText = \case-  "minimal" -> pure ThinkingMinimal-  "low" -> pure ThinkingLow-  "medium" -> pure ThinkingMedium-  "high" -> pure ThinkingHigh-  "xhigh" -> pure ThinkingXHigh-  "max" -> pure ThinkingMax-  other -> fail ("unknown thinking level: " <> show other)+parseThinkingLevelText t =+  maybe (fail ("unknown thinking level: " <> show t)) pure (parseThinkingLevel t)  -- | What a canonical 'ThinkingLevel' actually became on the wire for -- one specific provider.@@ -336,6 +424,12 @@     -- | Everything that happened to the request between the canonical     -- level and the wire, in the order it was applied. Empty means the     -- request was expressed exactly.+    --+    -- Reasoning /and/ sampling changes travel here: a+    -- 'SamplingDroppedUnsupportedModel' entry can appear on a call+    -- whose 'mode' is 'ThinkingModeAbsent', because nothing about+    -- thinking was asked and something about sampling was dropped.+    -- 'mode' describes the thinking configuration only.     adjustments :: ![ThinkingAdjustment]   }   deriving stock (Eq, Show, Generic)@@ -368,6 +462,12 @@ -- Distinct from a call that asked for a level the transport could not -- express, which is 'ThinkingModeUnsupported' with a non-empty -- 'adjustments' list.+--+-- This value's 'adjustments' list is empty, but a real call that asked+-- for no thinking may still carry adjustments: a dropped sampling+-- parameter is recorded whatever the thinking mode. Build such a+-- translation by adding to this one rather than by assuming+-- @mode = absent@ implies nothing happened. noThinkingRequested :: ThinkingTranslation noThinkingRequested =   ThinkingTranslation@@ -379,6 +479,47 @@       adjustments = []     } +-- | The translation for a path where no adapter ran: the caller's+-- level exactly, and no claim about the wire. 'noThinkingRequested'+-- when no level was set, so the two statements stay distinct.+--+-- The 'adjustments' list is empty on purpose: an untranslated request+-- has not been downgraded, it has not been looked at, and strict+-- evidence mode refuses a call over a non-empty list.+untranslatedThinking :: Maybe ThinkingLevel -> ThinkingTranslation+untranslatedThinking = \case+  Nothing -> noThinkingRequested+  Just lvl ->+    ThinkingTranslation+      { requested = Just lvl,+        mode = ThinkingModeNotTranslated,+        effortText = Nothing,+        budgetTokens = Nothing,+        wireField = Nothing,+        adjustments = []+      }++-- | The usage a response digest commits to: the token counts the+-- provider reported, and never the cost.+--+-- 'Usage.cost' is computed here from the caller's own catalog rates,+-- not reported by the provider, so including it made+-- @response_commitment@ change whenever pricing was edited and left a+-- verifier holding only the response unable to recompute it. The six+-- counts are listed through record selectors rather than encoded+-- wholesale, so a field added to 'Usage' later does not silently join+-- the digest.+usageEnvelope :: Usage -> Value+usageEnvelope u =+  object+    [ "input_tokens" .= Usage.inputTokens u,+      "output_tokens" .= Usage.outputTokens u,+      "cache_read_tokens" .= Usage.cacheReadTokens u,+      "cache_write_tokens" .= Usage.cacheWriteTokens u,+      "reasoning_tokens" .= Usage.reasoningTokens u,+      "total_tokens" .= Usage.totalTokens u+    ]+ -- ============================================================ -- Endpoint and transport -- ============================================================@@ -522,16 +663,23 @@   EvidenceModelObserved -> "model_observed"   EvidenceFullyObserved -> "fully_observed" +-- | The inverse of 'renderEvidenceStrength'. Beside its renderer, so+-- the two cannot drift when a level is added; the 'FromJSON' instance+-- and @baikai-agent@'s @--require-evidence@ parser both go through it.+parseEvidenceStrength :: Text -> Maybe EvidenceStrength+parseEvidenceStrength = \case+  "requested_only" -> Just EvidenceRequestedOnly+  "correlated" -> Just EvidenceCorrelated+  "model_observed" -> Just EvidenceModelObserved+  "fully_observed" -> Just EvidenceFullyObserved+  _ -> Nothing+ instance ToJSON EvidenceStrength where   toJSON = String . renderEvidenceStrength  instance FromJSON EvidenceStrength where-  parseJSON = withText "EvidenceStrength" $ \case-    "requested_only" -> pure EvidenceRequestedOnly-    "correlated" -> pure EvidenceCorrelated-    "model_observed" -> pure EvidenceModelObserved-    "fully_observed" -> pure EvidenceFullyObserved-    other -> fail ("unknown evidence strength: " <> show other)+  parseJSON = withText "EvidenceStrength" $ \t ->+    maybe (fail ("unknown evidence strength: " <> show t)) pure (parseEvidenceStrength t)  -- | The highest strength a transport can reach when everything goes -- well.@@ -569,6 +717,45 @@ -- -- * 'Custom' declares 'EvidenceRequestedOnly'. Baikai knows nothing --   about a caller-supplied transport and must not assume on its behalf.+-- | The one rule that turns observations into a strength.+--+-- A __correlation identifier__ is the provider's request id (typically a+-- response header) or its response id; either locates the call in the+-- provider's own records, which is what 'EvidenceCorrelated' means.+--+-- A model is 'EvidenceModelObserved' only /in addition to/ one, because+-- the scale is cumulative by its own documentation. An unlocatable model+-- claim does not climb it — it stays recorded in @observed_model@, where+-- a reader can see it — and no shipped transport produces that+-- combination. Nothing reaches 'EvidenceFullyObserved'.+--+-- A successful status is deliberately not an argument. A 200 means the+-- request was accepted, not that any particular model ran.+--+-- Three copies of this rule had drifted apart: the subprocess one+-- counted a session or thread id as correlation while the two API ones+-- looked only at a captured header, so a host reporting @model@ and @id@+-- on every chunk but no header landed at 'EvidenceRequestedOnly',+-- /below/ a host that sent only a header.+deriveStrength ::+  -- | The model the provider reported serving.+  Observed Text ->+  -- | The provider's request id, typically from a response header.+  Observed Text ->+  -- | The provider's response id.+  Observed Text ->+  EvidenceStrength+deriveStrength observedModel providerRequestId responseId =+  case (observedModel, correlated) of+    (Observed _, True) -> EvidenceModelObserved+    (_, True) -> EvidenceCorrelated+    _ -> EvidenceRequestedOnly+  where+    correlated = case (providerRequestId, responseId) of+      (Observed _, _) -> True+      (_, Observed _) -> True+      _ -> False+ declaredStrength :: Api -> EvidenceStrength declaredStrength = \case   AnthropicMessages -> EvidenceModelObserved@@ -619,6 +806,9 @@ -- behaves exactly as it did before this vocabulary existed: no digest -- is computed, no call identifier is generated for evidence purposes, -- and no evidence is emitted.+--+-- Construction: the constructor is deliberately not exported. Start+-- from 'evidenceRequest' and override fields by record update. data EvidenceRequest = EvidenceRequest   { -- | The caller's identifier for the logical unit of work this call     -- belongs to. Baikai treats it as opaque text and never parses it.@@ -662,14 +852,41 @@ -- existing readers working; bump the major component when a field is -- removed, changes meaning, or when 'canonicalEncode' changes, since -- that invalidates every previously recorded digest.+-- The @1.1@ minor bump added the @sampling_dropped_unsupported_model@+-- and @sampling_dropped_unsupported_api@ adjustment kinds. They are a+-- compatible addition: a reader that switches on @kind@ and ignores+-- what it does not know keeps working, and no existing digest changes.+--+-- The @2.0@ major bump changed what two digests cover, so a verifier+-- must select its rules by @schema_version@ rather than assume:+--+-- * @response_commitment@ covers @{"content", "stop_reason", "usage"}@+--   where @usage@ is the provider-reported token counts only+--   ('usageEnvelope'). Under @1.x@ it also covered baikai's computed+--   @cost@, which comes from the caller's catalog rates rather than+--   from the response, so the digest changed whenever pricing was+--   edited and a verifier holding only the response could not+--   recompute it.+-- * @request_configuration@ additionally summarises @output_config@ and+--   @response_format@ ('configurationProjection'), because a+--   structured-output JSON schema is author-written content wherever it+--   appears. Under @1.x@ both survived verbatim.+--+-- A @1.x@ record's digests are recomputed under @1.x@ rules. One+-- further compatible addition rides along: @thinking.mode@ may now be+-- @"not_translated"@. evidenceSchemaVersion :: Text-evidenceSchemaVersion = "baikai.model-call-evidence/1.0"+evidenceSchemaVersion = "baikai.model-call-evidence/2.0"  -- | Everything Baikai can say about one completed provider call. -- -- The field order is the story the record tells: who ran it, where it -- went, what was asked, what came back, how it went, and what it costs -- to believe.+--+-- Construction: the constructor is deliberately not exported. Start+-- from 'baseEvidence' and override fields by record update, so that a+-- field added in a later release cannot break a construction site. data ModelCallEvidence = ModelCallEvidence   { -- Identity ------------------------------------------------------- @@ -981,13 +1198,25 @@ -- the digest until someone adds it here, which loses fidelity rather -- than leaking. ----- Keys outside the list are dropped entirely. Three keys are kept but+-- Keys outside the list are dropped entirely. Five keys are kept but -- replaced with structural summaries: @messages@ becomes one object -- per message carrying its role, its block count, and the total -- character length of every string inside it; @system@ becomes just -- that character count; @tools@ becomes each tool's name and nothing--- else, so descriptions and JSON schemas do not survive.+-- else; @output_config@ keeps its effort and reduces its @format@ to a+-- type and a character count; and @response_format@ keeps its type and+-- reduces its @json_schema@ to a name, a strictness flag and a+-- character count. --+-- The rule the last three share: __a JSON schema is content wherever it+-- appears.__ A schema carries author-written @description@ strings that+-- describe the caller's domain as freely as a prompt does.+-- @tools[].input_schema@ was already stripped on that ground while the+-- same kind of schema, reached through @output_config.format.schema@ or+-- @response_format.json_schema@, survived verbatim into a digest+-- callers were told is content-free. The names, types and flags around+-- it are configuration in the sense a tool's name is.+-- -- A top-level value that is not an object has no named fields for the -- allow-list to admit, so it projects to 'Null' rather than passing -- through.@@ -1000,6 +1229,8 @@       "messages" -> [(k, summariseMessages v)]       "system" -> [(k, charSummary v)]       "tools" -> [(k, summariseTools v)]+      "output_config" -> [(k, summariseOutputConfig v)]+      "response_format" -> [(k, summariseResponseFormat v)]       name         | name `Set.member` configurationKeys -> [(k, v)]         | otherwise -> []@@ -1016,11 +1247,9 @@       "max_completion_tokens",       "max_tokens",       "model",-      "output_config",       "presence_penalty",       "reasoning",       "reasoning_effort",-      "response_format",       "seed",       "stop_sequences",       "stream",@@ -1074,6 +1303,56 @@ -- schema are author-written content. Both wire shapes are handled: the -- Anthropic form with @name@ at the top level, and the OpenAI form -- that nests it under @function@.+-- | Anthropic's @output_config@: @{"effort": <text>, "format":+-- {"type": ..., "schema": ...}}@ at claude 0.6 (@Claude.V1.Messages@,+-- @OutputConfig@ and @OutputFormat@). Every key is kept as it is except+-- @format@, whose schema is content.+summariseOutputConfig :: Value -> Value+summariseOutputConfig = \case+  Object o -> Object (KeyMap.mapWithKey summarise o)+  _ -> Null+  where+    summarise k v+      | Key.toText k == "format" =+          object+            [ "type" .= formatType v,+              "chars" .= totalStringChars v+            ]+      | otherwise = v+    formatType = \case+      Object f -> case KeyMap.lookup "type" f of+        Just t@(String _) -> t+        _ -> Null+      _ -> Null++-- | The OpenAI-compatible @response_format@: @{"type": "json_schema",+-- "json_schema": {"name": ..., "strict": ..., "schema": ...}}@. The+-- type, the schema's name and its strictness are configuration; the+-- schema itself is content.+summariseResponseFormat :: Value -> Value+summariseResponseFormat = \case+  Object o ->+    object+      [ "type" .= lookupString "type" o,+        "json_schema" .= schemaSummary (KeyMap.lookup "json_schema" o)+      ]+  _ -> Null+  where+    lookupString k o = case KeyMap.lookup (Key.fromText k) o of+      Just t@(String _) -> t+      _ -> Null+    schemaSummary = \case+      Just v@(Object js) ->+        object+          [ "name" .= lookupString "name" js,+            "strict" .= strictOf js,+            "chars" .= totalStringChars v+          ]+      _ -> Null+    strictOf js = case KeyMap.lookup "strict" js of+      Just b@(Bool _) -> b+      _ -> Null+ summariseTools :: Value -> Value summariseTools = \case   Array xs -> Array (fmap summariseTool xs)
src/Baikai/Evidence/Build.hs view
@@ -16,10 +16,14 @@ -- cannot forget it. module Baikai.Evidence.Build   ( minimalEvidence,+    minimalEvidenceAt,     prepareEvidence,+    prepareEvidenceAt,     endpointIdentity,+    endpointIdentityAt,     sanitizeEndpoint,     dispatchEnvelope,+    requestedTranslation,     transportForModel,     baikaiPackageVersion, @@ -28,6 +32,10 @@     sinkFailureIsFatal,     sinkFailureError, +    -- * Strict mode+    strictnessOf,+    missingEvidenceError,+     -- * The pre-dispatch strictness gate     EvidenceRefusal (..),     renderEvidenceRefusal,@@ -50,14 +58,16 @@     baseEvidence,     commitmentDigest,     configurationDigest,-    declaredStrength,     newCallId,     renderEvidenceStrength,+    untranslatedThinking,+    weakensThinking,   ) import Baikai.Model (Model) import Baikai.Options (Options) import Baikai.Prelude import Baikai.ThinkingLevel (renderThinkingLevel)+import Baikai.Url qualified as Url import Control.Exception (SomeException, displayException) import Data.Aeson qualified as Aeson import Data.Maybe (fromMaybe)@@ -125,8 +135,36 @@   -- documentation rather than enforced by the type.   Maybe BaikaiError ->   IO (Maybe ModelCallEvidence)-minimalEvidence m opts transport translation envelope started ended st err = do-  mk <- prepareEvidence m opts transport translation envelope started+minimalEvidence m opts =+  minimalEvidenceAt (m ^. #baseUrl) m opts++-- | 'minimalEvidence' against the base URL the adapter actually+-- resolved, rather than the possibly-empty one on the 'Model'.+--+-- Both API adapters substitute a vendor default for an empty+-- @baseUrl@ inside their own @prepareCall@, so the call goes to a+-- definite host while the model still says @""@ — and+-- 'sanitizeEndpoint' then recorded @null@ for a call whose destination+-- was perfectly well known. The core cannot know a vendor default, so+-- where no adapter ran @null@ remains the truthful answer and the+-- unsuffixed functions keep passing @m ^. #baseUrl@.+minimalEvidenceAt ::+  -- | The resolved base URL.+  Text ->+  Model ->+  Options ->+  TransportKind ->+  ThinkingTranslation ->+  -- | The request envelope. Lazy, for the reason 'minimalEvidence'+  -- documents at length.+  Aeson.Value ->+  UTCTime ->+  UTCTime ->+  CallStatus ->+  Maybe BaikaiError ->+  IO (Maybe ModelCallEvidence)+minimalEvidenceAt baseUrl m opts transport translation envelope started ended st err = do+  mk <- prepareEvidenceAt baseUrl m opts transport translation envelope started   pure (fmap (\finish -> finish ended st err) mk)  -- | 'minimalEvidence' for a transport that learns its terminal@@ -154,12 +192,30 @@   -- | Started at.   UTCTime ->   IO (Maybe (UTCTime -> CallStatus -> Maybe BaikaiError -> ModelCallEvidence))-prepareEvidence m opts transport translation envelope started =+prepareEvidence m opts =+  prepareEvidenceAt (m ^. #baseUrl) m opts++-- | 'prepareEvidence' against the base URL the adapter actually+-- resolved. See 'minimalEvidenceAt'.+prepareEvidenceAt ::+  -- | The resolved base URL.+  Text ->+  Model ->+  Options ->+  TransportKind ->+  ThinkingTranslation ->+  -- | The request envelope. Lazy, for the reason 'minimalEvidence'+  -- documents at length.+  Aeson.Value ->+  -- | Started at.+  UTCTime ->+  IO (Maybe (UTCTime -> CallStatus -> Maybe BaikaiError -> ModelCallEvidence))+prepareEvidenceAt baseUrl m opts transport translation envelope started =   case opts ^. #evidence of     Nothing -> pure Nothing     Just req -> do       cid <- newCallId-      let ep = endpointIdentity m transport+      let ep = endpointIdentityAt baseUrl m transport           commitment = commitmentDigest envelope           configuration = configurationDigest envelope       pure $@@ -179,6 +235,40 @@             { errorInfo = err             } +-- | The strictness a call was dispatched under. A call with no+-- evidence request is best-effort.+strictnessOf :: Options -> EvidenceStrictness+strictnessOf opts =+  maybe EvidenceBestEffort (^. #strictness) (opts ^. #evidence)++-- | The error a strict call fails with when its provider produced a+-- successful terminal and attached no evidence record to it.+--+-- Built with 'providerError' for the reason 'sinkFailureError' is:+-- nothing about the request was invalid and the provider did its job,+-- and 'Baikai.Error.ErrorCategory' is closed. The message prefix is the+-- contract until the surface freeze decides on a category.+missingEvidenceError :: BaikaiError+missingEvidenceError =+  providerError+    "this call required evidence, but the provider attached no evidence record to its \+    \terminal event; the response is reported failed rather than left unaccounted for"++-- | The translation to record where no provider adapter ran: an+-- unregistered provider, and a @complete@ handler that threw before+-- returning.+--+-- It carries the caller's level and says @not_translated@, so the+-- record states the request without claiming a wire shape that was+-- never built. Where an adapter /did/ run — the consumer-abort path in+-- "Baikai.Trace", and each adapter's own @immediateError@ — call that+-- adapter's @describeThinking@ instead; re-deriving a description in+-- the core is what+-- @docs\/adr\/0003-the-adapter-owns-the-translation-description.md@+-- forbids.+requestedTranslation :: Options -> ThinkingTranslation+requestedTranslation opts = untranslatedThinking (opts ^. #thinking)+ -- | Where a call went, without recording a credential. -- -- 'implementationVersion' is left 'Nothing' here. An API provider knows@@ -186,53 +276,43 @@ -- executable, but neither fact is available to the core, and inventing -- one would be worse than admitting the gap. endpointIdentity :: Model -> TransportKind -> EndpointIdentity-endpointIdentity m transport =+endpointIdentity m = endpointIdentityAt (m ^. #baseUrl) m++-- | 'endpointIdentity' against the base URL the adapter actually+-- resolved. See 'minimalEvidenceAt'.+endpointIdentityAt :: Text -> Model -> TransportKind -> EndpointIdentity+endpointIdentityAt baseUrl m transport =   EndpointIdentity     { provider = m ^. #provider,       api = renderApi (m ^. #api),       transport = transport,-      endpoint = sanitizeEndpoint (m ^. #baseUrl),+      endpoint = sanitizeEndpoint baseUrl,       baikaiVersion = baikaiPackageVersion,       implementationVersion = Nothing     }  -- | Reduce a base URL to scheme, host, port, and path. ----- The query string is dropped __wholesale__ rather than filtered field--- by field, because some gateways carry an API key in a query--- parameter and an allow-list of safe parameter names would be wrong--- the first time a host invented one. Any @userinfo@ component--- (@https:\/\/user:secret\@host\/@) is dropped for the same reason. A--- fragment cannot carry a credential to a server but is dropped too,--- since it is never part of what was requested.+-- This is "Baikai.Url" applied to the recording problem: 'Url.parseUrl'+-- never holds the userinfo, the query string or the fragment in the+-- first place, and 'Url.renderEndpoint' can only put back what it has.+-- The query string is therefore dropped __wholesale__ rather than+-- filtered field by field, which is the right behaviour rather than a+-- convenient one: some gateways carry an API key in a query parameter,+-- and an allow-list of safe parameter names would be wrong the first+-- time a host invented one. Userinfo+-- (@https:\/\/user:secret\@host\/@) goes for the same reason; a+-- fragment cannot carry a credential to a server but is never part of+-- what was requested either. --+-- The scheme and host come back lower-cased, because that is what+-- "Baikai.Url" says a host is; the path is kept verbatim.+-- -- An empty base URL yields 'Nothing' rather than an empty string, so a -- reader can tell "baikai recorded no endpoint" from "the endpoint was -- the empty string". sanitizeEndpoint :: Text -> Maybe Text-sanitizeEndpoint raw-  | Text.null trimmed = Nothing-  | Text.null cleaned = Nothing-  | otherwise = Just cleaned-  where-    trimmed = Text.strip raw-    withoutFragment = Text.takeWhile (/= '#') trimmed-    withoutQuery = Text.takeWhile (/= '?') withoutFragment-    cleaned = dropUserInfo withoutQuery---- | Drop a @user:password\@@ prefix from the authority component,--- keeping the scheme. Splits on the last @\@@ before the first @\/@ of--- the path so that an @\@@ later in the path is not mistaken for--- userinfo.-dropUserInfo :: Text -> Text-dropUserInfo url =-  let (scheme, rest) = case Text.breakOn "://" url of-        (s, r) | not (Text.null r) -> (s <> "://", Text.drop 3 r)-        _ -> ("", url)-      (authority, path) = Text.break (== '/') rest-   in case Text.breakOnEnd "@" authority of-        (before, after) | not (Text.null before) -> scheme <> after <> path-        _ -> scheme <> authority <> path+sanitizeEndpoint = fmap Url.renderEndpoint . Url.parseUrl  -- | The request envelope for the paths where __no provider adapter ran -- to completion__, and therefore no wire request body exists for this@@ -305,10 +385,13 @@     "the reasoning-effort request would not reach the provider as asked: "       <> Text.intercalate "; " (map describeAdjustment adjustments) --- | One downgrade, in words. These are the six places baikai weakens a+-- | One adjustment, in words. Six of these are places baikai weakens a -- thinking request, and the whole point of strict mode is that a caller -- can refuse each of them by name rather than discovering it in a trace--- afterwards.+-- afterwards. The two sampling entries are rendered here as well, so a+-- record printed for a human reads completely, even though+-- 'Baikai.Evidence.weakensThinking' keeps them out of the refusal+-- list. describeAdjustment :: ThinkingAdjustment -> Text describeAdjustment = \case   EffortClamped lvl wire ->@@ -332,6 +415,12 @@       <> Text.pack (show budget)       <> "-token budget does not fit inside the resolved output ceiling of "       <> Text.pack (show maxOut)+  SamplingDroppedUnsupportedModel fields ->+    Text.intercalate ", " fields+      <> " would be dropped, because this model generation rejects sampling parameters"+  SamplingDroppedUnsupportedApi fields ->+    Text.intercalate ", " fields+      <> " would be dropped, because this API has no such field on any generation"  -- | The pre-dispatch gate: every reason this call must not proceed, or -- an empty list when it may.@@ -355,22 +444,37 @@ -- The downgrade rule needs one judgement stated, because it is not -- obvious. A caller who requested no level at all is never downgraded — -- there is nothing to weaken, and 'Baikai.Evidence.noThinkingRequested'--- carries no adjustments, so this falls out. But /every/ non-empty--- adjustment list refuses, including+-- carries no adjustments, so this falls out. But every adjustment that+-- weakens the thinking request refuses, including -- 'Baikai.Evidence.EffortOmitted', which is the subtlest: that request -- is not weaker in effect, it is merely indistinguishable on the wire -- from the provider's default. A caller who demanded strict evidence and -- receives a request they cannot later prove asked for @high@ has not -- got what they demanded.+--+-- The adjustment list is filtered through+-- 'Baikai.Evidence.weakensThinking' rather than tested for emptiness,+-- because it also carries the sampling drops. The documented contract is+-- refusing a call that would /weaken the requested thinking level/; a+-- caller who set @seed@ on a Claude model, where the API has no such+-- field on any generation, must not have every strict call refused over+-- it. The drop is still in the record, where they can see it. checkEvidenceRequirements ::-  EvidenceStrictness -> Api -> ThinkingTranslation -> [EvidenceRefusal]+  EvidenceStrictness ->+  -- | The provider's own ceiling+  -- ('Baikai.Provider.Registry.strengthCeiling'), not a value looked up+  -- by 'Baikai.Api.Api': only the provider knows what its evidence can+  -- reach, and a tag-keyed table capped every caller-supplied transport+  -- at 'EvidenceRequestedOnly'.+  EvidenceStrength ->+  ThinkingTranslation ->+  [EvidenceRefusal] checkEvidenceRequirements EvidenceBestEffort _ _ = []-checkEvidenceRequirements (EvidenceRequired needed) api translation =+checkEvidenceRequirements (EvidenceRequired needed) declared translation =   [StrengthUnreachable needed declared | declared < needed]     <> [ThinkingWouldDowngrade downgrades | not (null downgrades)]   where-    declared = declaredStrength api-    downgrades = adjustments translation+    downgrades = filter weakensThinking (adjustments translation)  -- | Turn a non-empty refusal list into the error the call fails with. --@@ -425,10 +529,18 @@ -- did its job. It is baikai's own machinery that failed the caller, so -- it is a plain provider-side error naming the sink and carrying the -- sink's own message.+--+-- "Not confirmed written" rather than "not written": this covers a+-- sink that threw, whose record certainly was not written, and a sink+-- that stalled past "Baikai.Trace"'s drain bound, whose worker was+-- abandoned with the events still queued and may yet deliver them. What+-- the strict caller is told in both cases is the same and is the honest+-- claim — the call returned without the record's delivery being+-- confirmed. sinkFailureError :: SomeException -> BaikaiError sinkFailureError e =   providerError     ( "the trace sink failed and this call required evidence, so its record was \-      \not written: "+      \not confirmed written: "         <> Text.pack (displayException e)     )
+ src/Baikai/Header.hs view
@@ -0,0 +1,77 @@+-- | HTTP header names as a type that carries the case-insensitivity+-- rule.+--+-- A header name is case-insensitive on the wire, so @Authorization@ and+-- @authorization@ are one header. A @Map Text Text@ of header overrides+-- does not know that: it holds both, and which one reaches the provider+-- is decided by the fold order of whatever code assembles the request.+-- 'HeaderName' puts the rule in the key type, so a @Map HeaderName Text@+-- holds at most one value per header and the last write wins, as a+-- caller writing two spellings would expect.+--+-- The original spelling is preserved and is what goes out on the wire+-- and into JSON, so a host that (wrongly) cares about case still sees+-- what the caller wrote.+--+-- The type is baikai's own rather than a bare+-- 'Data.CaseInsensitive.CI' 'Data.Text.Text' because the aeson+-- instances would then be orphans, which two packages can define+-- incompatibly.+module Baikai.Header+  ( HeaderName,+    headerName,+    renderHeaderName,+  )+where++import Data.Aeson+  ( FromJSON (parseJSON),+    FromJSONKey (fromJSONKey),+    FromJSONKeyFunction (FromJSONKeyText),+    ToJSON (toJSON),+    ToJSONKey (toJSONKey),+    withText,+  )+import Data.Aeson.Types (toJSONKeyText)+import Data.CaseInsensitive (CI)+import Data.CaseInsensitive qualified as CI+import Data.String (IsString (fromString))+import Data.Text (Text)+import Data.Text qualified as Text+import GHC.Generics (Generic)++-- | A case-insensitive HTTP header name that remembers its original+-- spelling.+newtype HeaderName = HeaderName (CI Text)+  deriving stock (Eq, Ord, Generic)++-- | Shows the original spelling, so a header map prints as it was+-- written.+instance Show HeaderName where+  showsPrec d = showsPrec d . renderHeaderName++-- | So that @Map.singleton "x-test" "1" :: Map HeaderName Text@ keeps+-- compiling and reading naturally.+instance IsString HeaderName where+  fromString = headerName . Text.pack++instance ToJSON HeaderName where+  toJSON = toJSON . renderHeaderName++instance FromJSON HeaderName where+  parseJSON = withText "HeaderName" (pure . headerName)++instance ToJSONKey HeaderName where+  toJSONKey = toJSONKeyText renderHeaderName++instance FromJSONKey HeaderName where+  fromJSONKey = FromJSONKeyText headerName++-- | A header name from its text. Comparison ignores case from here on;+-- the spelling given is what 'renderHeaderName' returns.+headerName :: Text -> HeaderName+headerName = HeaderName . CI.mk++-- | The name as it was originally written.+renderHeaderName :: HeaderName -> Text+renderHeaderName (HeaderName n) = CI.original n
+ src/Baikai/Http.hs view
@@ -0,0 +1,135 @@+-- | The one HTTP client cache, and the one place a base URL becomes+-- something baikai will actually connect to.+--+-- Building a @ClientEnv@ — @servant-client@'s pairing of a parsed base+-- URL with an @http-client@ 'HTTP.Manager', which owns the connection+-- pool and the TLS state — costs a TLS manager setup, so baikai keeps+-- one per base URL for the life of the process. That cache used to be+-- duplicated in each provider package and keyed on the raw base-URL+-- text, which meant @https:\/\/h@ and @https:\/\/h\/@ were two managers+-- and two connection pools to one host, and that the three copies could+-- disagree about what "the same host" means. There is one cache here+-- now, and its key is the canonical rendering of "Baikai.Url"'s parse.+--+-- The cache is unbounded on purpose. The set of distinct base URLs a+-- process talks to is configuration-sized rather than request-sized;+-- normalisation removes the one unbounded source (textual variants of a+-- single host); and how long a connection lives is already the+-- 'HTTP.Manager''s idle timeout. A fleet of per-tenant base URLs is not+-- a supported use of @Model.baseUrl@.+module Baikai.Http+  ( canonicalBaseUrl,+    getClientEnvCached,+    cachedClientEnvCount,+  )+where++import Baikai.Error (invalidRequest)+import Baikai.Url qualified as Url+import Control.Concurrent.MVar (MVar, modifyMVar, newMVar)+import Control.Exception (throwIO)+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Text qualified as Text+import Network.HTTP.Client qualified as HTTP+import Network.HTTP.Client.TLS qualified as TLS+import Servant.Client qualified as Client+import System.IO.Unsafe (unsafePerformIO)++-- | Parse a base URL into the @servant-client@ 'Client.BaseUrl' baikai+-- will send to, normalised so that every spelling of one target is one+-- value: the host lower-cased by 'Url.parseUrl', the port made explicit+-- from the scheme's default when none was given, trailing slashes+-- removed from the path, and one trailing @\/v1@ segment removed.+--+-- That last rule is the base-URL convention: @Model.baseUrl@ is the API+-- __root__ — the host, or the prefix a host mounts the API under —+-- without the version segment, because baikai appends+-- @\/v1\/chat\/completions@, @\/v1\/messages@ or @\/v1\/embeddings@+-- itself. A @\/v1@ suffix is nevertheless accepted and removed rather+-- than refused, because @https:\/\/api.deepseek.com\/v1@ is what every+-- OpenAI SDK teaches and refusing it would break a working configuration+-- for no security gain. Without the rule that base URL composed to+-- @\/v1\/v1\/chat\/completions@.+--+-- Built from 'Url.parseUrl' directly rather than by handing the raw text+-- to @servant-client@'s own @parseBaseUrl@, so that the host baikai+-- resolves a key for and the host it opens a connection to are decided+-- by the same function. (@parseBaseUrl@ also silently prepends+-- @http:\/\/@ to a scheme-less URL, which would send a bearer token in+-- plaintext, and rejects userinfo and query strings with an exception+-- that says nothing useful.)+--+-- 'Left' carries a reason fit to show a caller.+canonicalBaseUrl :: Text -> Either Text Client.BaseUrl+canonicalBaseUrl raw = case Url.parseUrl raw of+  Nothing -> Left "no host could be found in it"+  Just parts -> case Url.scheme parts of+    Nothing ->+      Left+        ( Url.renderEndpoint parts+            <> " has no scheme; start it with https:// or http://"+        )+    Just s+      | s /= "http",+        s /= "https" ->+          Left+            ( Url.renderEndpoint parts+                <> " uses the scheme "+                <> s+                <> "; only http and https are sent"+            )+      | otherwise ->+          let secure = s == "https"+           in Right+                Client.BaseUrl+                  { Client.baseUrlScheme = if secure then Client.Https else Client.Http,+                    Client.baseUrlHost = Text.unpack (Url.host parts),+                    Client.baseUrlPort =+                      maybe (if secure then 443 else 80) id (Url.port parts),+                    Client.baseUrlPath =+                      Text.unpack (Url.stripApiVersion (Url.path parts))+                  }++-- | The cached 'Client.ClientEnv' for a base URL, building one on first+-- use. Two spellings of one target share an entry, because the key is+-- 'canonicalBaseUrl''s rendering rather than the caller's text.+--+-- Throws a 'Baikai.Error.BaikaiError' in the+-- 'Baikai.Error.InvalidRequest' category when the base URL is not one+-- baikai can send to.+getClientEnvCached :: Text -> IO Client.ClientEnv+getClientEnvCached raw = case canonicalBaseUrl raw of+  Left problem ->+    throwIO (invalidRequest ("Model.baseUrl is not usable: " <> problem))+  Right base -> do+    let key = Text.pack (Client.showBaseUrl base)+    modifyMVar clientEnvCache $ \cache ->+      case Map.lookup key cache of+        Just env -> pure (cache, env)+        Nothing -> do+          env <- newClientEnv base+          pure (Map.insert key env cache, env)++-- | How many distinct targets the cache holds. Exposed so a test can+-- observe that two spellings of one host are one entry.+cachedClientEnvCount :: IO Int+cachedClientEnvCount =+  modifyMVar clientEnvCache $ \cache -> pure (cache, Map.size cache)++-- | A fresh manager with no per-response timeout: a streaming response+-- is open for as long as the model is thinking, and @Options.timeoutMs@+-- bounds the whole call from outside.+newClientEnv :: Client.BaseUrl -> IO Client.ClientEnv+newClientEnv base = do+  manager <-+    TLS.newTlsManagerWith+      TLS.tlsManagerSettings+        { HTTP.managerResponseTimeout = HTTP.responseTimeoutNone+        }+  pure (Client.mkClientEnv manager base)++{-# NOINLINE clientEnvCache #-}+clientEnvCache :: MVar (Map Text Client.ClientEnv)+clientEnvCache = unsafePerformIO (newMVar Map.empty)
src/Baikai/Interactive.hs view
@@ -22,8 +22,6 @@     InteractiveLaunchResult (..),     interactiveLaunchRequest,     interactiveLaunchResult,-    _InteractiveLaunchRequest,-    _InteractiveLaunchResult,     renderInteractiveProvider,     renderInteractiveScope,     renderCodexSandboxMode,@@ -78,11 +76,26 @@   | CodexDangerFullAccess   deriving stock (Eq, Ord, Show, Generic) +-- | When Codex asks a human before running a command.+--+-- The first two are spellings older Codex generations accepted and+-- current ones reject. They are kept so the type stays stable for a+-- caller that matches on it, and the Codex launcher in @baikai-openai@+-- refuses a request carrying one with 'Baikai.Agent.SafetyNotExpressible'+-- before starting anything, rather than letting the CLI fail with a+-- usage error after a process was created. data CodexApprovalPolicy-  = CodexApprovalUntrusted-  | CodexApprovalOnFailure-  | CodexApprovalOnRequest-  | CodexApprovalNever+  = -- | Spelled @untrusted@. Rejected by current Codex releases; the+    -- Codex launcher refuses a request carrying it.+    CodexApprovalUntrusted+  | -- | Spelled @on-failure@. Rejected by current Codex releases; the+    -- Codex launcher refuses a request carrying it.+    CodexApprovalOnFailure+  | -- | Spelled @on-request@: the model decides when to ask.+    CodexApprovalOnRequest+  | -- | Spelled @never@: execution failures go straight back to the+    -- model.+    CodexApprovalNever   deriving stock (Eq, Ord, Show, Generic)  -- | Process-level outcome after the interactive CLI exits.@@ -111,14 +124,6 @@     { provider = p,       exitCode = code     }--{-# DEPRECATED _InteractiveLaunchRequest "Use interactiveLaunchRequest instead." #-}-_InteractiveLaunchRequest :: Text -> InteractiveLaunchRequest-_InteractiveLaunchRequest = interactiveLaunchRequest--{-# DEPRECATED _InteractiveLaunchResult "Use interactiveLaunchResult instead." #-}-_InteractiveLaunchResult :: InteractiveProvider -> ExitCode -> InteractiveLaunchResult-_InteractiveLaunchResult = interactiveLaunchResult  renderInteractiveProvider :: InteractiveProvider -> Text renderInteractiveProvider InteractiveClaude = "claude"
src/Baikai/Message.hs view
@@ -15,9 +15,10 @@ --   answers, the tool's name, the result 'content' (text or image), an --   'isError' flag, and an optional timestamp). ----- The 'system' constructor from prior versions is removed: system--- prompts live on 'Baikai.Request.Request.systemPrompt'. The 'Role'--- enum is also removed — pattern-match on the constructor instead.+-- There is no 'system' constructor: a system prompt lives on+-- 'Baikai.Context.Context.systemPrompt', not in the message vector.+-- There is no 'Role' enum either — pattern-match on the constructor+-- instead. -- -- Each constructor wraps a dedicated single-constructor payload record -- ('UserPayload', 'AssistantPayload', 'ToolResultPayload') rather than
src/Baikai/Model.hs view
@@ -3,12 +3,13 @@ -- front to talk to a provider: the 'Api' tag (used to look up the -- registered handler), the provider name, the base URL, the -- per-million-token pricing rates, the context window and max output--- cap, default per-call headers, and a per-API 'Compat' record (a--- placeholder until EP-5 populates the real shims).+-- cap, default per-call headers, and a per-API 'Compat' record+-- ('CompatNone' lets the provider auto-detect the record from the base+-- URL; see "Baikai.Compat"). ----- The previous newtype @Model = Model Text@ — a thin tag for the--- model id — is retired. Use 'modelId' to read the selected upstream--- model identifier, or 'mkModel' to build a dispatchable record.+-- Use 'modelId' to read the selected upstream model identifier, or+-- 'mkModel' to build a dispatchable record from the three+-- discriminators. module Baikai.Model   ( -- * Model     Model,@@ -25,13 +26,11 @@     headers,     compat,     emptyModel,-    _Model,     mkModel,      -- * Cost rates     ModelCost (..),     zeroModelCost,-    _ModelCost,      -- * Capabilities     InputModality (..),@@ -44,23 +43,29 @@ where  import Baikai.Api (Api (..), renderApi)+import Baikai.Auth qualified as Auth import Baikai.Compat-  ( AnthropicMessagesCompat (..),+  ( AnthropicMessagesCompat,     OpenAICompletionsCompat,     autoDetectAnthropicMessages,     autoDetectOpenAICompletions,-    defaultAnthropicThinkingStyle,   )-import Data.Aeson (FromJSON, ToJSON)+import Baikai.Header (HeaderName)+import Data.Aeson+  ( FromJSON,+    ToJSON (toEncoding, toJSON),+    defaultOptions,+    genericToEncoding,+    genericToJSON,+  ) import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map import Data.Text (Text) import GHC.Generics (Generic) import Numeric.Natural (Natural) --- | What kinds of input a model accepts. EP-1 introduced the typed--- content blocks; this field documents which of them the chosen--- 'Model' is allowed to consume.+-- | What kinds of input a model accepts: which typed content blocks+-- ("Baikai.Content") the chosen 'Model' may be given. data InputModality   = InputText   | InputImage@@ -102,13 +107,21 @@ -- the explicit one if 'compat' is 'CompatAnthropicMessages', -- otherwise the result of inspecting 'baseUrl' via -- 'autoDetectAnthropicMessages'.+--+-- An explicit record always wins. 'CompatNone' means host+-- auto-detection alone: the budget thinking style and sampling+-- parameters supported, which is what every generation before Opus 4.7+-- and every known compatible host accepts. The model id is never+-- consulted, because a generation's wire quirks are a fact of the+-- catalog record, not of its id — every Anthropic model in+-- "Baikai.Models.Generated" carries an explicit+-- 'CompatAnthropicMessages'. A hand-rolled model naming an+-- adaptive-era id must set its own record or start from the catalog+-- value. anthropicMessagesCompatFor :: Model -> AnthropicMessagesCompat anthropicMessagesCompatFor m = case compat m of   CompatAnthropicMessages c -> c-  _ ->-    (autoDetectAnthropicMessages (baseUrl m))-      { thinkingStyle = defaultAnthropicThinkingStyle (modelId m)-      }+  _ -> autoDetectAnthropicMessages (baseUrl m)  -- | The data record baikai dispatches on. data Model = Model@@ -121,13 +134,73 @@     input :: ![InputModality],     cost :: !ModelCost,     contextWindow :: !Natural,+    -- | The provider's cap on output tokens for this model, or @0@+    -- when it is unknown (a hand-rolled model built from+    -- 'emptyModel', or a catalog entry upstream published no limit+    -- for). @0@ is not a request for zero output: the OpenAI adapter+    -- omits the cap entirely, and the Anthropic adapter — whose API+    -- requires the field and rejects @0@ — sends+    -- @Baikai.Provider.Claude.Internal.Request.uncappedMaxTokensFloor@+    -- instead. An explicit 'Baikai.Options.maxTokens' always wins,+    -- including an explicit @Just 0@.     maxOutputTokens :: !Natural,-    headers :: !(Map Text Text),+    headers :: !(Map HeaderName Text),     compat :: !Compat   }-  deriving stock (Eq, Show, Generic)-  deriving anyclass (FromJSON, ToJSON)+  deriving stock (Eq, Generic)+  deriving anyclass (FromJSON) +-- | Rendered field by field rather than derived, so that the value of a+-- credential-carrying header prints as 'Auth.redactedMarker'. A 'Model'+-- is the record most likely to reach a log: it is embedded in every+-- 'Baikai.Response.Response', and the guides tell people to @print@+-- one.+--+-- The format is exactly what @deriving stock Show@ produces — the same+-- record syntax, field order and precedence — because the point is to+-- redact one value, not to invent a rendering. A test in+-- @baikai\/test\/Main.hs@ walks the 'Generic' representation and asserts+-- that every field name appears here, so a field added later cannot+-- silently vanish from 'show'.+--+-- 'Eq' is untouched, and so is 'FromJSON': the field itself still holds+-- what the caller put there and the header is still sent. The one lossy+-- path is a JSON round trip — 'toJSON' writes the marker, so decoding+-- the result gives a 'Model' whose credential header /is/ the marker.+-- That is deliberate; a serialised 'Model' is exactly the thing that+-- should not carry a key.+instance Show Model where+  showsPrec d m =+    showParen (d >= 11) $+      showString "Model {"+        . field "modelId" (modelId m)+        . next "name" (name m)+        . next "api" (api m)+        . next "provider" (provider m)+        . next "baseUrl" (baseUrl m)+        . next "reasoning" (reasoning m)+        . next "input" (input m)+        . next "cost" (cost m)+        . next "contextWindow" (contextWindow m)+        . next "maxOutputTokens" (maxOutputTokens m)+        . next "headers" (Auth.redactHeaderValues (headers m))+        . next "compat" (compat m)+        . showChar '}'+    where+      field label v = showString label . showString " = " . showsPrec 0 v+      next label v = showString ", " . field label v++-- | Encoded through the 'Generic' representation of a copy whose+-- credential headers have been replaced, so the output is byte-identical+-- to the derived instance's for every model that carries none, and there+-- is no recursion back into this instance.+instance ToJSON Model where+  toJSON = genericToJSON defaultOptions . redactModel+  toEncoding = genericToEncoding defaultOptions . redactModel++redactModel :: Model -> Model+redactModel m = m {headers = Auth.redactHeaderValues (headers m)}+ -- | A zero 'ModelCost' across all rates. Useful as a default for -- models without published pricing (CLI providers, custom hosts). zeroModelCost :: ModelCost@@ -141,6 +214,11 @@  -- | A blank 'Model'. Useful as a record-update base for hand-rolled -- 'Model' values in tests and one-shot scripts.+--+-- Its @api@ is @Custom ""@, which no handler can be registered under+-- meaningfully: dispatching a model that still carries it fails with+-- @No provider registered for API: \<blank Custom tag …\>@. Set @api@+-- (and @modelId@) before calling anything. emptyModel :: Model emptyModel =   Model@@ -171,11 +249,3 @@       provider = renderApi apiTag,       baseUrl = baseUrl_     }--{-# DEPRECATED _ModelCost "Use zeroModelCost instead." #-}-_ModelCost :: ModelCost-_ModelCost = zeroModelCost--{-# DEPRECATED _Model "Use emptyModel instead." #-}-_Model :: Model-_Model = emptyModel
src/Baikai/Models/Generated.hs view
@@ -8,9 +8,25 @@  import Baikai.Api (Api (..)) import Baikai.Compat-  ( AnthropicThinkingStyle (..),+  ( AnthropicMessagesCompat+      ( sendSessionAffinityHeaders,+        supportsCacheControlOnTools,+        supportsLongCacheRetention,+        supportsSamplingParameters,+        thinkingStyle+      ),+    AnthropicThinkingStyle (..),     CacheControlFormat (..),     MaxTokensField (..),+    OpenAICompletionsCompat+      ( cacheControlFormat,+        maxTokensField,+        requiresThinkingAsText,+        supportsLongCacheRetention,+        supportsStrictMode,+        supportsUsageInStreaming,+        thinkingFormat+      ),     ThinkingFormat (..),     defaultAnthropicMessagesCompat,     defaultOpenAICompletionsCompat,@@ -57,7 +73,15 @@       contextWindow = 1000000,       maxOutputTokens = 128000,       headers = Map.empty,-      compat = CompatNone+      compat =+        CompatAnthropicMessages+          defaultAnthropicMessagesCompat+            { supportsLongCacheRetention = True,+              supportsCacheControlOnTools = True,+              sendSessionAffinityHeaders = False,+              thinkingStyle = AnthropicThinkingAdaptive,+              supportsSamplingParameters = False+            }     }  anthropic_claude_haiku_4_5 :: Model@@ -80,7 +104,15 @@       contextWindow = 200000,       maxOutputTokens = 64000,       headers = Map.empty,-      compat = CompatNone+      compat =+        CompatAnthropicMessages+          defaultAnthropicMessagesCompat+            { supportsLongCacheRetention = True,+              supportsCacheControlOnTools = True,+              sendSessionAffinityHeaders = False,+              thinkingStyle = AnthropicThinkingBudget,+              supportsSamplingParameters = True+            }     }  anthropic_claude_opus_4_5 :: Model@@ -103,7 +135,15 @@       contextWindow = 200000,       maxOutputTokens = 64000,       headers = Map.empty,-      compat = CompatNone+      compat =+        CompatAnthropicMessages+          defaultAnthropicMessagesCompat+            { supportsLongCacheRetention = True,+              supportsCacheControlOnTools = True,+              sendSessionAffinityHeaders = False,+              thinkingStyle = AnthropicThinkingBudget,+              supportsSamplingParameters = True+            }     }  anthropic_claude_opus_4_6 :: Model@@ -126,7 +166,15 @@       contextWindow = 1000000,       maxOutputTokens = 128000,       headers = Map.empty,-      compat = CompatNone+      compat =+        CompatAnthropicMessages+          defaultAnthropicMessagesCompat+            { supportsLongCacheRetention = True,+              supportsCacheControlOnTools = True,+              sendSessionAffinityHeaders = False,+              thinkingStyle = AnthropicThinkingAdaptive,+              supportsSamplingParameters = True+            }     }  anthropic_claude_opus_4_7 :: Model@@ -149,7 +197,15 @@       contextWindow = 1000000,       maxOutputTokens = 128000,       headers = Map.empty,-      compat = CompatNone+      compat =+        CompatAnthropicMessages+          defaultAnthropicMessagesCompat+            { supportsLongCacheRetention = True,+              supportsCacheControlOnTools = True,+              sendSessionAffinityHeaders = False,+              thinkingStyle = AnthropicThinkingAdaptive,+              supportsSamplingParameters = False+            }     }  anthropic_claude_opus_4_8 :: Model@@ -172,9 +228,48 @@       contextWindow = 1000000,       maxOutputTokens = 128000,       headers = Map.empty,-      compat = CompatNone+      compat =+        CompatAnthropicMessages+          defaultAnthropicMessagesCompat+            { supportsLongCacheRetention = True,+              supportsCacheControlOnTools = True,+              sendSessionAffinityHeaders = False,+              thinkingStyle = AnthropicThinkingAdaptive,+              supportsSamplingParameters = False+            }     } +anthropic_claude_opus_5 :: Model+anthropic_claude_opus_5 =+  emptyModel+    { modelId = "claude-opus-5",+      name = "Claude Opus 5",+      api = AnthropicMessages,+      provider = "anthropic",+      baseUrl = "https://api.anthropic.com",+      reasoning = True,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 5 % 1,+            outputCost = 25 % 1,+            cacheReadCost = 1 % 2,+            cacheWriteCost = 25 % 4+          },+      contextWindow = 1000000,+      maxOutputTokens = 128000,+      headers = Map.empty,+      compat =+        CompatAnthropicMessages+          defaultAnthropicMessagesCompat+            { supportsLongCacheRetention = True,+              supportsCacheControlOnTools = True,+              sendSessionAffinityHeaders = False,+              thinkingStyle = AnthropicThinkingAdaptive,+              supportsSamplingParameters = False+            }+    }+ anthropic_claude_sonnet_4_5 :: Model anthropic_claude_sonnet_4_5 =   emptyModel@@ -195,7 +290,15 @@       contextWindow = 1000000,       maxOutputTokens = 64000,       headers = Map.empty,-      compat = CompatNone+      compat =+        CompatAnthropicMessages+          defaultAnthropicMessagesCompat+            { supportsLongCacheRetention = True,+              supportsCacheControlOnTools = True,+              sendSessionAffinityHeaders = False,+              thinkingStyle = AnthropicThinkingBudget,+              supportsSamplingParameters = True+            }     }  anthropic_claude_sonnet_4_6 :: Model@@ -218,7 +321,15 @@       contextWindow = 1000000,       maxOutputTokens = 128000,       headers = Map.empty,-      compat = CompatNone+      compat =+        CompatAnthropicMessages+          defaultAnthropicMessagesCompat+            { supportsLongCacheRetention = True,+              supportsCacheControlOnTools = True,+              sendSessionAffinityHeaders = False,+              thinkingStyle = AnthropicThinkingAdaptive,+              supportsSamplingParameters = True+            }     }  anthropic_claude_sonnet_5 :: Model@@ -241,7 +352,15 @@       contextWindow = 1000000,       maxOutputTokens = 128000,       headers = Map.empty,-      compat = CompatNone+      compat =+        CompatAnthropicMessages+          defaultAnthropicMessagesCompat+            { supportsLongCacheRetention = True,+              supportsCacheControlOnTools = True,+              sendSessionAffinityHeaders = False,+              thinkingStyle = AnthropicThinkingAdaptive,+              supportsSamplingParameters = False+            }     }  deepseek_deepseek_chat :: Model@@ -578,10 +697,10 @@       input = [InputText, InputImage],       cost =         ModelCost-          { inputCost = 5 % 1,-            outputCost = 30 % 1,-            cacheReadCost = 1 % 2,-            cacheWriteCost = 25 % 4+          { inputCost = 4 % 1,+            outputCost = 20 % 1,+            cacheReadCost = 2 % 5,+            cacheWriteCost = 5 % 1           },       contextWindow = 1050000,       maxOutputTokens = 128000,@@ -601,10 +720,10 @@       input = [InputText, InputImage],       cost =         ModelCost-          { inputCost = 1 % 1,-            outputCost = 6 % 1,-            cacheReadCost = 1 % 10,-            cacheWriteCost = 5 % 4+          { inputCost = 1 % 5,+            outputCost = 6 % 5,+            cacheReadCost = 1 % 50,+            cacheWriteCost = 1 % 4           },       contextWindow = 1050000,       maxOutputTokens = 128000,@@ -624,10 +743,10 @@       input = [InputText, InputImage],       cost =         ModelCost-          { inputCost = 5 % 1,-            outputCost = 30 % 1,-            cacheReadCost = 1 % 2,-            cacheWriteCost = 25 % 4+          { inputCost = 4 % 1,+            outputCost = 20 % 1,+            cacheReadCost = 2 % 5,+            cacheWriteCost = 5 % 1           },       contextWindow = 1050000,       maxOutputTokens = 128000,@@ -647,10 +766,10 @@       input = [InputText, InputImage],       cost =         ModelCost-          { inputCost = 5 % 2,-            outputCost = 15 % 1,-            cacheReadCost = 1 % 4,-            cacheWriteCost = 25 % 8+          { inputCost = 2 % 1,+            outputCost = 12 % 1,+            cacheReadCost = 1 % 5,+            cacheWriteCost = 5 % 2           },       contextWindow = 1050000,       maxOutputTokens = 128000,@@ -851,6 +970,7 @@     anthropic_claude_opus_4_6,     anthropic_claude_opus_4_7,     anthropic_claude_opus_4_8,+    anthropic_claude_opus_5,     anthropic_claude_sonnet_4_5,     anthropic_claude_sonnet_4_6,     anthropic_claude_sonnet_5,
src/Baikai/Options.hs view
@@ -19,19 +19,24 @@ -- in the OpenAI and Claude providers: connection setup, response -- headers, and full stream drain. On expiry the stream terminates -- in-band with a retryable transient 'Baikai.Error.BaikaiError'.+-- @Just n@ with @n <= 0@ is refused as+-- 'Baikai.Error.InvalidRequest' before any connection is opened;+-- 'Nothing' is the only spelling of \"no bound\". -- -- 'headers' are per-call HTTP header overrides for API providers. -- Provider defaults are built first, then 'Baikai.Model.headers', -- then this field; later values replace earlier ones by -- case-insensitive header name, including auth headers for callers--- intentionally fronting a gateway.+-- intentionally fronting a gateway. Because that is an invitation to+-- put a credential here, the 'Show' and 'ToJSON' instances below print+-- 'Baikai.Auth.redactedMarker' in place of the value of any header+-- whose name looks credential-carrying. The field itself is untouched+-- and the header is still sent exactly as written. ----- 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). EP-2 (shikumi) adds @responseFormat@, the--- provider-agnostic structured-output preference — see--- 'Baikai.ResponseFormat'.+-- @cacheRetention@, @thinking@ and @responseFormat@ are+-- provider-agnostic preferences that each provider maps onto its own+-- primitive — see 'Baikai.CacheRetention', 'Baikai.ThinkingLevel' and+-- 'Baikai.ResponseFormat' for the mappings. -- -- 'evidence' is the per-call request for verifiable model-call -- evidence — see 'Baikai.Evidence.EvidenceRequest'. It carries the@@ -59,21 +64,27 @@     frequencyPenalty,     presencePenalty,     emptyOptions,-    _Options,   ) where  import Baikai.Auth (ApiKeySource)+import Baikai.Auth qualified as Auth import Baikai.CacheRetention (CacheRetention) import Baikai.Evidence (EvidenceRequest)+import Baikai.Header (HeaderName) import Baikai.ResponseFormat (ResponseFormat) import Baikai.ThinkingLevel (ThinkingLevel) import Baikai.Tool (ToolChoice)-import Data.Aeson (ToJSON, Value)+import Data.Aeson+  ( ToJSON (toEncoding, toJSON),+    Value,+    defaultOptions,+    genericToEncoding,+    genericToJSON,+  ) import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map import Data.Text (Text)-import Data.Vector (Vector) import GHC.Generics (Generic) import Numeric.Natural (Natural) @@ -82,22 +93,81 @@     temperature :: !(Maybe Double),     apiKey :: !(Maybe ApiKeySource),     timeoutMs :: !(Maybe Int),-    headers :: !(Map Text Text),+    headers :: !(Map HeaderName Text),     metadata :: !(Map Text Value),+    -- | 'Nothing' and @Just 'ToolChoiceAuto'@ are the same request: both+    -- send no @tool_choice@ and let the provider apply its own default,+    -- which is @auto@ at Anthropic and OpenAI. The constructor is kept+    -- for a caller who wants to say "auto" explicitly.     toolChoice :: !(Maybe ToolChoice),+    -- | 'Nothing' and @Just 'CacheRetentionNone'@ are the same request:+    -- both send no cache-control marker. The constructor is kept for a+    -- caller who wants to say "no caching" explicitly.     cacheRetention :: !(Maybe CacheRetention),     thinking :: !(Maybe ThinkingLevel),     responseFormat :: !(Maybe ResponseFormat),     evidence :: !(Maybe EvidenceRequest),     topP :: !(Maybe Double),-    stopSequences :: !(Maybe (Vector Text)),-    seed :: !(Maybe Integer),+    -- | Sequences that stop generation. Empty means "send nothing" —+    -- one representation, where @Nothing@ and @Just []@ used to be two+    -- indistinguishable ones.+    stopSequences :: ![Text],+    -- | A machine integer, like 'timeoutMs': every provider that accepts+    -- a seed accepts one.+    seed :: !(Maybe Int),     frequencyPenalty :: !(Maybe Double),     presencePenalty :: !(Maybe Double)   }-  deriving stock (Eq, Show, Generic)-  deriving anyclass (ToJSON)+  deriving stock (Eq, Generic) +-- | Rendered field by field rather than derived, so that the value of a+-- credential-carrying header prints as 'Auth.redactedMarker'.+--+-- The format is exactly what @deriving stock Show@ produces — the same+-- record syntax, the same field order, the same @showsPrec@ precedence+-- — because the point is to redact one value, not to invent a new+-- rendering. A test in @baikai\/test\/Main.hs@ walks the 'Generic'+-- representation and asserts that every field name appears here, so a+-- field added later cannot silently vanish from 'show'.+--+-- 'Eq' is untouched: two 'Options' whose credential headers differ are+-- still unequal.+instance Show Options where+  showsPrec d o =+    showParen (d >= 11) $+      showString "Options {"+        . field "maxTokens" (maxTokens o)+        . next "temperature" (temperature o)+        . next "apiKey" (apiKey o)+        . next "timeoutMs" (timeoutMs o)+        . next "headers" (Auth.redactHeaderValues (headers o))+        . next "metadata" (metadata o)+        . next "toolChoice" (toolChoice o)+        . next "cacheRetention" (cacheRetention o)+        . next "thinking" (thinking o)+        . next "responseFormat" (responseFormat o)+        . next "evidence" (evidence o)+        . next "topP" (topP o)+        . next "stopSequences" (stopSequences o)+        . next "seed" (seed o)+        . next "frequencyPenalty" (frequencyPenalty o)+        . next "presencePenalty" (presencePenalty o)+        . showChar '}'+    where+      field name v = showString name . showString " = " . showsPrec 0 v+      next name v = showString ", " . field name v++-- | Encoded through the 'Generic' representation of a copy whose+-- credential headers have been replaced, so the output is byte-identical+-- to the derived instance's for every record that carries none, and+-- there is no recursion back into this instance.+instance ToJSON Options where+  toJSON = genericToJSON defaultOptions . redactOptions+  toEncoding = genericToEncoding defaultOptions . redactOptions++redactOptions :: Options -> Options+redactOptions o = o {headers = Auth.redactHeaderValues (headers o)}+ emptyOptions :: Options emptyOptions =   Options@@ -113,12 +183,8 @@       responseFormat = Nothing,       evidence = Nothing,       topP = Nothing,-      stopSequences = Nothing,+      stopSequences = [],       seed = Nothing,       frequencyPenalty = Nothing,       presencePenalty = Nothing     }--{-# DEPRECATED _Options "Use emptyOptions instead." #-}-_Options :: Options-_Options = emptyOptions
src/Baikai/Provider.hs view
@@ -10,7 +10,9 @@ -- @import Baikai.Provider@ habit still resolves the symbols a -- caller cares about. module Baikai.Provider-  ( ApiProvider (..),+  ( ApiProvider (apiTag, stream, complete, describeThinking, strengthCeiling),+    apiProvider,+    apiProviderWith,     ProviderRegistry,     newProviderRegistry,     newProviderRegistryFrom,@@ -28,9 +30,14 @@   ) where +import Baikai.Api (Api)+import Baikai.Context (Context)+import Baikai.Model (Model)+import Baikai.Options (Options) import Baikai.Provider.Registry   ( ApiProvider (..),     ProviderRegistry,+    apiProviderWith,     assertRegistered,     completeRequest,     completeRequestWith,@@ -45,3 +52,23 @@     runToolLoop,     runToolLoopWith,   )+import Baikai.Stream (streamingComplete)+import Baikai.Stream.Event (AssistantMessageEvent)+import Streamly.Data.Stream (Stream)++-- | Build an 'ApiProvider' from an 'Baikai.Api.Api' tag and a streaming+-- producer, deriving the synchronous @complete@ by draining that stream+-- with 'Baikai.Stream.streamingComplete'.+--+-- This is the documented construction path. The 'ApiProvider'+-- constructor is not exported, so a field added in a later release+-- cannot break a registration site: start here and override what you+-- need by record update.+--+-- > apiProvider (Custom "my-api") myStream+-- >   & #describeThinking .~ myDescribeThinking+apiProvider ::+  Api ->+  (Model -> Context -> Options -> Stream IO AssistantMessageEvent) ->+  ApiProvider+apiProvider tag producer = apiProviderWith tag producer (streamingComplete producer)
src/Baikai/Provider/Cli/Internal.hs view
@@ -42,7 +42,7 @@ import Baikai.Context (Context) import Baikai.Cost (Cost (..), zeroCost, zeroCostBreakdown) import Baikai.Error (BaikaiError, decodeError)-import Baikai.Evidence (EvidenceStrength (..), Observed (..))+import Baikai.Evidence (EvidenceStrength (..), Observed (..), deriveStrength, usageEnvelope) import Baikai.Message   ( AssistantPayload (..),     Message (..),@@ -87,7 +87,6 @@ import Streamly.Data.Fold qualified as Fold import Streamly.Data.Stream (Stream) import Streamly.Data.Stream qualified as Stream-import Streamly.Data.Unfold qualified as Unfold import System.Directory qualified as Directory import System.Exit (ExitCode (..)) import System.FilePath (isPathSeparator)@@ -242,16 +241,26 @@ -- A line that is not valid JSON is skipped rather than failing the run: -- codex writes progress chatter to stderr, but a future version writing -- a non-JSON line to stdout must not turn a completed model call into a--- decode error.+-- decode error. A last line with no trailing newline is still parsed.+--+-- Lines are cut out of each chunk with 'BS.elemIndex' and 'BS.splitAt',+-- which are a scan and a constant-time slice, and the pieces of a line+-- that spans a chunk boundary are carried as a reversed list and joined+-- once, when its newline arrives. Every byte is therefore copied a+-- bounded number of times however long the line is. The obvious+-- alternative — unpacking each chunk into a stream of bytes and+-- appending them one at a time with 'BS.snoc' — copies the whole+-- accumulator per byte, which is quadratic in line length: a codex event+-- carrying a two-million-character message cost on the order of a+-- trillion byte moves and in practice never finished. parseCodexJsonlStream :: Stream IO ByteString -> IO CodexRunReport parseCodexJsonlStream chunks = do-  let bytes :: Stream IO Word8-      bytes = Stream.unfoldEach Unfold.fromList (fmap BS.unpack chunks)-      lineFold = Fold.takeEndBy_ (== newlineByte) (Fold.foldl' BS.snoc BS.empty)-  acc <--    Stream.foldMany lineFold bytes-      & Stream.mapMaybe Aeson.decodeStrict-      & Stream.fold (Fold.foldl' absorbCodexEvent emptyCodexAccumulator)+  (folded, pending) <-+    Stream.fold (Fold.foldl' absorbChunk (emptyCodexAccumulator, [])) chunks+  -- Whatever follows the last newline. An empty remainder — the ordinary+  -- case, because codex terminates every line — decodes to Nothing and+  -- is skipped, exactly as a non-JSON line is.+  let acc = absorbLine folded (joinPieces pending)   pure     CodexRunReport       { message = Text.concat (reverse (acc ^. #messages)),@@ -259,6 +268,15 @@         reportedModel = acc ^. #reportedModel,         usage = acc ^. #usage       }+  where+    absorbChunk (acc, pending) chunk = case BS.elemIndex newlineByte chunk of+      Nothing -> (acc, chunk : pending)+      Just at ->+        let (piece, rest) = BS.splitAt at chunk+            acc' = absorbLine acc (joinPieces (piece : pending))+         in absorbChunk (acc', []) (BS.drop 1 rest)+    absorbLine acc line = maybe acc (absorbCodexEvent acc) (Aeson.decodeStrict line)+    joinPieces = BS.concat . reverse  -- | Fold one decoded codex event into the accumulator. --@@ -711,7 +729,8 @@   Aeson.object     [ "content" .= Vector.singleton (AssistantText (TextContent body)),       "stop_reason" .= Stop,-      "usage" .= used+      -- Token counts only; see 'Evidence.usageEnvelope'.+      "usage" .= usageEnvelope used     ]  -- | How much a subprocess call's evidence proves.@@ -722,6 +741,12 @@ -- provider at all — so a successful exit never raises the strength, and -- the exit status is deliberately not an argument to this function. -- Only a value the tool itself reported can raise it.+--+-- The rule itself is 'Evidence.deriveStrength', shared with the HTTP+-- transports. A subprocess has no response header to capture, so the+-- tool's session or thread identifier is the correlation identifier it+-- passes; this keeps its argument order for the three call sites that+-- already have one. subprocessStrength ::   -- | The session or thread identifier the tool reported.   Observed Text ->@@ -729,7 +754,4 @@   Observed Text ->   EvidenceStrength subprocessStrength sessionIdentifier reported =-  case (reported, sessionIdentifier) of-    (Observed _, Observed _) -> EvidenceModelObserved-    (_, Observed _) -> EvidenceCorrelated-    _ -> EvidenceRequestedOnly+  deriveStrength reported Unobserved sessionIdentifier
+ src/Baikai/Provider/Internal/StreamWorker.hs view
@@ -0,0 +1,136 @@+-- | The hand-off between a provider's SSE worker thread and the+-- consumer draining its 'Stream'.+--+-- __This module is internal.__ Like "Baikai.Provider.Cli.Internal" it is+-- exposed so the provider packages can share one implementation, and it+-- is outside baikai's PVP promise: its contents may change in a minor+-- release.+--+-- A provider forks one worker per call to read frames off the socket and+-- push them here; the consumer pulls them out on the other side. Three+-- things about that hand-off are deliberate, and a reader of either+-- provider's @Api.hs@ will find the reasoning only here.+--+-- __The queue is bounded.__ 'frameQueueCapacity' slots, and 'pushFrame'+-- blocks when they are full. A consumer that simply stops pulling — it+-- took the first three events and moved on — therefore stops the socket+-- read after at most 'frameQueueCapacity' further frames, with the+-- worker parked in an interruptible STM wait. No garbage collection and+-- no timer is involved: the bound alone stops the read, and the provider+-- stops being billed for a generation nobody is reading. An unbounded+-- channel gives the opposite behaviour, draining the whole response into+-- memory for a consumer that will never look at it.+--+-- __Cleanup has three strengths, and they are not the same.__+--+-- * /Immediate/ when the consumer stops by exception. 'withFrameWorker'+--   wraps the consumer in 'Stream.bracketIO', so an exception thrown+--   into the draining thread — @Ctrl-C@, 'System.Timeout.timeout',+--   @cancel@ — lands while that thread sits inside the stream's own+--   step, inside the bracket. streamly runs the release synchronously:+--   the worker is killed, the transport's own @bracket@ around the HTTP+--   response runs, and the connection is back in the pool before the+--   exception reaches the caller.+--+-- * /Immediate/ when the stream ends normally, for the same reason.+--+-- * /Eventual/ when the consumer abandons the stream without an+--   exception (@Stream.take 3@ and carry on). Nothing runs at that+--   moment, because nothing knows it happened; the bound above has+--   already stopped the read, and streamly's GC finaliser runs the same+--   'killThread' at the next major collection, which is when the+--   connection is released. Callers who need the connection back at a+--   known moment cancel the draining thread or wrap the drain in+--   'System.Timeout.timeout'.+--+-- A "consumer still alive" flag was considered and rejected: nothing+-- sets it to false on abandonment, so only the collector can answer+-- "will anyone pull again". So was a stall deadline on a full queue —+-- a slow but live consumer, a callback that takes minutes per event,+-- would be cut off, and correctness must not depend on consumer speed.+--+-- __The worker never writes a sentinel.__ End-of-frames is a 'TVar'+-- flag set by 'forkFrameWorker''s 'finally', not a @Nothing@ pushed onto+-- the queue. A sentinel write can block on a full queue and so defeat+-- the very cleanup it is part of; a 'TVar' write never blocks. This is+-- also why an asynchronous exception delivered to the worker can no+-- longer strand the consumer: the flag is set however the body ends.+module Baikai.Provider.Internal.StreamWorker+  ( FrameQueue,+    frameQueueCapacity,+    newFrameQueue,+    pushFrame,+    closeFrames,+    pullFrame,+    forkFrameWorker,+    withFrameWorker,+  )+where++import Control.Concurrent (ThreadId, forkIOWithUnmask, killThread)+import Control.Concurrent.STM+  ( TVar,+    atomically,+    check,+    newTVarIO,+    orElse,+    readTVar,+    writeTVar,+  )+import Control.Concurrent.STM.TBQueue (TBQueue, newTBQueueIO, readTBQueue, writeTBQueue)+import Control.Exception (finally, mask_)+import GHC.Generics (Generic)+import Numeric.Natural (Natural)+import Streamly.Data.Stream (Stream)+import Streamly.Data.Stream qualified as Stream++-- | The bounded hand-off between one worker and one consumer.+data FrameQueue a = FrameQueue+  { frames :: !(TBQueue a),+    closed :: !(TVar Bool)+  }+  deriving stock (Generic)++-- | How many frames a worker may run ahead of its consumer.+--+-- Large enough that a consumer doing ordinary per-event work is never+-- the bottleneck, small enough that an abandoned stream stops reading+-- the socket almost at once.+frameQueueCapacity :: Natural+frameQueueCapacity = 64++newFrameQueue :: IO (FrameQueue a)+newFrameQueue = FrameQueue <$> newTBQueueIO frameQueueCapacity <*> newTVarIO False++-- | Push one frame. Blocks while the queue is full, interruptibly, so a+-- worker parked here dies as soon as it is killed.+pushFrame :: FrameQueue a -> a -> IO ()+pushFrame q a = atomically (writeTBQueue (frames q) a)++-- | Mark the queue closed. Never blocks, so it is safe inside a+-- 'finally' on a full queue.+closeFrames :: FrameQueue a -> IO ()+closeFrames q = atomically (writeTVar (closed q) True)++-- | The next frame, or 'Nothing' once the queue is empty /and/ closed.+-- Frames pushed before the close are always delivered first.+pullFrame :: FrameQueue a -> IO (Maybe a)+pullFrame q =+  atomically $+    (Just <$> readTBQueue (frames q))+      `orElse` (readTVar (closed q) >>= check >> pure Nothing)++-- | Fork a worker body so that its 'ThreadId' cannot be lost to an+-- asynchronous exception arriving between the fork and the caller+-- recording it, and so that the queue is closed however the body ends —+-- normal return, synchronous exception, or 'killThread'.+forkFrameWorker :: FrameQueue a -> IO () -> IO ThreadId+forkFrameWorker q body =+  mask_ (forkIOWithUnmask (\unmask -> unmask body `finally` closeFrames q))++-- | Run a consumer stream with the worker alive, killing the worker when+-- the stream stops, throws, or is collected. See the module+-- documentation for which of those is immediate and which is eventual.+withFrameWorker :: FrameQueue a -> IO () -> Stream IO b -> Stream IO b+withFrameWorker q body consumer =+  Stream.bracketIO (forkFrameWorker q body) killThread (const consumer)
src/Baikai/Provider/Registry.hs view
@@ -1,10 +1,12 @@+{-# LANGUAGE LambdaCase #-}+ -- | The provider registry — the dispatch surface that replaces the -- prior 'Baikai.Provider' typeclass and 'SomeProvider' existential. ----- An 'ApiProvider' is the per-API handler. EP-3 promotes 'stream' to--- the primary method: every handler exposes a streaming producer--- that emits 'AssistantMessageEvent' values, and 'complete' is the--- synchronous draining wrapper (typically @streamingComplete . stream@).+-- An 'ApiProvider' is the per-API handler, and 'stream' is its primary+-- method: every handler exposes a streaming producer that emits+-- 'AssistantMessageEvent' values, and 'complete' is the synchronous+-- draining wrapper (typically @streamingComplete . stream@). -- Callers can use an explicit 'ProviderRegistry' handle to isolate handler -- sets, or use the global convenience registry for simple scripts. --@@ -13,7 +15,9 @@ -- error-shaped 'Response' in the 'Baikai.Error.ProviderUnavailable' -- category. module Baikai.Provider.Registry-  ( ApiProvider (..),+  ( ApiProvider (apiTag, stream, complete, describeThinking, strengthCeiling),+    apiProviderWith,+    describeApi,     ProviderRegistry,     newProviderRegistry,     newProviderRegistryFrom,@@ -26,17 +30,19 @@     lookupApiProvider,     completeRequestWith,     completeRequest,+    requireEvidenceOnResponse,     runToolLoopWith,     runToolLoop,     completeText,   ) where -import Baikai.Api (Api, renderApi)-import Baikai.Content (AssistantContent (..), ToolCall)+import Baikai.Api (Api (..), normaliseApi, renderApi)+import Baikai.Content (AssistantContent (..), ToolCall, isCutOffToolCall) import Baikai.Context (Context, appendToolResult, contextOf) import Baikai.Error (providerUnavailable)-import Baikai.Evidence (ThinkingTranslation, noThinkingRequested)+import Baikai.Error qualified as Error+import Baikai.Evidence (ThinkingTranslation) import Baikai.Evidence qualified as Evidence import Baikai.Evidence.Build qualified as Build import Baikai.Message (AssistantPayload (..), ToolResult, toolResultErrorText, user)@@ -57,12 +63,18 @@ import Data.Text qualified as Text import Data.Time (getCurrentTime) import Data.Vector qualified as Vector+import GHC.Generics (Generic) import Streamly.Data.Stream (Stream) import System.IO.Unsafe (unsafePerformIO)  -- | A per-API handler. 'stream' is the primary streaming -- entry point; 'complete' is the synchronous draining wrapper, -- typically @streamingComplete . stream@ from "Baikai.Stream".+--+-- Construction: the constructor is deliberately not exported. Start+-- from 'Baikai.Provider.apiProvider' and override fields by record+-- update, so that a field added in a later release cannot break a+-- registration site — as adding 'describeThinking' in 0.5.0.0 did. data ApiProvider = ApiProvider   { apiTag :: !Api,     stream :: !(Model -> Context -> Options -> Stream IO AssistantMessageEvent),@@ -80,9 +92,68 @@     --     -- Never called for a caller who set no @evidence@ request or who     -- asked for best-effort evidence, which is every existing caller.-    describeThinking :: !(Model -> Options -> ThinkingTranslation)+    describeThinking :: !(Model -> Options -> ThinkingTranslation),+    -- | The highest strength this provider's evidence can reach when+    -- everything goes well: a static declaration the pre-dispatch gate+    -- compares against a strict caller's requirement.+    --+    -- Only the provider knows this, which is why it is declared here+    -- rather than looked up by tag. 'Evidence.declaredStrength' is where+    -- the built-in providers get their value; a caller-supplied+    -- transport that observes a model was previously capped at+    -- 'Evidence.EvidenceRequestedOnly' by that table and so could never+    -- satisfy a strict 'Evidence.EvidenceCorrelated' caller.+    --+    -- Declaring more than the provider delivers is the one remaining way+    -- to make strict mode lie, so a declaration above+    -- 'Evidence.EvidenceRequestedOnly' needs a test that drives the+    -- provider to it. A provider that attaches no record at all must+    -- declare 'Evidence.EvidenceRequestedOnly', and will still fail a+    -- strict caller at the terminal — see+    -- @docs\/adr\/0014-strict-evidence-means-a-record-exists.md@.+    strengthCeiling :: !Evidence.EvidenceStrength   }+  deriving stock (Generic) +-- | Build an 'ApiProvider' from its three functions, leaving every+-- later-added field at a safe default.+--+-- This is the explicit builder: it takes the streaming producer /and/+-- the synchronous completer, because "Baikai.Provider.Registry" cannot+-- import 'Baikai.Stream.streamingComplete' without a module cycle.+-- Most callers want 'Baikai.Provider.apiProvider', which supplies the+-- completer by draining the stream.+--+-- 'describeThinking' defaults to reporting that nothing was requested+-- and nothing translated, which is honest for a transport with no+-- reasoning controls; 'strengthCeiling' defaults to+-- 'Evidence.EvidenceRequestedOnly', matching @declaredStrength (Custom _)@.+-- Override either by record update.+apiProviderWith ::+  Api ->+  (Model -> Context -> Options -> Stream IO AssistantMessageEvent) ->+  (Model -> Context -> Options -> IO Response) ->+  ApiProvider+apiProviderWith tag producer completer =+  ApiProvider+    { apiTag = tag,+      stream = producer,+      complete = completer,+      describeThinking = \_ _ -> Evidence.noThinkingRequested,+      strengthCeiling = Evidence.EvidenceRequestedOnly+    }++-- | How an 'Api' tag reads in a dispatch failure.+--+-- 'renderApi' everywhere except @Custom ""@, which renders as the empty+-- string and made "No provider registered for API: " the whole message.+-- A blank tag has one cause — 'Baikai.Model.emptyModel' whose @api@ was+-- never set — so the message says that instead of nothing.+describeApi :: Api -> Text+describeApi = \case+  Custom "" -> "<blank Custom tag — emptyModel.api was never set>"+  other -> renderApi other+ -- | A mutable provider registry handle. Each handle owns its own handler map, -- so tests and applications can maintain isolated provider sets in one process. newtype ProviderRegistry = ProviderRegistry@@ -112,9 +183,16 @@ -- | Install (or replace) a handler. Idempotent for the same 'Api' -- tag — calling 'registerApiProviderWith' twice for the same tag keeps only -- the second handler.+--+-- The key is 'normaliseApi' of the provider's own tag, so registering+-- under @Custom \"anthropic-messages\"@ and under+-- 'Baikai.Api.AnthropicMessages' collide as one entry rather than+-- sitting side by side and dispatching by which spelling the model+-- happened to use. registerApiProviderWith :: ProviderRegistry -> ApiProvider -> IO () registerApiProviderWith reg p =-  atomicModifyIORef' (registryRef reg) $ \m -> (Map.insert (apiTag p) p m, ())+  atomicModifyIORef' (registryRef reg) $ \m ->+    (Map.insert (normaliseApi (apiTag p)) p m, ())  -- | Install (or replace) a handler in the process-global registry. registerApiProvider :: ApiProvider -> IO ()@@ -140,8 +218,13 @@       )  -- | Look up the handler registered for an 'Api' tag.+--+-- Both the stored key and the query go through 'normaliseApi', so a+-- handler registered under @Custom \"anthropic-messages\"@ answers a+-- model tagged 'Baikai.Api.AnthropicMessages', and the reverse. lookupApiProviderWith :: ProviderRegistry -> Api -> IO (Maybe ApiProvider)-lookupApiProviderWith reg tag = Map.lookup tag <$> readIORef (registryRef reg)+lookupApiProviderWith reg tag =+  Map.lookup (normaliseApi tag) <$> readIORef (registryRef reg)  -- | Look up the handler registered for an 'Api' tag in the process-global -- registry.@@ -156,21 +239,21 @@   mProvider <- lookupApiProviderWith reg (Model.api m)   case mProvider of     Just p -> case evidenceRefusals p m opts of-      [] -> complete p m ctx opts+      [] -> requireEvidenceOnResponse opts <$> complete p m ctx opts       refusals -> refusedResponse m opts (describeThinking p m opts) refusals     Nothing -> do       now <- getCurrentTime       -- "No provider was registered" is a fact about the call, so a       -- caller who asked for evidence gets a record of it. Nothing was       -- sent, so the digests are over 'Build.dispatchEnvelope'.-      let detail = "No provider registered for API: " <> renderApi (Model.api m)+      let detail = "No provider registered for API: " <> describeApi (Model.api m)           err = providerUnavailable detail       ev <-         Build.minimalEvidence           m           opts           (Build.transportForModel m)-          noThinkingRequested+          (Build.requestedTranslation opts)           (Build.dispatchEnvelope m opts)           now           now@@ -179,6 +262,39 @@       let resp = errorResponse m now 0 err       pure resp {evidence = ev} +-- | The 'Response' twin of 'Baikai.Stream.requireEvidenceOnTerminal':+-- fail a strict call whose successful response carries no evidence+-- record.+--+-- Both dispatch points need the rule because the built-in providers'+-- @complete@ is @streamingComplete . stream@, which reassembles the+-- provider's own stream and never passes through+-- 'Baikai.Stream.streamRequestWith'. A caller using 'completeRequest'+-- with no sink at all therefore gets the same guarantee as a streaming+-- one: under 'Evidence.EvidenceRequired', a record exists or the call+-- failed.+--+-- A response that already failed keeps its own error, which is more+-- useful than this one and already satisfies the contract.+requireEvidenceOnResponse :: Options -> Response -> Response+requireEvidenceOnResponse opts resp = case Build.strictnessOf opts of+  Evidence.EvidenceRequired _ | recordMissing -> failResponse resp+  _ -> resp+  where+    recordMissing = case (responseError resp, resp) of+      (Nothing, Response {evidence = Nothing}) -> True+      _ -> False++    failResponse r@Response {message = msg} =+      r+        { errorInfo = Just Build.missingEvidenceError,+          message =+            msg+              { stopReason = ErrorReason,+                errorMessage = Just (Error.message Build.missingEvidenceError)+              }+        }+ -- | Every reason strict evidence mode must refuse this call before it -- is dispatched, or an empty list. --@@ -194,7 +310,7 @@   Just req ->     Build.checkEvidenceRequirements       (Evidence.strictness req)-      (Model.api m)+      (strengthCeiling p)       (describeThinking p m opts)  -- | The error-shaped response a refused call returns.@@ -250,6 +366,12 @@ -- exceptions become error tool results so the model can recover; asynchronous -- exceptions are rethrown. Dispatchers should return 'toolResultErrorText' for -- unknown tool names rather than throwing.+--+-- The loop also stops, with the response and its tool calls intact, when+-- any tool call was cut off by the output cap+-- ('Baikai.Content.isCutOffToolCall'). The model asked for something it+-- could not finish, and the only useful next step -- raise @maxTokens@+-- and retry -- is the caller's to take. runToolLoopWith ::   ProviderRegistry ->   Int ->@@ -269,10 +391,17 @@           ctx' <- appendToolResult ctx resp (safeDispatcher dispatcher)           go (remaining - 1) ctx' +    -- A cut-off call is normally a 'Length' stop, which the second+    -- clause already catches, but a compatible host that reports+    -- @finish_reason: tool_calls@ for truncated arguments would slip+    -- through it. Dispatching a call the model never finished asking+    -- for is the one outcome this loop must not have, so the check is+    -- on the calls themselves.     shouldStop remaining resp =       responseError resp /= Nothing         || responseStopReason resp /= ToolUse         || Vector.null (responseToolCalls resp)+        || Vector.any isCutOffToolCall (responseToolCalls resp)         || remaining <= 1  -- | One-shot text completion through the global registry. Throws the
+ src/Baikai/Provider/Transport/Classify.hs view
@@ -0,0 +1,202 @@+-- | Transport-failure classification, shared by every HTTP provider.+--+-- The rule is /where/ the failure happened, not what type it is. A+-- failure after the request went out that breaks or ends the connection+-- is 'TransientError': the same call may well succeed on the next+-- attempt. A failure that says the caller's request or the process's+-- configuration is wrong — a bad URL, an unsendable header, a proxy or+-- TLS setup that cannot work, a server that does not speak HTTP — is+-- not retryable. A programming error is neither, and stays+-- 'OtherError' so it is not silently retried forever.+--+-- Three exception types reach a provider's worker, because+-- @http-client@ delivers the same underlying failure differently+-- depending on the phase it happened in. At connect time the manager's+-- exception wrapper turns a socket or TLS failure into+-- @HttpExceptionRequest _ (InternalException _)@ or+-- @ConnectionFailure@. While the response body is streaming, only+-- @http-client@'s own thin wrapper is in play, so an 'IOException' from+-- the socket or a 'TLS.TLSException' from the session reaches the+-- caller /raw/ — which is why a classifier that understood+-- 'HTTP.HttpException' alone called a mid-stream reset 'OtherError'+-- while calling the identical reset at connect time transient.+--+-- Providers call 'classifyTransportException' and keep their own+-- fallback for a 'Nothing'; see+-- @Baikai.Provider.Claude.Internal.ErrorClass.classifyException@.+module Baikai.Provider.Transport.Classify+  ( classifyTransportException,+    classifyHttpException,+    classifyHttpExceptionContent,+    classifyIOException,+    classifyTlsException,+  )+where++import Baikai.Error+  ( BaikaiError (..),+    ErrorCategory (..),+    httpError,+    invalidRequest,+    parseHttpDate,+    parseRetryAfterSeconds,+    providerError,+    retryAfterSecondsAt,+  )+import Control.Exception (SomeException, displayException, fromException)+import Data.ByteString (ByteString)+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import Data.Text.Encoding.Error qualified as Text+import Foreign.C.Error+  ( Errno (..),+    eCONNABORTED,+    eCONNRESET,+    eHOSTDOWN,+    eHOSTUNREACH,+    eNETDOWN,+    eNETRESET,+    eNETUNREACH,+    ePIPE,+    eTIMEDOUT,+  )+-- Qualified: its 'IOErrorType' has a constructor named @OtherError@,+-- which collides with the 'ErrorCategory' constructor of that name.+import GHC.IO.Exception qualified as IOE+import Network.HTTP.Client qualified as HTTP+import Network.HTTP.Types.Header (hDate, hRetryAfter)+import Network.HTTP.Types.Status (statusCode)+import Network.TLS qualified as TLS++-- | Classify any exception a transport can raise. 'Nothing' means "not+-- a transport failure at all" — the caller keeps its own fallback,+-- which is what makes a @userError@ from a buggy callback stay+-- 'OtherError' instead of being reported as a network blip.+classifyTransportException :: SomeException -> Maybe BaikaiError+classifyTransportException ex+  | Just httpEx <- fromException ex = Just (classifyHttpException httpEx)+  | Just tlsEx <- fromException ex = Just (classifyTlsException tlsEx)+  | Just ioEx <- fromException ex = classifyIOException ioEx+  | otherwise = Nothing++-- | Classify an @http-client@ 'HTTP.HttpException'.+classifyHttpException :: HTTP.HttpException -> BaikaiError+classifyHttpException = \case+  HTTP.InvalidUrlException url reason ->+    invalidRequest (Text.pack (url <> ": " <> reason))+  HTTP.HttpExceptionRequest _ content -> classifyHttpExceptionContent content++-- | Classify the payload of an 'HTTP.HttpExceptionRequest'.+classifyHttpExceptionContent :: HTTP.HttpExceptionContent -> BaikaiError+classifyHttpExceptionContent = \case+  -- A response arrived and carried a failing status. Unreachable from+  -- baikai's own transports, which never install+  -- 'throwErrorStatusCodes'; mapped for third-party providers built on+  -- http-client.+  HTTP.StatusCodeException resp body ->+    let hdrs = HTTP.responseHeaders resp+        headerText name = decodeLenient <$> lookup name hdrs+        -- The server's own Date is the reference instant, so an+        -- HTTP-date Retry-After does not inherit this machine's clock+        -- skew. Falling back to epoch would be worse than falling back+        -- to the integer form alone, so a missing Date leaves the date+        -- form unconverted here; the transports, which are in IO, use+        -- the local clock instead.+        retryAfter = case parseHttpDate =<< headerText hDate of+          Just reference -> retryAfterSecondsAt reference =<< headerText hRetryAfter+          Nothing -> parseRetryAfterSeconds =<< headerText hRetryAfter+     in httpError (statusCode (HTTP.responseStatus resp)) retryAfter (decodeLenient body)+  -- The connection could not be made, or went quiet, or went away.+  HTTP.ConnectionFailure e -> transient ("connection failure: " <> Text.pack (displayException e))+  HTTP.ConnectionTimeout -> transient "connection timeout"+  HTTP.ResponseTimeout -> transient "response timeout"+  HTTP.ConnectionClosed -> transient "connection closed"+  HTTP.NoResponseDataReceived -> transient "no response data received"+  HTTP.IncompleteHeaders -> transient "incomplete response headers"+  -- The body broke after the status line: framing, declared length, or+  -- inflation. A server that closes the socket mid-chunk surfaces here.+  HTTP.InvalidChunkHeaders -> transient "chunked response body ended or broke mid-chunk"+  HTTP.ResponseBodyTooShort expected actual ->+    transient+      ( "response body too short: expected "+          <> tshow expected+          <> " bytes, got "+          <> tshow actual+      )+  HTTP.HttpZlibException e ->+    transient ("compressed response body could not be inflated: " <> tshow e)+  -- http-client-tls's wrapper for a socket or TLS failure at connect+  -- time. The constructor is documented as carrying exactly those, so+  -- an unrecognised inner exception is still a connection failure.+  HTTP.InternalException inner+    | Just tlsEx <- fromException inner -> classifyTlsException tlsEx+    | Just ioEx <- fromException inner ->+        transient (Text.pack (displayException (ioEx :: IOE.IOException)))+    | otherwise -> transient (Text.pack (displayException inner))+  -- The caller's request cannot be sent as written.+  HTTP.InvalidRequestHeader h -> invalidRequest ("invalid request header: " <> decodeLenient h)+  HTTP.InvalidDestinationHost h -> invalidRequest ("invalid destination host: " <> decodeLenient h)+  HTTP.WrongRequestBodyStreamSize expected actual ->+    invalidRequest+      ( "request body size mismatch: declared "+          <> tshow expected+          <> ", sent "+          <> tshow actual+      )+  -- Everything else is a server that does not speak HTTP, or a proxy or+  -- redirect configuration that cannot work. Retrying changes nothing.+  other -> providerError (Text.take 300 (tshow other))+  where+    tshow :: (Show a) => a -> Text+    tshow = Text.pack . show++-- | Classify a raw 'IOE.IOException', which is what a socket failure+-- during the body read looks like.+--+-- Both the error /type/ and the errno are consulted, because @base@+-- maps @ECONNABORTED@ to the 'IOE.OtherError' error type: a type-only+-- rule would call an aborted connection a programming error.+classifyIOException :: IOE.IOException -> Maybe BaikaiError+classifyIOException ioe+  | IOE.ioe_type ioe `elem` [IOE.ResourceVanished, IOE.EOF, IOE.TimeExpired] =+      Just (transient detail)+  | Just n <- IOE.ioe_errno ioe, Errno n `elem` socketErrnos = Just (transient detail)+  | otherwise = Nothing+  where+    detail = Text.pack (displayException ioe)+    socketErrnos =+      [ eCONNABORTED,+        eCONNRESET,+        eNETRESET,+        eNETDOWN,+        eNETUNREACH,+        eHOSTDOWN,+        eHOSTUNREACH,+        eTIMEDOUT,+        ePIPE+      ]++-- | Classify a 'TLS.TLSException'. The constructor names encode /when/+-- the failure happened, which is exactly the fact the rule needs: a+-- session that existed and broke is transient, a session that never+-- existed is a trust-store, protocol or library-misuse problem that a+-- retry will reproduce.+classifyTlsException :: TLS.TLSException -> BaikaiError+classifyTlsException = \case+  TLS.Terminated _ why err ->+    transient ("TLS session terminated: " <> Text.pack why <> " (" <> tshow err <> ")")+  TLS.PostHandshake err -> transient ("TLS failure after handshake: " <> tshow err)+  TLS.Uncontextualized err -> transient ("TLS failure: " <> tshow err)+  TLS.HandshakeFailed err -> providerError ("TLS handshake failed: " <> tshow err)+  TLS.ConnectionNotEstablished -> providerError "TLS connection not established"+  TLS.MissingHandshake -> providerError "TLS handshake missing"+  where+    tshow :: (Show a) => a -> Text+    tshow = Text.pack . show++transient :: Text -> BaikaiError+transient t = (providerError ("connection error: " <> t)) {category = TransientError}++decodeLenient :: ByteString -> Text+decodeLenient = Text.decodeUtf8With Text.lenientDecode
src/Baikai/Response.hs view
@@ -13,7 +13,6 @@ module Baikai.Response   ( Response (..),     emptyResponse,-    _Response,     responseMessage,     flattenAssistantBlocks,     flattenAssistantText,@@ -68,7 +67,7 @@   }   deriving stock (Eq, Show, Generic) --- | A blank assistant turn at epoch start. Useful as a fixture base+-- | A blank assistant turn with no timestamp. Useful as a fixture base -- for tests and as the default in error paths where no message was -- received. emptyResponse :: Response@@ -140,7 +139,3 @@       errorInfo = Just err,       evidence = Nothing     }--{-# DEPRECATED _Response "Use emptyResponse instead." #-}-_Response :: Response-_Response = emptyResponse
src/Baikai/ResponseFormat.hs view
@@ -1,4 +1,4 @@-{-# OPTIONS_GHC -Wno-partial-fields #-}+{-# LANGUAGE OverloadedRecordDot #-}  -- | Provider-agnostic structured-output preference. --@@ -8,26 +8,57 @@ -- structured-output constraint (today's behaviour). module Baikai.ResponseFormat   ( ResponseFormat (..),+    JsonSchemaFormat (name, schema, strict),+    jsonSchemaFormat,   ) where -import Data.Aeson (FromJSON, ToJSON, Value)+import Data.Aeson+  ( FromJSON (parseJSON),+    ToJSON (toJSON),+    Value,+    object,+    withObject,+    (.:),+    (.:?),+    (.=),+  )+import Data.Maybe (fromMaybe) import Data.Text (Text) import GHC.Generics (Generic) +-- | A named JSON Schema to enforce.+--+-- 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).+--+-- Construction: the constructor is deliberately not exported. Start from+-- 'jsonSchemaFormat' and override 'strict' by record update.+data JsonSchemaFormat = JsonSchemaFormat+  { name :: !Text,+    schema :: !Value,+    strict :: !Bool+  }+  deriving stock (Eq, Show, Generic)++-- | A schema request from its name and its schema document, with+-- @strict = False@.+jsonSchemaFormat :: Text -> Value -> JsonSchemaFormat+jsonSchemaFormat schemaName schemaDoc =+  JsonSchemaFormat {name = schemaName, schema = schemaDoc, strict = False}+ -- | How to constrain the model's output.+--+-- The schema fields live on 'JsonSchemaFormat' rather than directly on+-- the 'JsonSchema' constructor: as fields of a sum they were partial+-- selectors, and @name f@ on a 'JsonObject' was a crash rather than a+-- type error. 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-      }+  = -- | Enforce a named JSON Schema.+    JsonSchema !JsonSchemaFormat   | -- | 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@@ -35,4 +66,33 @@     --   @{"type":"object"}@ schema.     JsonObject   deriving stock (Eq, Show, Generic)-  deriving anyclass (FromJSON, ToJSON)++-- | Hand-written to keep the flat encoding the derived instances+-- produced before 'JsonSchemaFormat' existed:+-- @{"tag":"JsonSchema","name":…,"schema":…,"strict":…}@ and+-- @{"tag":"JsonObject"}@. 'Baikai.Options.Options' derives 'ToJSON'+-- through this, and at least one consumer keys a cache on the result.+instance ToJSON ResponseFormat where+  toJSON (JsonSchema f) =+    object+      [ "tag" .= ("JsonSchema" :: Text),+        "name" .= f.name,+        "schema" .= f.schema,+        "strict" .= f.strict+      ]+  toJSON JsonObject = object ["tag" .= ("JsonObject" :: Text)]++instance FromJSON ResponseFormat where+  parseJSON = withObject "ResponseFormat" $ \o -> do+    tag <- o .: "tag"+    case tag :: Text of+      "JsonObject" -> pure JsonObject+      "JsonSchema" -> do+        schemaName <- o .: "name"+        schemaDoc <- o .: "schema"+        isStrict <- o .:? "strict"+        pure+          ( JsonSchema+              (jsonSchemaFormat schemaName schemaDoc) {strict = fromMaybe False isStrict}+          )+      other -> fail ("unknown ResponseFormat tag: " <> show other)
src/Baikai/StopReason.hs view
@@ -8,7 +8,7 @@ -- subprocess reports an error. -- -- Constructor encoding on the wire is snake-case: @"stop"@, @"length"@,--- @"tool_use"@, @"error"@, @"aborted"@. @ErrorReason@ is renamed to+-- @"tool_use"@, @"error"@. @ErrorReason@ is renamed to -- @"error"@ so the Haskell name does not clash with @Prelude.Either.Left@ -- callers and the wire shape stays terse. module Baikai.StopReason (StopReason (..)) where@@ -29,7 +29,6 @@   | Length   | ToolUse   | ErrorReason-  | Aborted   deriving stock (Eq, Show, Generic)  stopReasonOptions :: Options
src/Baikai/Stream.hs view
@@ -24,10 +24,10 @@     streamingComplete,     reassembleResponse,     liftCompleteToStream,+    requireEvidenceOnTerminal,   ) where -import Baikai.Api (renderApi) import Baikai.Content   ( AssistantContent (..),     TextContent (..),@@ -36,7 +36,7 @@ import Baikai.Content qualified as Content import Baikai.Context (Context) import Baikai.Error (BaikaiError, providerError, providerUnavailable)-import Baikai.Evidence (ModelCallEvidence, noThinkingRequested)+import Baikai.Evidence (ModelCallEvidence) import Baikai.Evidence qualified as Evidence import Baikai.Evidence.Build qualified as Build import Baikai.Message (AssistantPayload (..), Message (AssistantMessage))@@ -46,6 +46,7 @@ import Baikai.Provider.Registry   ( ApiProvider (..),     ProviderRegistry,+    describeApi,     evidenceRefusals,     globalProviderRegistry,     lookupApiProviderWith,@@ -94,7 +95,8 @@ streamRequest = streamRequestWith globalProviderRegistry  -- | Dispatch a streaming call through the selected provider registry.--- Returns a one-event error stream when no handler is registered for that tag.+-- Returns an 'EventStart' then 'EventError' stream when no handler is+-- registered for that tag. streamRequestWith ::   ProviderRegistry ->   Model ->@@ -107,9 +109,15 @@     case mProvider of       Nothing -> Stream.fromList <$> noProviderEvents m opts       Just p -> case evidenceRefusals p m opts of-        [] -> pure (stream p m ctx opts)+        [] -> pure (applyStrict (stream p m ctx opts))         refusals ->           Stream.fromList <$> refusedEvents m opts (describeThinking p m opts) refusals+  where+    -- A best-effort or opted-out call pays one 'Maybe' test here and no+    -- per-event map; only a strict call is rewritten event by event.+    applyStrict = case Build.strictnessOf opts of+      Evidence.EvidenceRequired _ -> fmap (requireEvidenceOnTerminal opts)+      Evidence.EvidenceBestEffort -> id  -- | Stream a request through the process-global registry, invoking the -- callback once per event, then return the same reassembled 'Response'@@ -182,7 +190,11 @@   { model :: !Model,     -- | 'Just' once 'EventStart' has been observed.     skeleton :: !(Maybe Message),-    -- | Captured by the reassembler when the fold starts driving the stream.+    -- | Captured by the reassembler when the fold starts driving the+    -- stream, and used to measure 'latencyMs' when the provider stamped+    -- no timestamps on its skeleton or its terminal. Provider+    -- timestamps stay primary: a lifted or replaying provider stamps+    -- the true provider window, which this clock cannot see.     wallStart :: !UTCTime,     -- | Provider message id, preferring the terminal payload over the start payload.     responseId :: !(Maybe Text),@@ -223,61 +235,76 @@       terminal = Nothing     } +-- | Fold one event into the assembly.+--+-- Two totality rules hold over the whole fold and are stated here+-- because they are invisible at the individual branches. __The first+-- terminal wins__: once 'terminal' is 'Just', every further event is+-- ignored, so a producer that keeps talking after its terminal cannot+-- rewrite the answer. __The first start wins__: a duplicated+-- 'EventStart' keeps the first skeleton, and @responseId@ merges with+-- '<|>' on every event that carries one, so a later 'Nothing' never+-- erases an id an earlier event supplied. Both match the OpenAI+-- assembler's @firstObserved@ discipline. step :: ReassemblyState -> AssistantMessageEvent -> ReassemblyState-step s = \case-  EventStart StartPayload {partial = sk, responseId = rid} ->-    s & #skeleton .~ Just sk & #responseId .~ rid-  TextStart IndexPayload {contentIndex = i} ->-    s & #textBuf %~ IntMap.insert i Text.empty-  TextDelta DeltaPayload {contentIndex = i, delta = d} ->-    s & #textBuf %~ IntMap.insertWith (\new old -> old <> new) i d-  TextEnd BlockEndPayload {contentIndex = i, content = body} ->-    s-      & #blocks %~ IntMap.insert i (AssistantText (TextContent body))-      & #textBuf %~ IntMap.delete i-  ThinkingStart IndexPayload {contentIndex = i} ->-    s & #thinkBuf %~ IntMap.insert i Text.empty-  ThinkingDelta DeltaPayload {contentIndex = i, delta = d} ->-    s & #thinkBuf %~ IntMap.insertWith (\new old -> old <> new) i d-  ThinkingEnd ThinkingEndPayload {contentIndex = i, content = tc} ->-    s-      & #blocks-        %~ IntMap.insert-          i-          (AssistantThinking tc)-      & #thinkBuf %~ IntMap.delete i-  ToolCallStart IndexPayload {contentIndex = i} ->-    s & #toolArgsBuf %~ IntMap.insert i Text.empty-  ToolCallDelta DeltaPayload {contentIndex = i, delta = d} ->-    s & #toolArgsBuf %~ IntMap.insertWith (\new old -> old <> new) i d-  ToolCallEnd ToolCallEndPayload {contentIndex = i, toolCall = tc} ->-    s-      & #blocks %~ IntMap.insert i (AssistantToolCall tc)-      & #toolArgsBuf %~ IntMap.delete i-  EventDone TerminalPayload {reason = r, message = msg, responseId = rid, evidence = ev} ->-    s-      & #terminal-        .~ Just-          TerminalSeen-            { reason = r,-              message = msg,-              errorInfo = Nothing,-              evidence = ev,-              failed = False-            }-      & #responseId %~ (\old -> rid <|> old)-  EventError TerminalPayload {reason = r, message = msg, responseId = rid, errorInfo = ei, evidence = ev} ->-    s-      & #terminal-        .~ Just-          TerminalSeen-            { reason = r,-              message = msg,-              errorInfo = ei,-              evidence = ev,-              failed = True-            }-      & #responseId %~ (\old -> rid <|> old)+step s event+  | Just _ <- s ^. #terminal = s+  | otherwise = case event of+      EventStart StartPayload {partial = sk, responseId = rid} ->+        s+          & #skeleton %~ (\old -> old <|> Just sk)+          & #responseId %~ (\old -> rid <|> old)+      TextStart IndexPayload {contentIndex = i} ->+        s & #textBuf %~ IntMap.insert i Text.empty+      TextDelta DeltaPayload {contentIndex = i, delta = d} ->+        s & #textBuf %~ IntMap.insertWith (\new old -> old <> new) i d+      TextEnd BlockEndPayload {contentIndex = i, content = body} ->+        s+          & #blocks %~ IntMap.insert i (AssistantText (TextContent body))+          & #textBuf %~ IntMap.delete i+      ThinkingStart IndexPayload {contentIndex = i} ->+        s & #thinkBuf %~ IntMap.insert i Text.empty+      ThinkingDelta DeltaPayload {contentIndex = i, delta = d} ->+        s & #thinkBuf %~ IntMap.insertWith (\new old -> old <> new) i d+      ThinkingEnd ThinkingEndPayload {contentIndex = i, content = tc} ->+        s+          & #blocks+            %~ IntMap.insert+              i+              (AssistantThinking tc)+          & #thinkBuf %~ IntMap.delete i+      ToolCallStart IndexPayload {contentIndex = i} ->+        s & #toolArgsBuf %~ IntMap.insert i Text.empty+      ToolCallDelta DeltaPayload {contentIndex = i, delta = d} ->+        s & #toolArgsBuf %~ IntMap.insertWith (\new old -> old <> new) i d+      ToolCallEnd ToolCallEndPayload {contentIndex = i, toolCall = tc} ->+        s+          & #blocks %~ IntMap.insert i (AssistantToolCall tc)+          & #toolArgsBuf %~ IntMap.delete i+      EventDone TerminalPayload {reason = r, message = msg, responseId = rid, evidence = ev} ->+        s+          & #terminal+            .~ Just+              TerminalSeen+                { reason = r,+                  message = msg,+                  errorInfo = Nothing,+                  evidence = ev,+                  failed = False+                }+          & #responseId %~ (\old -> rid <|> old)+      EventError TerminalPayload {reason = r, message = msg, responseId = rid, errorInfo = ei, evidence = ev} ->+        s+          & #terminal+            .~ Just+              TerminalSeen+                { reason = r,+                  message = msg,+                  errorInfo = ei,+                  evidence = ev,+                  failed = True+                }+          & #responseId %~ (\old -> rid <|> old)  finalizeState :: ReassemblyState -> IO Response finalizeState s = do@@ -303,7 +330,9 @@       message' = overrideBlocksAndReason terminalReason terminalMsg finalContent now       latency = case (s ^. #skeleton >>= messageTimestamp, assistantPayloadTimestamp message') of         (Just startTs, Just endTs) -> millisBetween startTs endTs-        _ -> 0+        -- No provider timestamps: measure the window this fold actually+        -- saw rather than reporting zero, which reads as "instant".+        _ -> millisBetween (s ^. #wallStart) now   pure     Response       { message = message',@@ -346,9 +375,7 @@     toolBlock raw       | Text.null raw = Nothing       | otherwise =-          let decoded = case Aeson.eitherDecodeStrict (Text.encodeUtf8 raw) of-                Right v -> v-                Left _ -> Aeson.String raw+          let decoded = Content.toolArgumentsFromText raw            in Just                 ( AssistantToolCall                     Content.ToolCall@@ -467,9 +494,12 @@     Right a -> pure (Right a)  -- | Build the synthetic event list for a fully resolved 'Response'.--- The 'EventStart' carries the supplied @startTs@ on its message--- skeleton so 'reassembleResponse' can recover 'latencyMs' from the--- start/end timestamps.+--+-- The 'EventStart' carries the response's message skeleton — empty+-- content, but the final usage, stop reason and error text already+-- filled in, because the lifted response is complete before the stream+-- begins — and the supplied @startTs@, so 'reassembleResponse' can+-- recover 'latencyMs' from the start/end timestamps. eventsFor :: UTCTime -> Response -> [AssistantMessageEvent] eventsFor startTs resp =   let payload = resp ^. #message@@ -556,7 +586,7 @@       m       opts       (Build.transportForModel m)-      noThinkingRequested+      (Build.requestedTranslation opts)       (Build.dispatchEnvelope m opts)       startTs       now@@ -567,6 +597,47 @@       EventError (errorTerminal ev Nothing ErrorReason msg err)     ] +-- | Fail a strict call whose successful terminal carries no evidence+-- record.+--+-- Strict mode already guaranteed that a record which was built and then+-- lost fails the call; it did not guarantee that one was built. A+-- provider that attaches nothing returned a successful response and+-- wrote no @call_evidence@ line, with no error anywhere — evidence that+-- can vanish without the caller noticing is not evidence. Under+-- 'Evidence.EvidenceRequired' such a terminal becomes an 'EventError'+-- carrying 'Build.missingEvidenceError'.+--+-- Everything else is returned unchanged: an error terminal (whose own+-- error is more useful than this one and which already satisfies the+-- contract — the call failed), any terminal carrying a record, every+-- non-terminal event, and every best-effort or opted-out call.+requireEvidenceOnTerminal :: Options -> AssistantMessageEvent -> AssistantMessageEvent+requireEvidenceOnTerminal opts ev = case (Build.strictnessOf opts, ev) of+  (Evidence.EvidenceRequired _, EventDone p)+    | Nothing <- p ^. #evidence ->+        EventError+          ( p+              & #reason+                .~ ErrorReason+              & #errorInfo+                .~ Just Build.missingEvidenceError+              & #message+                %~ markFailed+          )+  _ -> ev+  where+    markFailed = \case+      AssistantMessage p ->+        AssistantMessage+          ( p+              & #stopReason+                .~ ErrorReason+              & #errorMessage+                .~ Just (Build.missingEvidenceError ^. #message)+          )+      other -> other+ -- | The synthetic error stream used when no provider is registered for -- the model's API tag. --@@ -575,8 +646,8 @@ -- omits it is worse than one that records the failure. There is no wire -- request body to digest here because nothing was ever sent, so the -- digests are over 'Build.dispatchEnvelope'.--- | The one-event error stream a strict call refused before dispatch--- returns.+-- | The 'EventStart' then 'EventError' stream a strict call refused+-- before dispatch returns. -- -- Shaped exactly like 'noProviderEvents', because from a consumer's -- point of view both are the same thing: a call that produced a terminal@@ -623,7 +694,7 @@ noProviderEvents :: Model -> Options -> IO [AssistantMessageEvent] noProviderEvents m opts = do   now <- getCurrentTime-  let detail = "No provider registered for API: " <> renderApi (m ^. #api)+  let detail = "No provider registered for API: " <> describeApi (m ^. #api)       be = providerUnavailable detail       msg =         AssistantMessage@@ -639,7 +710,7 @@       m       opts       (Build.transportForModel m)-      noThinkingRequested+      (Build.requestedTranslation opts)       (Build.dispatchEnvelope m opts)       now       now
src/Baikai/Stream/Event.hs view
@@ -4,17 +4,19 @@ -- -- A provider call exposes its progress as a 'Streamly.Data.Stream.Stream -- IO AssistantMessageEvent'. The stream begins with a single--- 'EventStart' carrying an empty 'AssistantMessage' skeleton (api,--- provider, model id), interleaves per-content-block lifecycle events+-- 'EventStart' carrying an 'AssistantMessage' skeleton — empty content,+-- zero usage, no stop reason yet — interleaves per-content-block+-- lifecycle events -- (@_Start@ / @_Delta@ / @_End@) keyed by 'contentIndex', and -- terminates with exactly one 'EventDone' (success) or 'EventError' -- (any failure that bubbled out of the producer). This EventStart-first -- invariant includes error-only streams produced by core dispatch and -- request-preparation failures; they emit a synthetic skeleton before--- the terminal error. One temporary provider-side gap remains: a Claude--- mid-call failure before @message_start@ can still terminate without a--- start event until the EP-7 Claude streaming rewrite pre-seeds its--- skeleton. The terminal event carries the fully assembled+-- the terminal error. It holds without exception: both HTTP providers+-- pre-seed their skeleton before the first wire read, so a failure that+-- arrives before the provider has said anything about the response+-- still begins its stream with 'EventStart'.+-- The terminal event carries the fully assembled -- 'AssistantMessage' so a consumer that only pattern-matches on the -- terminal event still gets a correct response without folding deltas. --@@ -70,9 +72,9 @@ -- constructors. data AssistantMessageEvent   = -- | The first event in every stream. The payload's 'partial' is an-    -- 'AssistantMessage' with empty content; downstream consumers that-    -- care only about the message skeleton (api, provider, model id)-    -- can read it here.+    -- 'AssistantMessage' skeleton: empty content, zero usage, and no+    -- stop reason yet. The api, provider and model id live on the+    -- 'Baikai.Response.Response', not on the message.     EventStart StartPayload   | -- | A text content block is about to receive deltas.     TextStart IndexPayload@@ -110,15 +112,23 @@   | -- | The stream's terminal failure event. The payload's 'message'     -- is an 'AssistantMessage' carrying whatever content blocks were     -- already closed before the failure, plus a populated-    -- 'errorMessage' and @stopReason = ErrorReason@ or-    -- @stopReason = Aborted@.+    -- 'errorMessage' and @stopReason = ErrorReason@.     EventError TerminalPayload   deriving stock (Eq, Show, Generic)   deriving anyclass (ToJSON)  -- | Payload of 'EventStart': the message skeleton observed up front,--- plus the provider's message id when the provider learns it this--- early (Anthropic's @message_start.id@). 'Nothing' otherwise.+-- plus the provider's message id when the provider knows it before its+-- first event.+--+-- Neither HTTP provider does: both pre-seed this event before the first+-- wire read, so that a failure arriving before the provider has said+-- anything still begins the stream the way the protocol says every+-- stream begins. The id, when it arrives, rides+-- 'TerminalPayload.responseId', which+-- 'Baikai.Stream.reassembleResponse' prefers over this one anyway. A+-- lifted or replaying provider that knows the id up front may still set+-- it here. data StartPayload = StartPayload   { partial :: !Message,     responseId :: !(Maybe Text)
src/Baikai/ThinkingLevel.hs view
@@ -10,6 +10,7 @@ module Baikai.ThinkingLevel   ( ThinkingLevel (..),     renderThinkingLevel,+    parseThinkingLevel,     thinkingTokenBudget,   ) where@@ -42,6 +43,21 @@   ThinkingHigh -> "high"   ThinkingXHigh -> "xhigh"   ThinkingMax -> "max"++-- | The inverse of 'renderThinkingLevel': parse a canonical level name.+--+-- Beside its renderer so the two cannot drift, which three hand-copied+-- tables — in 'Baikai.Evidence', @Baikai.Agent.Config@ and+-- @Baikai.Agent.Cli@ — did the first time a level was added.+parseThinkingLevel :: Text -> Maybe ThinkingLevel+parseThinkingLevel = \case+  "minimal" -> Just ThinkingMinimal+  "low" -> Just ThinkingLow+  "medium" -> Just ThinkingMedium+  "high" -> Just ThinkingHigh+  "xhigh" -> Just ThinkingXHigh+  "max" -> Just ThinkingMax+  _ -> Nothing  -- | Recommended token budget for providers that take an explicit -- count (Anthropic's @thinking.budget_tokens@).
src/Baikai/Tool.hs view
@@ -18,10 +18,10 @@ -- between this module (which 'Baikai.Context' imports for the @tools@ -- field type) and 'Baikai.Context' itself. module Baikai.Tool-  ( Tool (..),+  ( Tool (name, description, parameters),+    mkTool,     ToolChoice (..),     emptyTool,-    _Tool,   ) where @@ -42,6 +42,12 @@  -- | A caller-declared tool. @parameters@ holds a JSON Schema; the -- provider-side encoders pass it through unchanged.+--+-- Construction: the constructor is deliberately not exported. Use+-- 'mkTool', which takes the three fields every provider needs, and+-- override anything else by record update. 'emptyTool' remains for+-- fixtures, but a tool declared from it and sent unchanged reaches the+-- wire with @input_schema: null@. data Tool = Tool   { name :: !Text,     description :: !Text,@@ -50,6 +56,18 @@   deriving stock (Eq, Show, Generic)   deriving anyclass (FromJSON, ToJSON) +-- | A tool from its name, its description and its JSON Schema — the+-- three things every provider requires.+--+-- > mkTool "get_weather" "Look up the weather" schema+mkTool :: Text -> Text -> Value -> Tool+mkTool toolName toolDescription toolParameters =+  Tool+    { name = toolName,+      description = toolDescription,+      parameters = toolParameters+    }+ -- | How the model should pick between the registered tools. -- -- * 'ToolChoiceAuto' — model decides (the default at most providers).@@ -84,7 +102,3 @@       description = Text.empty,       parameters = Null     }--{-# DEPRECATED _Tool "Use emptyTool instead." #-}-_Tool :: Tool-_Tool = emptyTool
src/Baikai/Trace.hs view
@@ -2,7 +2,7 @@  -- | The 'withTrace' wrapper and supporting helpers. ----- After EP-3, the trace bridge is stream-shaped at the core:+-- The trace bridge is stream-shaped at the core: -- 'withTraceStream' returns a 'Stream IO AssistantMessageEvent' -- that side-effects 'CallStarted' / 'CallFinished' / 'CallFailed' -- events to a user-supplied 'TraceSink' as the stream's lifecycle@@ -16,12 +16,27 @@ -- 'AssistantMessageEvent' is emitted), then watch for the stream's -- terminal event ('EventDone' or 'EventError') and push the -- matching 'CallFinished' / 'CallFailed' before yielding the--- terminal event to the consumer. Cleanup ('Nothing' sentinel on--- the channel + 'takeMVar' on the worker) is idempotent and runs--- through 'Stream.finallyIO' so an early-aborting consumer eventually--- records a synthetic 'CallFailed' and never leaks the worker. Sink--- exceptions are captured by the worker and reported once on stderr--- during cleanup; they do not propagate into the provider call.+-- terminal event to the consumer.+--+-- Cleanup — the 'Nothing' sentinel on the channel, then a wait for+-- the worker — runs exactly once per call. On a normal terminal it+-- runs on the calling thread, so when 'withTrace' returns the sink+-- has processed this call's events. When the consumer abandons the+-- stream instead, it runs from streamly's garbage-collection hook:+-- the synthetic 'CallFailed' and its @aborted@ evidence record are+-- delivered at the next major collection after the stream becomes+-- unreachable, and are __not guaranteed before process exit__. A+-- caller who needs the record before exiting drains the stream to+-- its terminal ('withTrace', or a fold that keeps consuming) rather+-- than stopping early.+--+-- The wait for the worker is bounded by 'sinkDrainBoundMicros'. A+-- sink that blocks forever costs the call one second, after which+-- the worker is abandoned and the stall is reported. Sink+-- exceptions — and stalls — are recorded by the worker and reported+-- once on stderr during cleanup; they fail the call only under+-- 'Baikai.Evidence.EvidenceRequired', where a record whose delivery+-- was never confirmed is not one the caller can account for. module Baikai.Trace   ( -- * Re-exports     TraceEvent (..),@@ -36,7 +51,6 @@     runRequestWithRegistry,      -- * Helpers-    newEventId,     summarizeContext,   ) where@@ -55,10 +69,8 @@ -- both belong in this module, so the status constructors stay behind -- the @Evidence.@ qualifier. import Baikai.Evidence-  ( EvidenceStrictness (..),-    ModelCallEvidence,+  ( ModelCallEvidence,     newCallId,-    noThinkingRequested,   ) import Baikai.Evidence qualified as Evidence import Baikai.Evidence.Build qualified as Build@@ -67,6 +79,7 @@ import Baikai.Options (Options) import Baikai.Prelude import Baikai.Provider.Registry (ProviderRegistry, globalProviderRegistry)+import Baikai.Provider.Registry qualified as Registry import Baikai.Response (Response) import Baikai.StopReason (StopReason (ErrorReason)) import Baikai.Stream (reassembleResponse, streamRequestWith)@@ -77,16 +90,18 @@ import Baikai.Usage qualified as Usage import Control.Concurrent (forkIO) import Control.Concurrent.Chan (Chan, newChan, readChan, writeChan)-import Control.Concurrent.MVar (MVar, newEmptyMVar, putMVar, takeMVar)-import Control.Exception (SomeException, try)+import Control.Concurrent.MVar (MVar, newEmptyMVar, putMVar, readMVar)+import Control.Exception (Exception (..), SomeException, mask, onException, try, uninterruptibleMask_) import Control.Monad (forM_, unless, void) import Control.Monad.IO.Unlift (MonadUnliftIO, withRunInIO) import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef, writeIORef)-import Data.Maybe (fromMaybe)+import Data.Maybe (fromMaybe, isJust) import Data.Time (UTCTime, diffUTCTime, getCurrentTime) import Foreign.StablePtr (StablePtr, freeStablePtr, newStablePtr) import Streamly.Data.Stream (Stream) import Streamly.Data.Stream qualified as Stream+import System.IO (hPutStrLn, stderr)+import System.Timeout (timeout)  -- ============================================================ -- Stream-shaped trace bridge@@ -157,20 +172,17 @@         -- is already over — so a fatal sink failure discovered here has         -- nowhere to go but the stderr line 'reportSinkError' already         -- wrote. The terminal event below is where it can still matter.-        (void (finalizeTrace state eid start m opts))-        (Stream.mapM (traceEvent state eid start m opts) (streamRequestWith reg m ctx opts))+        (void (finalizeTrace reg state eid start m opts))+        (Stream.mapM (traceEvent reg state eid start m opts) (streamRequestWith reg m ctx opts))  -- | Synchronous trace wrapper. Drains 'withTraceStream' into a -- 'Response' through 'reassembleResponse'. ----- Unlike the EP-2 'withTrace' (which re-threw the producer's--- exception), this implementation never throws for producer-side--- failures: errors flow through the stream as a terminal--- 'EventError' and the drained 'Response' carries--- @stopReason = ErrorReason@ plus 'errorMessage'. The masterplan's--- Vision & Scope section commits to "partial output is always--- recoverable" and the plan's Decision Log records that producer--- failures must surface as response data, not exceptions.+-- This never throws for producer-side failures: errors flow through+-- the stream as a terminal 'EventError' and the drained 'Response'+-- carries @stopReason = ErrorReason@ plus 'errorMessage'. Partial+-- output must always be recoverable, so a producer failure surfaces as+-- response data rather than as an exception. -- Downstream-of-the-fold exceptions (e.g. an 'appendEntry' that -- fails) still propagate unchanged. withTrace ::@@ -235,8 +247,8 @@ -- answer can still change the call's outcome; by cleanup time the -- stream is over. finalizeTrace ::-  TraceState -> Text -> UTCTime -> Model -> Options -> IO (Maybe BaikaiError)-finalizeTrace s eid start m opts = do+  ProviderRegistry -> TraceState -> Text -> UTCTime -> Model -> Options -> IO (Maybe BaikaiError)+finalizeTrace reg s eid start m opts = mask $ \restore -> do   alreadyClosed <-     atomicModifyIORef' (s ^. #closed) (\b -> (True, b))   if alreadyClosed@@ -262,12 +274,26 @@         -- would misattribute it. The digests are over         -- 'Build.dispatchEnvelope' — see its documentation for what that         -- does and does not commit to.+        --+        -- The translation comes from the registered adapter's own+        -- 'Registry.describeThinking': the adapter /did/ run on this+        -- path, so its description is the truthful one and the only one+        -- @docs\/adr\/0003-the-adapter-owns-the-translation-description.md@+        -- permits. Where no provider is registered there is nothing to+        -- ask, and 'Build.requestedTranslation' says the caller\'s level+        -- was never translated. Either way the caller\'s own level is+        -- recorded, which passing 'Evidence.noThinkingRequested' here+        -- silently denied.+        mProvider <- Registry.lookupApiProviderWith reg (m ^. #api)+        let translation = case mProvider of+              Just p -> Registry.describeThinking p m opts+              Nothing -> Build.requestedTranslation opts         mev <-           Build.minimalEvidence             m             opts             (Build.transportForModel m)-            noThinkingRequested+            translation             (Build.dispatchEnvelope m opts)             start             now@@ -278,14 +304,71 @@             -- because nothing about the provider went wrong: the consumer             -- stopped reading. The message says exactly that.             (Just (providerError abortText))-        pushEvidence s eid now m mev-        writeChan (s ^. #chan) (Just aborted)+        commitTerminal s eid now m mev aborted       writeChan (s ^. #chan) Nothing-      takeMVar (s ^. #done)+      -- The claim-through-sentinel region above cannot be interrupted;+      -- the wait below can, which is the whole point of the 'mask' /+      -- 'restore' pair. The GC-hook path enters here already under+      -- 'Control.Exception.mask_', and 'restore' puts back /that/ state,+      -- in which a blocking 'readMVar' is still interruptible — so+      -- 'timeout' can deliver its exception on either path. The+      -- 'onException' releases the root if the wait is interrupted: the+      -- sentinel is already queued, so the worker cannot block on the+      -- channel again and no longer needs rooting.+      drained <- restore (awaitWorker s) `onException` releaseStableRoot s+      unless drained $+        atomicModifyIORef' (s ^. #sinkError) $ \old ->+          (Just (fromMaybe (toException (TraceSinkStalled sinkDrainBoundMicros)) old), ())       fatal <- reportSinkError s opts       releaseStableRoot s       pure fatal +-- | How long 'finalizeTrace' waits for the trace worker after writing+-- the shutdown sentinel.+--+-- On expiry the worker is abandoned, not killed, and the call proceeds.+-- One second is chosen because a call produces at most four events, the+-- wait covers only their delivery and the sink's end-of-stream action,+-- and a sink whose per-call latency approaches a second is+-- mis-configured for per-call tracing — an OpenTelemetry exporter+-- belongs behind the non-blocking batch processor. Not a public option:+-- if the bound ever proves tight the answer is an 'Options' field.+sinkDrainBoundMicros :: Int+sinkDrainBoundMicros = 1_000_000++-- | The trace sink did not confirm delivery within+-- 'sinkDrainBoundMicros', carried here as the microsecond bound.+--+-- Stored in the trace state's @sinkError@ as a plain exception, so the+-- strict-mode decision in "Baikai.Evidence.Build" applies to it exactly+-- as it does to a sink that threw: best-effort callers get the stderr+-- line and their answer, a caller who required evidence gets a failed+-- call. Not exported — it renders as text through both paths, and an+-- exported type is a name the surface freeze would have to keep.+newtype TraceSinkStalled = TraceSinkStalled Int+  deriving stock (Show)++instance Exception TraceSinkStalled where+  displayException (TraceSinkStalled us) =+    "the trace sink did not confirm delivery within "+      <> show (us `div` 1000)+      <> " ms; its worker was abandoned, and events already queued may still be \+         \delivered later"++-- | Wait for the worker to signal completion, for at most+-- 'sinkDrainBoundMicros'. 'True' when it did.+--+-- On 'False' the worker is left running: killing it would abort the+-- sink's fold mid-step and lose its end-of-stream action. An abandoned+-- worker finishes when the sink unblocks, or is reaped with+-- 'Control.Exception.BlockedIndefinitelyOnMVar' — which its 'try'+-- catches — when whatever it blocks on becomes unreachable.+--+-- 'readMVar', not 'takeMVar', so the worker's eventual 'putMVar' can+-- never block on a slot this thread emptied.+awaitWorker :: TraceState -> IO Bool+awaitWorker s = isJust <$> timeout sinkDrainBoundMicros (readMVar (s ^. #done))+ -- | Push the 'CallEvidence' event for a call, when there is one. -- -- An absent evidence value means one of two things and this layer must@@ -315,6 +398,35 @@             evidence = ev           } +-- | Commit a call's terminal to the sink: mark the terminal as sent,+-- push the evidence record (when there is one), then push the terminal+-- event.+--+-- One unit with respect to asynchronous exceptions. An exception+-- delivered between the terminal push and the flag write made+-- 'finalizeTrace' read the flag as unset and push a second evidence+-- record and an @aborted@ 'CallFailed' after the real terminal, so a+-- sink saw two records and two contradictory terminals for one call.+-- Plain 'Control.Exception.mask_' closes the window everywhere except+-- inside 'writeChan', whose internal 'takeMVar' on the channel's write+-- lock is interruptible; it never blocks in practice, because the+-- worker only reads, but "never in practice" is what this exists to+-- remove. Every write here is a non-blocking push to an unbounded+-- 'Chan' or one 'IORef' write, so the uninterruptible block holds for+-- microseconds and cannot become an un-cancellable hang.+--+-- The flag goes /first/ so a synchronous failure inside the block+-- yields a missing terminal — which the abort machinery tolerates —+-- rather than a duplicated one. The wait for the worker is outside the+-- block, in 'finalizeTrace'.+commitTerminal ::+  TraceState -> Text -> UTCTime -> Model -> Maybe ModelCallEvidence -> TraceEvent -> IO ()+commitTerminal s eid now m mev terminal =+  uninterruptibleMask_ $ do+    writeIORef (s ^. #terminalSent) True+    pushEvidence s eid now m mev+    writeChan (s ^. #chan) (Just terminal)+ releaseStableRoot :: TraceState -> IO () releaseStableRoot s = do   msp <- atomicModifyIORef' (s ^. #stableRoot) (\sp -> (Nothing, sp))@@ -333,22 +445,24 @@   case merr of     Nothing -> pure Nothing     Just e -> do-      Build.onSinkFailure strictness e+      -- A stall is not a throw, and 'Build.onSinkFailure's line says+      -- the events "were dropped", which is the one thing an abandoned+      -- worker's events were not: they are still queued and may yet be+      -- delivered. The fatality decision below is identical for both.+      case fromException e of+        Just stalled@TraceSinkStalled {} ->+          hPutStrLn stderr ("baikai: " <> displayException stalled)+        Nothing -> Build.onSinkFailure strictness e       pure         ( if Build.sinkFailureIsFatal strictness             then Just (Build.sinkFailureError e)             else Nothing         )   where-    strictness = strictnessOf opts---- | The strictness a call was dispatched under. A call with no evidence--- request is best-effort.-strictnessOf :: Options -> EvidenceStrictness-strictnessOf opts =-  maybe EvidenceBestEffort (^. #strictness) (opts ^. #evidence)+    strictness = Build.strictnessOf opts  traceEvent ::+  ProviderRegistry ->   TraceState ->   Text ->   UTCTime ->@@ -356,7 +470,7 @@   Options ->   AssistantMessageEvent ->   IO AssistantMessageEvent-traceEvent state eid start m opts ev = do+traceEvent reg state eid start m opts ev = do   case ev of     EventDone TerminalPayload {message = msg, evidence = mev} -> do       now <- getCurrentTime@@ -393,10 +507,8 @@       -- call's state open when it arrives. The OpenTelemetry sink ends       -- and removes its span on the terminal, so the other order left       -- its evidence branch unreachable from a live stream.-      pushEvidence state eid now m mev-      writeChan (state ^. #chan) (Just finished)-      writeIORef (state ^. #terminalSent) True-      fatal <- finalizeTrace state eid start m opts+      commitTerminal state eid now m mev finished+      fatal <- finalizeTrace reg state eid start m opts       -- A strict caller whose record did not survive gets a failed call       -- rather than an answer they cannot account for. This is the only       -- place in baikai where a call that reached the provider and came@@ -417,13 +529,11 @@                 latencyMs = latency,                 errorMessage = errMsg               }-      pushEvidence state eid now m mev-      writeChan (state ^. #chan) (Just failed)-      writeIORef (state ^. #terminalSent) True+      commitTerminal state eid now m mev failed       -- Already an error: a sink failure on top changes nothing the       -- caller can act on, and overwriting the provider's own error with       -- baikai's would lose the more useful of the two.-      _ <- finalizeTrace state eid start m opts+      _ <- finalizeTrace reg state eid start m opts       pure ev     _ -> pure ev @@ -525,19 +635,3 @@  millisBetween :: UTCTime -> UTCTime -> Int millisBetween a b = round (realToFrac (diffUTCTime b a) * (1000 :: Double))---- ============================================================--- Event id--- ============================================================---- | Generate an identifier for one traced call.------ Delegates to 'newCallId'. The previous implementation combined the--- process-start POSIX /second/ with a process-local counter and--- produced 16 hexadecimal characters, which meant two processes--- started within the same second emitted identical identifier--- sequences. 'newCallId' produces 32 characters and is unique across--- processes.-newEventId :: IO Text-newEventId = newCallId-{-# DEPRECATED newEventId "Use Baikai.Evidence.newCallId; newEventId's ids were only unique within one process." #-}
src/Baikai/Trace/Event.hs view
@@ -39,9 +39,10 @@ -- -- Every event carries an 'eventId' that correlates the @started@ event -- with its matching @finished@, @failed@, or @evidence@ event within a--- single process run. Token counts are 'Maybe' because subscription-based--- providers (the CLIs) do not report them; 'omitNothingFields' keeps the--- absent fields out of the rendered JSON.+-- single process run. Token counts are 'Maybe' because a non-assistant+-- terminal has no usage and a subprocess tool may report nothing; since+-- 0.5.0.0 both CLI providers carry the counts the tool reported.+-- 'omitNothingFields' keeps the absent fields out of the rendered JSON. -- -- 'usd' is deliberately /not/ 'Maybe'-shaped as an "unknown" marker: it -- was until this release, and a computed cost of zero was suppressed, so@@ -49,6 +50,14 @@ -- looked identical in a trace. The field is still 'Maybe' because a -- non-assistant terminal has no usage at all, but a zero cost now -- renders as @0@.+--+-- The @model@ field carries the __requested__ 'Baikai.Model.modelId' on+-- every constructor, including 'CallEvidence'. The model the provider+-- actually served — which can differ, and which is an observation+-- rather than a request — is available only inside 'CallEvidence'\'s+-- record, as+-- 'Baikai.Evidence.ModelCallEvidence'\'s @observedModel@. A sink must+-- not present @model@ under a response-model key. data TraceEvent   = CallStarted       { eventId :: !Text,@@ -86,8 +95,10 @@       }   | -- | The complete evidence record for one terminal provider call.     ---    -- Emitted exactly once per call, immediately after the matching-    -- 'CallFinished' or 'CallFailed', and only when the caller set+    -- Emitted exactly once per call, immediately __before__ the+    -- matching 'CallFinished' or 'CallFailed', so a sink that keys+    -- per-call state off the started/terminal pair still has the call+    -- open when the record arrives. Only when the caller set     -- 'Baikai.Options.evidence' and the provider built a record. A     -- consumer that wants only evidence can filter on this kind alone,     -- and a consumer written before this constructor existed is
src/Baikai/Trace/Sink.hs view
@@ -1,4 +1,5 @@ {-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedRecordDot #-}  -- | The 'TraceSink' newtype and four built-in sinks. --@@ -7,6 +8,12 @@ -- combinators like 'Fold.tee' (fan to two folds), 'Fold.filter' (drop inputs -- failing a predicate), and 'Fold.lmap' (project each input), so future -- sinks (OpenTelemetry, redaction, projection) plug in without an adapter.+--+-- 'multiSink' is the one place that does /not/ compose with 'Fold.tee':+-- 'Fold.tee' runs one member then the other and lets either's exception+-- escape, so a single throwing member stopped delivery to its siblings+-- and skipped their end-of-stream actions. Each member now runs on its+-- own drain thread; see 'multiSink'. module Baikai.Trace.Sink   ( TraceSink (..),     silent,@@ -19,14 +26,21 @@  import Baikai.Evidence qualified as Evidence import Baikai.Trace.Event (TraceEvent (..))+import Control.Concurrent (forkIO)+import Control.Concurrent.Chan (Chan, newChan, readChan, writeChan)+import Control.Concurrent.MVar (MVar, newEmptyMVar, putMVar, readMVar)+import Control.Exception (Exception (..), SomeException, throwIO, try)+import Control.Monad (forM_, unless) import Data.Aeson qualified as Aeson import Data.ByteString.Lazy qualified as BSL+import Data.List (intercalate) import Data.Text (Text) import Data.Text qualified as Text import Data.Text.IO qualified as Text.IO import Data.Time (defaultTimeLocale, formatTime) import Streamly.Data.Fold (Fold) import Streamly.Data.Fold qualified as Fold+import Streamly.Data.Stream qualified as Stream import System.IO (IOMode (AppendMode), withFile)  -- | A trace sink is a streamly fold over 'TraceEvent' values. Folds@@ -54,15 +68,82 @@     withFile path AppendMode $ \h ->       BSL.hPut h (Aeson.encode e <> "\n") --- | Fan every event out to every sink in the list. Implemented by folding--- 'Fold.tee' across the input list; 'Fold.tee' runs both folds on each--- input and returns the pair of their accumulators, which we discard.+-- | Fan every event out to every sink in the list.+--+-- Each member runs on its own drain thread behind its own unbounded+-- channel, so a member that throws or blocks cannot stop delivery to+-- the others or skip their end-of-stream action. This fold's step never+-- blocks. Its final action sends every member the sentinel, waits for+-- every member, and throws one 'TraceSinkFailure' naming each failed+-- member by zero-based index when any failed — which the trace worker+-- records like any other sink failure.+--+-- The wait for a member is unbounded here; "Baikai.Trace" bounds the+-- whole drain, so a member that blocks forever costs the call the drain+-- bound and no more. One consequence is accepted: while such a member+-- is blocked the aggregate is never thrown, so a /throwing/ sibling's+-- message does not reach stderr in that combination. The stall line+-- names the actionable fact, and the sibling's events were delivered+-- regardless. multiSink :: [TraceSink] -> TraceSink multiSink sinks =-  TraceSink (foldr step Fold.drain sinks)+  TraceSink (Fold.rmapM finish (Fold.foldlM' deliver start))   where-    step (TraceSink f) acc = fmap (const ()) (Fold.tee f acc)+    start :: IO [Member]+    start = mapM startMember sinks +    deliver :: [Member] -> TraceEvent -> IO [Member]+    deliver members e = do+      forM_ members $ \member -> writeChan (chan member) (Just e)+      pure members++    finish :: [Member] -> IO ()+    finish members = do+      forM_ members $ \member -> writeChan (chan member) Nothing+      outcomes <- mapM (readMVar . outcome) members+      let failures = [(i, e) | (i, Just e) <- zip [0 :: Int ..] outcomes]+      unless (null failures) $+        throwIO (TraceSinkFailure (length members) failures)++-- | One member of a 'multiSink': the channel it is fed through and the+-- slot its drain thread fills with the outcome of its fold.+data Member = Member+  { chan :: !(Chan (Maybe TraceEvent)),+    outcome :: !(MVar (Maybe SomeException))+  }++-- | Fork one member's drain thread. The 'try' is @SomeException@ for+-- the same reason the trace worker's is: nothing throws /to/ this+-- thread, so the catch cannot swallow a cancellation aimed at anyone,+-- and a member abandoned by a stalled drain is reaped with+-- 'Control.Exception.BlockedIndefinitelyOnMVar', which is worth+-- recording rather than printing through the runtime.+startMember :: TraceSink -> IO Member+startMember (TraceSink f) = do+  c <- newChan+  o <- newEmptyMVar+  _ <- forkIO $ do+    let step () = fmap (fmap (\e -> (e, ()))) (readChan c)+    r <- try (Stream.fold f (Stream.unfoldrM step ())) :: IO (Either SomeException ())+    putMVar o (either Just (const Nothing) r)+  pure Member {chan = c, outcome = o}++-- | One or more members of a 'multiSink' failed. Not exported: the+-- strict-mode error and the stderr line both render its text, and an+-- exported type is a name the surface freeze would have to keep.+data TraceSinkFailure = TraceSinkFailure Int [(Int, SomeException)]+  deriving stock (Show)++instance Exception TraceSinkFailure where+  displayException (TraceSinkFailure total failures) =+    show (length failures)+      <> " of "+      <> show total+      <> " member sinks failed: "+      <> intercalate+        "; "+        ["member " <> show i <> ": " <> displayException e | (i, e) <- failures]+ -- | Format an event as a single human-readable line. renderHuman :: TraceEvent -> Text renderHuman = \case@@ -114,15 +195,15 @@     tshow x = Text.pack (show x)     fmtTime t = Text.pack (formatTime defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" t) --- | Read through a record pattern rather than bare selectors:+-- | Read through 'OverloadedRecordDot' rather than bare selectors: -- 'Evidence.ModelCallEvidence' and 'Evidence.EvidenceRequest' both -- carry @runId@, so under @DuplicateRecordFields@ a bare--- @Evidence.runId ev@ is an ambiguous occurrence.+-- @Evidence.runId ev@ is an ambiguous occurrence. A record pattern+-- would also work, but the constructor is no longer exported. evidenceSummary :: Evidence.ModelCallEvidence -> Text-evidenceSummary-  Evidence.ModelCallEvidence {Evidence.runId, Evidence.callId, Evidence.strength} =-    Text.unwords-      [ "run=" <> runId,-        "call=" <> callId,-        "strength=" <> Text.pack (show strength)-      ]+evidenceSummary ev =+  Text.unwords+    [ "run=" <> ev.runId,+      "call=" <> ev.callId,+      "strength=" <> Text.pack (show ev.strength)+    ]
+ src/Baikai/Url.hs view
@@ -0,0 +1,254 @@+-- | 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"]
src/Baikai/Usage.hs view
@@ -22,7 +22,7 @@ -- every cost-reading caller would have to handle. 'Baikai.Cost.Pricing.computeCost' -- depends on the token classes being disjoint so each class is billed -- exactly once.-module Baikai.Usage (Usage (..), zeroUsage, _Usage, sumUsage) where+module Baikai.Usage (Usage (..), zeroUsage, sumUsage) where  import Baikai.Cost (Cost, zeroCost) import Data.Aeson@@ -112,7 +112,3 @@ -- | Total a collection of per-call usages into one. sumUsage :: (Foldable f) => f Usage -> Usage sumUsage = foldl' (<>) mempty--{-# DEPRECATED _Usage "Use zeroUsage instead." #-}-_Usage :: Usage-_Usage = zeroUsage
test/AgentAssetsSpec.hs view
@@ -12,7 +12,8 @@     "Baikai.AgentAssets"     [ pathTests,       layoutTests,-      codexTomlTest+      codexTomlTest,+      codexTomlLiteralBodyTests     ]  pathTests :: TestTree@@ -64,7 +65,7 @@  codexTomlTest :: TestTree codexTomlTest =-  testCase "Codex custom-agent TOML escapes strings and preserves instructions" $ do+  testCase "Codex custom-agent TOML uses a literal body and escapes basic strings" $ do     codexCustomAgentToml       CodexCustomAgent         { name = "repo\"reviewer",@@ -74,5 +75,75 @@       @?= Text.unlines         [ "name = \"repo\\\"reviewer\"",           "description = \"Reviews\\tchanges\"",-          "developer_instructions = \"\"\"\nRead first.\nAvoid triple quotes: \\\"\\\"\\\"\n\"\"\""+          -- A literal string interprets nothing, so the three quotation+          -- marks in the body need no escape at all; only three+          -- apostrophes would, and there are none.+          "developer_instructions = \'\'\'\nRead first.\nAvoid triple quotes: \"\"\"\n\'\'\'"         ]++-- | The body of a Codex custom agent is Markdown a human reads in+-- @.codex\/agents\/*.toml@, so it is rendered as a TOML /literal/+-- multi-line string — delimited by three apostrophes, interpreting+-- nothing — and comes back byte for byte.+--+-- This is the defect these cases exist for: rendered as a /basic/+-- string, every backslash in the body is the start of an escape+-- sequence, so a body containing @\\d+@ made Codex refuse to load the+-- file with an unknown-escape error.+--+-- A literal string cannot contain three apostrophes, a bare carriage+-- return, or any control character other than tab and newline, so such a+-- body falls back to a fully escaped basic string rather than being+-- refused.+codexTomlLiteralBodyTests :: TestTree+codexTomlLiteralBodyTests =+  testGroup+    "Codex custom-agent bodies"+    [ testCase "backslashes render verbatim in a literal string" $+        bodyBlock "Match \\d+ then \\ and stop."+          @?= "developer_instructions = \'\'\'\nMatch \\d+ then \\ and stop.\n\'\'\'",+      testCase "a body containing three apostrophes falls back to a basic string" $+        bodyBlock "say \'\'\'hi\'\'\'"+          @?= "developer_instructions = \"\"\"\nsay \'\'\'hi\'\'\'\n\"\"\"",+      testCase "the fallback escapes backslashes and quotation marks" $+        bodyBlock "a\\b \"c\" \'\'\'"+          @?= "developer_instructions = \"\"\"\na\\\\b \\\"c\\\" \'\'\'\n\"\"\"",+      testCase "a control character in the body forces the fallback and is escaped" $+        bodyBlock "before\SOHafter"+          @?= "developer_instructions = \"\"\"\nbefore\\u0001after\n\"\"\"",+      testCase "newlines survive the fallback as newlines" $+        bodyBlock "first\nsecond\SOH"+          @?= "developer_instructions = \"\"\"\nfirst\nsecond\\u0001\n\"\"\"",+      testCase "control characters in name and description are escaped" $ do+        let rendered =+              Text.lines+                ( codexCustomAgentToml+                    CodexCustomAgent+                      { name = "x\SOHy",+                        description = "\DEL",+                        developerInstructions = "body"+                      }+                )+        take 2 rendered+          @?= [ "name = \"x\\u0001y\"",+                "description = \"\\u007F\""+              ]+    ]+  where+    -- Everything from the third line on: the body's own delimiters and+    -- the lines between them.+    bodyBlock body =+      Text.intercalate+        "\n"+        ( drop+            2+            ( Text.lines+                ( codexCustomAgentToml+                    CodexCustomAgent+                      { name = "n",+                        description = "d",+                        developerInstructions = body+                      }+                )+            )+        )
test/AgentSpec.hs view
@@ -18,6 +18,10 @@       multipleViolationTest,       emptyAllowedProvidersTest,       providerArgsCeilingTest,+      toolGrantCeilingTest,+      impliedGrantsTest,+      timeoutCeilingTest,+      outputLimitCeilingTest,       violationRenderingTest,       capturedOutputTest,       failureRenderingTest,@@ -43,8 +47,9 @@     req ^. #safety . #providerArgs @?= []     req ^. #timeout @?= Nothing     req ^. #output @?= InheritOutput+    req ^. #outputFormat @?= TextFormat     req ^. #outputLimit @?= Nothing-    req ^. #envPassthrough @?= []+    req ^. #envRequires @?= []  canonicalRenderingTest :: TestTree canonicalRenderingTest =@@ -73,21 +78,50 @@     parseAgentOutputMode "tee" @?= Just TeeOutput     parseAgentOutputMode "Tee" @?= Nothing +    renderAgentOutputFormat TextFormat @?= "text"+    renderAgentOutputFormat JsonFormat @?= "json"+    parseAgentOutputFormat "text" @?= Just TextFormat+    parseAgentOutputFormat "json" @?= Just JsonFormat+    parseAgentOutputFormat "JSON" @?= Nothing+    parseAgentOutputFormat "stream-json" @?= Nothing++-- | A request carrying a per-stream output limit.+--+-- 'agentRunRequest' defaults 'outputLimit' to 'Nothing', which means+-- \"capture without bound\", and the default ceiling's+-- 'defaultMaxOutputLimit' refuses exactly that. Every case below that is+-- not itself about the output limit starts from this helper, so the+-- violation it asserts is the only one in the list. Jobs resolved+-- through @baikai-agent@ never hit this, because that layer's own+-- default supplies a finite limit.+bounded :: AgentRunRequest -> AgentRunRequest+bounded request = request & #outputLimit .~ Just 4096+ -- | Accepting a request must return it byte-identical. The equality -- assertion against the original value is what proves no clamping -- happened. ceilingAcceptanceTest :: TestTree ceilingAcceptanceTest =   testCase "the default ceiling accepts read-only and edit-workspace unchanged" $ do-    let readOnly = agentRunRequest AgentClaude "/tmp/work" "look around"-        editing = readOnly & #safety .~ agentSafety AgentEditWorkspace+    let readOnly = bounded (agentRunRequest AgentClaude "/tmp/work" "look around")+        editing =+          readOnly+            & #safety+            .~ (agentSafety AgentEditWorkspace & #allowedTools .~ ["Read", "Edit"])+            & #timeout+            .~ Just 600+            & #outputLimit+            .~ Just 1024     applyAgentCeiling defaultAgentCeiling readOnly @?= Right readOnly+    -- Grants the capability already implies, a timeout under an+    -- unlimited maximum, and a limit under the default maximum all pass+    -- through untouched.     applyAgentCeiling defaultAgentCeiling editing @?= Right editing  ceilingRefusalTest :: TestTree ceilingRefusalTest =   testCase "the ceiling refuses with the exact violation for each closed channel" $ do-    let base = agentRunRequest AgentClaude "/tmp/work" "rewrite everything"+    let base = bounded (agentRunRequest AgentClaude "/tmp/work" "rewrite everything")         greedy = base & #safety .~ agentSafety AgentFullAccess         rawArgs =           base@@ -95,7 +129,7 @@             . #providerArgs             .~ ["--dangerously-skip-permissions", "--verbose"]         claudeOnly = defaultAgentCeiling & #allowedProviders .~ [AgentClaude]-        codexRequest = agentRunRequest AgentCodex "/tmp/work" "rewrite everything"+        codexRequest = bounded (agentRunRequest AgentCodex "/tmp/work" "rewrite everything")     applyAgentCeiling defaultAgentCeiling greedy       @?= Left [CapabilityExceeded AgentFullAccess AgentEditWorkspace]     applyAgentCeiling defaultAgentCeiling rawArgs@@ -118,7 +152,7 @@             & #allowedProviders             .~ [AgentClaude]         req =-          agentRunRequest AgentCodex "/tmp/work" "rewrite everything"+          bounded (agentRunRequest AgentCodex "/tmp/work" "rewrite everything")             & #safety             .~ ( agentSafety AgentFullAccess                    & #providerArgs@@ -137,8 +171,8 @@ emptyAllowedProvidersTest =   testCase "an empty allowedProviders list permits no provider" $ do     let closed = defaultAgentCeiling & #allowedProviders .~ []-        claudeRequest = agentRunRequest AgentClaude "/tmp/work" "hello"-        codexRequest = agentRunRequest AgentCodex "/tmp/work" "hello"+        claudeRequest = bounded (agentRunRequest AgentClaude "/tmp/work" "hello")+        codexRequest = bounded (agentRunRequest AgentCodex "/tmp/work" "hello")     applyAgentCeiling closed claudeRequest       @?= Left [ProviderForbidden AgentClaude []]     applyAgentCeiling closed codexRequest@@ -148,7 +182,7 @@ providerArgsCeilingTest =   testCase "raw provider arguments pass only when the operator opens the channel" $ do     let req =-          agentRunRequest AgentClaude "/tmp/work" "hello"+          bounded (agentRunRequest AgentClaude "/tmp/work" "hello")             & #safety             . #providerArgs             .~ ["--some-vendor-flag"]@@ -157,6 +191,92 @@       @?= Left [ProviderArgsForbidden ["--some-vendor-flag"]]     applyAgentCeiling permissive req @?= Right req +-- | A tool grant is authority, so the capability decides which grants+-- need no operator involvement and the operator's allow-list supplies+-- the rest. @Bash@ is in neither implied set, which is the whole point+-- of the finding this pins: a repository file granting itself shell+-- access under @edit-workspace@ must be refused.+toolGrantCeilingTest :: TestTree+toolGrantCeilingTest =+  testCase "a tool grant needs the capability to imply it or the operator to grant it" $ do+    let granting names =+          bounded (agentRunRequest AgentClaude "/tmp/work" "look around")+            & #safety+            .~ (agentSafety AgentEditWorkspace & #allowedTools .~ names)+        bash = granting ["Bash"]+    applyAgentCeiling defaultAgentCeiling bash+      @?= Left [ToolGrantForbidden ["Bash"] AgentEditWorkspace]+    applyAgentCeiling (defaultAgentCeiling & #allowedTools .~ ["Bash"]) bash @?= Right bash+    applyAgentCeiling (defaultAgentCeiling & #maxCapability .~ AgentFullAccess) bash+      @?= Right bash+    -- Matching is exact on the whole string. A pattern-scoped grant is a+    -- different grant, so granting the bare name does not permit it and+    -- an operator who wants it writes it out.+    let scoped = granting ["Bash(git *)"]+    applyAgentCeiling (defaultAgentCeiling & #allowedTools .~ ["Bash"]) scoped+      @?= Left [ToolGrantForbidden ["Bash(git *)"] AgentEditWorkspace]+    -- Grants the capability already implies need no operator at all,+    -- and only the forbidden ones are named in the refusal.+    applyAgentCeiling defaultAgentCeiling (granting ["Read", "Write", "Bash", "WebFetch"])+      @?= Left [ToolGrantForbidden ["Bash", "WebFetch"] AgentEditWorkspace]++-- | The implied grant lists are a security boundary, so they are pinned+-- name by name rather than by a property. A name added here widens every+-- ceiling in existence, which should require editing this test.+impliedGrantsTest :: TestTree+impliedGrantsTest =+  testCase "each capability implies exactly the documented grants" $ do+    toolGrantsImpliedBy AgentReadOnly+      @?= Just ["Read", "Glob", "Grep", "NotebookRead", "TodoWrite"]+    toolGrantsImpliedBy AgentEditWorkspace+      @?= Just+        [ "Read",+          "Glob",+          "Grep",+          "NotebookRead",+          "TodoWrite",+          "Edit",+          "MultiEdit",+          "Write",+          "NotebookEdit"+        ]+    toolGrantsImpliedBy AgentFullAccess @?= Nothing++-- | A finite maximum bounds a requested timeout and also refuses a job+-- that requests none, because a maximum an operator can defeat by+-- omitting the setting is not a maximum.+timeoutCeilingTest :: TestTree+timeoutCeilingTest =+  testCase "a finite max-timeout refuses a longer run and an untimed one" $ do+    let twoHours = defaultAgentCeiling & #maxTimeout .~ Just 7200+        asking limit = bounded (agentRunRequest AgentClaude "/tmp/work" "work") & #timeout .~ limit+    applyAgentCeiling twoHours (asking (Just 3600)) @?= Right (asking (Just 3600))+    applyAgentCeiling twoHours (asking (Just 7200)) @?= Right (asking (Just 7200))+    applyAgentCeiling twoHours (asking (Just 10800))+      @?= Left [TimeoutExceeded (Just 10800) 7200]+    applyAgentCeiling twoHours (asking Nothing) @?= Left [TimeoutExceeded Nothing 7200]+    -- The default maximum is unlimited, so an untimed run passes.+    applyAgentCeiling defaultAgentCeiling (asking Nothing) @?= Right (asking Nothing)++-- | The default maximum is finite, so @unlimited@ is refused until the+-- operator opens it. The memory belongs to the operator's host.+outputLimitCeilingTest :: TestTree+outputLimitCeilingTest =+  testCase "a finite max-output-limit refuses a larger capture and an unlimited one" $ do+    let asking limit =+          bounded (agentRunRequest AgentClaude "/tmp/work" "work") & #outputLimit .~ limit+        unbounded = defaultAgentCeiling & #maxOutputLimit .~ Nothing+    defaultMaxOutputLimit @?= 67108864+    applyAgentCeiling defaultAgentCeiling (asking (Just 1024))+      @?= Right (asking (Just 1024))+    applyAgentCeiling defaultAgentCeiling (asking (Just defaultMaxOutputLimit))+      @?= Right (asking (Just defaultMaxOutputLimit))+    applyAgentCeiling defaultAgentCeiling (asking (Just (defaultMaxOutputLimit + 1)))+      @?= Left [OutputLimitExceeded (Just (defaultMaxOutputLimit + 1)) defaultMaxOutputLimit]+    applyAgentCeiling defaultAgentCeiling (asking Nothing)+      @?= Left [OutputLimitExceeded Nothing defaultMaxOutputLimit]+    applyAgentCeiling unbounded (asking Nothing) @?= Right (asking Nothing)+ -- | Pin that both the requested and the permitted value appear, not -- the exact sentence, so wording can improve without breaking tests. violationRenderingTest :: TestTree@@ -187,6 +307,48 @@       ("expected both providers in: " <> Text.unpack providerMessage)       ("codex" `Text.isInfixOf` providerMessage && "claude" `Text.isInfixOf` providerMessage) +    -- A grant refusal must name what to do about it, because the fix is+    -- in a file the person reading the message may not know exists.+    let grantMessage =+          renderCeilingViolation (ToolGrantForbidden ["Bash", "Skill"] AgentEditWorkspace)+    mapM_+      ( \fragment ->+          assertBool+            ("expected " <> Text.unpack fragment <> " in: " <> Text.unpack grantMessage)+            (fragment `Text.isInfixOf` grantMessage)+      )+      ["Bash", "Skill", "edit-workspace", "policy.allowed-tools"]++    -- Durations are rendered in the spellings the configuration parser+    -- accepts, so an operator can paste the maximum back into their file.+    let overTime = renderCeilingViolation (TimeoutExceeded (Just 10800) 7200)+        untimed = renderCeilingViolation (TimeoutExceeded Nothing 7200)+    assertBool+      ("expected both durations in: " <> Text.unpack overTime)+      ("3h" `Text.isInfixOf` overTime && "2h" `Text.isInfixOf` overTime)+    assertBool+      ("expected the permitted maximum in: " <> Text.unpack untimed)+      ("2h" `Text.isInfixOf` untimed && "no timeout" `Text.isInfixOf` untimed)++    let overBytes = renderCeilingViolation (OutputLimitExceeded (Just 99999999) 67108864)+        unlimitedBytes = renderCeilingViolation (OutputLimitExceeded Nothing 67108864)+    assertBool+      ("expected both byte counts in: " <> Text.unpack overBytes)+      ("99999999" `Text.isInfixOf` overBytes && "67108864" `Text.isInfixOf` overBytes)+    assertBool+      ("expected the word unlimited in: " <> Text.unpack unlimitedBytes)+      ("unlimited" `Text.isInfixOf` unlimitedBytes && "67108864" `Text.isInfixOf` unlimitedBytes)++    let scopeMessage = renderCeilingViolation (RepositoryScopeForbidden "executable")+    assertBool+      ("expected the setting name in: " <> Text.unpack scopeMessage)+      ("executable" `Text.isInfixOf` scopeMessage)+    let outsideMessage =+          renderCeilingViolation (WorkingDirOutsideRepository "/etc" "/tmp/checkout")+    assertBool+      ("expected both paths in: " <> Text.unpack outsideMessage)+      ("/etc" `Text.isInfixOf` outsideMessage && "/tmp/checkout" `Text.isInfixOf` outsideMessage)+ capturedOutputTest :: TestTree capturedOutputTest =   testCase "capturedBytes distinguishes uncaptured output from empty output" $ do@@ -207,10 +369,9 @@           ]         runFailures =           [ SpawnFailed "/usr/local/bin/claude" "no such file or directory",-            RunTimedOut 90,+            RunTimedOut (AgentTimedOut 90 OutputNotCaptured OutputNotCaptured),             MissingEnvironment ["KEIRO_PATH", "ANTHROPIC_API_KEY"],-            WorkingDirMissing "/tmp/gone",-            OutputMalformed "expected JSON, got a banner"+            WorkingDirMissing "/tmp/gone"           ]     mapM_       ( \e ->
test/CatalogSpec.hs view
@@ -18,11 +18,28 @@ -- JSON file changed without a paired regeneration. module CatalogSpec (tests) where +import Baikai.Api (Api (AnthropicMessages))+import Baikai.Compat+  ( AnthropicMessagesCompat,+    AnthropicThinkingStyle (..),+    supportsSamplingParameters,+    thinkingStyle,+  )+import Baikai.Model+  ( Compat (CompatAnthropicMessages),+    Model,+    api,+    compat,+    modelId,+  )+import Baikai.Models.Generated (allModels) import Data.ByteString qualified as BS+import Data.List (sort)+import Data.Text (Text) import System.IO.Temp (withSystemTempDirectory) import System.Process (callProcess) import Test.Tasty (TestTree, testGroup)-import Test.Tasty.HUnit (assertEqual, testCase)+import Test.Tasty.HUnit (assertEqual, assertFailure, testCase, (@?=))  tests :: TestTree tests =@@ -39,5 +56,56 @@             "Generated.hs is out of sync with data/models/*.json.\n\             \Run `cabal run baikai-gen-models` and commit the result."             committed-            regenerated+            regenerated,+      -- Which extended-thinking wire shape a generation accepts, and+      -- whether it accepts sampling parameters, cannot be recovered+      -- from the model id or the base URL. Every Anthropic catalog+      -- entry must therefore carry an explicit compat record stating+      -- both, and this table is where the shipped values are pinned:+      -- a catalog refresh that changes one has to change this row too.+      testCase "every Anthropic catalog entry carries an explicit thinking style and sampling flag" $ do+        assertEqual+          "the pinned table must cover exactly the catalog's Anthropic ids"+          (sort (map fst expectedAnthropicFacts))+          (sort (map modelId anthropicCatalogModels))+        mapM_ assertFacts anthropicCatalogModels     ]++-- | Every Anthropic model in the generated catalog.+anthropicCatalogModels :: [Model]+anthropicCatalogModels = [m | m <- allModels, api m == AnthropicMessages]++-- | The shipped thinking style and sampling support of each Anthropic+-- catalog id, written out by hand from+-- @baikai\/data\/models\/anthropic.json@.+expectedAnthropicFacts :: [(Text, (AnthropicThinkingStyle, Bool))]+expectedAnthropicFacts =+  [ ("claude-fable-5", (AnthropicThinkingAdaptive, False)),+    ("claude-haiku-4-5", (AnthropicThinkingBudget, True)),+    ("claude-opus-4-5", (AnthropicThinkingBudget, True)),+    ("claude-opus-4-6", (AnthropicThinkingAdaptive, True)),+    ("claude-opus-4-7", (AnthropicThinkingAdaptive, False)),+    ("claude-opus-4-8", (AnthropicThinkingAdaptive, False)),+    ("claude-opus-5", (AnthropicThinkingAdaptive, False)),+    ("claude-sonnet-4-5", (AnthropicThinkingBudget, True)),+    ("claude-sonnet-4-6", (AnthropicThinkingAdaptive, True)),+    ("claude-sonnet-5", (AnthropicThinkingAdaptive, False))+  ]++assertFacts :: Model -> IO ()+assertFacts m = case compat m of+  CompatAnthropicMessages c -> case lookup (modelId m) expectedAnthropicFacts of+    Just expected -> facts c @?= expected+    Nothing ->+      assertFailure+        ("no pinned facts for Anthropic catalog model " <> show (modelId m))+  other ->+    assertFailure+      ( "Anthropic catalog model "+          <> show (modelId m)+          <> " must carry an explicit CompatAnthropicMessages record, not "+          <> show other+      )+  where+    facts :: AnthropicMessagesCompat -> (AnthropicThinkingStyle, Bool)+    facts c = (thinkingStyle c, supportsSamplingParameters c)
test/CliInternalSpec.hs view
@@ -18,11 +18,13 @@ import Data.Generics.Labels () import Data.List (isInfixOf) import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text import Data.Vector qualified as Vector import Streamly.Data.Stream qualified as Stream import System.Directory (doesFileExist, getPermissions, setOwnerExecutable, setPermissions) import System.FilePath ((</>)) import System.IO.Temp (withSystemTempDirectory)+import System.Timeout qualified as Timeout import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=)) @@ -149,7 +151,41 @@             [ "{\"type\":\"turn.completed\",\               \\"usage\":{\"input_tokens\":5,\"cached_input_tokens\":9,\"output_tokens\":1}}\n"             ]-        fmap (^. #inputTokens) (report ^. #usage) @?= Just 0+        fmap (^. #inputTokens) (report ^. #usage) @?= Just 0,+      -- Chunk boundaries are the operating system's business, not the+      -- codex event schema's: a pipe read returns whatever bytes had+      -- arrived, which for a long event is the middle of a line.+      testCase "a line spanning several chunks is one event" $ do+        report <-+          parseCodex+            [ "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_mess",+              "age\",\"text\":\"split\"}}\n"+            ]+        report ^. #message @?= "split",+      testCase "a final line without a newline is still parsed" $ do+        report <-+          parseCodex+            ["{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"last\"}}"]+        report ^. #message @?= "last",+      -- The previous implementation appended one byte at a time with+      -- BS.snoc, copying the whole accumulator per byte: quadratic in+      -- line length, so a two-million-character message cost on the+      -- order of a trillion byte moves and never finished. The bound is+      -- what makes this a test rather than a benchmark.+      testCase "a multi-megabyte event is assembled in linear time" $ do+        let body = Text.replicate 2000000 "a"+            event =+              Text.encodeUtf8+                ( "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\""+                    <> body+                    <> "\"}}\n"+                )+        finished <- Timeout.timeout 10000000 (parseCodex [event])+        case finished of+          Nothing ->+            assertFailure+              "assembling one two-megabyte event did not finish within ten seconds"+          Just report -> Text.length (report ^. #message) @?= 2000000     ]  -- ============================================================
test/ContextSpec.hs view
@@ -15,7 +15,29 @@     [ monoidTests,       constructorTests,       timestampTests,-      flattenTextTests+      flattenTextTests,+      toolResultTests+    ]++-- | A failed call has no assistant turn worth replaying and no tool+-- calls to answer, so 'appendToolResult' appends nothing and runs+-- nothing. 'runToolLoop' has always stopped on such a response; the+-- documented direct round trip reaches here instead.+toolResultTests :: TestTree+toolResultTests =+  testGroup+    "appendToolResult"+    [ testCase "an error-shaped response leaves the context unchanged and never dispatches" $ do+        let ctx = contextOf [user "go"]+            failed =+              errorResponse+                emptyModel+                (read "2026-06-05 01:02:03 UTC" :: UTCTime)+                12+                (providerError "upstream died")+            explode _ = error "the dispatcher must not run for an error-shaped response"+        after <- appendToolResult ctx failed explode+        after @?= ctx     ]  monoidTests :: TestTree
test/CostSpec.hs view
@@ -5,20 +5,21 @@ import Baikai.Context (Context (..), emptyContext) import Baikai.Cost qualified as Cost import Baikai.Cost.Log-  ( CallLogConfig (..),-    CallLogEntry (..),+  ( CallLogEntry (..),     appendEntry,+    callLogConfig,+    closeCallLog,+    openCallLog,     runRequestWithLog,     withCallLog,   ) import Baikai.Cost.Pricing (attachCost, computeCost)-import Baikai.Evidence (noThinkingRequested) import Baikai.Message (AssistantPayload (..), user) import Baikai.Model (Model (..), ModelCost (..), emptyModel) import Baikai.Options (Options, emptyOptions) import Baikai.Prelude import Baikai.Provider-  ( ApiProvider (..),+  ( apiProviderWith,     registerApiProvider,   ) import Baikai.Response (Response (..), flattenAssistantBlocks)@@ -29,7 +30,7 @@ import Data.ByteString.Lazy.Char8 qualified as BSL import Data.List.NonEmpty (NonEmpty ((:|)), nonEmpty) import Data.Maybe (fromJust, isJust)-import Data.Time (getCurrentTime)+import Data.Time (UTCTime, getCurrentTime) import Data.Vector qualified as V import System.Directory (getTemporaryDirectory, removeFile) import System.FilePath ((</>))@@ -177,12 +178,11 @@ registerCanned resp =   let handler _m _ctx _opts = pure resp    in registerApiProvider-        ApiProvider-          { apiTag = cannedApi,-            stream = liftCompleteToStream handler,-            complete = handler,-            describeThinking = \_ _ -> noThinkingRequested-          }+        ( apiProviderWith+            cannedApi+            (liftCompleteToStream handler)+            (handler)+        )  cannedModel :: Model cannedModel = knownModel & #api .~ cannedApi@@ -199,7 +199,7 @@     "CallLog"     [ testCase "disabled handle skips disk I/O" $ do         registerCanned cannedHaiku-        let cfg = CallLogConfig {path = "/dev/null", enabled = False}+        let cfg = callLogConfig "/dev/null" & #enabled .~ False         withCallLog cfg $ \h -> do           resp <- runRequestWithLog h cannedModel ctxHello optsZero           flattenAssistantBlocks resp@@ -209,7 +209,7 @@         tmp <- getTemporaryDirectory         let path' = tmp </> "baikai-cost-test.jsonl"         writeFile path' ""-        let cfg = CallLogConfig {path = path', enabled = True}+        let cfg = callLogConfig path'         withCallLog cfg $ \h -> do           _ <- runRequestWithLog h cannedModel ctxHello optsZero           pure ()@@ -234,21 +234,42 @@       testCase "closeCallLog returns even when the log path is unwritable" $ do         tmp <- getTemporaryDirectory         let missing = tmp </> "baikai-costspec-no-such-dir" </> "entries.jsonl"-            cfg = CallLogConfig {path = missing, enabled = True}+            cfg = callLogConfig missing         now <- getCurrentTime-        let entry =-              CallLogEntry-                { timestamp = now,-                  provider = "test",-                  model = "m",-                  inputTokens = Nothing,-                  outputTokens = Nothing,-                  cachedInputTokens = Nothing,-                  reasoningTokens = Nothing,-                  usd = Nothing,-                  latencyMs = 0,-                  promptSummary = ""-                }-        result <- timeout 5000000 (withCallLog cfg (\h -> appendEntry h entry))+        result <- timeout 5000000 (withCallLog cfg (\h -> appendEntry h (sampleEntry now)))+        result @?= Just (),+      -- 'withCallLog' brackets a close around a body that may also close+      -- the handle, so the second close is a shape a caller reaches by+      -- accident. Before the claim it blocked forever on an 'MVar' the+      -- worker had already emptied.+      testCase "closeCallLog twice returns and appendEntry after close is a no-op" $ do+        tmp <- getTemporaryDirectory+        let path' = tmp </> "baikai-costspec-double-close.jsonl"+        writeFile path' ""+        let cfg = callLogConfig path'+        h <- openCallLog cfg+        result <- timeout 5000000 (closeCallLog h >> closeCallLog h)         result @?= Just ()+        now <- getCurrentTime+        appendEntry h (sampleEntry now)+        raw <- BSL.readFile path'+        BSL.length raw @?= 0+        removeFile path'     ]++-- | A minimal entry, shared by the call-log cases that need one to+-- enqueue rather than one to inspect.+sampleEntry :: UTCTime -> CallLogEntry+sampleEntry now =+  CallLogEntry+    { timestamp = now,+      provider = "test",+      model = "m",+      inputTokens = Nothing,+      outputTokens = Nothing,+      cachedInputTokens = Nothing,+      reasoningTokens = Nothing,+      usd = Nothing,+      latencyMs = 0,+      promptSummary = ""+    }
test/EmbeddingSpec.hs view
@@ -1,4 +1,4 @@--- | Tests for the embeddings client (EP-15, M1).+-- | Tests for the embeddings client. -- -- The request-mapping test is hermetic: it asserts on the pure -- 'mkEmbeddingRequest' (no network), proving the input text, model id, and@@ -7,14 +7,32 @@ -- default run stays offline. module EmbeddingSpec (tests) where -import Baikai.Embedding (embedOne, firstEmbedding, mkEmbeddingRequest, openAIEmbeddingModel)-import Baikai.Error (decodeError)+import Baikai.Auth (ApiKeySource (..))+import Baikai.Embedding+  ( EmbeddingModel (..),+    embedOne,+    embeddingClientEnv,+    emptyEmbeddingModel,+    firstEmbedding,+    mkEmbeddingRequest,+    openAIEmbeddingModel,+    resolveEmbeddingKey,+  )+import Baikai.Error (BaikaiError, ErrorCategory (..), decodeError)+import Baikai.Http qualified as Http+import Control.Exception qualified as Exception+import Control.Lens ((&), (.~), (^.))+import Data.Generics.Labels ()+import Data.Text (Text)+import Data.Text qualified as Text import Data.Vector qualified as V import OpenAI.V1.Embeddings qualified as Emb import OpenAI.V1.Models qualified as OpenAIModels+import Servant.Client qualified as Client import System.Environment (lookupEnv)+import System.Environment qualified as Environment import Test.Tasty (TestTree, testGroup)-import Test.Tasty.HUnit (testCase, (@?=))+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))  tests :: TestTree tests =@@ -36,6 +54,54 @@                   Emb.object = "embedding"                 }         firstEmbedding (V.singleton obj) @?= Right vec,+      testCase "an embedding host resolves its own key, not OpenAI's" $ do+        -- The defect: whatever the base URL said, the default key was+        -- OPENAI_API_KEY. Pointing an EmbeddingModel at DeepSeek sent an+        -- OpenAI key to DeepSeek.+        withEnv "OPENAI_API_KEY" (Just "openai-secret") $+          withEnv "DEEPSEEK_API_KEY" Nothing $ do+            err <-+              expectAuthError+                (emptyEmbeddingModel & #baseUrl .~ "https://api.deepseek.com")+            assertBool+              ("names the host's own variable: " <> Text.unpack (err ^. #message))+              ("DEEPSEEK_API_KEY" `Text.isInfixOf` (err ^. #message)),+      testCase "an unknown embedding host refuses rather than sending OpenAI's key" $+        withEnv "OPENAI_API_KEY" (Just "openai-secret") $ do+          err <-+            expectAuthError+              (emptyEmbeddingModel & #baseUrl .~ "https://vectors.example")+          assertBool+            ("says what to set: " <> Text.unpack (err ^. #message))+            ("EmbeddingModel.apiKey" `Text.isInfixOf` (err ^. #message)),+      testCase "the OpenAI default still resolves OPENAI_API_KEY" $+        withEnv "OPENAI_API_KEY" (Just "openai-secret") $ do+          resolved <- resolveEmbeddingKey (openAIEmbeddingModel "text-embedding-3-small")+          resolved @?= "openai-secret",+      testCase "an explicit key source wins over the per-host table" $+        withEnv "OPENAI_API_KEY" (Just "openai-secret") $ do+          resolved <-+            resolveEmbeddingKey+              ( openAIEmbeddingModel "m"+                  & #apiKey+                    .~ Just (ApiKeyLiteral "explicit-key")+              )+          resolved @?= "explicit-key",+      testCase "embeddings share the connection cache with the chat providers" $ do+        -- One TLS manager per host, not one per call: the SDK's own+        -- getClientEnv allocated a fresh manager every time embed ran.+        before <- Http.cachedClientEnvCount+        _ <- embeddingClientEnv (emptyEmbeddingModel & #baseUrl .~ "https://embed-cache.test")+        env <- embeddingClientEnv (emptyEmbeddingModel & #baseUrl .~ "https://Embed-Cache.test/")+        afterBoth <- Http.cachedClientEnvCount+        afterBoth @?= before + 1+        Client.baseUrlHost (Client.baseUrl env) @?= "embed-cache.test"+        Client.baseUrlPath (Client.baseUrl env) @?= "",+      testCase "the #field idiom compiles on EmbeddingModel" $ do+        -- It could not before: the record derived neither Generic nor Eq.+        let m = openAIEmbeddingModel "m" & #dimensions .~ Just 256+        m ^. #dimensions @?= Just 256+        m @?= (openAIEmbeddingModel "m" & #dimensions .~ Just 256),       testCase "live embedding returns a 1536-length vector" $ do         live <- lookupEnv "BAIKAI_EMBEDDING_LIVE"         case live of@@ -44,3 +110,29 @@             V.length v @?= 1536           _ -> putStrLn "BAIKAI_EMBEDDING_LIVE not set; skipping live test"     ]++-- | Resolve a model's key, expecting it to refuse.+expectAuthError :: EmbeddingModel -> IO BaikaiError+expectAuthError m = do+  thrown <- Exception.try (resolveEmbeddingKey m) :: IO (Either BaikaiError Text)+  case thrown of+    Right key -> assertFailure ("expected an AuthError, got a key: " <> Text.unpack key)+    Left err -> do+      err ^. #category @?= AuthError+      pure err++-- | Run an action with one environment variable set to a value, or+-- removed, restoring whatever was there before.+withEnv :: String -> Maybe String -> IO a -> IO a+withEnv name value action =+  Exception.bracket+    ( do+        old <- Environment.lookupEnv name+        apply value+        pure old+    )+    apply+    (const action)+  where+    apply Nothing = Environment.unsetEnv name+    apply (Just v) = Environment.setEnv name v
test/ErrorInfoSpec.hs view
@@ -51,12 +51,11 @@ registerErr :: IO () registerErr =   registerApiProvider-    ApiProvider-      { apiTag = errApi,-        stream = errStream,-        complete = streamingComplete errStream,-        describeThinking = \_ _ -> noThinkingRequested-      }+    ( apiProviderWith+        errApi+        (errStream)+        (streamingComplete errStream)+    )  tests :: TestTree tests =
test/ErrorSpec.hs view
@@ -9,10 +9,13 @@     httpError,     invalidRequest,     isRetryable,+    parseHttpDate,     parseRetryAfterSeconds,     processError,     rateLimited,+    retryAfterSecondsAt,   )+import Data.Time (UTCTime) import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (testCase, (@?=)) @@ -36,15 +39,45 @@         category e @?= RateLimited         httpStatus e @?= Just 429         retryAfterSeconds e @?= Just 12,+      -- Re-homed from the provider suites' servant fixtures: the+      -- assertion is about 'httpError', which is where it belongs.+      testCase "429 without Retry-After -> RateLimited, no hint" $ do+        let e = httpError 429 Nothing "slow down"+        category e @?= RateLimited+        retryAfterSeconds e @?= Nothing,       testCase "400 + overflow body -> ContextOverflow" $         category (httpError 400 Nothing "maximum context length exceeded")           @?= ContextOverflow,       testCase "integer Retry-After parses as seconds" $         parseRetryAfterSeconds "12" @?= Just 12,-      testCase "HTTP-date Retry-After is ignored" $-        parseRetryAfterSeconds "Wed, 21 Oct 2026 07:28:00 GMT" @?= Nothing+      -- The integer-only contract is now deliberate rather than a+      -- limitation: converting a date needs a reference instant, which+      -- 'retryAfterSecondsAt' takes and this function cannot.+      testCase "parseRetryAfterSeconds is integer-only" $+        parseRetryAfterSeconds "Wed, 21 Oct 2026 07:28:00 GMT" @?= Nothing,+      testCase "HTTP-date Retry-After yields seconds from the reference instant" $+        retryAfterSecondsAt referenceInstant "Wed, 21 Oct 2026 07:28:00 GMT" @?= Just 30,+      -- The server is saying "now", not "some time last week".+      testCase "HTTP-date Retry-After in the past yields zero" $+        retryAfterSecondsAt referenceInstant "Wed, 21 Oct 2026 07:00:00 GMT" @?= Just 0,+      testCase "integer Retry-After ignores the reference instant" $+        retryAfterSecondsAt referenceInstant "12" @?= Just 12,+      testCase "malformed Retry-After yields Nothing" $+        retryAfterSecondsAt referenceInstant "soonish" @?= Nothing,+      testCase "parseHttpDate accepts IMF-fixdate, RFC 850 and asctime" $ do+        let expected = Just (read "1994-11-06 08:49:37 UTC" :: UTCTime)+        parseHttpDate "Sun, 06 Nov 1994 08:49:37 GMT" @?= expected+        parseHttpDate "Sunday, 06-Nov-94 08:49:37 GMT" @?= expected+        parseHttpDate "Sun Nov  6 08:49:37 1994" @?= expected,+      testCase "parseHttpDate rejects text that is not a date" $+        parseHttpDate "tomorrow" @?= Nothing     ] +-- | Thirty seconds before the @Retry-After@ date the cases above use, so+-- the expected answer is a number a reader can check by eye.+referenceInstant :: UTCTime+referenceInstant = read "2026-10-21 07:27:30 UTC"+ bodyClassifyTests :: TestTree bodyClassifyTests =   testGroup@@ -62,7 +95,13 @@         classifyHttpStatusWithBody 429 Nothing "context length whatever"           @?= RateLimited,       testCase "500 defers to status -> TransientError" $-        classifyHttpStatusWithBody 500 Nothing "context length" @?= TransientError+        classifyHttpStatusWithBody 500 Nothing "context length" @?= TransientError,+      -- 413 is the size-limit status, so the body's wording changes+      -- nothing: the caller's remedy is to shrink the input either way.+      testCase "413 + ordinary body -> ContextOverflow" $+        classifyHttpStatusWithBody 413 Nothing "payload too large" @?= ContextOverflow,+      testCase "413 + request_too_large body -> ContextOverflow" $+        classifyHttpStatusWithBody 413 Nothing "request_too_large" @?= ContextOverflow     ]  classifyTests :: TestTree@@ -79,6 +118,7 @@       testCase "500 -> TransientError" $ classifyHttpStatus 500 Nothing @?= TransientError,       testCase "502 -> TransientError" $ classifyHttpStatus 502 Nothing @?= TransientError,       testCase "503 -> TransientError" $ classifyHttpStatus 503 Nothing @?= TransientError,+      testCase "413 -> ContextOverflow" $ classifyHttpStatus 413 Nothing @?= ContextOverflow,       testCase "418 -> OtherError" $ classifyHttpStatus 418 Nothing @?= OtherError     ] 
test/EvidenceSpec.hs view
@@ -1,11 +1,16 @@+{-# LANGUAGE OverloadedRecordDot #-}+ -- | Tests for "Baikai.Evidence": that the canonical encoding really is -- canonical, that the two digests differ in exactly the way they are -- documented to, and that the configuration projection lets no content -- through. module EvidenceSpec (tests) where +import Baikai.Cost (Cost (..), zeroCost) import Baikai.Evidence import Baikai.Provider.Cli.Internal qualified as Internal+import Baikai.ThinkingLevel (ThinkingLevel (..))+import Baikai.Usage (Usage (..), zeroUsage) import Control.Concurrent (threadDelay) import Control.Monad (replicateM) import Data.Aeson (Value (Number, Object, String), object, (.=))@@ -24,12 +29,61 @@     "Evidence"     [ canonicalTests,       digestTests,+      usageEnvelopeTests,+      deriveStrengthTests,       redactionTests,       observedTests,+      adjustmentJsonTests,       callIdTests     ]  -- ============================================================+-- Adjustment JSON+-- ============================================================++-- | Every adjustment kind, through JSON and back.+--+-- The two sampling kinds carry a @fields@ array and no @requested@+-- level, so a decoder that reads @requested@ before it reads @kind@+-- fails on them. Round-tripping every constructor is what keeps that+-- ordering honest as constructors are added.+adjustmentJsonTests :: TestTree+adjustmentJsonTests =+  testGroup+    "ThinkingAdjustment JSON"+    ( [ testCase (show adjustment) (roundTripAdjustment adjustment)+      | adjustment <-+          [ EffortClamped ThinkingMax "high",+            EffortCollapsedToToggle ThinkingHigh,+            EffortOmitted ThinkingHigh,+            ThinkingDroppedUnsupportedModel ThinkingLow,+            ThinkingDroppedUnsupportedHost ThinkingMinimal,+            ThinkingDroppedBudgetExceeded ThinkingMax 32000 8192,+            SamplingDroppedUnsupportedModel ["temperature", "top_p"],+            SamplingDroppedUnsupportedApi ["seed", "frequency_penalty", "presence_penalty"]+          ]+      ]+        <> [ testCase "a sampling drop encodes its kind and fields and no requested level" $+               Aeson.toJSON (SamplingDroppedUnsupportedModel ["temperature", "top_p"])+                 @?= Aeson.object+                   [ "kind" Aeson..= ("sampling_dropped_unsupported_model" :: Text.Text),+                     "fields" Aeson..= (["temperature", "top_p"] :: [Text.Text])+                   ],+             testCase "an API-level sampling drop names its own kind" $+               Aeson.toJSON (SamplingDroppedUnsupportedApi ["seed"])+                 @?= Aeson.object+                   [ "kind" Aeson..= ("sampling_dropped_unsupported_api" :: Text.Text),+                     "fields" Aeson..= (["seed"] :: [Text.Text])+                   ]+           ]+    )+  where+    roundTripAdjustment :: ThinkingAdjustment -> IO ()+    roundTripAdjustment v = case Aeson.fromJSON (Aeson.toJSON v) of+      Aeson.Success v' -> v' @?= v+      Aeson.Error e -> assertFailure ("round trip failed: " <> e)++-- ============================================================ -- Canonical encoding -- ============================================================ @@ -118,14 +172,20 @@       -- changed, and every digest recorded by an earlier build has       -- become unverifiable. That is a major bump of       -- evidenceSchemaVersion, not a value to paste over.+      --+      -- Both values changed at schema version 2.0, because the fixture+      -- gained an `output_config` and a `response_format` and the+      -- projection now summarises both. They were recomputed only after+      -- the redaction group above was green: a golden value pasted while+      -- a marker still leaked would pin the leak.       testCase "the request commitment matches the golden value" $ do         env <- loadFixture         commitmentDigest env-          @?= "sha256:ee1baf81dad750bb61bbcd6a737b8266c206a4288403510be5e10cace20b5798",+          @?= "sha256:7328ef9e177fbf71793c2167c25749b98845ecc5ea1cf9cd5a38ef3aa52d3b0b",       testCase "the configuration digest matches the golden value" $ do         env <- loadFixture         configurationDigest env-          @?= "sha256:858f0d5ec35ba6f8bac39140c6523785abcbf4e8007c770c1ba2e13f0e72d6b5",+          @?= "sha256:5ed62ecd1a00798c06de88363e9f6a591610449f1e06fa8bd8260ee7934ea366",       testCase "the configuration digest ignores content, the commitment does not" $ do         let ask subject =               object@@ -156,6 +216,84 @@     ]  -- ============================================================+-- The one strength rule+-- ============================================================++-- | All eight combinations, one named case per row.+--+-- Three copies of this rule had drifted: the subprocess one counted a+-- session or thread id as correlation while the two API ones looked only+-- at a captured header, so a host reporting @model@ and @id@ on every+-- chunk but no header landed below a host that sent only a header.+deriveStrengthTests :: TestTree+deriveStrengthTests =+  testGroup+    "deriveStrength"+    [ row "nothing observed" Unobserved Unobserved Unobserved EvidenceRequestedOnly,+      row "a model alone does not climb the scale" (Observed "m") Unobserved Unobserved EvidenceRequestedOnly,+      row "a request id alone is correlation" Unobserved (Observed "req") Unobserved EvidenceCorrelated,+      row "A RESPONSE ID ALONE IS ALSO CORRELATION" Unobserved Unobserved (Observed "resp") EvidenceCorrelated,+      row "both identifiers are still correlation" Unobserved (Observed "req") (Observed "resp") EvidenceCorrelated,+      row "a model with a request id is model_observed" (Observed "m") (Observed "req") Unobserved EvidenceModelObserved,+      row "A MODEL WITH A RESPONSE ID IS ALSO model_observed" (Observed "m") Unobserved (Observed "resp") EvidenceModelObserved,+      row "a model with both identifiers is model_observed" (Observed "m") (Observed "req") (Observed "resp") EvidenceModelObserved,+      testCase "nothing reaches fully_observed" $+        assertBool+          "no combination of these three observations may reach the top of the scale"+          ( all+              (< EvidenceFullyObserved)+              [ deriveStrength o r i+              | o <- both,+                r <- both,+                i <- both+              ]+          )+    ]+  where+    row name observedModel requestId responseId expected =+      testCase name (deriveStrength observedModel requestId responseId @?= expected)+    both = [Unobserved, Observed "x"]++-- ============================================================+-- The usage a response digest commits to+-- ============================================================++usageEnvelopeTests :: TestTree+usageEnvelopeTests =+  testGroup+    "usage envelope"+    [ testCase "two usages differing only in cost produce the same envelope" $ do+        -- The cost is computed here from the caller's catalog rates, not+        -- read off the response, so a verifier holding only the response+        -- could not recompute a digest that covered it — and the digest+        -- changed whenever a price was edited.+        let cheap = zeroUsage {inputTokens = 10, outputTokens = 20}+            dear = cheap {cost = zeroCost {usd = 1234}}+        usageEnvelope cheap @?= usageEnvelope dear,+      testCase "the encoded envelope carries no cost key" $ do+        let encoded = BS8.unpack (canonicalEncode (usageEnvelope zeroUsage))+        assertBool+          ("cost survived into the usage envelope: " <> encoded)+          (not ("cost" `isInfix` encoded))+        mapM_+          ( \k ->+              assertBool+                (k <> " missing from the usage envelope: " <> encoded)+                (k `isInfix` encoded)+          )+          [ "input_tokens",+            "output_tokens",+            "cache_read_tokens",+            "cache_write_tokens",+            "reasoning_tokens",+            "total_tokens"+          ]+    ]+  where+    isInfix needle haystack =+      Text.isInfixOf (Text.pack needle) (Text.pack haystack)++-- ============================================================ -- Redaction -- ============================================================ @@ -182,7 +320,18 @@             "SYSTEM-PROMPT-BODY-MARKER",             "REASONING-TEXT-MARKER",             "TOOL-PAYLOAD-MARKER",-            "Fetch a quarterly report by identifier."+            "Fetch a quarterly report by identifier.",+            -- A JSON schema is content wherever it appears. These two+            -- markers sit in the `description` of a structured-output+            -- schema reached two different ways: Anthropic's+            -- `output_config.format.schema` and the OpenAI-compatible+            -- `response_format.json_schema.schema`. The fixture is one+            -- recorded envelope serving both the digest and the+            -- redaction tests, and it already carries a non-wire+            -- `extra_headers` key, so mixing an OpenAI-shaped key into+            -- an Anthropic-shaped body is in keeping.+            "OUTPUT-SCHEMA-MARKER",+            "RESPONSE-SCHEMA-MARKER"           ],       testCase "the projection keeps the configuration it is supposed to" $ do         env <- loadFixture@@ -198,7 +347,13 @@             "max_tokens",             "temperature",             -- A tool's name is configuration; its description is not.-            "fetch_report"+            "fetch_report",+            -- The same rule around a structured-output schema: the+            -- effort, the schema's name and its strictness are how the+            -- call is configured.+            "effort",+            "quarterly_report",+            "strict"           ],       testCase "the commitment digest does see the content" $ do         env <- loadFixture@@ -271,20 +426,15 @@       -- 'attempt', and 'supersedes' name a field on both       -- 'EvidenceRequest' and 'ModelCallEvidence', and under       -- DuplicateRecordFields a bare selector is ambiguous. Library-      -- code reaches these through the generic-lens labels-      -- (@r ^. #runId@) that the rest of this codebase uses.-      testCase "evidenceRequest defaults to best effort, attempt one" $-        case evidenceRequest "run-42" of-          EvidenceRequest-            { runId = rid,-              strictness = strict,-              attempt = att,-              supersedes = prev-            } -> do-              rid @?= "run-42"-              strict @?= EvidenceBestEffort-              att @?= 1-              prev @?= Nothing+      -- code reaches these through 'OverloadedRecordDot' or the+      -- generic-lens labels (@r ^. #runId@) the rest of this codebase+      -- uses; the constructor is no longer exported.+      testCase "evidenceRequest defaults to best effort, attempt one" $ do+        let req = evidenceRequest "run-42"+        req.runId @?= "run-42"+        req.strictness @?= EvidenceBestEffort+        req.attempt @?= 1+        req.supersedes @?= Nothing     ]   where     roundTrip :: Observed Text.Text -> IO ()
test/FetchModelsSpec.hs view
@@ -4,6 +4,7 @@ -- models.dev-shaped fixture. No network is involved. module FetchModelsSpec (tests) where +import Baikai.Compat (AnthropicThinkingStyle (..)) import Baikai.Model (InputModality (..)) import Baikai.Prelude import Data.Aeson qualified as Aeson@@ -57,7 +58,8 @@               input = [InputText, InputImage],               cost = CatalogCost 0.05 0.4 0 0,               contextWindow = 400000,-              maxOutputTokens = 128000+              maxOutputTokens = 128000,+              compat = Nothing             },           CatalogModel             { modelId = "gpt-5.4",@@ -66,11 +68,43 @@               input = [InputText, InputImage],               cost = CatalogCost 2.5 15 0.25 0,               contextWindow = 1050000,-              maxOutputTokens = 128000+              maxOutputTokens = 128000,+              compat = Nothing             }         ]     } +-- | Expected Anthropic catalog after normalization. The one fixture+-- model carries the generation facts curated in 'anthropicInclude':+-- the budget thinking shape, sampling parameters accepted.+expectedAnthropic :: Catalog+expectedAnthropic =+  Catalog+    { provider = "anthropic",+      baseUrl = "https://api.anthropic.com",+      api = "anthropic-messages",+      models =+        [ CatalogModel+            { modelId = "claude-opus-4-5",+              name = "Claude Opus 4.5",+              reasoning = True,+              input = [InputText, InputImage],+              cost = CatalogCost 5 25 1.5 6.25,+              contextWindow = 200000,+              maxOutputTokens = 64000,+              compat =+                Just+                  ( CatalogAnthropicCompat+                      ( AnthropicGenerationFacts+                          { thinkingStyle = AnthropicThinkingBudget,+                            supportsSamplingParameters = True+                          }+                      )+                  )+            }+        ]+    }+ tests :: TestTree tests =   testGroup@@ -124,7 +158,8 @@                           input = [InputText],                           cost = CatalogCost 0 0 0 0,                           contextWindow = 1,-                          maxOutputTokens = 1+                          maxOutputTokens = 1,+                          compat = Nothing                         }                     ]                 }@@ -147,6 +182,29 @@         upstream <- loadUpstream         let ids = map (^. #modelId) (catalogFor upstream anthropicSpec ^. #models)         ids @?= ["claude-opus-4-5"],+      testCase "Anthropic normalization carries the curated generation facts" $ do+        upstream <- loadUpstream+        catalogFor upstream anthropicSpec @?= expectedAnthropic,+      testCase "the generation facts render as a per-model compat block" $ do+        upstream <- loadUpstream+        let rendered = renderText (catalogFor upstream anthropicSpec)+        assertBool+          "compat block rendered"+          ( Text.unlines+              [ "      \"compat\": {",+                "        \"kind\": \"anthropic-messages\",",+                "        \"thinkingStyle\": \"budget\",",+                "        \"supportsSamplingParameters\": true",+                "      },"+              ]+              `Text.isInfixOf` rendered+          ),+      testCase "an OpenAI model renders no compat block" $ do+        upstream <- loadUpstream+        let rendered = renderText (catalogFor upstream openaiSpec)+        assertBool+          "no per-model compat block (the file-level \"compat\": \"auto\" stays)"+          (not ("\"compat\": {" `Text.isInfixOf` rendered)),       testCase "\" (latest)\" suffix is stripped from display names" $ do         upstream <- loadUpstream         let cat = catalogFor upstream anthropicSpec
test/GenModelsSpec.hs view
@@ -1,6 +1,12 @@ module GenModelsSpec (tests) where -import Baikai.Api (Api (OpenAIChatCompletions))+import Baikai.Api (Api (AnthropicMessages, OpenAIChatCompletions))+import Baikai.Compat+  ( AnthropicThinkingStyle (AnthropicThinkingAdaptive),+    defaultAnthropicMessagesCompat,+    supportsSamplingParameters,+    thinkingStyle,+  ) import Baikai.Model (InputModality (InputText)) import Data.Text (Text) import Data.Text qualified as Text@@ -19,8 +25,48 @@           Left err -> do             assertBool "mentions duplicate identifier" ("openai_a_b" `Text.isInfixOf` err)             assertBool "mentions first origin" ("openai/a-b" `Text.isInfixOf` err)-            assertBool "mentions second origin" ("openai/a_b" `Text.isInfixOf` err)+            assertBool "mentions second origin" ("openai/a_b" `Text.isInfixOf` err),+      testCase "checkAnthropicCompat rejects an entry left at compat auto" $ do+        let entries = flattenEntries (anthropicCatalog CatalogCompatAuto Nothing)+        case checkAnthropicCompat entries of+          Right () ->+            assertFailure+              "expected an anthropic-messages entry with no compat block to be rejected"+          Left err -> do+            assertBool "names the entry" ("anthropic/claude-x" `Text.isInfixOf` err)+            assertBool "names the fix" ("thinkingStyle" `Text.isInfixOf` err)+            assertBool+              "names the sampling field"+              ("supportsSamplingParameters" `Text.isInfixOf` err),+      testCase "checkAnthropicCompat accepts an entry that states its facts" $ do+        let block =+              CatalogCompatAnthropic+                defaultAnthropicMessagesCompat+                  { thinkingStyle = AnthropicThinkingAdaptive,+                    supportsSamplingParameters = False+                  }+            entries = flattenEntries (anthropicCatalog CatalogCompatAuto (Just block))+        case checkAnthropicCompat entries of+          Right () -> pure ()+          Left err -> assertFailure ("unexpected rejection: " <> Text.unpack err),+      testCase "checkAnthropicCompat ignores an OpenAI catalog" $ do+        case checkAnthropicCompat (flattenEntries collisionCatalog) of+          Right () -> pure ()+          Left err -> assertFailure ("unexpected rejection: " <> Text.unpack err)     ]++-- | A one-model @anthropic-messages@ catalog, with the file-level+-- compat directive and the per-model override both under the caller's+-- control.+anthropicCatalog :: CatalogCompat -> Maybe CatalogCompat -> CatalogFile+anthropicCatalog fileCompat override =+  CatalogFile+    { provider = "anthropic",+      baseUrl = "https://api.anthropic.com",+      api = AnthropicMessages,+      compat = fileCompat,+      models = [(model "claude-x") {entryCompatOverride = override}]+    }  collisionCatalog :: CatalogFile collisionCatalog =
test/HelpersSpec.hs view
@@ -5,6 +5,8 @@ import Control.Exception qualified as Exception import Data.Aeson qualified as Aeson import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)+import Data.Map.Strict qualified as Map+import Data.Maybe (isJust) import Data.Text qualified as Text import Data.Time (UTCTime) import Data.Vector qualified as Vector@@ -33,7 +35,36 @@ tests =   testGroup     "Baikai helpers"-    [ testCase "runToolLoop resolves repeated tool turns and leaves final response separate" $ do+    [ -- The registry keys on 'normaliseApi' of the tag, at registration+      -- and at lookup, so the two spellings of a built-in API are one+      -- entry rather than two that dispatch by whichever the model used.+      testCase "a handler registered under a Custom spelling answers the built-in tag" $ do+        reg <- newProviderRegistryFrom [oneShotProvider (Custom "anthropic-messages") "spelled custom"]+        found <- lookupApiProviderWith reg AnthropicMessages+        assertBool "AnthropicMessages finds the Custom-spelled handler" (isJust found),+      testCase "a handler registered under the built-in tag answers a Custom spelling" $ do+        reg <- newProviderRegistryFrom [oneShotProvider AnthropicMessages "spelled built-in"]+        found <- lookupApiProviderWith reg (Custom "anthropic-messages")+        assertBool "Custom \"anthropic-messages\" finds the built-in handler" (isJust found),+      -- A header name is case-insensitive on the wire, so a map keyed+      -- on 'HeaderName' must hold one entry for two spellings rather+      -- than two entries whose winner depends on Map order.+      testCase "two spellings of one header name are one map entry" $ do+        let hs = Map.fromList [("Authorization", "a"), ("authorization", "b")] :: Map.Map HeaderName Text+        Map.size hs @?= 1+        Map.lookup "AUTHORIZATION" hs @?= Just "b",+      -- 'emptyModel' carries @Custom ""@, which used to render as nothing+      -- at all: "No provider registered for API: " with an empty tail.+      testCase "dispatching emptyModel names emptyModel rather than nothing" $ do+        reg <- newProviderRegistry+        resp <- completeRequestWith reg emptyModel emptyContext emptyOptions+        case responseError resp of+          Nothing -> assertFailure "expected a ProviderUnavailable response"+          Just err ->+            assertBool+              ("expected the blank-tag hint, got: " <> Text.unpack (err ^. #message))+              ("blank Custom tag" `Text.isInfixOf` (err ^. #message)),+      testCase "runToolLoop resolves repeated tool turns and leaves final response separate" $ do         scripted <- newScripted [toolUseResponse "call_1" "get_time", toolUseResponse "call_2" "get_time", textResponse "done"] []         let ctx0 = addUser "start" emptyContext             dispatcher _ = pure (toolResultText "2026-06-05T00:00:00Z")@@ -139,6 +170,71 @@                 assertBool "message should include first name" (Text.pack first `Text.isInfixOf` (err ^. #message))                 assertBool "message should include second name" (Text.pack second `Text.isInfixOf` (err ^. #message))               Right key -> assertFailure ("expected auth error, got key: " <> Text.unpack key),+      testCase "ApiKeyEnv rejects a variable set to the empty string" $ do+        -- An empty key can never authenticate. Reporting it here, by+        -- name, beats sending "Authorization: Bearer " and reading a+        -- provider's 401 back.+        let name = "BAIKAI_HELPERS_EMPTY_KEY"+        withUnsetEnv name $ do+          Environment.setEnv name ""+          thrown <- Exception.try (resolveApiKey (ApiKeyEnv name)) :: IO (Either BaikaiError Text)+          case thrown of+            Left err -> do+              err ^. #category @?= AuthError+              assertBool+                ("message should name the variable: " <> Text.unpack (err ^. #message))+                (Text.pack name `Text.isInfixOf` (err ^. #message))+              assertBool+                ("message should say it is empty: " <> Text.unpack (err ^. #message))+                ("empty" `Text.isInfixOf` (err ^. #message))+            Right key -> assertFailure ("expected auth error, got key: " <> Text.unpack key),+      testCase "ApiKeyEnv rejects a whitespace-only variable" $ do+        let name = "BAIKAI_HELPERS_BLANK_KEY"+        withUnsetEnv name $ do+          Environment.setEnv name "   "+          thrown <- Exception.try (resolveApiKey (ApiKeyEnv name)) :: IO (Either BaikaiError Text)+          case thrown of+            Left err -> err ^. #category @?= AuthError+            Right key -> assertFailure ("expected auth error, got key: " <> Text.unpack key),+      testCase "ApiKeyEnv passes a real value through untrimmed" $ do+        -- Only a blank value counts as unset. Trimming a real key would+        -- be a second, unrelated behaviour change, and one that could+        -- break a key whose edge character matters.+        let name = "BAIKAI_HELPERS_PADDED_KEY"+        withUnsetEnv name $ do+          Environment.setEnv name " sk-padded "+          resolved <- resolveApiKey (ApiKeyEnv name)+          resolved @?= " sk-padded ",+      testCase "ApiKeyEnvChain skips a variable set to the empty string" $ do+        let first = "BAIKAI_HELPERS_CHAIN_EMPTY_A"+            second = "BAIKAI_HELPERS_CHAIN_EMPTY_B"+        withUnsetEnv first $+          withUnsetEnv second $ do+            Environment.setEnv first ""+            Environment.setEnv second "second-key"+            resolved <- resolveApiKey (ApiKeyEnvChain [first, second])+            resolved @?= "second-key",+      testCase "ApiKeyEnvChain reports every name when all are empty" $ do+        let first = "BAIKAI_HELPERS_CHAIN_ALL_EMPTY_A"+            second = "BAIKAI_HELPERS_CHAIN_ALL_EMPTY_B"+        withUnsetEnv first $+          withUnsetEnv second $ do+            Environment.setEnv first ""+            Environment.setEnv second ""+            thrown <- Exception.try (resolveApiKey (ApiKeyEnvChain [first, second])) :: IO (Either BaikaiError Text)+            case thrown of+              Left err -> do+                err ^. #category @?= AuthError+                assertBool+                  "message should include first name"+                  (Text.pack first `Text.isInfixOf` (err ^. #message))+                assertBool+                  "message should include second name"+                  (Text.pack second `Text.isInfixOf` (err ^. #message))+                assertBool+                  ("message should explain that empty counts as unset: " <> Text.unpack (err ^. #message))+                  ("empty" `Text.isInfixOf` (err ^. #message))+              Right key -> assertFailure ("expected auth error, got key: " <> Text.unpack key),       testCase "mkModel fills dispatch discriminators and defaults" $ do         let model = mkModel OpenAIChatCompletions "gpt-test" "https://example.test"         model ^. #api @?= OpenAIChatCompletions@@ -177,12 +273,10 @@   responsesRef <- newIORef responses   callsRef <- newIORef 0   registerApiProviderWith reg $-    ApiProvider-      { apiTag = helpersApi,-        complete = scriptedComplete responsesRef callsRef,-        stream = \_ _ _ -> Stream.fromList events,-        describeThinking = \_ _ -> noThinkingRequested-      }+    apiProviderWith+      helpersApi+      (\_ _ _ -> Stream.fromList events)+      (scriptedComplete responsesRef callsRef)   pure Scripted {scriptRegistry = reg, scriptCallRef = callsRef}  scriptedComplete :: IORef [Response] -> IORef Int -> Model -> Context -> Options -> IO Response@@ -204,30 +298,25 @@ registerOneShot :: Api -> Response -> IO () registerOneShot apiTag resp =   registerApiProvider-    ApiProvider-      { apiTag,-        complete = \model _ctx _opts -> pure (stampModel model resp),-        stream = \_ _ _ -> Stream.fromList [],-        describeThinking = \_ _ -> noThinkingRequested-      }+    ( apiProviderWith+        apiTag+        (\_ _ _ -> Stream.fromList [])+        (\model _ctx _opts -> pure (stampModel model resp))+    )  oneShotProvider :: Api -> Text -> ApiProvider oneShotProvider apiTag body =-  ApiProvider-    { apiTag,-      complete = \model _ctx _opts -> pure (stampModel model (textResponse body)),-      stream = \_ _ _ -> Stream.fromList [],-      describeThinking = \_ _ -> noThinkingRequested-    }+  apiProviderWith+    apiTag+    (\_ _ _ -> Stream.fromList [])+    (\model _ctx _opts -> pure (stampModel model (textResponse body)))  errorProvider :: Api -> BaikaiError -> ApiProvider errorProvider apiTag err =-  ApiProvider-    { apiTag,-      complete = \model _ctx _opts -> pure (errorResponse model epoch 0 err),-      stream = \_ _ _ -> Stream.fromList [],-      describeThinking = \_ _ -> noThinkingRequested-    }+  apiProviderWith+    apiTag+    (\_ _ _ -> Stream.fromList [])+    (\model _ctx _opts -> pure (errorResponse model epoch 0 err))  stampModel :: Model -> Response -> Response stampModel model resp =
test/Main.hs view
@@ -1,3 +1,6 @@+{-# LANGUAGE AllowAmbiguousTypes #-}+{-# LANGUAGE UndecidableInstances #-}+ module Main (main) where  import AgentAssetsSpec qualified@@ -8,10 +11,15 @@ import CatalogSpec qualified import CliInternalSpec qualified import ContextSpec qualified+import Control.Monad (forM_) import CostSpec qualified import Data.Aeson qualified as Aeson import Data.ByteString.Char8 qualified as BS8 import Data.ByteString.Lazy.Char8 qualified as LBS8+import Data.Kind (Type)+import Data.List (isInfixOf)+import Data.Map.Strict qualified as Map+import Data.Proxy (Proxy (..)) import Data.Text qualified as Text import Data.Vector qualified as V import EmbeddingSpec qualified@@ -19,19 +27,24 @@ import ErrorSpec qualified import EvidenceSpec qualified import FetchModelsSpec qualified+import GHC.Generics (C1, D1, Rep, S1, Selector (selName), (:*:)) import GenModelsSpec qualified import HelpersSpec qualified import InteractiveSpec qualified+import PublicSurfaceSpec qualified import StreamSpec qualified+import StreamWorkerSpec qualified import Streamly.Data.Stream qualified as Stream import StrictEvidenceSpec qualified import SurfaceSpec qualified import Test.Tasty (TestTree, defaultMain, testGroup)-import Test.Tasty.HUnit (assertBool, testCase, (@?=))+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=)) import Test.Tasty.QuickCheck (Gen) import Test.Tasty.QuickCheck qualified as QC import ThinkingLevelSpec qualified import TraceSpec qualified+import TransportClassifySpec qualified+import UrlSpec qualified import UsageSpec qualified  -- | Ground the test provider on a 'Custom' API tag so it does not@@ -78,12 +91,11 @@             .~ testApi             & #provider             .~ providerName-   in ApiProvider-        { apiTag = testApi,-          stream = liftCompleteToStream handler,-          complete = handler,-          describeThinking = \_ _ -> noThinkingRequested-        }+   in ( apiProviderWith+          testApi+          (liftCompleteToStream handler)+          (handler)+      )  main :: IO () main = do@@ -106,18 +118,22 @@         GenModelsSpec.tests,         HelpersSpec.tests,         InteractiveSpec.tests,+        PublicSurfaceSpec.tests,         StreamSpec.tests,+        StreamWorkerSpec.tests,         StrictEvidenceSpec.tests,         SurfaceSpec.tests,         ThinkingLevelSpec.tests,         TraceSpec.tests,+        TransportClassifySpec.tests,+        UrlSpec.urlTests,         UsageSpec.tests       ]  tests :: TestTree tests =   testGroup-    "baikai EP-2"+    "baikai core"     [ testCase "emptyContext defaults are zero-y" $ do         emptyContext ^. #systemPrompt @?= Nothing         V.length (emptyContext ^. #messages) @?= 0,@@ -133,9 +149,25 @@               Aeson.object                 [ "type" Aeson..= ("object" :: Text)                 ]-            schemaFmt = JsonSchema {name = "person", schema = person, strict = True}+            schemaFmt = JsonSchema (jsonSchemaFormat "person" person) {strict = True}         responseFormat (emptyOptions & #responseFormat .~ Just schemaFmt)           @?= Just schemaFmt,+      -- The wire shape is pinned, not merely round-tripped: 'Options'+      -- derives 'ToJSON' through it and at least one consumer keys a+      -- cache on the result, so moving the three fields onto+      -- 'JsonSchemaFormat' must not move them in JSON.+      testCase "ResponseFormat keeps its flat JSON encoding" $ do+        Aeson.toJSON (JsonSchema (jsonSchemaFormat "o" Aeson.Null))+          @?= Aeson.object+            [ "tag" Aeson..= ("JsonSchema" :: Text),+              "name" Aeson..= ("o" :: Text),+              "schema" Aeson..= Aeson.Null,+              "strict" Aeson..= False+            ]+        Aeson.toJSON JsonObject @?= Aeson.object ["tag" Aeson..= ("JsonObject" :: Text)]+        let strictFmt = JsonSchema (jsonSchemaFormat "o" Aeson.Null) {strict = True}+        Aeson.decode (Aeson.encode strictFmt) @?= Just strictFmt+        Aeson.decode (Aeson.encode JsonObject) @?= Just JsonObject,       testCase "Options Show redacts literal API keys" $ do         let secret = "sk-baikai-secret-never-print"             opts = emptyOptions & #apiKey .~ Just (ApiKeyLiteral secret)@@ -148,6 +180,63 @@         assertBool           "Aeson.encode opts must not contain the raw API key"           (not (secret `Text.isInfixOf` Text.pack (LBS8.unpack (Aeson.encode opts)))),+      testCase "Options Show and JSON redact credential headers" $ do+        -- Options.headers is documented as the place to put a gateway's+        -- own Authorization header, and the guides tell people to print+        -- a response. Both of those are fine; printing the credential+        -- is not.+        let opts = emptyOptions & #headers .~ credentialHeaders+            shown = Text.pack (show opts)+            encoded = Text.pack (LBS8.unpack (Aeson.encode opts))+        forM_ [shown, encoded] $ \rendered -> do+          assertBool+            ("the bearer token must not appear: " <> Text.unpack rendered)+            (not ("sk-live-secret" `Text.isInfixOf` rendered))+          assertBool+            ("the subscription key must not appear: " <> Text.unpack rendered)+            (not ("azure-secret" `Text.isInfixOf` rendered))+          assertBool+            ("an ordinary header still appears: " <> Text.unpack rendered)+            ("my app" `Text.isInfixOf` rendered)+          Text.count redactedMarker rendered @?= 2+        -- Redaction is about rendering, never about the value.+        Map.lookup "Authorization" (opts ^. #headers)+          @?= Just "Bearer sk-live-secret",+      testCase "Model and Response Show redact credential headers" $ do+        -- A Model is embedded in every Response, so `print resp` is the+        -- likeliest way a credential reaches a log.+        let m = emptyModel & #headers .~ credentialHeaders+            resp = emptyResponse & #model .~ m+        forM_ [Text.pack (show m), Text.pack (show resp)] $ \rendered -> do+          assertBool+            ("the bearer token must not appear: " <> Text.unpack rendered)+            (not ("sk-live-secret" `Text.isInfixOf` rendered))+          assertBool+            ("the redaction marker appears: " <> Text.unpack rendered)+            (redactedMarker `Text.isInfixOf` rendered),+      testCase "a Model round-tripped through JSON carries the marker, not the key" $ do+        -- Deliberately lossy: a serialised Model is exactly the thing+        -- that should not carry a key.+        let m = emptyModel & #headers .~ credentialHeaders+        case Aeson.decode (Aeson.encode m) :: Maybe Model of+          Nothing -> assertFailure "a redacted Model must still parse"+          Just decoded -> do+            Map.lookup "Authorization" (decoded ^. #headers) @?= Just redactedMarker+            Map.lookup "X-Title" (decoded ^. #headers) @?= Just "my app",+      testCase "Options and Model Show list every field" $ do+        -- The drift guard for the two hand-written Show instances: a+        -- field added later must fail here rather than quietly vanish+        -- from `show`.+        let shownOptions = show emptyOptions+            shownModel = show emptyModel+        forM_ (fieldNames @Options) $ \name ->+          assertBool+            ("Options Show omits the field " <> name)+            ((name <> " = ") `isInfixOf` shownOptions)+        forM_ (fieldNames @Model) $ \name ->+          assertBool+            ("Model Show omits the field " <> name)+            ((name <> " = ") `isInfixOf` shownModel),       testCase "completeRequest dispatches through the registered handler" $ do         let ctx = emptyContext & #messages .~ V.fromList [user "ping"]         resp <- completeRequest testModel ctx emptyOptions@@ -204,12 +293,24 @@           ^. #thinkingFormat           @?= ThinkingFormatOpenRouter         autoDetectOpenAICompletions ""-          @?= defaultOpenAICompletionsCompat,+          @?= defaultOpenAICompletionsCompat+        -- An "@" after the authority names nothing. A parser that took+        -- the text after the last "@" anywhere would hand a proxy the+        -- vendor's own compatibility record, and then its key.+        autoDetectOpenAICompletions "https://proxy.example.com/v1?u=@api.deepseek.com"+          @?= defaultOpenAICompletionsCompat+        urlHost "https://proxy.example.com/v1?u=@api.deepseek.com"+          @?= Just "proxy.example.com",       QC.testProperty "unknown OpenAI host suffixes use defaults" $         QC.forAll unknownHostGen $ \host ->           QC.property $             autoDetectOpenAICompletions ("https://" <> Text.pack host)               == defaultOpenAICompletionsCompat,+      QC.testProperty "no trailing @-suffix can rename a host" $+        QC.forAll ((,) <$> unknownHostGen <*> QC.elements atSuffixes) $ \(host, suffix) ->+          QC.property $+            urlHost ("https://" <> Text.pack host <> suffix)+              == Just (Text.pack host),       testCase "default API-key env table matches known hosts" $ do         defaultApiKeyEnvForBaseUrl "https://api.deepseek.com/v1"           @?= Just "DEEPSEEK_API_KEY"@@ -220,7 +321,20 @@         defaultApiKeyEnvForBaseUrl "https://api.xyz.ai"           @?= Nothing         defaultApiKeyEnvForBaseUrl ""-          @?= Nothing,+          @?= Nothing+        -- The credential-misdirection case. Every one of these named a+        -- known vendor host before the authority was bounded properly,+        -- so each resolved that vendor's key and sent it to the proxy.+        defaultApiKeyEnvForBaseUrl "https://proxy.example.com/v1?u=@api.openai.com"+          @?= Nothing+        defaultApiKeyEnvForBaseUrl "https://proxy.example.com?u=@api.anthropic.com"+          @?= Nothing+        defaultApiKeyEnvForBaseUrl "https://proxy.example.com#@api.deepseek.com"+          @?= Nothing+        -- And the benign case the same defect broke in the other+        -- direction: an "@" in the path is part of the path.+        defaultApiKeyEnvForBaseUrl "https://api.openai.com/v1/@x"+          @?= Just "OPENAI_API_KEY",       testCase "explicit OpenAI compat overrides baseUrl auto-detection" $ do         let explicit =               defaultOpenAICompletionsCompat@@ -249,32 +363,29 @@         compat ^. #supportsCacheControlOnTools @?= False         compat ^. #sendSessionAffinityHeaders @?= True         compat ^. #supportsLongCacheRetention @?= False-        compat ^. #thinkingStyle @?= AnthropicThinkingBudget,-      testCase "Anthropic compat defaults thinking style by model generation" $ do-        anthropicMessagesCompatFor anthropic_claude_opus_4_6-          ^. #thinkingStyle-          @?= AnthropicThinkingAdaptive-        anthropicMessagesCompatFor anthropic_claude_opus_4_7-          ^. #thinkingStyle-          @?= AnthropicThinkingAdaptive-        anthropicMessagesCompatFor anthropic_claude_opus_4_8-          ^. #thinkingStyle-          @?= AnthropicThinkingAdaptive-        anthropicMessagesCompatFor anthropic_claude_fable_5-          ^. #thinkingStyle-          @?= AnthropicThinkingAdaptive-        anthropicMessagesCompatFor anthropic_claude_haiku_4_5-          ^. #thinkingStyle-          @?= AnthropicThinkingBudget-        anthropicMessagesCompatFor anthropic_claude_opus_4_5-          ^. #thinkingStyle-          @?= AnthropicThinkingBudget-        anthropicMessagesCompatFor anthropic_claude_sonnet_4_5-          ^. #thinkingStyle-          @?= AnthropicThinkingBudget-        anthropicMessagesCompatFor anthropic_claude_sonnet_4_6-          ^. #thinkingStyle-          @?= AnthropicThinkingBudget,+        compat ^. #thinkingStyle @?= AnthropicThinkingBudget+        compat ^. #supportsSamplingParameters @?= True,+      testCase "Anthropic catalog compat records carry thinking style and sampling support" $ do+        let facts m =+              let c = anthropicMessagesCompatFor m+               in (c ^. #thinkingStyle, c ^. #supportsSamplingParameters)+        facts anthropic_claude_fable_5 @?= (AnthropicThinkingAdaptive, False)+        facts anthropic_claude_haiku_4_5 @?= (AnthropicThinkingBudget, True)+        facts anthropic_claude_opus_4_5 @?= (AnthropicThinkingBudget, True)+        facts anthropic_claude_opus_4_6 @?= (AnthropicThinkingAdaptive, True)+        facts anthropic_claude_opus_4_7 @?= (AnthropicThinkingAdaptive, False)+        facts anthropic_claude_opus_4_8 @?= (AnthropicThinkingAdaptive, False)+        facts anthropic_claude_sonnet_4_5 @?= (AnthropicThinkingBudget, True)+        facts anthropic_claude_sonnet_4_6 @?= (AnthropicThinkingAdaptive, True)+        facts anthropic_claude_sonnet_5 @?= (AnthropicThinkingAdaptive, False),+      testCase "a hand-rolled Anthropic model with CompatNone gets budget style and sampling supported" $ do+        -- The model id names an adaptive-era generation, but nothing+        -- reads it: a generation's wire facts are a field of the+        -- catalog record, and a hand-rolled model carries none.+        let handRolled = mkModel AnthropicMessages "claude-sonnet-5" ""+            compat = anthropicMessagesCompatFor handRolled+        compat ^. #thinkingStyle @?= AnthropicThinkingBudget+        compat ^. #supportsSamplingParameters @?= True,       testCase "user smart constructor produces a UserMessage" $ do         let ts = read "2026-06-05 01:02:03 UTC"         case userAt ts "hello" of@@ -366,3 +477,50 @@ unknownHostGen = do   label <- QC.listOf1 (QC.elements (['a' .. 'z'] <> ['0' .. '9']))   pure (label <> ".example.invalid")++-- | A header map with two credential-carrying names, spelled the way a+-- gateway would, and one ordinary header that must survive redaction.+credentialHeaders :: Map.Map HeaderName Text+credentialHeaders =+  Map.fromList+    [ ("Authorization", "Bearer sk-live-secret"),+      ("X-Title", "my app"),+      ("Ocp-Apim-Subscription-Key", "azure-secret")+    ]++-- | The record field names of a type, read off its 'Generic'+-- representation.+--+-- This exists to guard the two hand-written 'Show' instances on+-- 'Options' and 'Model': they list their fields by hand, so a field+-- added later would silently stop being printed. Asking the compiler+-- what the fields actually are turns that into a test failure that names+-- the missing one.+class GFieldNames (f :: Type -> Type) where+  gFieldNames :: Proxy f -> [String]++instance (GFieldNames f) => GFieldNames (D1 m f) where+  gFieldNames _ = gFieldNames (Proxy @f)++instance (GFieldNames f) => GFieldNames (C1 m f) where+  gFieldNames _ = gFieldNames (Proxy @f)++instance (GFieldNames f, GFieldNames g) => GFieldNames (f :*: g) where+  gFieldNames _ = gFieldNames (Proxy @f) <> gFieldNames (Proxy @g)++instance (Selector m) => GFieldNames (S1 m f) where+  gFieldNames _ = [selName (undefined :: S1 m f ())]++fieldNames :: forall a. (GFieldNames (Rep a)) => [String]+fieldNames = gFieldNames (Proxy @(Rep a))++-- | Ways of writing another provider's host after the authority ends.+-- None of them may change which host a URL names, because which host a+-- URL names is which key baikai sends.+atSuffixes :: [Text]+atSuffixes =+  [ "/v1?u=@api.openai.com",+    "/@api.anthropic.com",+    "?x=@api.deepseek.com",+    "#@openrouter.ai"+  ]
+ test/PublicSurfaceSpec.hs view
@@ -0,0 +1,88 @@+{-# LANGUAGE OverloadedRecordDot #-}++-- | A downstream consumer's view of baikai, compiled.+--+-- This module imports __only__ modules a published consumer can import:+-- no @Baikai.Prelude@, no @Control.Lens@, no generic-lens, no+-- @.Internal@ module. Everything it does, it does with record update,+-- plain selectors and the exported base values.+--+-- Its value is that it compiles. Plan 43 chose compile-time probes over+-- a golden @:browse@ dump, because a dump goes stale silently while a+-- module that sees what a downstream sees fails the build the moment a+-- name a consumer needs stops being exported — or the moment a record+-- can no longer be built without the constructor this release hid.+--+-- It exports one 'TestTree' so the suite runs the few facts that are+-- cheap to assert here; the compilation is the real test.+module PublicSurfaceSpec (tests) where++import Baikai+import Baikai.Cost.Log (CallLogConfig (enabled, path), callLogConfig)+import Baikai.Embedding qualified as Embedding+import Data.Aeson (Value (Null))+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Vector qualified as V+import Streamly.Data.Stream qualified as Stream+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "public surface"+    [ testCase "every hidden record is buildable with record update alone" $ do+        probeTool.name @?= "probe"+        probeLog.path @?= "/dev/null"+        probeLog.enabled @?= True+        Embedding.modelId probeEmbedding @?= "text-embedding-probe"+        headerCount @?= 1,+      testCase "a provider registered from apiProvider dispatches" $ do+        reg <- newProviderRegistryFrom [probeProvider]+        resp <- completeRequestWith reg probeModel probeContext probeOptions+        -- The stream is empty, so reassembly produces a response with no+        -- content and no error. What matters is that dispatch found the+        -- handler and that a consumer could build it.+        assertBool "the call produced no error" (responseError resp == Nothing)+    ]++-- | Built from 'apiProvider' — re-exported by the umbrella — not from a+-- constructor.+probeProvider :: ApiProvider+probeProvider = apiProvider (Custom "public-surface-probe") (\_ _ _ -> Stream.fromList [])++probeModel :: Model+probeModel =+  emptyModel+    { modelId = "probe-model",+      api = Custom "public-surface-probe",+      provider = "probe"+    }++probeContext :: Context+probeContext = emptyContext {messages = V.singleton (user "hello")}++probeOptions :: Options+probeOptions = emptyOptions {maxTokens = Just 16}++probeTool :: Tool+probeTool = mkTool "probe" "a probe" Null++probeLog :: CallLogConfig+probeLog = callLogConfig "/dev/null"++-- Qualified because @modelId@ alone does not name a type: 'Model',+-- 'EmbeddingModel' and 'InteractiveLaunchRequest' all have it, and under+-- @DuplicateRecordFields@ a record update whose fields do not determine+-- the datatype is ambiguous. Hiding constructors did not cause that and+-- does not change it; a consumer either qualifies, as here, or reaches+-- for generic-lens.+probeEmbedding :: Embedding.EmbeddingModel+probeEmbedding =+  Embedding.emptyEmbeddingModel {Embedding.modelId = "text-embedding-probe"}++-- | Two spellings of one header name, through the public 'HeaderName'.+headerCount :: Int+headerCount =+  Map.size (Map.fromList [("X-Probe", "a"), ("x-probe", "b")] :: Map.Map HeaderName Text)
test/StreamSpec.hs view
@@ -5,6 +5,7 @@ import Control.Concurrent (forkIO, newEmptyMVar, putMVar, takeMVar, threadDelay, throwTo) import Control.Exception qualified as Exception import Data.Aeson qualified as Aeson+import Data.IORef (modifyIORef', newIORef, readIORef) import Data.Time (UTCTime) import Data.Vector qualified as Vector import Streamly.Data.Stream qualified as Stream@@ -138,7 +139,15 @@                 ]         resp ^. #message ^. #content @?= expected         resp ^. #message ^. #stopReason @?= Stop-        resp ^. #message ^. #errorMessage @?= Just "stream ended without terminal event",+        resp ^. #message ^. #errorMessage @?= Just "stream ended without terminal event"+        -- The recovered call is the same shape the two provider+        -- assemblers now produce for a cut-off call, and it says so.+        [tc | AssistantToolCall tc <- Vector.toList (resp ^. #message ^. #content)]+          @?= [ToolCall {id_ = "", name = "", arguments = Aeson.String "{\"a\":1"}]+        assertBool+          "a flushed dangling tool call is marked cut off"+          (all isCutOffToolCall [tc | AssistantToolCall tc <- Vector.toList (resp ^. #message ^. #content)]),+      cutOffToolCallIsNeverDispatchedTest,       testCase "latencyMs is clamped at zero" $ do         let oldResponse =               responseWith Nothing [AssistantText (TextContent "old")]@@ -147,6 +156,11 @@             handler _ _ _ = pure oldResponse         resp <- streamingComplete (liftCompleteToStream handler) streamModel streamContext streamOptions         assertBool "latencyMs should be non-negative" (resp ^. #latencyMs >= 0),+      duplicateStartTest,+      eventsAfterTerminalTest,+      failedTerminalAppendsDanglingTest,+      emptySuccessfulTerminalFallsBackTest,+      wallClockLatencyTest,       testCase "async exceptions pass through liftCompleteToStream" $ do         done <- newEmptyMVar         let blocked _ _ _ = threadDelay (10 * 1000 * 1000) *> pure (responseWith Nothing [])@@ -184,3 +198,193 @@             be ^. #retryAfterSeconds @?= Just 5           Nothing -> assertFailure "expected lifted BaikaiError to survive reassembly"     ]++-- | A tool call the model never finished asking for is not executed.+--+-- Both halves: 'runToolLoopWith' stops with the response intact rather+-- than dispatching, and 'appendToolResult' -- the documented direct+-- round-trip, which a caller drives by hand -- appends an error result+-- without calling the dispatcher either.+cutOffToolCallIsNeverDispatchedTest :: TestTree+cutOffToolCallIsNeverDispatchedTest =+  testCase "a cut-off tool call is never dispatched" $ do+    let cutOffCall = ToolCall {id_ = "call_1", name = "search", arguments = Aeson.String "{\"a\":1"}+        -- 'Length' is what a real cut-off carries; the guard does not+        -- rely on it, because a compatible host can report+        -- @finish_reason: tool_calls@ for truncated arguments.+        cutOffResponse =+          emptyResponse+            & #message+            .~ assistantPayload (Vector.singleton (AssistantToolCall cutOffCall)) Length Nothing epoch+            & #model+            .~ cutOffModel+            & #api+            .~ cutOffApi+            & #provider+            .~ "stream-spec"+    reg <- newProviderRegistry+    registerApiProviderWith+      reg+      ( apiProviderWith+          cutOffApi+          (liftCompleteToStream (\_ _ _ -> pure cutOffResponse))+          (\_ _ _ -> pure cutOffResponse)+      )+    dispatched <- newIORef ([] :: [ToolCall])+    let dispatcher tc = modifyIORef' dispatched (<> [tc]) >> pure (toolResultText "never")++    (_, looped) <- runToolLoopWith reg 4 dispatcher cutOffModel streamContext streamOptions+    looped ^. #message ^. #content @?= Vector.singleton (AssistantToolCall cutOffCall)+    looped ^. #message ^. #stopReason @?= Length+    readIORef dispatched >>= \calls -> calls @?= []++    ctx' <- appendToolResult streamContext cutOffResponse dispatcher+    readIORef dispatched >>= \calls -> calls @?= []+    case Vector.toList (ctx' ^. #messages) of+      [_assistant, ToolResultMessage p] -> do+        p ^. #isError @?= True+        p ^. #toolCallId @?= "call_1"+      other -> assertFailure ("expected the assistant message then one tool result, got: " <> show (length other))++-- | Its own tag, so this case cannot collide with the module's other+-- registrations when the suite runs in one process.+cutOffApi :: Api+cutOffApi = Custom "baikai-stream-spec-cutoff"++cutOffModel :: Model+cutOffModel =+  emptyModel+    & #modelId+    .~ "stream-spec-cutoff-model"+    & #api+    .~ cutOffApi+    & #provider+    .~ "stream-spec"++-- | A duplicated start does not rewrite the assembly.+--+-- First skeleton wins, so the latency window is measured from the first+-- event the provider actually sent; @responseId@ merges, so a later+-- 'Nothing' cannot erase an id an earlier event supplied.+duplicateStartTest :: TestTree+duplicateStartTest =+  testCase "a duplicate EventStart keeps the first skeleton and merges responseId" $ do+    let firstSkeleton = AssistantMessage (assistantPayload Vector.empty Stop Nothing later)+        staleSkeleton = AssistantMessage (assistantPayload Vector.empty Stop Nothing epoch)+    resp <-+      runEvents+        [ EventStart StartPayload {partial = firstSkeleton, responseId = Just "msg_1"},+          EventStart StartPayload {partial = staleSkeleton, responseId = Nothing},+          EventDone+            ( doneTerminal+                Nothing+                Nothing+                Stop+                (AssistantMessage (assistantPayload (Vector.singleton (AssistantText (TextContent "hi"))) Stop Nothing muchLater))+            )+        ]+    resp ^. #responseId @?= Just "msg_1"+    -- Measured from the first skeleton's timestamp, not the stale one:+    -- the stale skeleton is at the epoch, which would give a latency of+    -- decades.+    resp ^. #latencyMs @?= 2000++-- | The first terminal wins. A producer that keeps talking afterwards+-- cannot rewrite the answer a consumer has already been handed.+eventsAfterTerminalTest :: TestTree+eventsAfterTerminalTest =+  testCase "events after the terminal are ignored" $ do+    resp <-+      runEvents+        [ startEvent Nothing,+          doneEvent Nothing [AssistantText (TextContent "final")],+          TextStart IndexPayload {contentIndex = 5},+          TextDelta DeltaPayload {contentIndex = 5, delta = "late"},+          EventError+            ( errorTerminal+                Nothing+                Nothing+                ErrorReason+                (AssistantMessage (assistantPayload Vector.empty ErrorReason (Just "too late") epoch))+                (providerError "too late")+            )+        ]+    resp ^. #message ^. #content @?= Vector.singleton (AssistantText (TextContent "final"))+    resp ^. #message ^. #stopReason @?= Stop+    resp ^. #errorInfo @?= Nothing++-- | A failed terminal's own content comes first and the blocks that were+-- still open are appended after it. Safe because an open index is always+-- greater than every closed one.+failedTerminalAppendsDanglingTest :: TestTree+failedTerminalAppendsDanglingTest =+  testCase "a failed terminal appends dangling blocks after its content" $ do+    resp <-+      runEvents+        [ startEvent Nothing,+          TextStart IndexPayload {contentIndex = 0},+          TextDelta DeltaPayload {contentIndex = 0, delta = "closed"},+          TextEnd BlockEndPayload {contentIndex = 0, content = "closed"},+          ThinkingStart IndexPayload {contentIndex = 1},+          ThinkingDelta DeltaPayload {contentIndex = 1, delta = "half a thought"},+          EventError+            ( errorTerminal+                Nothing+                Nothing+                ErrorReason+                (AssistantMessage (assistantPayload (Vector.singleton (AssistantText (TextContent "closed"))) ErrorReason (Just "boom") epoch))+                (providerError "boom")+            )+        ]+    resp ^. #message ^. #content+      @?= Vector.fromList+        [ AssistantText (TextContent "closed"),+          AssistantThinking ThinkingContent {thinking = "half a thought", signature = Nothing, redacted = False}+        ]++-- | A terminal that carries no content is not authoritative about+-- content: the blocks the stream assembled are.+emptySuccessfulTerminalFallsBackTest :: TestTree+emptySuccessfulTerminalFallsBackTest =+  testCase "a successful terminal with empty content falls back to the assembled blocks" $ do+    resp <-+      runEvents+        [ startEvent Nothing,+          TextStart IndexPayload {contentIndex = 0},+          TextDelta DeltaPayload {contentIndex = 0, delta = "assembled"},+          TextEnd BlockEndPayload {contentIndex = 0, content = "assembled"},+          doneEvent Nothing []+        ]+    resp ^. #message ^. #content @?= Vector.singleton (AssistantText (TextContent "assembled"))++-- | With no provider timestamps, latency is the window this fold saw+-- rather than a zero that reads as "instant".+wallClockLatencyTest :: TestTree+wallClockLatencyTest =+  testCase "latencyMs falls back to the wall clock when timestamps are absent" $ do+    let untimed sr err blocks =+          AssistantMessage+            AssistantPayload+              { content = Vector.fromList blocks,+                usage = zeroUsage,+                stopReason = sr,+                errorMessage = err,+                timestamp = Nothing+              }+        events =+          [ EventStart StartPayload {partial = untimed Stop Nothing [], responseId = Nothing},+            EventDone (doneTerminal Nothing Nothing Stop (untimed Stop Nothing [AssistantText (TextContent "slow")]))+          ]+    resp <-+      Stream.fold+        (reassembleResponse streamModel)+        (Stream.mapM (\e -> threadDelay 20000 >> pure e) (Stream.fromList events))+    assertBool+      ("expected a wall-clock latency of at least 20ms, got: " <> show (resp ^. #latencyMs))+      (resp ^. #latencyMs >= 20)++later :: UTCTime+later = read "2000-01-01 00:00:01 UTC"++muchLater :: UTCTime+muchLater = read "2000-01-01 00:00:03 UTC"
+ test/StreamWorkerSpec.hs view
@@ -0,0 +1,86 @@+-- | The bounded worker/consumer hand-off in+-- "Baikai.Provider.Internal.StreamWorker".+--+-- Both HTTP providers depend on the three properties pinned here: every+-- frame pushed before the close is delivered, a worker blocked on a full+-- queue is interruptible, and the queue closes however the body ends.+module StreamWorkerSpec (tests) where++import Baikai.Provider.Internal.StreamWorker+  ( FrameQueue,+    closeFrames,+    forkFrameWorker,+    frameQueueCapacity,+    newFrameQueue,+    pullFrame,+    pushFrame,+  )+import Control.Concurrent (forkIO, killThread, threadDelay)+import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar, tryTakeMVar)+import Control.Exception (finally)+import Control.Monad (forM_)+import Data.IORef (newIORef, readIORef, writeIORef)+import System.Timeout (timeout)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "Baikai.Provider.Internal.StreamWorker"+    [ deliversEveryFrameTest,+      blockedPushIsInterruptibleTest,+      killedBodyClosesQueueTest+    ]++-- | Ordering and completeness: the close flag never overtakes frames+-- already in the queue.+deliversEveryFrameTest :: TestTree+deliversEveryFrameTest =+  testCase "pullFrame delivers every frame pushed before close" $ do+    q <- newFrameQueue+    forM_ [1 :: Int .. 10] (pushFrame q)+    closeFrames q+    let drain acc =+          pullFrame q >>= \case+            Nothing -> pure (reverse acc)+            Just a -> drain (a : acc)+    got <- drain []+    got @?= [1 .. 10]++-- | A worker whose consumer has stopped parks on a full queue rather+-- than reading on, and the park is an interruptible STM wait, so+-- 'killThread' reaches it.+blockedPushIsInterruptibleTest :: TestTree+blockedPushIsInterruptibleTest =+  testCase "pushFrame blocks when the queue is full and is interruptible" $ do+    q <- newFrameQueue+    pushed <- newEmptyMVar+    diedRef <- newIORef False+    tid <- forkIO $ do+      ( do+          forM_ [1 .. fromIntegral frameQueueCapacity] (pushFrame q :: Int -> IO ())+          pushFrame q 0+          putMVar pushed ()+        )+        `finally` writeIORef diedRef True+    threadDelay 100000+    stillBlocked <- tryTakeMVar pushed+    stillBlocked @?= Nothing+    killThread tid+    threadDelay 50000+    died <- readIORef diedRef+    assertBool "the blocked pusher was interrupted" died++-- | The close flag is set by the fork's own @finally@, so a worker that+-- dies by asynchronous exception cannot leave the consumer waiting.+killedBodyClosesQueueTest :: TestTree+killedBodyClosesQueueTest =+  testCase "forkFrameWorker closes the queue when the body is killed" $ do+    q <- newFrameQueue :: IO (FrameQueue Int)+    blocked <- newEmptyMVar+    tid <- forkFrameWorker q (takeMVar blocked)+    threadDelay 20000+    killThread tid+    got <- timeout 1000000 (pullFrame q)+    got @?= Just Nothing
test/StrictEvidenceSpec.hs view
@@ -13,9 +13,11 @@ import Control.Exception (evaluate, try) import Control.Exception qualified as Exception import Control.Lens ((&), (.~), (^.))+import Data.Aeson qualified as Aeson import Data.Generics.Labels () import Data.Text (Text) import Data.Text qualified as Text+import Data.Time (getCurrentTime) import Data.Vector qualified as Vector import Streamly.Data.Stream qualified as Stream import Test.Tasty (TestTree, testGroup)@@ -88,7 +90,7 @@     [ testCase "a custom transport cannot supply model_observed" $         case checkEvidenceRequirements           (EvidenceRequired EvidenceModelObserved)-          (Custom "someone-elses-gateway")+          (declaredStrength (Custom "someone-elses-gateway"))           noThinkingRequested of           [StrengthUnreachable needed declared] -> do             needed @?= EvidenceModelObserved@@ -97,14 +99,14 @@       testCase "the codex CLI cannot supply model_observed, because it names no model" $         case checkEvidenceRequirements           (EvidenceRequired EvidenceModelObserved)-          OpenAICompletionsCli+          (declaredStrength OpenAICompletionsCli)           noThinkingRequested of           [StrengthUnreachable _ declared] -> declared @?= EvidenceCorrelated           other -> assertFailure ("expected one StrengthUnreachable, got: " <> show other),       testCase "the codex CLI can supply correlated" $         checkEvidenceRequirements           (EvidenceRequired EvidenceCorrelated)-          OpenAICompletionsCli+          (declaredStrength OpenAICompletionsCli)           noThinkingRequested           @?= [],       testCase "an exactly-met requirement is not a refusal" $@@ -112,7 +114,7 @@         -- what was asked for satisfies it.         checkEvidenceRequirements           (EvidenceRequired EvidenceModelObserved)-          AnthropicMessages+          (declaredStrength AnthropicMessages)           noThinkingRequested           @?= [],       testCase "both halves of the gate report together, not one per attempt" $@@ -122,7 +124,7 @@         length           ( checkEvidenceRequirements               (EvidenceRequired EvidenceModelObserved)-              (Custom "gateway")+              (declaredStrength (Custom "gateway"))               (downgradedBy (EffortClamped ThinkingMax "high"))           )           @?= 2@@ -149,7 +151,7 @@   testCase name $     case checkEvidenceRequirements       (EvidenceRequired EvidenceRequestedOnly)-      AnthropicMessages+      (declaredStrength AnthropicMessages)       (downgradedBy adjustment) of       [ThinkingWouldDowngrade [reported]] -> do         reported @?= adjustment@@ -196,7 +198,7 @@       testCase "several downgrades on one call are reported together" $         case checkEvidenceRequirements           (EvidenceRequired EvidenceRequestedOnly)-          AnthropicMessages+          (declaredStrength AnthropicMessages)           ( noThinkingRequested               & #requested .~ Just ThinkingMax               & #adjustments@@ -210,16 +212,44 @@         -- no thinking level must still run.         checkEvidenceRequirements           (EvidenceRequired EvidenceModelObserved)-          AnthropicMessages+          (declaredStrength AnthropicMessages)           noThinkingRequested           @?= [],+      testCase "A DROPPED SAMPLING PARAMETER IS NOT A THINKING DOWNGRADE" $+        -- The documented contract is refusing a call that would weaken+        -- the requested thinking level. A sampling parameter the model+        -- generation or the API has nowhere to put is recorded in the+        -- evidence — that is what the adjustment is for — but it is not+        -- a thinking downgrade, and a caller who set `temperature` on a+        -- Claude model must not have every strict call refused over it.+        checkEvidenceRequirements+          (EvidenceRequired EvidenceRequestedOnly)+          (declaredStrength AnthropicMessages)+          ( noThinkingRequested+              & #adjustments .~ [SamplingDroppedUnsupportedModel ["temperature"]]+          )+          @?= [],+      testCase "a sampling drop alongside a real downgrade reports only the downgrade" $+        case checkEvidenceRequirements+          (EvidenceRequired EvidenceRequestedOnly)+          (declaredStrength AnthropicMessages)+          ( noThinkingRequested+              & #requested .~ Just ThinkingMax+              & #adjustments+                .~ [ EffortOmitted ThinkingMax,+                     SamplingDroppedUnsupportedModel ["temperature", "top_p"]+                   ]+          ) of+          [ThinkingWouldDowngrade reported] ->+            reported @?= [EffortOmitted ThinkingMax]+          other -> assertFailure ("expected one ThinkingWouldDowngrade, got: " <> show other),       testCase "a level expressed exactly is not a downgrade" $         -- The native OpenAI shape sends every canonical level verbatim         -- and codex accepts all six. Refusing those would reject the         -- configurations that honour the caller in full.         checkEvidenceRequirements           (EvidenceRequired EvidenceModelObserved)-          OpenAIChatCompletions+          (declaredStrength OpenAIChatCompletions)           ( noThinkingRequested               & #requested .~ Just ThinkingXHigh               & #mode .~ ThinkingModeAdaptive@@ -240,7 +270,7 @@     -- a sample is a promise about the sample.     "a best-effort caller is never refused, on any transport at any level"     [ testCase (Text.unpack (renderApi api) <> " / " <> label) $-        checkEvidenceRequirements EvidenceBestEffort api translation @?= []+        checkEvidenceRequirements EvidenceBestEffort (declaredStrength api) translation @?= []     | api <-         [ AnthropicMessages,           OpenAIChatCompletions,@@ -267,7 +297,9 @@                   EffortOmitted lvl,                   ThinkingDroppedUnsupportedModel lvl,                   ThinkingDroppedUnsupportedHost lvl,-                  ThinkingDroppedBudgetExceeded lvl 32000 8192+                  ThinkingDroppedBudgetExceeded lvl 32000 8192,+                  SamplingDroppedUnsupportedModel ["temperature"],+                  SamplingDroppedUnsupportedApi ["seed"]                 ]             ]     ]@@ -282,6 +314,8 @@   ThinkingDroppedUnsupportedModel {} -> "dropped-model"   ThinkingDroppedUnsupportedHost {} -> "dropped-host"   ThinkingDroppedBudgetExceeded {} -> "dropped-budget"+  SamplingDroppedUnsupportedModel {} -> "sampling-dropped-model"+  SamplingDroppedUnsupportedApi {} -> "sampling-dropped-api"  -- ============================================================ -- The gate does no work on the default path@@ -297,7 +331,16 @@     "the gate never computes a translation it does not need"     [ testCase "A BEST-EFFORT CALL NEVER FORCES THE TRANSLATION" $ do         outcome <--          try (evaluate (length (checkEvidenceRequirements EvidenceBestEffort AnthropicMessages explodes)))+          try+            ( evaluate+                ( length+                    ( checkEvidenceRequirements+                        EvidenceBestEffort+                        (declaredStrength AnthropicMessages)+                        explodes+                    )+                )+            )         case outcome :: Either Exception.SomeException Int of           Right n -> n @?= 0           Left e -> assertFailure ("the translation was forced: " <> show e),@@ -308,7 +351,7 @@                 ( length                     ( checkEvidenceRequirements                         (EvidenceRequired EvidenceRequestedOnly)-                        AnthropicMessages+                        (declaredStrength AnthropicMessages)                         explodes                     )                 )@@ -378,7 +421,49 @@         registerApiProviderWith reg countingProvider         resp <- completeRequestWith reg customModel testContext emptyOptions         responseError resp @?= Nothing-        flattenAssistantText (flattenAssistantBlocks resp) @?= "the provider ran"+        flattenAssistantText (flattenAssistantBlocks resp) @?= "the provider ran",+      testCase "a strict completeRequest with a record-less provider fails after the call" $ do+        -- The gate lets this through: a custom provider declaring+        -- requested_only can satisfy a requested_only requirement, and+        -- one that builds a minimal record does. This one does not, and+        -- the failure is caught at the terminal instead — with no sink+        -- anywhere, which is the point of enforcing at dispatch.+        reg <- newProviderRegistry+        registerApiProviderWith reg countingProvider+        resp <- completeRequestWith reg customModel testContext (strictly EvidenceRequestedOnly)+        case responseError resp of+          Nothing -> assertFailure "expected the missing record to fail the call"+          Just err ->+            assertBool+              ("the message names the missing record: " <> Text.unpack (err ^. #message))+              ("attached no evidence record" `Text.isInfixOf` (err ^. #message))+        -- The provider was reached and its content is kept, so a caller+        -- reading the failure can still see what came back.+        flattenAssistantText (flattenAssistantBlocks resp) @?= "the provider ran",+      testCase "A CUSTOM PROVIDER DECLARING correlated SATISFIES A STRICT correlated CALL" $ do+        -- Under the tag-keyed table this was impossible: every Custom+        -- transport was capped at requested_only whatever its evidence+        -- actually reached, so a gateway that observes a response id+        -- could never serve a strict correlated caller.+        reg <- newProviderRegistry+        registerApiProviderWith reg correlatingProvider+        resp <- completeRequestWith reg customModel testContext (strictly EvidenceCorrelated)+        responseError resp @?= Nothing+        case resp ^. #evidence of+          Nothing -> assertFailure "a strict caller opted into evidence and must get a record"+          Just ev -> ev ^. #strength @?= EvidenceCorrelated,+      testCase "a declaration is still a ceiling, not a blank cheque" $ do+        reg <- newProviderRegistry+        registerApiProviderWith reg correlatingProvider+        resp <- completeRequestWith reg customModel testContext (strictly EvidenceModelObserved)+        case responseError resp of+          Nothing -> assertFailure "expected a refusal"+          Just err ->+            assertBool+              ("the message names both strengths: " <> Text.unpack (err ^. #message))+              ( "model_observed" `Text.isInfixOf` (err ^. #message)+                  && "correlated" `Text.isInfixOf` (err ^. #message)+              )     ]  -- ============================================================@@ -406,26 +491,59 @@ bestEffortOptions :: Options bestEffortOptions = emptyOptions & #evidence .~ Just (evidenceRequest "run-57") +-- | A custom transport that declares, and delivers, 'EvidenceCorrelated'+-- — a response id it observed. Its ceiling is its own declaration, which+-- the tag-keyed table could never express.+correlatingProvider :: ApiProvider+correlatingProvider =+  apiProviderWith+    customApi+    (liftCompleteToStream handler)+    (handler)+    & #strengthCeiling .~ (EvidenceCorrelated)+  where+    handler m _ opts = do+      now <- getCurrentTime+      ev <-+        minimalEvidence+          m+          opts+          TransportHttpApi+          noThinkingRequested+          (Aeson.object ["model" Aeson..= (m ^. #modelId :: Text)])+          now+          now+          CallSucceeded+          Nothing+      let seen = Observed "gateway-response-1" :: Observed Text+          observed e =+            e+              & #responseId .~ seen+              & #strength .~ deriveStrength Unobserved Unobserved seen+      pure+        ( emptyResponse+            & #model .~ m+            & #evidence .~ fmap observed ev+            & #message . #content+              .~ Vector.singleton (AssistantText (TextContent "the provider ran"))+        )+ -- | A provider that fails loudly if it is reached. Used to prove the -- gate refuses /before/ dispatch rather than annotating afterwards. explodingProvider :: ApiProvider explodingProvider =-  ApiProvider-    { apiTag = customApi,-      stream = \_ _ _ -> error "the provider was dispatched despite a strict refusal",-      complete = \_ _ _ -> error "the provider was dispatched despite a strict refusal",-      describeThinking = \_ _ -> noThinkingRequested-    }+  apiProviderWith+    customApi+    (\_ _ _ -> error "the provider was dispatched despite a strict refusal")+    (\_ _ _ -> error "the provider was dispatched despite a strict refusal")  -- | The same shape, but it answers. countingProvider :: ApiProvider countingProvider =-  ApiProvider-    { apiTag = customApi,-      stream = liftCompleteToStream handler,-      complete = handler,-      describeThinking = \_ _ -> noThinkingRequested-    }+  apiProviderWith+    customApi+    (liftCompleteToStream handler)+    (handler)   where     handler m _ _ =       pure
test/SurfaceSpec.hs view
@@ -1,11 +1,13 @@ module SurfaceSpec (tests) where  import Baikai+import Baikai.Cost.Log (callLogConfig) import Baikai.Embedding qualified as Embedding import Baikai.Prelude import Data.Aeson qualified as Aeson import Data.Map.Strict qualified as Map import Data.Vector qualified as V+import Streamly.Data.Stream qualified as Stream import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (testCase, (@?=)) @@ -49,5 +51,24 @@         zeroModelCost ^. #inputCost @?= 0         emptyTool ^. #parameters @?= Aeson.Null         emptyToolCall ^. #arguments @?= Aeson.Null-        Embedding.modelId Embedding.emptyEmbeddingModel @?= ""+        Embedding.modelId Embedding.emptyEmbeddingModel @?= "",+      -- Every record whose constructor this release hid must still be+      -- reachable: build each from its exported base and read one field+      -- back. A base value that disappears, or a field that stops being+      -- exported, fails to compile here rather than at a consumer.+      testCase "hidden records build from their bases" $ do+        let provider = apiProvider (Custom "probe") (\_ _ _ -> Stream.nil)+            req = evidenceRequest "r" & #attempt .~ 2+            tool = mkTool "t" "d" Aeson.Null+            embedding = Embedding.emptyEmbeddingModel & #modelId .~ "e"+            logCfg = callLogConfig "/dev/null"+        provider ^. #apiTag @?= Custom "probe"+        provider ^. #strengthCeiling @?= EvidenceRequestedOnly+        req ^. #attempt @?= 2+        req ^. #runId @?= "r"+        tool ^. #name @?= "t"+        tool ^. #parameters @?= Aeson.Null+        embedding ^. #modelId @?= "e"+        logCfg ^. #path @?= "/dev/null"+        logCfg ^. #enabled @?= True     ]
test/ThinkingLevelSpec.hs view
@@ -10,6 +10,7 @@   testGroup     "ThinkingLevel"     [ testGroup "canonical rendering" renderTests,+      testGroup "canonical parsing" parseTests,       testGroup "token budgets" budgetTests     ] @@ -28,6 +29,18 @@   [ testCase name $ renderThinkingLevel level @?= expected   | (name, level, expected, _) <- levels   ]++-- | 'parseThinkingLevel' is the inverse of 'renderThinkingLevel' on+-- every level, which is what lets @baikai-agent@'s KDL decoder and the+-- evidence schema read the table instead of copying it.+parseTests :: [TestTree]+parseTests =+  [ testCase name $ do+      parseThinkingLevel expected @?= Just level+      parseThinkingLevel (renderThinkingLevel level) @?= Just level+  | (name, level, expected, _) <- levels+  ]+    <> [testCase "an unknown name is Nothing" $ parseThinkingLevel "enormous" @?= Nothing]  budgetTests :: [TestTree] budgetTests =
test/TraceSpec.hs view
@@ -1,15 +1,9 @@--- This module deliberately exercises 'newEventId', which is deprecated--- in favour of 'Baikai.Evidence.newCallId'. The alias is still part of--- the public surface, so it keeps a test; suppressing the warning here--- is narrower than dropping the coverage.-{-# OPTIONS_GHC -Wno-deprecations #-}- module TraceSpec (tests) where  import Baikai.Api (Api (..)) import Baikai.Content (AssistantContent (..), TextContent (..)) import Baikai.Context (Context (..), emptyContext)-import Baikai.Error (BaikaiError, providerError)+import Baikai.Error (BaikaiError, ErrorCategory (..), providerError) import Baikai.Evidence   ( ModelCallEvidence,     TransportKind (..),@@ -22,24 +16,28 @@ import Baikai.Model (Model (..), emptyModel) import Baikai.Options (Options, emptyOptions) import Baikai.Prelude-import Baikai.Provider (ApiProvider (..), registerApiProvider)+import Baikai.Provider (apiProviderWith, registerApiProvider) import Baikai.Response (Response (..), responseError) import Baikai.StopReason (StopReason (..)) import Baikai.Stream (liftCompleteToStream) import Baikai.Stream.Event (AssistantMessageEvent (..))-import Baikai.Trace (newEventId, withTrace, withTraceStream)+import Baikai.ThinkingLevel (ThinkingLevel (..))+import Baikai.Trace (withTrace, withTraceStream) import Baikai.Trace.Event (TraceEvent (..))-import Baikai.Trace.Sink (TraceSink (..), silent)+import Baikai.Trace.Sink (TraceSink (..), multiSink, silent) import Baikai.Usage (Usage, zeroUsage)-import Control.Concurrent (threadDelay)+import Control.Concurrent (forkIO, threadDelay, throwTo)+import Control.Concurrent.MVar (MVar, newEmptyMVar, putMVar, readMVar, takeMVar) import Control.Concurrent.STM (TVar, atomically, modifyTVar', newTVarIO, readTVarIO)-import Control.Exception (throwIO)-import Control.Monad (replicateM)+import Control.Exception (AsyncException (ThreadKilled), SomeException, throwIO, try)+import Control.Monad (forM_, replicateM) import Data.Aeson (Value (..)) import Data.Aeson qualified as Aeson import Data.Aeson.Key qualified as Key import Data.Aeson.KeyMap qualified as KeyMap import Data.ByteString.Lazy.Char8 qualified as BL8+import Data.Either (isLeft)+import Data.List (findIndex) import Data.Set qualified as Set import Data.Text qualified as Text import Data.Text.Encoding qualified as TextEncoding@@ -61,10 +59,18 @@       memoryFinishTest,       memoryFailTest,       throwingSinkTest,+      blockingSinkTest,+      blockingSinkStrictTest,+      multiSinkThrowingMemberTest,+      multiSinkBlockingMemberTest,+      multiSinkStrictNamesMemberTest,+      terminalPathAtomicityTest,+      throwToAroundTerminalTest,       eventIdUniquenessTest,       earlyAbortTest,       fidelityTest,       evidenceTests,+      requestedLevelTests,       encodingTests     ] @@ -113,23 +119,21 @@ registerOk a =   let handler _m _ctx _opts = pure (stubResponse a)    in registerApiProvider-        ApiProvider-          { apiTag = a,-            stream = liftCompleteToStream handler,-            complete = handler,-            describeThinking = \_ _ -> noThinkingRequested-          }+        ( apiProviderWith+            a+            (liftCompleteToStream handler)+            (handler)+        )  registerFail :: Api -> BaikaiError -> IO () registerFail a e =   let handler _m _ctx _opts = throwIO e    in registerApiProvider-        ApiProvider-          { apiTag = a,-            stream = liftCompleteToStream handler,-            complete = handler,-            describeThinking = \_ _ -> noThinkingRequested-          }+        ( apiProviderWith+            a+            (liftCompleteToStream handler)+            (handler)+        )  memorySink :: IO (TVar [TraceEvent], TraceSink) memorySink = do@@ -215,17 +219,217 @@         let AssistantPayload {stopReason = sr} = resp ^. #message         sr @?= Stop +-- | A sink that never returns from its first step until released.+blockingSink :: IO (MVar (), TraceSink)+blockingSink = do+  release <- newEmptyMVar+  pure (release, TraceSink (Fold.drainMapM (\_ -> readMVar release)))++-- | Unfixed, 'finalizeTrace' blocked on the worker forever and the+-- guard below reported the hang. The bound turns a pathological sink+-- into about one second and a stderr line.+blockingSinkTest :: TestTree+blockingSinkTest =+  testCase "a sink that blocks forever cannot hold withTrace past the drain bound" $ do+    let a = Custom "baikai-trace-blocking-sink"+    registerOk a+    (release, sink) <- blockingSink+    result <- timeout 2000000 (withTrace sink (stubModel a) stubContext stubOptions)+    case result of+      Nothing -> assertFailure "withTrace hung on a blocking sink"+      Just resp -> do+        let AssistantPayload {stopReason = sr} = resp ^. #message+        sr @?= Stop+    putMVar release ()++-- | A record whose delivery was never confirmed is not one a strict+-- caller can account for, so the stall fails the call through the same+-- path a throwing sink does.+blockingSinkStrictTest :: TestTree+blockingSinkStrictTest =+  testCase "a strict call whose sink never confirms delivery fails" $ do+    let a = Custom "baikai-trace-blocking-sink-strict"+    -- The evidence-building fixture, so the sink is the only reason+    -- this call can fail.+    registerOkWithEvidence a+    (release, sink) <- blockingSink+    result <- timeout 2000000 (withTrace sink (stubModel a) stubContext strictOptions)+    case result of+      Nothing -> assertFailure "withTrace hung on a blocking sink"+      Just resp -> do+        let AssistantPayload {stopReason = sr} = resp ^. #message+        sr @?= ErrorReason+        case responseError resp of+          Nothing -> assertFailure "expected the stall to reach the response"+          Just be ->+            assertBool+              ("the error names the stall: " <> Text.unpack (be ^. #message))+              ("did not confirm delivery" `Text.isInfixOf` (be ^. #message))+    putMVar release ()++-- | Under 'Fold.tee' the throwing member's exception stopped delivery+-- to the sibling for the rest of the call and skipped its end-of-stream+-- action, so this sibling was empty.+multiSinkThrowingMemberTest :: TestTree+multiSinkThrowingMemberTest =+  testCase "a throwing multiSink member does not starve its sibling" $ do+    let a = Custom "baikai-trace-multisink-throwing"+    registerOk a+    (ref, memory) <- memorySink+    result <-+      timeout+        5000000+        (withTrace (multiSink [throwingSink, memory]) (stubModel a) stubContext stubOptions)+    case result of+      Nothing -> assertFailure "withTrace hung on a throwing multiSink member"+      Just resp -> do+        let AssistantPayload {stopReason = sr} = resp ^. #message+        sr @?= Stop+    events <- reverse <$> readTVarIO ref+    case events of+      [CallStarted {}, CallFinished {}] -> pure ()+      other -> assertFailure ("the sibling missed events: " <> show other)++multiSinkBlockingMemberTest :: TestTree+multiSinkBlockingMemberTest =+  testCase "a blocking multiSink member does not starve its sibling" $ do+    let a = Custom "baikai-trace-multisink-blocking"+    registerOk a+    (release, blocking) <- blockingSink+    (ref, memory) <- memorySink+    result <-+      timeout+        2000000+        (withTrace (multiSink [blocking, memory]) (stubModel a) stubContext stubOptions)+    case result of+      Nothing -> assertFailure "withTrace hung on a blocking multiSink member"+      Just resp -> do+        let AssistantPayload {stopReason = sr} = resp ^. #message+        sr @?= Stop+    events <- reverse <$> readTVarIO ref+    case events of+      [CallStarted {}, CallFinished {}] -> pure ()+      other -> assertFailure ("the sibling missed events: " <> show other)+    putMVar release ()++-- | The aggregate failure has to say /which/ member failed, or an+-- operator with three sinks learns only that tracing broke.+multiSinkStrictNamesMemberTest :: TestTree+multiSinkStrictNamesMemberTest =+  testCase "a strict call names the multiSink member that failed" $ do+    let a = Custom "baikai-trace-multisink-strict"+    registerOkWithEvidence a+    (_ref, memory) <- memorySink+    result <-+      timeout+        5000000+        (withTrace (multiSink [throwingSink, memory]) (stubModel a) stubContext strictOptions)+    case result of+      Nothing -> assertFailure "withTrace hung on a throwing multiSink member"+      Just resp -> do+        let AssistantPayload {stopReason = sr} = resp ^. #message+        sr @?= ErrorReason+        case responseError resp of+          Nothing -> assertFailure "expected the member failure to reach the response"+          Just be -> do+            let msg = be ^. #message+            assertBool+              ("the error names the member index: " <> Text.unpack msg)+              ("member 0" `Text.isInfixOf` msg)+            assertBool+              ("the error carries the member's own message: " <> Text.unpack msg)+              ("sink exploded" `Text.isInfixOf` msg)++-- | A memory sink that parks on the terminal event until released.+--+-- The park is what makes the atomicity test deterministic: when+-- @parked@ is filled the consumer has already run 'commitTerminal' to+-- completion and is waiting for the worker, which is exactly the moment+-- an asynchronous exception used to leave a half-committed terminal+-- behind.+gatedSink :: IO (TVar [TraceEvent], MVar (), MVar (), TraceSink)+gatedSink = do+  ref <- newTVarIO []+  parked <- newEmptyMVar+  release <- newEmptyMVar+  let step () e = do+        atomically (modifyTVar' ref (e :))+        case e of+          CallFinished {} -> putMVar parked () >> readMVar release+          _ -> pure ()+      sink = TraceSink (Fold.foldlM' step (pure ()))+  pure (ref, parked, release, sink)++-- | Kill the consumer while it waits for a sink that has already taken+-- the terminal. The stream's exception path runs the trace finaliser a+-- second time, and it must find nothing left to do: one evidence+-- record, one terminal, and no synthetic @aborted@ 'CallFailed' on top+-- of the real 'CallFinished'.+terminalPathAtomicityTest :: TestTree+terminalPathAtomicityTest =+  testCase "an async exception on the terminal path leaves one terminal and one evidence" $ do+    let a = Custom "baikai-trace-terminal-atomicity"+    registerOkWithEvidence a+    (ref, parked, release, sink) <- gatedSink+    outcome <- newEmptyMVar+    consumer <- forkIO $ do+      r <- try (withTrace sink (stubModel a) stubContext evidenceOptions)+      putMVar outcome (r :: Either SomeException Response)+    takeMVar parked+    throwTo consumer ThreadKilled+    r <- takeMVar outcome+    assertBool "the consumer was killed" (isLeft r)+    putMVar release ()+    events <- awaitEvents ref 3+    length [e | e@CallEvidence {} <- events] @?= 1+    length [e | e@CallFinished {} <- events] @?= 1+    length [e | e@CallFailed {} <- events] @?= 0++-- | Aim an asynchronous exception at the consumer the instant the+-- evidence event reaches the sink — while the consumer is pushing the+-- terminal and setting the flag. Fifty times, because the window is a+-- few instructions wide and no scheduling hook can hit it+-- deterministically; the plan's widened-window demonstration shows the+-- test detects the defect.+throwToAroundTerminalTest :: TestTree+throwToAroundTerminalTest =+  testCase "fifty exceptions aimed at the terminal push never duplicate terminal or evidence" $+    forM_ [1 .. 50 :: Int] $ \i -> do+      let a = Custom ("baikai-trace-throwto-" <> Text.pack (show i))+      registerOkWithEvidence a+      ref <- newTVarIO []+      consumerVar <- newEmptyMVar+      let step () e = do+            atomically (modifyTVar' ref (e :))+            case e of+              CallEvidence {} -> readMVar consumerVar >>= \tid -> throwTo tid ThreadKilled+              _ -> pure ()+          sink = TraceSink (Fold.foldlM' step (pure ()))+      outcome <- newEmptyMVar+      tid <- forkIO $ do+        r <- try (withTrace sink (stubModel a) stubContext evidenceOptions)+        putMVar outcome (r :: Either SomeException Response)+      putMVar consumerVar tid+      _ <- takeMVar outcome+      _ <- awaitEvents ref 3+      -- Let anything the finaliser might still push arrive before counting.+      threadDelay 200000+      performMajorGC+      settled <- reverse <$> readTVarIO ref+      length [e | e@CallEvidence {} <- settled] @?= 1+      length [e | e@CallFinished {} <- settled] + length [e | e@CallFailed {} <- settled] @?= 1+ -- | The length assertion here used to read -- @assertBool "every id is 16 chars" (all ((== 16) . Text.length) ids)@.--- It now reads 32, because 'newEventId' delegates to--- 'Baikai.Evidence.newCallId', which carries 128 bits rather than 64.--- The widening is the point of the replacement: the old generator--- packed a process-start /second/ into its high half and so repeated--- itself across processes started in the same second.+-- It reads 32 because 'Baikai.Evidence.newCallId', which replaced the+-- removed @newEventId@, carries 128 bits rather than 64. The widening+-- is the point of the replacement: the old generator packed a+-- process-start /second/ into its high half and so repeated itself+-- across processes started in the same second. eventIdUniquenessTest :: TestTree eventIdUniquenessTest =-  testCase "newEventId yields 70000 distinct 32-char ids" $ do-    ids <- replicateM 70000 newEventId+  testCase "newCallId yields 70000 distinct 32-char ids" $ do+    ids <- replicateM 70000 Ev.newCallId     Set.size (Set.fromList ids) @?= 70000     assertBool "every id is 32 chars" (all ((== 32) . Text.length) ids) @@ -289,12 +493,11 @@   let resp = stubResponse a & #message . #usage .~ u       handler _m _ctx _opts = pure resp    in registerApiProvider-        ApiProvider-          { apiTag = a,-            stream = liftCompleteToStream handler,-            complete = handler,-            describeThinking = \_ _ -> noThinkingRequested-          }+        ( apiProviderWith+            a+            (liftCompleteToStream handler)+            (handler)+        )  fidelityTest :: TestTree fidelityTest =@@ -383,13 +586,129 @@             Nothing         pure (stubResponse a & #evidence .~ ev)    in registerApiProvider-        ApiProvider-          { apiTag = a,-            stream = liftCompleteToStream handler,-            complete = handler,-            describeThinking = \_ _ -> noThinkingRequested+        ( apiProviderWith+            a+            (liftCompleteToStream handler)+            (handler)+        )++-- | 'registerOk' with an honest describer.+--+-- The other fixtures answer 'noThinkingRequested' whatever the caller+-- set, which is exactly what hid the defect these tests pin: a stub+-- that always says "nothing was asked" cannot tell a path that lost the+-- caller's level from one that kept it.+registerOkHonest :: Api -> IO ()+registerOkHonest a =+  let handler _m _ctx _opts = pure (stubResponse a)+   in registerApiProvider+        ( apiProviderWith+            a+            (liftCompleteToStream handler)+            (handler)+            & #describeThinking+            .~ (\_ o -> Build.requestedTranslation o)+        )++-- | A describer that answers with a wire shape of its own, so a test+-- can tell "the core asked the adapter" from "the core spelled+-- not_translated itself".+registerOkBudgetDescriber :: Api -> IO ()+registerOkBudgetDescriber a =+  let handler _m _ctx _opts = pure (stubResponse a)+      budgetTranslation o =+        Ev.ThinkingTranslation+          { Ev.requested = o ^. #thinking,+            Ev.mode = Ev.ThinkingModeBudget,+            Ev.effortText = Nothing,+            Ev.budgetTokens = Just 1024,+            Ev.wireField = Just "thinking",+            Ev.adjustments = []           }+   in registerApiProvider+        ( apiProviderWith+            a+            (liftCompleteToStream handler)+            (handler)+            & #describeThinking+            .~ (\_ o -> budgetTranslation o)+        ) +thinkingOptions :: Options+thinkingOptions = evidenceOptions & #thinking .~ Just ThinkingMax++-- | Read one key out of the encoded @thinking@ object.+thinkingField :: Text -> ModelCallEvidence -> Maybe Value+thinkingField k ev =+  KeyMap.lookup (Key.fromText k) (asObject (maybe Null id (evidenceField "thinking" ev)))++requestedLevelTests :: TestTree+requestedLevelTests =+  testGroup+    "the caller's thinking level on every evidence path"+    [ abortRecordsRequestedLevelTest,+      abortUsesTheAdapterDescriberTest,+      noProviderRecordsRequestedLevelTest,+      throwingHandlerRecordsRequestedLevelTest+    ]++abortRecordsRequestedLevelTest :: TestTree+abortRecordsRequestedLevelTest =+  testCase "an abandoned stream records the level the caller asked for" $ do+    let a = Custom "baikai-trace-abort-thinking"+    registerOkHonest a+    (ref, sink) <- memorySink+    emitted <-+      Stream.toList+        (Stream.take 1 (withTraceStream sink (stubModel a) stubContext thinkingOptions))+    length emitted @?= 1+    events <- awaitEvents ref 3+    ev <- exactlyOneEvidence events+    thinkingField "requested" ev @?= Just (String "max")+    thinkingField "mode" ev @?= Just (String "not_translated")++abortUsesTheAdapterDescriberTest :: TestTree+abortUsesTheAdapterDescriberTest =+  testCase "an abandoned stream asks the registered adapter to describe the translation" $ do+    let a = Custom "baikai-trace-abort-describer"+    registerOkBudgetDescriber a+    (ref, sink) <- memorySink+    emitted <-+      Stream.toList+        (Stream.take 1 (withTraceStream sink (stubModel a) stubContext thinkingOptions))+    length emitted @?= 1+    events <- awaitEvents ref 3+    ev <- exactlyOneEvidence events+    thinkingField "requested" ev @?= Just (String "max")+    -- The proof that the core consulted the adapter rather than+    -- spelling not_translated unconditionally.+    thinkingField "mode" ev @?= Just (String "budget")+    thinkingField "budget_tokens" ev @?= Just (Number 1024)++noProviderRecordsRequestedLevelTest :: TestTree+noProviderRecordsRequestedLevelTest =+  testCase "an unregistered provider records the level the caller asked for" $ do+    let a = Custom "baikai-trace-unregistered-thinking"+    (ref, sink) <- memorySink+    _ <- withTrace sink (stubModel a) stubContext thinkingOptions+    events <- awaitEvents ref 3+    ev <- exactlyOneEvidence events+    thinkingField "requested" ev @?= Just (String "max")+    thinkingField "mode" ev @?= Just (String "not_translated")++throwingHandlerRecordsRequestedLevelTest :: TestTree+throwingHandlerRecordsRequestedLevelTest =+  testCase "a handler that threw records the level the caller asked for" $ do+    let a = Custom "baikai-trace-throwing-thinking"+    registerFail a (providerError "stub-failure")+    (ref, sink) <- memorySink+    _ <- withTrace sink (stubModel a) stubContext thinkingOptions+    events <- awaitEvents ref 3+    ev <- exactlyOneEvidence events+    thinkingField "requested" ev @?= Just (String "max")+    thinkingField "mode" ev @?= Just (String "not_translated")+    evidenceField "status" ev @?= Just (String "failed")+ evidencesIn :: [TraceEvent] -> [ModelCallEvidence] evidencesIn events = [ev | CallEvidence {evidence = ev} <- events] @@ -420,7 +739,12 @@       strictSinkFailureIsStillOneTerminalTest,       optOutSilentTest,       optOutGoldenTest,-      envelopeNotForcedTest+      envelopeNotForcedTest,+      strictNoRecordFailsTest,+      strictNoRecordIsOneTerminalTest,+      strictWithRecordSucceedsTest,+      strictNoRecordErrorPathKeepsProviderErrorTest,+      bestEffortNoRecordStillSucceedsTest     ]  -- | Assert the shape every record this plan produces must have: the@@ -446,12 +770,37 @@  -- | Exactly one evidence record per call, joined to the rest of the -- call's lines by the trace @eventId@.+-- | The record must reach the sink while the call is still open there.+--+-- "Baikai.Trace" pushes 'CallEvidence' before the terminal since commit+-- @1717694@, because the OpenTelemetry sink ends and removes its span on+-- the terminal and so could never attach evidence that arrived after it.+-- 'docs\/capabilities\/model-call-evidence.md' claimed an ordering+-- assertion existed; this is it, and every evidence case runs it on its+-- own path — success, failure, abort, unregistered provider.+assertEvidencePrecedesTerminal :: [TraceEvent] -> IO ()+assertEvidencePrecedesTerminal events =+  case (findIndex isEvidence events, findIndex isTerminal events) of+    (Just i, Just j) ->+      assertBool+        ("CallEvidence at " <> show i <> " must precede the terminal at " <> show j)+        (i < j)+    (Just _, Nothing) -> assertFailure "an evidence event without a terminal"+    _ -> assertFailure "no evidence event to order"+  where+    isEvidence = \case CallEvidence {} -> True; _ -> False+    isTerminal = \case+      CallFinished {} -> True+      CallFailed {} -> True+      _ -> False+ exactlyOneEvidence :: [TraceEvent] -> IO ModelCallEvidence exactlyOneEvidence events = case evidencesIn events of   [ev] -> do     let ids = Set.fromList [e ^. #eventId :: Text | e <- events]     Set.size ids @?= 1     assertMinimalShape ev+    assertEvidencePrecedesTerminal events     pure ev   other ->     assertFailure@@ -549,7 +898,11 @@     -- something they cannot account for, with no way to notice: evidence     -- that can vanish without the caller noticing is not evidence.     let a = Custom "baikai-evidence-strict-throwing-sink"-    registerOk a+    -- The evidence-building fixture, so the sink is the only reason this+    -- call can fail. With a provider that attaches no record, a strict+    -- call now fails on that account before the sink is ever reached,+    -- and this case would assert the sink rule against the record rule.+    registerOkWithEvidence a     result <-       timeout 5000000 (withTrace throwingSink (stubModel a) stubContext strictOptions)     case result of@@ -564,6 +917,86 @@               ("the error names the sink: " <> Text.unpack (be ^. #message))               ("trace sink failed" `Text.isInfixOf` (be ^. #message)) +-- | Strict mode guaranteed that a record which was built and then lost+-- fails the call. It did not guarantee that one was built: a provider+-- that attached nothing returned a successful response and wrote no+-- @call_evidence@ line, with no error anywhere.+strictNoRecordFailsTest :: TestTree+strictNoRecordFailsTest =+  testCase "A STRICT CALL WHOSE PROVIDER ATTACHED NO RECORD FAILS, AND EMITS NO RECORD" $ do+    let a = Custom "baikai-evidence-strict-no-record"+    registerOk a+    (ref, sink) <- memorySink+    resp <- withTrace sink (stubModel a) stubContext strictOptions+    let AssistantPayload {stopReason = sr} = resp ^. #message+    sr @?= ErrorReason+    case responseError resp of+      Nothing -> assertFailure "expected the missing record to fail the call"+      Just be -> do+        be ^. #category @?= OtherError+        assertBool+          ("the error names the missing record: " <> Text.unpack (be ^. #message))+          ("attached no evidence record" `Text.isInfixOf` (be ^. #message))+    events <- awaitEvents ref 2+    length [e | e@CallStarted {} <- events] @?= 1+    length [e | e@CallFailed {} <- events] @?= 1+    length (evidencesIn events) @?= 0++-- | The rewrite produces one terminal, not two.+strictNoRecordIsOneTerminalTest :: TestTree+strictNoRecordIsOneTerminalTest =+  testCase "a record-less strict stream yields one EventError and no EventDone" $ do+    let a = Custom "baikai-evidence-strict-no-record-stream"+    registerOk a+    events <-+      Stream.toList (withTraceStream silent (stubModel a) stubContext strictOptions)+    length [e | e@(EventDone _) <- events] @?= 0+    length [e | e@(EventError _) <- events] @?= 1++-- | The rewrite fires on the absence of a record, not on strictness+-- alone.+strictWithRecordSucceedsTest :: TestTree+strictWithRecordSucceedsTest =+  testCase "a strict call whose provider attached a record still succeeds" $ do+    let a = Custom "baikai-evidence-strict-with-record"+    registerOkWithEvidence a+    (ref, sink) <- memorySink+    resp <- withTrace sink (stubModel a) stubContext strictOptions+    let AssistantPayload {stopReason = sr} = resp ^. #message+    sr @?= Stop+    responseError resp @?= Nothing+    events <- awaitEvents ref 3+    length (evidencesIn events) @?= 1++-- | On the error path the provider's own error is the more useful of+-- the two, and the strict contract already holds: the call failed.+strictNoRecordErrorPathKeepsProviderErrorTest :: TestTree+strictNoRecordErrorPathKeepsProviderErrorTest =+  testCase "a failed strict call keeps the provider's own error" $ do+    let a = Custom "baikai-evidence-strict-provider-error"+    registerFail a (providerError "stub-failure")+    resp <- withTrace silent (stubModel a) stubContext strictOptions+    case responseError resp of+      Nothing -> assertFailure "expected the provider's failure to reach the response"+      Just be -> do+        assertBool+          ("the provider's error survives: " <> Text.unpack (be ^. #message))+          ("stub-failure" `Text.isInfixOf` (be ^. #message))+        assertBool+          "the missing-record error must not overwrite it"+          (not ("attached no evidence record" `Text.isInfixOf` (be ^. #message)))++-- | Best effort never refuses, here as everywhere.+bestEffortNoRecordStillSucceedsTest :: TestTree+bestEffortNoRecordStillSucceedsTest =+  testCase "a best-effort call whose provider attached no record still succeeds" $ do+    let a = Custom "baikai-evidence-best-effort-no-record"+    registerOk a+    resp <- withTrace silent (stubModel a) stubContext evidenceOptions+    let AssistantPayload {stopReason = sr} = resp ^. #message+    sr @?= Stop+    responseError resp @?= Nothing+ -- | The exactly-once guarantee still holds when the terminal is -- rewritten. strictSinkFailureIsStillOneTerminalTest :: TestTree@@ -696,12 +1129,11 @@               Nothing           pure (stubResponse a & #evidence .~ ev)     registerApiProvider-      ApiProvider-        { apiTag = a,-          stream = liftCompleteToStream handler,-          complete = handler,-          describeThinking = \_ _ -> noThinkingRequested-        }+      ( apiProviderWith+          a+          (liftCompleteToStream handler)+          (handler)+      )     (ref, sink) <- memorySink     _ <- withTrace sink (stubModel a) stubContext stubOptions     events <- reverse <$> readTVarIO ref
+ test/TransportClassifySpec.hs view
@@ -0,0 +1,265 @@+-- | The one transport classifier, pinned against the exception shapes+-- @http-client@, @tls@ and the socket layer actually raise.+--+-- The rule under test is /where/ the failure happened, not what type it+-- is: a connection that existed and broke is retryable, a connection+-- that could never work is not, and a programming error is neither. The+-- cases below therefore pair each constructor with the phase it belongs+-- to, and the negative cases matter as much as the positive ones — a+-- classifier that calls a @userError@ a network blip feeds a retry loop+-- a bug it can never retry away.+module TransportClassifySpec (tests) where++import Baikai.Error (BaikaiError (..), ErrorCategory (..), isRetryable)+import Baikai.Provider.Transport.Classify+  ( classifyHttpException,+    classifyHttpExceptionContent,+    classifyIOException,+    classifyTlsException,+    classifyTransportException,+  )+import Control.Exception (toException)+import Data.ByteString (ByteString)+import Data.CaseInsensitive qualified as CI+import Data.Text qualified as Text+import Foreign.C.Error (Errno (..), eCONNABORTED, eCONNRESET)+import GHC.IO.Exception qualified as IOE+import Network.HTTP.Client qualified as HTTP+import Network.HTTP.Client.Internal qualified as HTTPI+import Network.HTTP.Types.Status (mkStatus)+import Network.HTTP.Types.Version (http11)+import Network.TLS qualified as TLS+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (Assertion, assertBool, assertFailure, testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "Baikai.Provider.Transport.Classify"+    [ testGroup "socket failures during the body read" ioTests,+      testGroup "http-client exception content" httpContentTests,+      testGroup "TLS failures" tlsTests,+      testGroup "the top-level dispatcher" dispatchTests+    ]++-- ============================================================+-- Fixtures+-- ============================================================++-- | An 'IOError' shaped the way the socket layer raises one during a+-- body read: a location naming the recv call, a description from the+-- kernel, an error type and an errno.+socketError :: IOE.IOErrorType -> Errno -> String -> IOE.IOException+socketError ty (Errno n) description =+  IOE.IOError+    { IOE.ioe_handle = Nothing,+      IOE.ioe_type = ty,+      IOE.ioe_location = "Network.Socket.recvBuf",+      IOE.ioe_description = description,+      IOE.ioe_errno = Just n,+      IOE.ioe_filename = Nothing+    }++-- | The canonical mid-stream reset: the peer sent RST while the+-- response body was still arriving.+connectionReset :: IOE.IOException+connectionReset = socketError IOE.ResourceVanished eCONNRESET "Connection reset by peer"++assertTransient :: BaikaiError -> Assertion+assertTransient be = do+  category be @?= TransientError+  isRetryable be @?= True++assertNotRetryable :: ErrorCategory -> BaikaiError -> Assertion+assertNotRetryable expected be = do+  category be @?= expected+  isRetryable be @?= False++assertJustTransient :: Maybe BaikaiError -> Assertion+assertJustTransient = \case+  Just be -> assertTransient be+  Nothing -> assertFailure "expected a classified transport failure, got Nothing"++-- ============================================================+-- Raw IOExceptions+-- ============================================================++ioTests :: [TestTree]+ioTests =+  [ testCase "a connection reset during the body read is transient" $+      assertJustTransient (classifyIOException connectionReset),+    -- base maps ECONNABORTED to the IOErrorType constructor named+    -- OtherError, so a rule that looked only at the type would call an+    -- aborted connection a programming error.+    testCase "ECONNABORTED is recognised by errno when the error type is OtherError" $+      assertJustTransient+        ( classifyIOException+            (socketError IOE.OtherError eCONNABORTED "Software caused connection abort")+        ),+    testCase "an end-of-file on the socket is transient" $+      assertJustTransient+        ( classifyIOException+            (IOE.IOError Nothing IOE.EOF "brRead" "end of input" Nothing Nothing)+        ),+    testCase "a timed-out read is transient" $+      assertJustTransient+        ( classifyIOException+            (IOE.IOError Nothing IOE.TimeExpired "recv" "operation timed out" Nothing Nothing)+        ),+    testCase "a userError is not a transport failure" $ do+      classifyIOException (userError "bug") @?= Nothing+      classifyTransportException (toException (userError "bug")) @?= Nothing,+    testCase "a missing file is not a transport failure" $+      classifyIOException+        (IOE.IOError Nothing IOE.NoSuchThing "openFile" "does not exist" Nothing (Just "/nope"))+        @?= Nothing,+    testCase "the classified message keeps the socket detail" $+      case classifyIOException connectionReset of+        Just be -> assertBool "message names the reset" ("reset by peer" `Text.isInfixOf` message be)+        Nothing -> assertFailure "expected a classified transport failure"+  ]++-- ============================================================+-- HttpExceptionContent+-- ============================================================++httpContentTests :: [TestTree]+httpContentTests =+  [ testCase "InvalidChunkHeaders is transient" $+      assertTransient (classifyHttpExceptionContent HTTP.InvalidChunkHeaders),+    testCase "ResponseBodyTooShort is transient" $+      assertTransient (classifyHttpExceptionContent (HTTP.ResponseBodyTooShort 100 40)),+    testCase "ConnectionClosed is transient" $+      assertTransient (classifyHttpExceptionContent HTTP.ConnectionClosed),+    testCase "IncompleteHeaders is transient" $+      assertTransient (classifyHttpExceptionContent HTTP.IncompleteHeaders),+    testCase "NoResponseDataReceived is transient" $+      assertTransient (classifyHttpExceptionContent HTTP.NoResponseDataReceived),+    testCase "ConnectionTimeout and ResponseTimeout are transient" $ do+      assertTransient (classifyHttpExceptionContent HTTP.ConnectionTimeout)+      assertTransient (classifyHttpExceptionContent HTTP.ResponseTimeout),+    testCase "ConnectionFailure is transient" $+      assertTransient+        (classifyHttpExceptionContent (HTTP.ConnectionFailure (toException connectionReset))),+    testCase "InternalException unwraps to the inner socket rule" $+      assertTransient+        (classifyHttpExceptionContent (HTTP.InternalException (toException connectionReset))),+    testCase "InternalException unwraps to the inner TLS rule" $+      assertNotRetryable+        OtherError+        ( classifyHttpExceptionContent+            ( HTTP.InternalException+                (toException (TLS.HandshakeFailed (TLS.Error_Misc "certificate rejected")))+            )+        ),+    testCase "InvalidUrlException is InvalidRequest" $+      assertNotRetryable+        InvalidRequest+        (classifyHttpException (HTTP.InvalidUrlException "http://%%%" "invalid escape")),+    testCase "InvalidRequestHeader is InvalidRequest" $+      assertNotRetryable+        InvalidRequest+        (classifyHttpExceptionContent (HTTP.InvalidRequestHeader "X-Bad: \n")),+    testCase "InvalidDestinationHost is InvalidRequest" $+      assertNotRetryable+        InvalidRequest+        (classifyHttpExceptionContent (HTTP.InvalidDestinationHost "bad host")),+    testCase "WrongRequestBodyStreamSize is InvalidRequest" $+      assertNotRetryable+        InvalidRequest+        (classifyHttpExceptionContent (HTTP.WrongRequestBodyStreamSize 10 4)),+    -- Unreachable from baikai's own transports, which never install+    -- throwErrorStatusCodes; pinned for third-party providers built on+    -- http-client, and because it is the one arm that reads headers.+    testCase "StatusCodeException classifies by status and converts an HTTP-date Retry-After" $ do+      let be =+            classifyHttpExceptionContent+              ( HTTP.StatusCodeException+                  ( statusResponse+                      429+                      [ ("Retry-After", "Wed, 21 Oct 2026 07:28:00 GMT"),+                        ("Date", "Wed, 21 Oct 2026 07:27:15 GMT")+                      ]+                  )+                  "slow down"+              )+      category be @?= RateLimited+      httpStatus be @?= Just 429+      retryAfterSeconds be @?= Just 45,+    testCase "StatusCodeException falls back to the integer form when there is no Date" $ do+      let be =+            classifyHttpExceptionContent+              (HTTP.StatusCodeException (statusResponse 429 [("Retry-After", "9")]) "")+      retryAfterSeconds be @?= Just 9,+    -- A server that does not speak HTTP, or a proxy or TLS setup that+    -- cannot work, will answer the retry exactly the same way.+    testCase "InvalidStatusLine, TooManyHeaderFields and TlsNotSupported are not retryable" $ do+      assertNotRetryable OtherError (classifyHttpExceptionContent (HTTP.InvalidStatusLine "gibberish"))+      assertNotRetryable OtherError (classifyHttpExceptionContent HTTP.TooManyHeaderFields)+      assertNotRetryable OtherError (classifyHttpExceptionContent HTTP.TlsNotSupported)+      assertNotRetryable OtherError (classifyHttpExceptionContent (HTTP.TooManyRedirects []))+  ]++-- | The header-carrying half of a 'HTTP.StatusCodeException': the body+-- travels separately, so the response's own body is @()@.+statusResponse :: Int -> [(ByteString, ByteString)] -> HTTP.Response ()+statusResponse status hdrs =+  HTTPI.Response+    { HTTPI.responseStatus = mkStatus status "",+      HTTPI.responseVersion = http11,+      HTTPI.responseHeaders = [(CI.mk n, v) | (n, v) <- hdrs],+      HTTPI.responseBody = (),+      HTTPI.responseCookieJar = HTTP.createCookieJar [],+      HTTPI.responseClose' = HTTPI.ResponseClose (pure ()),+      HTTPI.responseOriginalRequest = HTTP.defaultRequest,+      HTTPI.responseEarlyHints = []+    }++-- ============================================================+-- TLS+-- ============================================================++tlsTests :: [TestTree]+tlsTests =+  [ -- Upstream's own manager agrees: http-client-tls treats a+    -- post-handshake EOF as retryable.+    testCase "a TLS end-of-file after the handshake is transient" $+      assertTransient (classifyTlsException (TLS.PostHandshake TLS.Error_EOF)),+    testCase "a terminated TLS session is transient" $+      assertTransient (classifyTlsException (TLS.Terminated True "peer closed" TLS.Error_EOF)),+    testCase "an uncontextualized TLS failure is transient" $+      assertTransient (classifyTlsException (TLS.Uncontextualized TLS.Error_EOF)),+    -- Against a well-known API host a handshake failure is a trust-store+    -- or protocol mismatch, which the retry reproduces. A socket reset+    -- during connect arrives as ConnectionFailure instead, and is+    -- transient.+    testCase "a failed TLS handshake is not retryable" $+      assertNotRetryable+        OtherError+        (classifyTlsException (TLS.HandshakeFailed (TLS.Error_Misc "certificate rejected"))),+    testCase "a session that never existed is not retryable" $ do+      assertNotRetryable OtherError (classifyTlsException TLS.ConnectionNotEstablished)+      assertNotRetryable OtherError (classifyTlsException TLS.MissingHandshake)+  ]++-- ============================================================+-- Dispatch+-- ============================================================++dispatchTests :: [TestTree]+dispatchTests =+  [ testCase "a raw IOException reaches the socket rule" $+      assertJustTransient (classifyTransportException (toException connectionReset)),+    -- This is the shape that reaches a worker raw: http-client wraps the+    -- body reader with nothing that would convert it.+    testCase "a raw TLSException reaches the TLS rule" $+      assertJustTransient+        (classifyTransportException (toException (TLS.PostHandshake TLS.Error_EOF))),+    testCase "an HttpException reaches the http-client rule" $+      assertJustTransient+        ( classifyTransportException+            (toException (HTTP.HttpExceptionRequest HTTP.defaultRequest HTTP.InvalidChunkHeaders))+        ),+    testCase "anything else is not a transport failure" $+      classifyTransportException (toException (userError "callback bug")) @?= Nothing+  ]
+ test/UrlSpec.hs view
@@ -0,0 +1,275 @@+-- | The one URL parser, and the three decisions that hang off it: which+-- API key a base URL resolves, which compatibility record it selects,+-- and what an evidence record calls the endpoint.+--+-- The cases that matter most are the negative ones. baikai routes a+-- credential by host name, so a parser that can be talked into naming+-- the wrong host is a parser that can be talked into sending one+-- provider's key to another.+module UrlSpec (urlTests) where++import Baikai+  ( autoDetectAnthropicMessages,+    autoDetectOpenAICompletions,+    defaultAnthropicMessagesCompat,+    defaultApiKeyEnvForBaseUrl,+    defaultOpenAICompletionsCompat,+  )+import Baikai.Evidence.Build (sanitizeEndpoint)+import Baikai.Http qualified as Http+import Baikai.Url+  ( UrlParts (..),+    baseUrlProblem,+    parseUrl,+    renderEndpoint,+    stripApiVersion,+    urlHost,+  )+import Control.Monad (forM_)+import Data.Text (Text)+import Data.Text qualified as Text+import Servant.Client qualified as Client+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))++urlTests :: TestTree+urlTests =+  testGroup+    "Baikai.Url"+    [ authorityBoundaryTests,+      hostAndPortTests,+      renderingTests,+      stripApiVersionTests,+      baseUrlProblemTests,+      canonicalBaseUrlTests+    ]++-- --------------------------------------------------------------------+-- Where the authority ends+-- --------------------------------------------------------------------++-- | The defect this module exists for. Reading the text after the last+-- @\@@ anywhere in a URL lets anyone who can set @baseUrl@ choose which+-- provider's key baikai sends — and send it to their own host.+authorityBoundaryTests :: TestTree+authorityBoundaryTests =+  testGroup+    "the authority ends at the first /, ? or #"+    [ testCase "an @ in the query does not rename the host" $ do+        let url = "https://proxy.example.com/v1?u=@api.openai.com"+        urlHost url @?= Just "proxy.example.com"+        -- The consequences, asserted rather than assumed: no key is+        -- resolved for an unknown host, and no vendor compat record is+        -- selected for it either.+        defaultApiKeyEnvForBaseUrl url @?= Nothing+        assertBool+          "no OpenAI compat record for a proxy host"+          (autoDetectOpenAICompletions url == defaultOpenAICompletionsCompat)+        assertBool+          "no vendor Anthropic compat record for a proxy host"+          (autoDetectAnthropicMessages url == defaultAnthropicMessagesCompat),+      testCase "an @ in a query with no path does not rename the host" $+        -- The case the evidence module's own parser got wrong: it+        -- bounded the authority at the first "/" only.+        urlHost "https://proxy.example.com?u=@api.openai.com"+          @?= Just "proxy.example.com",+      testCase "an @ in a fragment does not rename the host" $+        urlHost "https://proxy.example.com#@api.openai.com"+          @?= Just "proxy.example.com",+      testCase "an @ in the path does not rename the host" $ do+        urlHost "https://api.openai.com/v1/@x" @?= Just "api.openai.com"+        defaultApiKeyEnvForBaseUrl "https://api.openai.com/v1/@x"+          @?= Just "OPENAI_API_KEY",+      testCase "real userinfo is still dropped" $ do+        let url = "https://user:pw@api.openai.com/"+        urlHost url @?= Just "api.openai.com"+        fmap hasUserInfo (parseUrl url) @?= Just True+        defaultApiKeyEnvForBaseUrl url @?= Just "OPENAI_API_KEY"+    ]++-- --------------------------------------------------------------------+-- Hosts, ports and paths+-- --------------------------------------------------------------------++hostAndPortTests :: TestTree+hostAndPortTests =+  testGroup+    "hosts, ports and paths"+    [ testCase "an IPv6 literal keeps its brackets and its port" $ do+        parts <- expectParse "http://[::1]:8080/v1"+        host parts @?= "[::1]"+        port parts @?= Just 8080+        path parts @?= "/v1",+      testCase "an IPv6 literal with no port has no port" $ do+        parts <- expectParse "https://[::1]"+        host parts @?= "[::1]"+        port parts @?= Nothing,+      testCase "the host is lower-cased and the path is not" $ do+        parts <- expectParse "https://Api.OpenAI.com:443/V1/"+        host parts @?= "api.openai.com"+        port parts @?= Just 443+        path parts @?= "/V1/",+      testCase "a non-numeric port is ignored and the host survives" $ do+        parts <- expectParse "https://api.openai.com:notaport/v1"+        host parts @?= "api.openai.com"+        port parts @?= Nothing,+      testCase "a scheme-less URL parses with no scheme" $ do+        parts <- expectParse "api.openai.com"+        scheme parts @?= Nothing+        host parts @?= "api.openai.com",+      testCase "a scheme is recognised only when it looks like one" $ do+        parts <- expectParse "HTTPS://Api.OpenAI.com"+        scheme parts @?= Just "https",+      testCase "no host means no result" $ do+        parseUrl "" @?= Nothing+        parseUrl "https://" @?= Nothing+        parseUrl "   " @?= Nothing+    ]++-- --------------------------------------------------------------------+-- Rendering an endpoint+-- --------------------------------------------------------------------++renderingTests :: TestTree+renderingTests =+  testGroup+    "rendering an endpoint"+    [ testCase "userinfo, query and fragment are gone; scheme and host are lower-cased" $ do+        let url = "https://user:pw@Host.example:8443/a/b?k=v#f"+        parts <- expectParse url+        renderEndpoint parts @?= "https://host.example:8443/a/b"+        -- The evidence record's endpoint is the same function, so the+        -- two cannot drift.+        sanitizeEndpoint url @?= Just "https://host.example:8443/a/b",+      testCase "an empty endpoint is absent rather than empty" $+        sanitizeEndpoint "" @?= Nothing+    ]++-- --------------------------------------------------------------------+-- Stripping a version segment+-- --------------------------------------------------------------------++stripApiVersionTests :: TestTree+stripApiVersionTests =+  testGroup+    "stripApiVersion removes one trailing /v1 segment"+    [ testCase "a bare version path becomes empty" $ do+        stripApiVersion "/v1" @?= ""+        stripApiVersion "/v1/" @?= ""+        stripApiVersion "/" @?= ""+        stripApiVersion "" @?= ""+        stripApiVersion "v1" @?= "",+      testCase "a mounted API keeps its prefix" $ do+        stripApiVersion "/api/v1" @?= "/api"+        stripApiVersion "/compatible-mode/v1/" @?= "/compatible-mode"+        stripApiVersion "api" @?= "/api",+      testCase "a segment that merely starts with v1 is untouched" $ do+        stripApiVersion "/v10" @?= "/v10"+        stripApiVersion "/v1beta" @?= "/v1beta"+    ]++-- --------------------------------------------------------------------+-- Fitness as a base URL+-- --------------------------------------------------------------------++baseUrlProblemTests :: TestTree+baseUrlProblemTests =+  testGroup+    "baseUrlProblem"+    [ testCase "the shapes baikai supports are accepted" $ do+        baseUrlProblem "https://api.openai.com" @?= Nothing+        baseUrlProblem "https://api.deepseek.com/v1" @?= Nothing+        baseUrlProblem "https://openrouter.ai/api" @?= Nothing+        baseUrlProblem "http://localhost:11434" @?= Nothing,+      testCase "a query string is refused without echoing it" $ do+        problem <- expectProblem "https://h.example/v1?api-version=1"+        assertBool+          ("names the problem: " <> Text.unpack problem)+          ("query string" `Text.isInfixOf` problem)+        assertBool+          ("does not echo the query: " <> Text.unpack problem)+          (not ("api-version=1" `Text.isInfixOf` problem)),+      testCase "userinfo is refused without echoing the password" $ do+        problem <- expectProblem "https://u:secret@h.example"+        assertBool+          ("names the problem: " <> Text.unpack problem)+          ("credentials" `Text.isInfixOf` problem)+        assertBool+          ("does not echo the password: " <> Text.unpack problem)+          (not ("secret" `Text.isInfixOf` problem)),+      testCase "a missing scheme is refused, saying which to use" $ do+        problem <- expectProblem "h.example"+        assertBool+          ("names the fix: " <> Text.unpack problem)+          ("https://" `Text.isInfixOf` problem),+      testCase "a scheme baikai does not send is refused" $ do+        problem <- expectProblem "ftp://h.example"+        assertBool+          ("names the scheme: " <> Text.unpack problem)+          ("ftp" `Text.isInfixOf` problem),+      testCase "a fragment is refused" $ do+        problem <- expectProblem "https://h.example/v1#frag"+        assertBool+          ("names the problem: " <> Text.unpack problem)+          ("fragment" `Text.isInfixOf` problem),+      testCase "a full endpoint URL is refused as a base URL" $ do+        forM_ ["https://h.example/v1/chat/completions", "https://h.example/v1/messages", "https://h.example/v1/embeddings"] $ \url -> do+          problem <- expectProblem url+          assertBool+            ("names the problem for " <> Text.unpack url <> ": " <> Text.unpack problem)+            ("endpoint path" `Text.isInfixOf` problem),+      testCase "text that names no host is refused" $ do+        problem <- expectProblem ""+        assertBool+          ("names the problem: " <> Text.unpack problem)+          ("no host" `Text.isInfixOf` problem)+    ]++-- --------------------------------------------------------------------+-- Helpers+-- --------------------------------------------------------------------++expectParse :: Text -> IO UrlParts+expectParse url = case parseUrl url of+  Nothing -> assertFailure ("expected " <> Text.unpack url <> " to parse")+  Just parts -> pure parts++expectProblem :: Text -> IO Text+expectProblem url = case baseUrlProblem url of+  Nothing -> assertFailure ("expected " <> Text.unpack url <> " to be refused")+  Just problem -> pure problem++-- --------------------------------------------------------------------+-- What the transports actually connect to+-- --------------------------------------------------------------------++-- | The normalisation the connection cache keys on, and the composition+-- rule the transports rely on.+canonicalBaseUrlTests :: TestTree+canonicalBaseUrlTests =+  testGroup+    "canonicalBaseUrl"+    [ testCase "a trailing /v1 and its absence are the same target" $ do+        withVersion <- expectCanonical "https://api.deepseek.com/v1"+        without <- expectCanonical "https://api.deepseek.com"+        Client.showBaseUrl withVersion @?= Client.showBaseUrl without,+      testCase "the host is lower-cased and a default port is implied" $ do+        base <- expectCanonical "https://Api.OpenAI.com:443/"+        Client.showBaseUrl base @?= "https://api.openai.com",+      testCase "a mounted API keeps its prefix without its version" $ do+        base <- expectCanonical "https://openrouter.ai/api/v1/"+        Client.baseUrlPath base @?= "/api",+      testCase "an unusable base URL is a reason, not an exception" $+        case Http.canonicalBaseUrl "h.test" of+          Right base ->+            assertFailure ("expected a refusal, got " <> Client.showBaseUrl base)+          Left problem ->+            assertBool+              ("names the fix: " <> Text.unpack problem)+              ("https://" `Text.isInfixOf` problem)+    ]++expectCanonical :: Text -> IO Client.BaseUrl+expectCanonical url = case Http.canonicalBaseUrl url of+  Left problem -> assertFailure (Text.unpack (url <> " was refused: " <> problem))+  Right base -> pure base