packages feed

baikai 0.7.2.0 → 0.8.0.0

raw patch · 26 files changed

+2003/−38 lines, 26 filesdep +unixPVP ok

version bump matches the API change (PVP)

Dependencies added: unix

API changes (from Hackage documentation)

+ Baikai.Models.Generated: anthropic_claude_haiku_5_5 :: Model
+ Baikai.Provider.Cli.Process.Internal: withOwnedProcess :: CreateProcess -> (Maybe Handle -> Maybe Handle -> Maybe Handle -> ProcessHandle -> IO a) -> IO a
+ Baikai.Provider.Cli.Process.Internal: withOwnedWorker :: IO a -> (IO a -> IO b) -> IO b
- Baikai.Model: InputPriceTier :: !Natural -> !ModelCost -> InputPriceTier
+ Baikai.Model: InputPriceTier :: !Natural -> !ModelCost -> !Maybe Rational -> InputPriceTier
- Baikai.Model: [longCacheWriteCost] :: PricingPolicy -> !Maybe Rational
+ Baikai.Model: [longCacheWriteCost] :: InputPriceTier -> !Maybe Rational

Files

CHANGELOG.md view
@@ -7,6 +7,173 @@  ## [Unreleased] +## [baikai 0.8.0.0] - 2026-10-10++### Added++- Catalog: `anthropic_claude_haiku_5_5` (Claude Haiku 5.5, released+  2026-10-07). It uses Messages with adaptive thinking, drops sampling options,+  forwards forced tool choice, and has a 1,000,000-token context and+  128,000-token output limit. Prompts over 100,000 tokens, counting cache reads+  and writes, are priced at $0.50/$0.05/$0.625/$2.50 per million input, cache+  read, cache write and output tokens instead of $0.10/$0.01/$0.125/$0.50, and+  one-hour cache writes at $1 instead of $0.20. Live text and tool cases+  passed on 2026-10-10, and a live request confirmed forced tool choice with+  adaptive thinking.++### Changed (breaking)++- `Baikai.Model.InputPriceTier` gains a third field, `longCacheWriteCost ::+  Maybe Rational`: the one-hour cache-write rate for requests that cross that+  tier. `resolveRates` now takes the long rate from the selected tier rather+  than overriding every tier with the policy-level rate, and validation rejects+  a policy whose policy-level long rate leaves any tier without one. Tier JSON+  without the key decodes as `Nothing`. __Breaking__: positional constructions+  of `InputPriceTier` need the new argument.++### Changed++- Catalog: the 2026-10-10 model refresh lowers `anthropic_claude_sonnet_5_5`'s+  cache-read rate from $0.20 to $0.10 per million tokens, following Anthropic's+  2026-10-07 price cut, and corrects `anthropic_claude_sonnet_4_5`'s context+  window from 1,000,000 to 200,000 tokens (its 1M beta ended on 2026-04-30).+  Claude Haiku 5.5 was curated separately (see Added). No OpenAI model was+  added.++- Documentation: the 2026-10-02 model refresh found no new OpenAI or Anthropic+  models; the catalog is unchanged. `docs/user/models-and-providers.md` notes+  Anthropic's retirement of Claude Sonnet 4.5 on 2026-11-30 (deprecation of+  `anthropic_claude_sonnet_4_5` is planned in plan 89 for a future release;+  it remains in 0.8.0.0),+  why the access-gated Claude Mythos models are not curated, and how GPT-6+  Astra's new ultrafast service tier is costed.++### Fixed++- The shared batch subprocess scope now owns POSIX process groups, joins pipe+  readers, and synchronously reaps direct children before acknowledging+  cancellation. Executable-version probes use the same scope. Darwin/Linux+  coverage excludes processes that deliberately escape the group and adopted+  descendant reaping; Windows retains direct-child cleanup. The provider fixes+  ship in `baikai-claude 0.7.1.1` and `baikai-openai 0.7.1.1`; see+  [plan 90](docs/plans/90-terminate-owned-batch-cli-process-groups-before-acknowledging-cancellation.md)+  and [BUG-1](docs/bug-reports/batch-cli-cancellation-leaves-child-alive.md).++- `baikai`'s source distribution now ships `data/models/*.json` and the test+  suite's fixtures under `test/fixtures/`. Without them, running the suite from+  the Hackage tarball failed 27 of 794 tests on missing files: the fixtures+  themselves, plus the catalog round-trip test, whose `baikai-gen-models` run+  had no model data to read.++## [baikai-claude 0.7.1.1] - 2026-10-10++### Fixed++- Batch `claude -p` calls stop owned POSIX process-group members and join pipe+  readers before acknowledging cancellation, including active synthetic streams+  and evidence probes. Preserves the original asynchronous exception. Resolves+  [BUG-1](docs/bug-reports/batch-cli-cancellation-leaves-child-alive.md).++### Changed++- Requires `baikai ^>=0.8.0` for shared subprocess ownership and the new pricing+  tier field. No provider API change. Haiku 5.5 request contracts are covered+  by offline tests.++## [baikai-openai 0.7.1.1] - 2026-10-10++### Fixed++- Batch `codex exec` calls stop owned POSIX process-group members and join pipe+  readers before acknowledging cancellation, including active synthetic streams,+  repeated cancellation, and evidence probes. The temporary schema survives+  until subprocess cleanup finishes. Resolves+  [BUG-1](docs/bug-reports/batch-cli-cancellation-leaves-child-alive.md).++### Changed++- Requires `baikai ^>=0.8.0` for shared subprocess ownership and the new pricing+  tier field. No provider API change.++## [baikai-trace-otel 0.4.0.2] - 2026-10-10++### Changed++- Requires `baikai ^>=0.8.0` for compatibility with the core pricing tier+  change. No package API change.++## [baikai-effectful 0.4.0.3] - 2026-10-10++### Changed++- Requires `baikai ^>=0.8.0` for compatibility with the core pricing tier+  change. No package API change.++## [baikai-kit 0.4.0.1] - 2026-10-10++### Changed++- Requires `baikai ^>=0.8.0` for compatibility with the core pricing tier+  change. No package API change.++## [baikai-agent 0.2.0.2] - 2026-10-10++### Changed++- Requires `baikai ^>=0.8.0` for compatibility with the core pricing tier+  change. No package API change.+- Raises the provider bounds to `baikai-claude ^>=0.7.1.1` and+  `baikai-openai ^>=0.7.1.1`, so installing the tool selects the fixed batch+  adapters. Binary regressions use the shared process fixture and cover both+  cancellation and a 1MiB output flood.++## [baikai-kit 0.4.0.0] - 2026-10-02++### Added++- Per-item `visibility` (`tool-only` by default, or `shared`) for manifest+  versions 1 and 2, with `kit install --shared`, `--tool-only`, and+  `--accept-shared-codex`. Shared Claude skills and agents use tracked links;+  tool-only Codex skills use tracked disabled entries in `config.toml`.+- `codexSessionArgs`, required in the `extraArgs` of a consumer's own Codex+  launches to re-enable its tool-only skills. This is the one launcher step+  beyond a dependency bump; the standard `kitConfig`/`kitCommandParser`/`runKit`+  integration otherwise compiles unchanged. Codex custom agents cannot be+  isolated and require explicit acceptance, or shared visibility.+- Requested and effective visibility in status (table and JSON), the+  `visibility-broken` condition, and a migration note for legacy shared Codex+  skills. Update repairs visibility even for items skipped for local edits;+  uninstall removes only owned links and config entries.++### Changed (breaking)++- `SkillEntry` and `AgentEntry` gain `visibility`; `SidecarMeta` gains+  `visibility`, `visibilitySource`, `sharedLinks`, and `codexDisabledSkills`;+  `newSidecarMeta` takes these values. `RemovalOutcome` gains `linksRemoved`+  and `configEntriesRemoved`; `StatusRow` gains `requestedVisibility` and+  `effectiveVisibility`; `KitConfig` gains `confirmSharedCodex` (default+  `Nothing` in `kitConfig`).+- `KitInstall` gains `InstallOptions`; `installItem` and `installFrom` take+  it as their last argument. Use `defaultInstallOptions` to follow the+  manifest. Explicit install flags persist across updates, while new+  manifest-driven installs follow updated defaults. Legacy placement is kept.+- JSON adds list `visibility`, status `requestedVisibility` and+  `effectiveVisibility`, and the new condition value. `formatVersion` stays+  1 because all additions preserve existing keys and their meaning.++### Fixed++- Codex installs refuse destinations without this tool's sidecar instead of+  overwriting user or other-tool assets. Uninstall also preserves foreign+  Codex assets. Shared Claude names are checked before any provider write.+- Codex config edits preserve UTF-8 text, comments and permissions, refuse+  symlinks and invalid/conflicting config, and verify the semantic change+  before atomic rename. Reused user-owned disabled entries survive uninstall.+  Shared visibility refuses a user-owned entry that would keep the skill hidden.+- The source distribution ships the test suite's manifest fixtures and JSON+  goldens, so the test suite passes when run from the Hackage tarball. Since+  0.3.0.0 it had failed there, because those files were missing.+ ## [baikai 0.7.2.0] - 2026-09-30  ### Added
baikai.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.4 name: baikai-version: 0.7.2.0+version: 0.8.0.0 synopsis: Unified Haskell interface for multiple AI providers description:   baikai provides a unified, provider-agnostic Haskell interface for working@@ -18,6 +18,10 @@ build-type: Simple tested-with: ghc ==9.12.4 extra-doc-files: CHANGELOG.md+extra-source-files:+  data/models/*.json+  test/fixtures/*.json+  test/fixtures/*.jsonl  common common-options   ghc-options:@@ -85,6 +89,7 @@     Baikai.Prelude     Baikai.Provider     Baikai.Provider.Cli.Internal+    Baikai.Provider.Cli.Process.Internal     Baikai.Provider.Internal.StreamWorker     Baikai.Provider.Registry     Baikai.Provider.Transport.Classify@@ -138,6 +143,10 @@     unliftio-core ^>=0.2,     vector ^>=0.13, +  if !os(windows)+    build-depends: unix ^>=2.8+    c-sources: cbits/cli_group_anchor.c+ executable baikai-gen-models   import: common-options   hs-source-dirs: gen@@ -191,11 +200,14 @@     gen    main-is: Main.hs+  ghc-options: -threaded   other-modules:     AgentAssetsSpec     AgentSpec     CatalogSpec     CliInternalSpec+    CliProcessFixture+    CliProcessSpec     ContextSpec     CostSpec     EmbeddingSpec@@ -248,3 +260,6 @@     time,     tls,     vector,++  if !os(windows)+    build-depends: unix ^>=2.8
+ cbits/cli_group_anchor.c view
@@ -0,0 +1,49 @@+/* Reserve a process group after its CLI leader has been reaped. The parent+ * establishes membership before returning; it must not reap the anchor until+ * its final group signal/observation. A dead, unreaped anchor reserves it too.+ * The child runs only async-signal-safe operations: no Haskell or allocation. */+#include <errno.h>+#include <signal.h>+#include <sys/types.h>+#include <sys/wait.h>+#include <unistd.h>+#ifdef __linux__+#include <sys/syscall.h>+#endif++int baikai_cli_group_anchor(int group) {+    long limit = sysconf(_SC_OPEN_MAX);+    if (limit < 0) { errno = EINVAL; return -1; }+    pid_t child = fork();+    if (child < 0) return -1;+    if (child == 0) {+        struct sigaction ignore;+        sigset_t empty;+        ignore.sa_handler = SIG_IGN;+        ignore.sa_flags = 0;+        sigemptyset(&ignore.sa_mask);+        sigemptyset(&empty);+        if (sigaction(SIGINT, &ignore, 0) < 0 ||+            sigaction(SIGTERM, &ignore, 0) < 0 ||+            sigprocmask(SIG_SETMASK, &empty, 0) < 0) _exit(127);+        /* Inherited descriptors are never used and are closed before parking.+         * Cancellation may kill this child while it is closing descriptors;+         * its unreaped membership still protects the group's identity. */+#ifdef __linux__+#ifdef SYS_close_range+        if (syscall(SYS_close_range, 0U, ~0U, 0U) < 0)+#endif+#endif+            for (long fd = 0; fd < limit; ++fd) close((int)fd);+        for (;;) pause();+    }+    /* The anchor never execs, so the parent can set its process group here.+     * The CLI leader is still unreaped and no callback yet owns its handle. */+    if (setpgid(child, (pid_t)group) < 0) {+        int saved = errno;+        kill(child, SIGKILL);+        while (waitpid(child, 0, 0) < 0 && errno == EINTR) {}+        errno = saved; return -1;+    }+    return (int)child;+}
+ data/models/anthropic.json view
@@ -0,0 +1,325 @@+{+  "provider": "anthropic",+  "baseUrl": "https://api.anthropic.com",+  "api": "anthropic-messages",+  "compat": "auto",+  "models": [+    {+      "id": "claude-fable-5",+      "name": "Claude Fable 5",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 10.0,+        "output": 50.0,+        "cacheRead": 1.0,+        "cacheWrite": 12.5+      },+      "contextWindow": 1000000,+      "maxOutputTokens": 128000,+      "compat": {+        "kind": "anthropic-messages",+        "thinkingStyle": "adaptive",+        "supportsSamplingParameters": false,+        "supportsFastMode": false,+        "supportsForcedToolChoice": true+      },+      "enabled": true+    },+    {+      "id": "claude-fable-5-1",+      "name": "Claude Fable 5.1",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 10.0,+        "output": 50.0,+        "cacheRead": 0.25,+        "cacheWrite": 12.5+      },+      "contextWindow": 1000000,+      "maxOutputTokens": 128000,+      "pricingPolicy": {"inputTiers": [], "longCacheWriteCost": 20.0},+      "compat": {+        "kind": "anthropic-messages",+        "thinkingStyle": "adaptive",+        "supportsSamplingParameters": false,+        "supportsFastMode": false,+        "supportsForcedToolChoice": false+      },+      "enabled": true+    },+    {+      "id": "claude-haiku-4-5",+      "name": "Claude Haiku 4.5",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 1.0,+        "output": 5.0,+        "cacheRead": 0.1,+        "cacheWrite": 1.25+      },+      "contextWindow": 200000,+      "maxOutputTokens": 64000,+      "compat": {+        "kind": "anthropic-messages",+        "thinkingStyle": "budget",+        "supportsSamplingParameters": true,+        "supportsFastMode": false,+        "supportsForcedToolChoice": true+      },+      "enabled": true+    },+    {+      "id": "claude-haiku-5-5",+      "name": "Claude Haiku 5.5",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 0.1,+        "output": 0.5,+        "cacheRead": 0.01,+        "cacheWrite": 0.125+      },+      "contextWindow": 1000000,+      "maxOutputTokens": 128000,+      "pricingPolicy": {"inputTiers": [{"inputAbove": 100000, "rates": {"input": 0.5, "output": 2.5, "cacheRead": 0.05, "cacheWrite": 0.625}, "longCacheWriteCost": 1.0}], "longCacheWriteCost": 0.2},+      "compat": {+        "kind": "anthropic-messages",+        "thinkingStyle": "adaptive",+        "supportsSamplingParameters": false,+        "supportsFastMode": false,+        "supportsForcedToolChoice": true+      },+      "enabled": true+    },+    {+      "id": "claude-opus-4-5",+      "name": "Claude Opus 4.5",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 5.0,+        "output": 25.0,+        "cacheRead": 0.5,+        "cacheWrite": 6.25+      },+      "contextWindow": 200000,+      "maxOutputTokens": 64000,+      "compat": {+        "kind": "anthropic-messages",+        "thinkingStyle": "budget",+        "supportsSamplingParameters": true,+        "supportsFastMode": false,+        "supportsForcedToolChoice": true+      },+      "enabled": true+    },+    {+      "id": "claude-opus-4-6",+      "name": "Claude Opus 4.6",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 5.0,+        "output": 25.0,+        "cacheRead": 0.5,+        "cacheWrite": 6.25+      },+      "contextWindow": 1000000,+      "maxOutputTokens": 128000,+      "compat": {+        "kind": "anthropic-messages",+        "thinkingStyle": "adaptive",+        "supportsSamplingParameters": true,+        "supportsFastMode": false,+        "supportsForcedToolChoice": true+      },+      "enabled": true+    },+    {+      "id": "claude-opus-4-7",+      "name": "Claude Opus 4.7",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 5.0,+        "output": 25.0,+        "cacheRead": 0.5,+        "cacheWrite": 6.25+      },+      "contextWindow": 1000000,+      "maxOutputTokens": 128000,+      "compat": {+        "kind": "anthropic-messages",+        "thinkingStyle": "adaptive",+        "supportsSamplingParameters": false,+        "supportsFastMode": false,+        "supportsForcedToolChoice": true+      },+      "enabled": true+    },+    {+      "id": "claude-opus-4-8",+      "name": "Claude Opus 4.8",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 5.0,+        "output": 25.0,+        "cacheRead": 0.5,+        "cacheWrite": 6.25+      },+      "contextWindow": 1000000,+      "maxOutputTokens": 128000,+      "fastModeCost": {"input": 10.0, "output": 50.0, "cacheRead": 1.0, "cacheWrite": 12.5},+      "pricingPolicy": {"inputTiers": [], "longCacheWriteCost": 10.0},+      "compat": {+        "kind": "anthropic-messages",+        "thinkingStyle": "adaptive",+        "supportsSamplingParameters": false,+        "supportsFastMode": true,+        "supportsForcedToolChoice": true+      },+      "enabled": true+    },+    {+      "id": "claude-opus-5",+      "name": "Claude Opus 5",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 5.0,+        "output": 25.0,+        "cacheRead": 0.5,+        "cacheWrite": 6.25+      },+      "contextWindow": 1000000,+      "maxOutputTokens": 128000,+      "fastModeCost": {"input": 10.0, "output": 50.0, "cacheRead": 1.0, "cacheWrite": 12.5},+      "pricingPolicy": {"inputTiers": [], "longCacheWriteCost": 10.0},+      "compat": {+        "kind": "anthropic-messages",+        "thinkingStyle": "adaptive",+        "supportsSamplingParameters": false,+        "supportsFastMode": true,+        "supportsForcedToolChoice": true+      },+      "enabled": true+    },+    {+      "id": "claude-opus-5-5",+      "name": "Claude Opus 5.5",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 4.0,+        "output": 20.0,+        "cacheRead": 0.2,+        "cacheWrite": 5.0+      },+      "contextWindow": 1000000,+      "maxOutputTokens": 128000,+      "fastModeCost": {"input": 8.0, "output": 40.0, "cacheRead": 0.4, "cacheWrite": 10.0},+      "pricingPolicy": {"inputTiers": [], "longCacheWriteCost": 8.0},+      "compat": {+        "kind": "anthropic-messages",+        "thinkingStyle": "adaptive",+        "supportsSamplingParameters": false,+        "supportsFastMode": true,+        "supportsForcedToolChoice": false+      },+      "enabled": true+    },+    {+      "id": "claude-sonnet-4-5",+      "name": "Claude Sonnet 4.5",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 3.0,+        "output": 15.0,+        "cacheRead": 0.3,+        "cacheWrite": 3.75+      },+      "contextWindow": 200000,+      "maxOutputTokens": 64000,+      "compat": {+        "kind": "anthropic-messages",+        "thinkingStyle": "budget",+        "supportsSamplingParameters": true,+        "supportsFastMode": false,+        "supportsForcedToolChoice": true+      },+      "enabled": true+    },+    {+      "id": "claude-sonnet-4-6",+      "name": "Claude Sonnet 4.6",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 3.0,+        "output": 15.0,+        "cacheRead": 0.3,+        "cacheWrite": 3.75+      },+      "contextWindow": 1000000,+      "maxOutputTokens": 128000,+      "compat": {+        "kind": "anthropic-messages",+        "thinkingStyle": "adaptive",+        "supportsSamplingParameters": true,+        "supportsFastMode": false,+        "supportsForcedToolChoice": true+      },+      "enabled": true+    },+    {+      "id": "claude-sonnet-5",+      "name": "Claude Sonnet 5",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 2.0,+        "output": 10.0,+        "cacheRead": 0.2,+        "cacheWrite": 2.5+      },+      "contextWindow": 1000000,+      "maxOutputTokens": 128000,+      "compat": {+        "kind": "anthropic-messages",+        "thinkingStyle": "adaptive",+        "supportsSamplingParameters": false,+        "supportsFastMode": false,+        "supportsForcedToolChoice": true+      },+      "enabled": true+    },+    {+      "id": "claude-sonnet-5-5",+      "name": "Claude Sonnet 5.5",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 2.0,+        "output": 10.0,+        "cacheRead": 0.1,+        "cacheWrite": 2.5+      },+      "contextWindow": 1000000,+      "maxOutputTokens": 128000,+      "pricingPolicy": {"inputTiers": [], "longCacheWriteCost": 4.0},+      "compat": {+        "kind": "anthropic-messages",+        "thinkingStyle": "adaptive",+        "supportsSamplingParameters": false,+        "supportsFastMode": false,+        "supportsForcedToolChoice": false+      },+      "enabled": true+    }+  ]+}
+ data/models/deepseek.json view
@@ -0,0 +1,38 @@+{+  "provider": "deepseek",+  "baseUrl": "https://api.deepseek.com",+  "api": "openai-chat-completions",+  "compat": "auto",+  "models": [+    {+      "id": "deepseek-chat",+      "name": "DeepSeek Chat",+      "reasoning": false,+      "input": ["text"],+      "cost": {+        "input": 0.27,+        "output": 1.1,+        "cacheRead": 0.07,+        "cacheWrite": 0.0+      },+      "contextWindow": 64000,+      "maxOutputTokens": 8192,+      "enabled": true+    },+    {+      "id": "deepseek-reasoner",+      "name": "DeepSeek Reasoner",+      "reasoning": true,+      "input": ["text"],+      "cost": {+        "input": 0.55,+        "output": 2.19,+        "cacheRead": 0.14,+        "cacheWrite": 0.0+      },+      "contextWindow": 64000,+      "maxOutputTokens": 8192,+      "enabled": true+    }+  ]+}
+ data/models/openai.json view
@@ -0,0 +1,434 @@+{+  "provider": "openai",+  "baseUrl": "https://api.openai.com",+  "api": "openai-chat-completions",+  "compat": "auto",+  "models": [+    {+      "id": "gpt-4.1",+      "name": "GPT-4.1",+      "reasoning": false,+      "input": ["text", "image"],+      "cost": {+        "input": 2.0,+        "output": 8.0,+        "cacheRead": 0.5,+        "cacheWrite": 0.0+      },+      "contextWindow": 1047576,+      "maxOutputTokens": 32768,+      "enabled": true+    },+    {+      "id": "gpt-4.1-mini",+      "name": "GPT-4.1 mini",+      "reasoning": false,+      "input": ["text", "image"],+      "cost": {+        "input": 0.4,+        "output": 1.6,+        "cacheRead": 0.1,+        "cacheWrite": 0.0+      },+      "contextWindow": 1047576,+      "maxOutputTokens": 32768,+      "enabled": true+    },+    {+      "id": "gpt-4.1-nano",+      "name": "GPT-4.1 nano",+      "reasoning": false,+      "input": ["text", "image"],+      "cost": {+        "input": 0.1,+        "output": 0.4,+        "cacheRead": 0.025,+        "cacheWrite": 0.0+      },+      "contextWindow": 1047576,+      "maxOutputTokens": 32768,+      "enabled": true+    },+    {+      "id": "gpt-4o",+      "name": "GPT-4o",+      "reasoning": false,+      "input": ["text", "image"],+      "cost": {+        "input": 2.5,+        "output": 10.0,+        "cacheRead": 1.25,+        "cacheWrite": 0.0+      },+      "contextWindow": 128000,+      "maxOutputTokens": 16384,+      "enabled": true+    },+    {+      "id": "gpt-4o-mini",+      "name": "GPT-4o mini",+      "reasoning": false,+      "input": ["text", "image"],+      "cost": {+        "input": 0.15,+        "output": 0.6,+        "cacheRead": 0.075,+        "cacheWrite": 0.0+      },+      "contextWindow": 128000,+      "maxOutputTokens": 16384,+      "enabled": true+    },+    {+      "id": "gpt-5",+      "name": "GPT-5",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 1.25,+        "output": 10.0,+        "cacheRead": 0.125,+        "cacheWrite": 0.0+      },+      "contextWindow": 400000,+      "maxOutputTokens": 128000,+      "enabled": true+    },+    {+      "id": "gpt-5-mini",+      "name": "GPT-5 Mini",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 0.25,+        "output": 2.0,+        "cacheRead": 0.025,+        "cacheWrite": 0.0+      },+      "contextWindow": 400000,+      "maxOutputTokens": 128000,+      "enabled": true+    },+    {+      "id": "gpt-5-nano",+      "name": "GPT-5 Nano",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 0.05,+        "output": 0.4,+        "cacheRead": 0.005,+        "cacheWrite": 0.0+      },+      "contextWindow": 400000,+      "maxOutputTokens": 128000,+      "enabled": true+    },+    {+      "id": "gpt-5.1",+      "name": "GPT-5.1",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 1.25,+        "output": 10.0,+        "cacheRead": 0.125,+        "cacheWrite": 0.0+      },+      "contextWindow": 400000,+      "maxOutputTokens": 128000,+      "enabled": true+    },+    {+      "id": "gpt-5.2",+      "name": "GPT-5.2",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 1.75,+        "output": 14.0,+        "cacheRead": 0.175,+        "cacheWrite": 0.0+      },+      "contextWindow": 400000,+      "maxOutputTokens": 128000,+      "enabled": true+    },+    {+      "id": "gpt-5.4",+      "name": "GPT-5.4",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 2.5,+        "output": 15.0,+        "cacheRead": 0.25,+        "cacheWrite": 0.0+      },+      "contextWindow": 1050000,+      "maxOutputTokens": 128000,+      "enabled": true+    },+    {+      "id": "gpt-5.4-mini",+      "name": "GPT-5.4 mini",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 0.75,+        "output": 4.5,+        "cacheRead": 0.075,+        "cacheWrite": 0.0+      },+      "contextWindow": 400000,+      "maxOutputTokens": 128000,+      "enabled": true+    },+    {+      "id": "gpt-5.4-nano",+      "name": "GPT-5.4 nano",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 0.2,+        "output": 1.25,+        "cacheRead": 0.02,+        "cacheWrite": 0.0+      },+      "contextWindow": 400000,+      "maxOutputTokens": 128000,+      "enabled": true+    },+    {+      "id": "gpt-5.5",+      "name": "GPT-5.5",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 5.0,+        "output": 30.0,+        "cacheRead": 0.5,+        "cacheWrite": 0.0+      },+      "contextWindow": 1050000,+      "maxOutputTokens": 128000,+      "enabled": true+    },+    {+      "id": "gpt-5.6",+      "name": "GPT-5.6",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 4.0,+        "output": 20.0,+        "cacheRead": 0.4,+        "cacheWrite": 5.0+      },+      "contextWindow": 1050000,+      "maxOutputTokens": 128000,+      "enabled": true+    },+    {+      "id": "gpt-5.6-luna",+      "name": "GPT-5.6 Luna",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 0.2,+        "output": 1.2,+        "cacheRead": 0.02,+        "cacheWrite": 0.25+      },+      "contextWindow": 1050000,+      "maxOutputTokens": 128000,+      "enabled": true+    },+    {+      "id": "gpt-5.6-sol",+      "name": "GPT-5.6 Sol",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 4.0,+        "output": 20.0,+        "cacheRead": 0.4,+        "cacheWrite": 5.0+      },+      "contextWindow": 1050000,+      "maxOutputTokens": 128000,+      "enabled": true+    },+    {+      "id": "gpt-5.6-terra",+      "name": "GPT-5.6 Terra",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 2.0,+        "output": 12.0,+        "cacheRead": 0.2,+        "cacheWrite": 2.5+      },+      "contextWindow": 1050000,+      "maxOutputTokens": 128000,+      "enabled": true+    },+    {+      "id": "gpt-6-astra",+      "name": "GPT-6 Astra",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 10.0,+        "output": 50.0,+        "cacheRead": 1.0,+        "cacheWrite": 12.5+      },+      "contextWindow": 1050000,+      "maxOutputTokens": 128000,+      "api": "openai-responses",+      "pricingPolicy": {"inputTiers": [{"inputAbove": 272000, "rates": {"input": 20.0, "output": 75.0, "cacheRead": 2.0, "cacheWrite": 25.0}}]},+      "compat": {+        "kind": "openai-responses",+        "supportsSamplingParameters": false,+        "supportsLongCacheRetention": false,+        "supportsPromptCacheOptions": true,+        "supportedReasoningEfforts": ["low", "medium", "high", "xhigh", "max"]+      },+      "enabled": true+    },+    {+      "id": "gpt-6-luna",+      "name": "GPT-6 Luna",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 0.1,+        "output": 0.5,+        "cacheRead": 0.01,+        "cacheWrite": 0.125+      },+      "contextWindow": 1050000,+      "maxOutputTokens": 128000,+      "api": "openai-responses",+      "pricingPolicy": {"inputTiers": [{"inputAbove": 272000, "rates": {"input": 0.2, "output": 0.75, "cacheRead": 0.02, "cacheWrite": 0.25}}]},+      "compat": {+        "kind": "openai-responses",+        "supportsSamplingParameters": false,+        "supportsLongCacheRetention": false,+        "supportsPromptCacheOptions": true,+        "supportedReasoningEfforts": ["low", "medium", "high", "xhigh", "max"]+      },+      "enabled": true+    },+    {+      "id": "gpt-6-sol",+      "name": "GPT-6 Sol",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 2.0,+        "output": 10.0,+        "cacheRead": 0.2,+        "cacheWrite": 2.5+      },+      "contextWindow": 1050000,+      "maxOutputTokens": 128000,+      "api": "openai-responses",+      "pricingPolicy": {"inputTiers": [{"inputAbove": 272000, "rates": {"input": 4.0, "output": 15.0, "cacheRead": 0.4, "cacheWrite": 5.0}}]},+      "compat": {+        "kind": "openai-responses",+        "supportsSamplingParameters": false,+        "supportsLongCacheRetention": false,+        "supportsPromptCacheOptions": true,+        "supportedReasoningEfforts": ["low", "medium", "high", "xhigh", "max"]+      },+      "enabled": true+    },+    {+      "id": "gpt-6.1-sol",+      "name": "GPT-6.1 Sol",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 2.0,+        "output": 10.0,+        "cacheRead": 0.1,+        "cacheWrite": 2.5+      },+      "contextWindow": 1050000,+      "maxOutputTokens": 128000,+      "api": "openai-responses",+      "pricingPolicy": {"inputTiers": [{"inputAbove": 272000, "rates": {"input": 4.0, "output": 15.0, "cacheRead": 0.2, "cacheWrite": 5.0}}]},+      "compat": {+        "kind": "openai-responses",+        "supportsSamplingParameters": false,+        "supportsLongCacheRetention": false,+        "supportsPromptCacheOptions": true,+        "supportedReasoningEfforts": ["low", "medium", "high", "xhigh", "max"]+      },+      "enabled": true+    },+    {+      "id": "o1",+      "name": "o1",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 15.0,+        "output": 60.0,+        "cacheRead": 7.5,+        "cacheWrite": 0.0+      },+      "contextWindow": 200000,+      "maxOutputTokens": 100000,+      "enabled": true+    },+    {+      "id": "o3",+      "name": "o3",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 2.0,+        "output": 8.0,+        "cacheRead": 0.5,+        "cacheWrite": 0.0+      },+      "contextWindow": 200000,+      "maxOutputTokens": 100000,+      "enabled": true+    },+    {+      "id": "o3-mini",+      "name": "o3-mini",+      "reasoning": true,+      "input": ["text"],+      "cost": {+        "input": 1.1,+        "output": 4.4,+        "cacheRead": 0.55,+        "cacheWrite": 0.0+      },+      "contextWindow": 200000,+      "maxOutputTokens": 100000,+      "enabled": true+    },+    {+      "id": "o4-mini",+      "name": "o4-mini",+      "reasoning": true,+      "input": ["text", "image"],+      "cost": {+        "input": 1.1,+        "output": 4.4,+        "cacheRead": 0.275,+        "cacheWrite": 0.0+      },+      "contextWindow": 200000,+      "maxOutputTokens": 100000,+      "enabled": true+    }+  ]+}
+ data/models/openrouter.json view
@@ -0,0 +1,38 @@+{+  "provider": "openrouter",+  "baseUrl": "https://openrouter.ai/api",+  "api": "openai-chat-completions",+  "compat": "auto",+  "models": [+    {+      "id": "openai/gpt-4o-mini",+      "name": "GPT-4o mini via OpenRouter",+      "reasoning": false,+      "input": ["text", "image"],+      "cost": {+        "input": 0.15,+        "output": 0.6,+        "cacheRead": 0.075,+        "cacheWrite": 0.0+      },+      "contextWindow": 128000,+      "maxOutputTokens": 16384,+      "enabled": true+    },+    {+      "id": "anthropic/claude-sonnet-4",+      "name": "Claude Sonnet 4 via OpenRouter",+      "reasoning": false,+      "input": ["text", "image"],+      "cost": {+        "input": 3.0,+        "output": 15.0,+        "cacheRead": 0.3,+        "cacheWrite": 3.75+      },+      "contextWindow": 200000,+      "maxOutputTokens": 8192,+      "enabled": true+    }+  ]+}
fetch/FetchModelsCore.hs view
@@ -330,6 +330,15 @@ -- 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.+--+-- 2026-10-02: @claude-mythos-5@ and @claude-mythos-5-1@ are deliberately+-- absent. Anthropic offers them only to approved Project Glasswing+-- customers, so no general key can prove them live, and models.dev omits+-- both from its @anthropic@ provider. Mythos 5.1 shares Fable 5.1's API+-- facts and standard prices; callers with access can record-update+-- @Baikai.Models.Generated.anthropic_claude_fable_5_1@ with the Mythos id.+-- https://platform.claude.com/docs/en/models/fable-5-1/migration-guide+-- https://platform.claude.com/docs/en/about-claude/pricing anthropicInclude :: Map Text AnthropicGenerationFacts anthropicInclude =   Map.fromList@@ -376,6 +385,14 @@       ("claude-sonnet-4-5", budgetWithSampling),       -- 2026-08-27: budget shape, sampling parameters accepted — same source.       ("claude-haiku-4-5", budgetWithSampling),+      -- 2026-10-10: adaptive thinking on by default, and manual budget+      -- thinking returns 400; nondefault sampling returns 400. Forced tool+      -- choice is accepted, and that response starts with the tool call+      -- and has no thinking block. No fast mode.+      -- https://platform.claude.com/docs/en/models/haiku-5-5/overview+      -- https://platform.claude.com/docs/en/models/haiku-5-5/migration-guide+      -- https://platform.claude.com/docs/en/build-with-claude/effort+      ("claude-haiku-5-5", adaptiveNoSampling),       -- 2026-08-27: adaptive-only, sampling parameters rejected — same source.       ("claude-fable-5", adaptiveNoSampling),       -- 2026-09-07: always-on adaptive thinking; omit sampling parameters.@@ -662,25 +679,31 @@ -- https://platform.claude.com/docs/en/models/fable-5-1/overview -- https://platform.claude.com/docs/en/models/opus-5-5/overview -- https://platform.claude.com/docs/en/build-with-claude/fast-mode+-- Claude Haiku 5.5 verified 2026-10-10: prompts over 100,000 tokens,+-- counting cache reads and writes, pay the higher rates on every category.+-- https://platform.claude.com/docs/en/about-claude/pricing#long-context-pricing pricingPolicies :: Map (Text, Text) Model.PricingPolicy pricingPolicies =   Map.fromList     [ (("anthropic", "claude-opus-5-5"), Model.PricingPolicy [] (Just 8)),       (("anthropic", "claude-opus-5"), Model.PricingPolicy [] (Just 10)),       (("anthropic", "claude-opus-4-8"), Model.PricingPolicy [] (Just 10)),-      (("openai", "gpt-6-astra"), Model.PricingPolicy [Model.InputPriceTier 272000 (Model.ModelCost 20 75 2 25)] Nothing),+      (("openai", "gpt-6-astra"), Model.PricingPolicy [Model.InputPriceTier 272000 (Model.ModelCost 20 75 2 25) Nothing] Nothing),       (("anthropic", "claude-sonnet-5-5"), Model.PricingPolicy [] (Just 4)),-      (("openai", "gpt-6.1-sol"), Model.PricingPolicy [Model.InputPriceTier 272000 (Model.ModelCost 4 15 0.2 5)] Nothing),-      (("openai", "gpt-6-sol"), Model.PricingPolicy [Model.InputPriceTier 272000 (Model.ModelCost 4 15 0.4 5)] Nothing),-      (("openai", "gpt-6-luna"), Model.PricingPolicy [Model.InputPriceTier 272000 (Model.ModelCost 0.2 0.75 0.02 0.25)] Nothing),-      (("anthropic", "claude-fable-5-1"), Model.PricingPolicy [] (Just 20))+      (("openai", "gpt-6.1-sol"), Model.PricingPolicy [Model.InputPriceTier 272000 (Model.ModelCost 4 15 0.2 5) Nothing] Nothing),+      (("openai", "gpt-6-sol"), Model.PricingPolicy [Model.InputPriceTier 272000 (Model.ModelCost 4 15 0.4 5) Nothing] Nothing),+      (("openai", "gpt-6-luna"), Model.PricingPolicy [Model.InputPriceTier 272000 (Model.ModelCost 0.2 0.75 0.02 0.25) Nothing] Nothing),+      (("anthropic", "claude-fable-5-1"), Model.PricingPolicy [] (Just 20)),+      (("anthropic", "claude-haiku-5-5"), Model.PricingPolicy [Model.InputPriceTier 100000 (Model.ModelCost 0.5 2.5 0.05 0.625) (Just 1)] (Just 0.2))     ]  renderPricingPolicy :: Model.PricingPolicy -> Text-renderPricingPolicy p = "{\"inputTiers\": [" <> Text.intercalate ", " (map tier (Model.inputTiers p)) <> "]" <> maybe "" (\r -> ", \"longCacheWriteCost\": " <> num r) (Model.longCacheWriteCost p) <> "}"+renderPricingPolicy p = "{\"inputTiers\": [" <> Text.intercalate ", " (map tier (Model.inputTiers p)) <> "]" <> long policyLong <> "}"   where+    Model.PricingPolicy {Model.longCacheWriteCost = policyLong} = p     num = renderNum . fst . fromRationalRepetendUnlimited-    tier t = "{\"inputAbove\": " <> Text.pack (show (Model.inputAbove t)) <> ", \"rates\": " <> rates (Model.rates t) <> "}"+    long = maybe "" (\r -> ", \"longCacheWriteCost\": " <> num r)+    tier (Model.InputPriceTier above tierRates tierLong) = "{\"inputAbove\": " <> Text.pack (show above) <> ", \"rates\": " <> rates tierRates <> long tierLong <> "}"     rates c = "{\"input\": " <> num (Model.inputCost c) <> ", \"output\": " <> num (Model.outputCost c) <> ", \"cacheRead\": " <> num (Model.cacheReadCost c) <> ", \"cacheWrite\": " <> num (Model.cacheWriteCost c) <> "}"  -- | Render the per-model @compat@ block, if the provider spec supplied
gen/GenModelsCore.hs view
@@ -305,7 +305,10 @@   either (fail . Text.unpack) (const (pure policy)) (Model.validatePricingPolicy policy)   where     parseTier = Aeson.withObject "InputPriceTier" $ \o ->-      Model.InputPriceTier <$> o .: "inputAbove" <*> (o .: "rates" >>= parseCompleteRates)+      Model.InputPriceTier+        <$> o .: "inputAbove"+        <*> (o .: "rates" >>= parseCompleteRates)+        <*> (fmap toRational <$> (o .:? "longCacheWriteCost" :: Parser (Maybe Scientific)))     parseCompleteRates = Aeson.withObject "Complete tier rates" $ \o ->       toModelCost <$> (CostEntry <$> o .: "input" <*> o .: "output" <*> o .: "cacheRead" <*> o .: "cacheWrite") @@ -602,9 +605,10 @@  renderPolicy :: Maybe Model.PricingPolicy -> Text renderPolicy Nothing = "Nothing"-renderPolicy (Just p) = "Just (PricingPolicy [" <> Text.intercalate ", " (map tier (Model.inputTiers p)) <> "] " <> maybe "Nothing" (\r -> "(Just (" <> renderRational r <> "))") (Model.longCacheWriteCost p) <> ")"+renderPolicy (Just (Model.PricingPolicy tiers policyLong)) = "Just (PricingPolicy [" <> Text.intercalate ", " (map tier tiers) <> "] " <> long policyLong <> ")"   where-    tier t = "InputPriceTier " <> Text.pack (show (Model.inputAbove t)) <> " (" <> rates (Model.rates t) <> ")"+    tier (Model.InputPriceTier above tierRates tierLong) = "InputPriceTier " <> Text.pack (show above) <> " (" <> rates tierRates <> ") " <> long tierLong+    long = maybe "Nothing" (\r -> "(Just (" <> renderRational r <> "))")     rates c = "ModelCost " <> Text.unwords (map (\r -> "(" <> renderRational r <> ")") [Model.inputCost c, Model.outputCost c, Model.cacheReadCost c, Model.cacheWriteCost c])  renderRational :: Rational -> Text
src/Baikai/Cost/Pricing.hs view
@@ -68,7 +68,7 @@   Nothing -> estimateCost [UnsupportedSpeed "fast"] (computeCostWith duration m u)   Just fast ->     let resolved = do-          validatePricingPolicy (PricingPolicy [InputPriceTier 0 fast] Nothing)+          validatePricingPolicy (PricingPolicy [InputPriceTier 0 fast Nothing] Nothing)           standard <- resolveRates duration m u           let base = m ^. #cost               -- Apply each premium rate's ratio to the resolved policy once.@@ -88,7 +88,7 @@ -- | Price one resolved rate record exactly once. computeCostAtRates :: ModelCost -> Usage -> Cost computeCostAtRates rates u =-  let resolved = validatePricingPolicy (PricingPolicy [InputPriceTier 0 rates] Nothing) >> pure rates+  let resolved = validatePricingPolicy (PricingPolicy [InputPriceTier 0 rates Nothing] Nothing) >> pure rates       computed = priceUsage resolved u    in computed & #basis . #sources .~ Set.singleton ResolvedTokenRates @@ -138,16 +138,21 @@  -- | Choose one complete rate record. Thresholds are exclusive and use -- disjoint normalized input categories, including both cache counters.+-- A shaped long-duration request takes its cache-write rate from the same+-- tier that supplied the other rates, or from the policy below every tier. resolveRates :: Maybe CacheRetention -> Model -> Usage -> Either Text ModelCost resolveRates duration m u = do-  validatePricingPolicy (PricingPolicy [InputPriceTier 0 (m ^. #cost)] Nothing)+  validatePricingPolicy (PricingPolicy [InputPriceTier 0 (m ^. #cost) Nothing] Nothing)   case m ^. #pricingPolicy of     Nothing -> pure (m ^. #cost)-    Just policy -> do+    Just policy@(PricingPolicy tiers policyLong) -> do       validatePricingPolicy policy       let totalInput = (u ^. #inputTokens) + (u ^. #cacheReadTokens) + (u ^. #cacheWriteTokens)-          selected = foldl' (\current tier -> if totalInput > inputAbove tier then rates tier else current) (m ^. #cost) (inputTiers policy)-      pure $ case (duration, longCacheWriteCost policy) of+          select current (InputPriceTier above tierRates tierLong)+            | totalInput > above = (tierRates, tierLong)+            | otherwise = current+          (selected, long) = foldl' select (m ^. #cost, policyLong) tiers+      pure $ case (duration, long) of         (Just CacheRetentionLong, Just price) -> selected {cacheWriteCost = price}         _ -> selected 
src/Baikai/Model.hs view
@@ -68,6 +68,7 @@     genericToJSON,     withObject,     (.!=),+    (.:),     (.:?),   ) import Data.List (nub, sort)@@ -97,16 +98,26 @@  -- | An exclusive input-context threshold. Its complete rate record -- applies to every token category in the call once total input exceeds it.+-- Its long cache-write rate is the absolute per-million price of a+-- long-duration cache write in such a call. JSON without that key decodes+-- to 'Nothing'. data InputPriceTier = InputPriceTier   { inputAbove :: !Natural,-    rates :: !ModelCost+    rates :: !ModelCost,+    longCacheWriteCost :: !(Maybe Rational)   }   deriving stock (Eq, Show, Generic)-  deriving anyclass (FromJSON, ToJSON)+  deriving anyclass (ToJSON) +instance FromJSON InputPriceTier where+  parseJSON = withObject "InputPriceTier" $ \o ->+    InputPriceTier <$> o .: "inputAbove" <*> o .: "rates" <*> o .:? "longCacheWriteCost"+ -- | Optional catalog policy layered over Model.cost. Long cache-write -- pricing is an absolute per-million rate, selected only for a shaped--- long-duration request. It overrides the selected tier's write rate.+-- long-duration request. The policy-level rate applies while no tier is+-- crossed; a crossed tier supplies its own. A policy with a long rate+-- must therefore give every tier one. data PricingPolicy = PricingPolicy   { inputTiers :: ![InputPriceTier],     longCacheWriteCost :: !(Maybe Rational)@@ -120,11 +131,13 @@     either (fail . show) (const (pure p)) (validatePricingPolicy p)  validatePricingPolicy :: PricingPolicy -> Either Text ()-validatePricingPolicy p = do-  let thresholds = map inputAbove (inputTiers p)+validatePricingPolicy (PricingPolicy tiers policyLong) = do+  let thresholds = map inputAbove tiers       validRates r = all (>= 0) [inputCost r, outputCost r, cacheReadCost r, cacheWriteCost r]+      tierLongs = [long | InputPriceTier {longCacheWriteCost = long} <- tiers]   unless (thresholds == sort (nub thresholds)) (Left "Pricing thresholds must be strictly increasing")-  unless (all (validRates . rates) (inputTiers p) && maybe True (>= 0) (longCacheWriteCost p)) (Left "Pricing rates must be nonnegative")+  unless (all (validRates . rates) tiers && all (maybe True (>= 0)) (policyLong : tierLongs)) (Left "Pricing rates must be nonnegative")+  unless (policyLong == Nothing || Nothing `notElem` tierLongs) (Left "Every input tier must state its long cache-write rate when the policy has one")  -- | Per-API compatibility shim. 'CompatNone' tells the provider to -- pick a sensible record by inspecting 'baseUrl'; the two real
src/Baikai/Models/Generated.hs view
@@ -170,6 +170,41 @@             }     } +anthropic_claude_haiku_5_5 :: Model+anthropic_claude_haiku_5_5 =+  emptyModel+    { modelId = "claude-haiku-5-5",+      name = "Claude Haiku 5.5",+      api = AnthropicMessages,+      provider = "anthropic",+      baseUrl = "https://api.anthropic.com",+      reasoning = True,+      input = [InputText, InputImage],+      cost =+        ModelCost+          { inputCost = 1 % 10,+            outputCost = 1 % 2,+            cacheReadCost = 1 % 100,+            cacheWriteCost = 1 % 8+          },+      fastModeCost = Nothing,+      pricingPolicy = Just (PricingPolicy [InputPriceTier 100000 (ModelCost (1 % 2) (5 % 2) (1 % 20) (5 % 8)) (Just (1 % 1))] (Just (1 % 5))),+      contextWindow = 1000000,+      maxOutputTokens = 128000,+      headers = Map.empty,+      compat =+        CompatAnthropicMessages+          defaultAnthropicMessagesCompat+            { supportsLongCacheRetention = True,+              supportsCacheControlOnTools = True,+              sendSessionAffinityHeaders = False,+              thinkingStyle = AnthropicThinkingAdaptive,+              supportsSamplingParameters = False,+              supportsFastMode = False,+              supportsForcedToolChoice = True+            }+    }+ anthropic_claude_opus_4_5 :: Model anthropic_claude_opus_4_5 =   emptyModel@@ -423,7 +458,7 @@           },       fastModeCost = Nothing,       pricingPolicy = Nothing,-      contextWindow = 1000000,+      contextWindow = 200000,       maxOutputTokens = 64000,       headers = Map.empty,       compat =@@ -523,7 +558,7 @@         ModelCost           { inputCost = 2 % 1,             outputCost = 10 % 1,-            cacheReadCost = 1 % 5,+            cacheReadCost = 1 % 10,             cacheWriteCost = 5 % 2           },       fastModeCost = Nothing,@@ -1062,7 +1097,7 @@             cacheWriteCost = 5 % 2           },       fastModeCost = Nothing,-      pricingPolicy = Just (PricingPolicy [InputPriceTier 272000 (ModelCost (4 % 1) (15 % 1) (1 % 5) (5 % 1))] Nothing),+      pricingPolicy = Just (PricingPolicy [InputPriceTier 272000 (ModelCost (4 % 1) (15 % 1) (1 % 5) (5 % 1)) Nothing] Nothing),       contextWindow = 1050000,       maxOutputTokens = 128000,       headers = Map.empty,@@ -1094,7 +1129,7 @@             cacheWriteCost = 25 % 2           },       fastModeCost = Nothing,-      pricingPolicy = Just (PricingPolicy [InputPriceTier 272000 (ModelCost (20 % 1) (75 % 1) (2 % 1) (25 % 1))] Nothing),+      pricingPolicy = Just (PricingPolicy [InputPriceTier 272000 (ModelCost (20 % 1) (75 % 1) (2 % 1) (25 % 1)) Nothing] Nothing),       contextWindow = 1050000,       maxOutputTokens = 128000,       headers = Map.empty,@@ -1126,7 +1161,7 @@             cacheWriteCost = 1 % 8           },       fastModeCost = Nothing,-      pricingPolicy = Just (PricingPolicy [InputPriceTier 272000 (ModelCost (1 % 5) (3 % 4) (1 % 50) (1 % 4))] Nothing),+      pricingPolicy = Just (PricingPolicy [InputPriceTier 272000 (ModelCost (1 % 5) (3 % 4) (1 % 50) (1 % 4)) Nothing] Nothing),       contextWindow = 1050000,       maxOutputTokens = 128000,       headers = Map.empty,@@ -1158,7 +1193,7 @@             cacheWriteCost = 5 % 2           },       fastModeCost = Nothing,-      pricingPolicy = Just (PricingPolicy [InputPriceTier 272000 (ModelCost (4 % 1) (15 % 1) (2 % 5) (5 % 1))] Nothing),+      pricingPolicy = Just (PricingPolicy [InputPriceTier 272000 (ModelCost (4 % 1) (15 % 1) (2 % 5) (5 % 1)) Nothing] Nothing),       contextWindow = 1050000,       maxOutputTokens = 128000,       headers = Map.empty,@@ -1328,6 +1363,7 @@   [ anthropic_claude_fable_5,     anthropic_claude_fable_5_1,     anthropic_claude_haiku_4_5,+    anthropic_claude_haiku_5_5,     anthropic_claude_opus_4_5,     anthropic_claude_opus_4_6,     anthropic_claude_opus_4_7,
src/Baikai/Provider/Cli/Internal.hs view
@@ -52,6 +52,7 @@     ToolResultPayload (..),     UserPayload (..),   )+import Baikai.Provider.Cli.Process.Internal (withOwnedProcess, withOwnedWorker) import Baikai.StopReason (StopReason (..)) import Baikai.Usage (Usage (..)) import Control.Applicative ((<|>))@@ -694,9 +695,25 @@  probeVersion :: FilePath -> IO (Maybe Text) probeVersion path = do-  outcome <- trySync (timeout versionProbeMicros (Process.readProcessWithExitCode path ["--version"] ""))+  outcome <- trySync+    $ timeout versionProbeMicros+    $ withOwnedProcess+      (Process.proc path ["--version"])+        { Process.std_in = Process.NoStream,+          Process.std_out = Process.CreatePipe,+          Process.std_err = Process.CreatePipe+        }+    $ \_ output errors ph -> case (output, errors) of+      (Just out, Just err) ->+        withOwnedWorker (BS.hGetContents out) $ \joinOut ->+          withOwnedWorker (BS.hGetContents err) $ \joinErr -> do+            stdoutBytes <- joinOut+            stderrBytes <- joinErr+            code <- Process.waitForProcess ph+            pure (code, stdoutBytes, stderrBytes)+      _ -> ioError (userError "version probe: capture handles missing")   pure $ case outcome of-    Right (Just (ExitSuccess, out, _)) -> firstNonBlankLine (Text.pack out)+    Right (Just (ExitSuccess, out, _)) -> firstNonBlankLine (decodeUtf8Lenient out)     _ -> Nothing  -- | Five seconds.
+ src/Baikai/Provider/Cli/Process/Internal.hs view
@@ -0,0 +1,241 @@+{-# LANGUAGE CPP #-}+{-# LANGUAGE ForeignFunctionInterface #-}++-- | Internal batch-process ownership shared by the vendor adapters. Not+-- covered by the public PVP interface. POSIX groups contain inherited children,+-- not processes deliberately escaping into another group or session.+module Baikai.Provider.Cli.Process.Internal+  ( withOwnedProcess,+    withOwnedWorker,+  )+where++import Control.Concurrent (forkIO, forkIOWithUnmask, killThread, threadDelay)+import Control.Concurrent.MVar (newEmptyMVar, putMVar, readMVar, tryReadMVar)+import Control.Exception+  ( SomeAsyncException,+    SomeException,+    catch,+    displayException,+    finally,+    fromException,+    mask,+    onException,+    throwIO,+    try,+  )+import Control.Monad (forM_, void)+import Data.ByteString qualified as BS+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import System.IO (Handle, hClose, stderr)+import System.Process qualified as P+#ifndef mingw32_HOST_OS+import Data.ByteString.Char8 qualified as BS8+import Data.Maybe (mapMaybe)+import Foreign.C.Error (throwErrnoIfMinus1)+import Foreign.C.Types (CInt (..))+import GHC.Clock (getMonotonicTimeNSec)+import System.Exit (ExitCode (..))+import System.Posix.Process (getProcessStatus)+import System.Posix.Signals (Signal, sigINT, sigTERM, sigKILL, signalProcess, signalProcessGroup)+import System.IO.Error (isDoesNotExistError)+import System.Posix.Types (ProcessID, ProcessGroupID)+import Text.Read (readMaybe)+#endif++-- | Run a callback with ordinary interruptibility, then finish release in a+-- private masked worker. Repeated cancellation interrupts only the join, never+-- release. Every worker exit publishes completion. Preserve the first async+-- exception, including one delivered after a successful callback.+ownedScope :: IO resource -> (resource -> IO ()) -> (resource -> IO a) -> IO a+ownedScope acquire release use = mask $ \restore -> do+  done <- newEmptyMVar+  resource <- acquire+  outcome <- try (restore (use resource))+  _ <- forkIO $ try (release resource) >>= putMVar done+  let initial = case outcome of+        Left e | isAsync e -> Just e+        _ -> Nothing+      join first =+        ((,first) <$> readMVar done) `catch` \e ->+          if isAsync e+            then join (case first of Nothing -> Just e; Just _ -> first)+            else throwIO e+  (cleaned, cancellation) <- join initial+  case cancellation of+    Just e -> do+      -- Cancellation remains asynchronous even if cleanup diagnosed a failure.+      -- Make that exceptional failure visible rather than silently losing it.+      case cleaned of+        Left failure -> void (try (BS.hPut stderr (Text.encodeUtf8 (Text.pack ("baikai CLI cleanup failed: " <> displayException failure <> "\n")))) :: IO (Either SomeException ()))+        Right () -> pure ()+      throwIO e+    Nothing -> case cleaned of+      Left e -> throwIO (e :: SomeException)+      Right () -> either throwIO pure outcome+  where+    isAsync e = case fromException e :: Maybe SomeAsyncException of+      Just _ -> True+      Nothing -> False++-- | The supplied join action returns the reader's value or exception. On every+-- scope exit an unfinished reader is cancelled and joined before pipe closure.+withOwnedWorker :: forall a b. IO a -> (IO a -> IO b) -> IO b+withOwnedWorker action use = ownedScope acquire release $ \(_, done) ->+  use (readMVar done >>= either throwIO pure)+  where+    acquire = do+      done <- newEmptyMVar+      tid <- forkIOWithUnmask $ \unmask -> do+        result <- try (unmask action)+        putMVar done (result :: Either SomeException a)+      pure (tid, done)+    release (tid, done) = do+      completed <- tryReadMVar done+      case completed of+        Nothing -> killThread tid+        Just _ -> pure ()+      void (readMVar done)++-- | Own handles, group identity, and synchronous direct-child reaping. Reader+-- scopes must be nested inside this callback, so no reader retains a handle lock+-- when release closes the pipes. Windows has direct-child-only cleanup.+withOwnedProcess ::+  P.CreateProcess ->+  (Maybe Handle -> Maybe Handle -> Maybe Handle -> P.ProcessHandle -> IO a) ->+  IO a+withOwnedProcess spec use = ownedScope (acquireProcess spec) releaseProcess $ \(handles, _) ->+  let (input, output, err, ph) = handles in use input output err ph++type ProcessHandles = (Maybe Handle, Maybe Handle, Maybe Handle, P.ProcessHandle)++#ifdef mingw32_HOST_OS+type GroupIdentity = ()++acquireProcess :: P.CreateProcess -> IO (ProcessHandles, GroupIdentity)+acquireProcess spec = do+  handles <- P.createProcess spec {P.create_group = True}+  pure (handles, ())++releaseProcess :: (ProcessHandles, GroupIdentity) -> IO ()+releaseProcess (handles@(_, _, _, ph), _) =+  (P.terminateProcess ph >> void (P.waitForProcess ph)) `finally` closeHandles handles+#else+type GroupIdentity = (ProcessGroupID, ProcessID)++acquireProcess :: P.CreateProcess -> IO (ProcessHandles, GroupIdentity)+acquireProcess spec = do+  handles@(_, _, _, ph) <- P.createProcess spec+    {P.create_group = True, P.new_session = False, P.delegate_ctlc = False}+  -- No callback can reap the leader before the anchor has joined. Even a+  -- quickly exiting leader is still an unreaped group member at this point.+  anchored <- try $ do+    group <- P.getPid ph >>= maybe (ioError (userError "CLI leader PID unavailable")) pure+    anchor <- throwErrnoIfMinus1 "CLI group anchor" (c_anchor (fromIntegral group))+    pure (group, fromIntegral anchor)+  case anchored of+    Right identity -> pure (handles, identity)+    Left (e :: SomeException) ->+      -- The unreaped leader reserves the group during acquisition failure.+      -- This nested scope also protects failed-acquisition cleanup from repeats.+      ownedScope (pure handles) cleanupFailure (\_ -> throwIO e)+  where+    cleanupFailure handles@(_, _, _, ph) =+      (do+        pid <- P.getPid ph+        forM_ pid $ \group -> terminateGroup group (-1) `onException` signalGroup sigKILL group+        ) `finally` (void (P.waitForProcess ph) `finally` closeHandles handles)++releaseProcess :: (ProcessHandles, GroupIdentity) -> IO ()+releaseProcess (handles@(_, _, _, ph), (group, anchor)) =+  ((terminateGroup group anchor `onException` signalGroup sigKILL group)+    `finally` void (P.waitForProcess ph))+    `finally` (finishAnchor `finally` closeHandles handles)+  where+    finishAnchor = do+      -- Darwin reports EPERM when signalling a zombie-only group/PID.+      -- Signalling is already complete, so we may now reap the anchor.+      status <- getProcessStatus False False anchor+      case status of+        Just _ -> pure ()+        Nothing -> signalPid sigKILL anchor >> void (getProcessStatus True False anchor)+#endif++closeHandles :: ProcessHandles -> IO ()+closeHandles (input, output, err, _) =+  -- finally ensures one failed close cannot skip the other owned descriptors.+  forM_ input hClose `finally` (forM_ output hClose `finally` forM_ err hClose)++#ifndef mingw32_HOST_OS+foreign import ccall safe "baikai_cli_group_anchor" c_anchor :: CInt -> IO CInt++signalGroup :: Signal -> ProcessGroupID -> IO ()+signalGroup signal group = absentOnly (signalProcessGroup signal group)++signalPid :: Signal -> ProcessID -> IO ()+signalPid signal pid = absentOnly (signalProcess signal pid)++-- Only ESRCH is benign. EPERM and all other observation/signal errors propagate.+absentOnly :: IO () -> IO ()+absentOnly action = action `catch` \e -> do+  if isDoesNotExistError e then pure () else throwIO (e :: IOError)++terminateGroup :: ProcessGroupID -> ProcessID -> IO ()+terminateGroup group anchor = do+  live <- runningMembers group anchor+  if null live then pure () else do+    signalGroup sigINT group+    interrupted <- settle 100000+    if interrupted then pure () else do+      signalGroup sigTERM group+      terminated <- settle 500000+      if terminated then pure () else do+        -- The anchor is killed too, but stays unreaped until all observations+        -- and group signals finish. Its zombie membership reserves the PGID.+        signalGroup sigKILL group+        killed <- settle 1000000+        if killed then pure () else ioError (userError "CLI group has live survivors after SIGKILL")+  where+    settle :: Int -> IO Bool+    settle micros = do+      start <- getMonotonicTimeNSec+      let deadline = start + fromIntegral micros * 1000+          poll = do+            members <- runningMembers group anchor+            if null members then pure True else do+              now <- getMonotonicTimeNSec+              if now >= deadline then pure False else threadDelay 10000 >> poll+      poll++-- Darwin and Linux both expose PID, PGID, and process state via POSIX ps.+-- A zombie is no longer running; adopted grandchildren are reaped elsewhere.+-- Never infer successful cleanup from kill(0), which includes zombies.+runningMembers :: ProcessGroupID -> ProcessID -> IO [ProcessID]+runningMembers group anchor = do+  (code, bytes) <- P.withCreateProcess+    (P.proc "/bin/ps" ["-axo", "pid=,pgid=,stat="])+      { P.std_in = P.NoStream, P.std_out = P.CreatePipe, P.std_err = P.CreatePipe }+    $ \_ output err ph -> case (output, err) of+      (Just out, Just errors) -> withOwnedWorker (BS.hGetContents errors) $ \joinErr -> do+        captured <- BS.hGetContents out+        void joinErr+        status <- P.waitForProcess ph+        pure (status, captured)+      _ -> ioError (userError "ps capture handles unavailable")+  case code of+    ExitFailure n -> ioError (userError ("CLI group observation failed: ps exit " <> show n))+    ExitSuccess -> do+      rows <- traverse parseRow (BS8.lines bytes)+      pure (mapMaybe running rows)+  where+    parseRow row = case BS8.words row of+      [pid, pgid, state] -> case (readMaybe (BS8.unpack pid), readMaybe (BS8.unpack pgid)) of+        (Just p, Just g) -> pure (p, g, state)+        _ -> invalid+      _ -> invalid+      where invalid = ioError (userError "CLI group observation: malformed ps row")+    running (pid, pgid, state)+      | pgid == group && pid /= anchor && not (BS8.elem 'Z' state) = Just pid+      | otherwise = Nothing+#endif
test/CatalogSpec.hs view
@@ -111,6 +111,7 @@   [ ("claude-fable-5", (AnthropicThinkingAdaptive, False, True, False)),     ("claude-fable-5-1", (AnthropicThinkingAdaptive, False, False, False)),     ("claude-haiku-4-5", (AnthropicThinkingBudget, True, True, False)),+    ("claude-haiku-5-5", (AnthropicThinkingAdaptive, False, True, False)),     ("claude-opus-4-5", (AnthropicThinkingBudget, True, True, False)),     ("claude-opus-4-6", (AnthropicThinkingAdaptive, True, True, False)),     ("claude-opus-4-7", (AnthropicThinkingAdaptive, False, True, False)),
+ test/CliProcessFixture.hs view
@@ -0,0 +1,158 @@+{-# LANGUAGE CPP #-}++-- | Real, offline subprocess fixtures shared by core and adapter regressions.+-- Keep the copies in each package identical: each source distribution must+-- contain its own test dependencies.+module CliProcessFixture+  ( Fixture (..),+    withFixture,+    awaitReady,+    assertStopped,+    cancelAndJoin,+    processState,+    writeExecutable,+  )+where++import Control.Concurrent (forkFinally, forkIO, killThread, threadDelay)+import Control.Concurrent.MVar (readMVar)+import Control.Concurrent.MVar qualified+import Control.Exception (SomeAsyncException, bracket, finally, fromException, try)+import Control.Monad (forM_, unless, void)+import Data.List (isInfixOf)+import System.Directory (doesFileExist, getPermissions, setOwnerExecutable, setPermissions)+import System.Exit (ExitCode (..))+import System.FilePath ((</>))+import System.IO.Temp (withSystemTempDirectory)+import System.Process qualified as P+import System.Timeout (timeout)+import Test.Tasty.HUnit (assertBool, assertFailure)+#ifndef mingw32_HOST_OS+import System.Posix.Signals (sigKILL, signalProcess)+#endif++data Fixture = Fixture+  { directory :: FilePath,+    executable :: FilePath,+    parentFile :: FilePath,+    childFile :: FilePath,+    readyFile :: FilePath+  }++-- The optional prefix can implement a successful normal invocation and hang+-- only in --version. Every fixture sleeps 30s so success cannot be natural expiry.+withFixture :: Bool -> Bool -> String -> (Fixture -> IO a) -> IO a+withFixture resistant early prefix use = withSystemTempDirectory "baikai-cli-cancellation" $ \dir -> do+  let sleeper = dir </> "sleeper"+      parent = dir </> "parent"+      child = dir </> "child"+      ready = dir </> "ready"+      body =+        unlines $+          ["#!/bin/sh", prefix]+            <> ["trap '' INT TERM" | resistant]+            <> [ "echo $$ > '" <> parent <> "'",+                 "sh -c 'echo $$ > \"" <> child <> "\"; exec \"" <> sleeper <> "\" 30' &",+                 "while [ ! -s '" <> child <> "' ]; do sleep 0.01; done",+                 "echo ready > '" <> ready <> "'"+               ]+            <> [if early then "exit 0" else "wait"]+  P.callProcess "/bin/ln" ["-s", "/bin/sleep", sleeper]+  exe <- writeExecutable dir "vendor" body+  let fixture = Fixture dir exe parent child ready+  -- Capture process identities before use returns/fails; identity comparison in+  -- emergency cleanup avoids signalling a PID reused after a fixture exits.+  bracket (pure fixture) cleanupFixture use++cleanupFixture :: Fixture -> IO ()+cleanupFixture fixture = forM_ [childFile fixture, parentFile fixture] $ \file -> do+  exists <- doesFileExist file+  if not exists+    then pure ()+    else do+      pid <- read <$> readFile file+      state <- processState pid+      -- Both the script and sleeper executable have this invocation's unique+      -- directory in their command line, including after exec. A reused PID+      -- belonging to a different invocation cannot match that identity.+      (_, identity, _) <- P.readProcessWithExitCode "/bin/ps" ["-p", show pid, "-o", "lstart=,command="] ""+      let ours = (directory fixture <> "/") `isInfixOf` identity+      unless (null state || 'Z' `elem` state || not ours) $ do+        (_, current, _) <- P.readProcessWithExitCode "/bin/ps" ["-p", show pid, "-o", "lstart=,command="] ""+        if current /= identity+          then pure ()+          else do+            killFixturePid pid++killFixturePid :: Int -> IO ()+#ifndef mingw32_HOST_OS+killFixturePid pid = void (try (signalProcess sigKILL (fromIntegral pid)) :: IO (Either IOError ()))+#else+killFixturePid _ = pure ()+#endif++writeExecutable :: FilePath -> String -> String -> IO FilePath+writeExecutable dir name body = do+  let path = dir </> name+  writeFile path body+  perms <- getPermissions path+  setPermissions path (setOwnerExecutable True perms)+  pure path++awaitReady :: Fixture -> IO (Int, Int)+awaitReady fixture = do+  ready <- timeout 2000000 poll+  case ready of+    Nothing -> assertFailure "fixture did not publish readiness within two seconds"+    Just () -> (,) <$> (read <$> readFile (parentFile fixture)) <*> (read <$> readFile (childFile fixture))+  where+    poll = do+      exists <- doesFileExist (readyFile fixture)+      if exists then pure () else threadDelay 10000 >> poll++processState :: Int -> IO String+processState pid = do+  (code, out, err) <- P.readProcessWithExitCode "/bin/ps" ["-p", show pid, "-o", "stat="] ""+  case code of+    ExitSuccess -> pure out+    ExitFailure 1 | null out && null err -> pure ""+    _ -> assertFailure ("ps observation failed: " <> show (code, out, err))++assertStopped :: (Int, Int) -> IO ()+assertStopped (parent, child) = do+  parentState <- processState parent+  assertBool ("direct child remains: " <> parentState) (null parentState)+  childState <- processState child+  assertBool ("descendant is running: " <> childState) (null childState || 'Z' `elem` childState)+  -- Removal of an adopted zombie is diagnostic, distinct from termination.+  unless (null childState) $ do+    removed <- timeout 200000 (let poll = processState child >>= \s -> if null s then pure () else threadDelay 10000 >> poll in poll)+    case removed of+      Nothing -> putStrLn "fixture descendant stopped; adoptive parent has not yet reaped zombie"+      Just () -> pure ()++cancelAndJoin :: Bool -> IO a -> Fixture -> ((Int, Int) -> IO ()) -> IO ()+cancelAndJoin repeated action fixture after = do+  done <- Control.Concurrent.MVar.newEmptyMVar+  tid <- forkFinally (void action) (Control.Concurrent.MVar.putMVar done)+  let stop = void (forkIO (killThread tid))+  ( do+      pids <- awaitReady fixture+      stop+      if repeated then void (forkIO (threadDelay 40000 >> killThread tid)) else pure ()+      -- Keep the original 200ms observation without using it as acknowledgement.+      threadDelay 200000+      diagnostic <- processState (snd pids)+      putStrLn ("descendant state at 200ms: " <> show diagnostic)+      terminal <- timeout 2800000 (readMVar done)+      case terminal of+        Just (Left e) | Just _ <- (fromException e :: Maybe SomeAsyncException) -> after pids+        other -> assertFailure ("expected acknowledged async cancellation within 3s, got " <> show other)+    )+    `finally` do+      stop+      cleanupFixture fixture+      joined <- timeout 3000000 (readMVar done)+      case joined of+        Nothing -> assertFailure "fixture worker failed to finish after emergency cleanup"+        Just _ -> pure ()
+ test/CliProcessSpec.hs view
@@ -0,0 +1,159 @@+{-# LANGUAGE CPP #-}++module CliProcessSpec (tests) where++import Baikai.Provider.Cli.Internal (ExecutableIdentity (..), executableIdentity)+import Baikai.Provider.Cli.Process.Internal+import CliProcessFixture+import Control.Concurrent (forkFinally, forkIO, killThread, threadDelay, throwTo)+import Control.Concurrent.MVar+import Control.Exception (AsyncException (..), SomeException, finally, fromException, throwIO, try)+import Control.Monad (void)+import Data.ByteString qualified as BS+import System.Exit (ExitCode (..))+import System.FilePath ((</>))+import System.IO (BufferMode (..), hSetBuffering)+import System.Process qualified as P+import System.Timeout (timeout)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))++tests :: TestTree+#ifndef mingw32_HOST_OS+tests = testGroup "CliProcessSpec: shared owned process scopes"+  [ testCase "sleeping descendant" $ cancelled False False False,+    testCase "leader exits with descendant holding pipes" $ cancelled False True False,+    testCase "callback reaps leader before cancellation" $ cancelled False True True,+    testCase "SIGKILL-resistant group and repeated cancellation" $ cancelled True False False,+    testCase "normal process exit and direct-child reaping" $ do+      pidCell <- newEmptyMVar+      anchors <- newEmptyMVar+      code <- withOwnedProcess (P.proc "/bin/sh" ["-c", "exit 0"]) $ \_ _ _ ph -> do+        pid <- P.getPid ph+        putMVar pidCell pid+        members <- maybe (assertFailure "missing leader PID") (groupPids . fromIntegral) pid+        putMVar anchors [p | p <- members, Just (fromIntegral p) /= pid]+        P.waitForProcess ph+      code @?= ExitSuccess+      readMVar pidCell >>= maybe (assertFailure "missing leader PID") (\pid -> processState (fromIntegral pid) >>= (@?= ""))+      recordedAnchors <- readMVar anchors+      length recordedAnchors @?= 1+      mapM_ (\pid -> processState pid >>= (@?= "")) recordedAnchors,+    testCase "synchronous callback failure still terminates descendants" $ withFixture False False "" $ \fixture -> do+      pidsCell <- newEmptyMVar+      result <- try $ withOwnedProcess (spec fixture) $ \_ _ _ _ -> do+        awaitReady fixture >>= putMVar pidsCell+        throwIO (userError "callback failed")+      case result :: Either SomeException () of+        Left _ -> readMVar pidsCell >>= assertStopped+        Right () -> assertFailure "callback exception swallowed",+    testCase "successful callback still cleans resistant descendants" $ withFixture True False "" $ \fixture -> do+      pids <- withOwnedProcess (spec fixture) $ \_ _ _ _ -> awaitReady fixture+      assertStopped pids,+    testCase "first cancellation during successful-callback cleanup survives repeats" $+      withFixture True False "" $ \fixture -> do+        returned <- newEmptyMVar+        done <- newEmptyMVar+        let action = withOwnedProcess (spec fixture) $ \_ _ _ _ -> do+              pids <- awaitReady fixture+              putMVar returned pids+        tid <- forkFinally action (putMVar done)+        (do+          ready <- timeout 2000000 (readMVar returned)+          pids <- maybe (assertFailure "callback did not publish readiness within two seconds") pure ready+          threadDelay 50000+          _ <- forkIO (throwTo tid UserInterrupt)+          _ <- forkIO (threadDelay 50000 >> killThread tid)+          terminal <- timeout 3000000 (readMVar done)+          case terminal of+            Just (Left e) -> (fromException e :: Maybe AsyncException) @?= Just UserInterrupt+            _ -> assertFailure "first cancellation during cleanup was lost"+          assertStopped pids+          ) `finally` do+            _ <- forkIO (killThread tid)+            void (timeout 3000000 (readMVar done)),+    testCase "cleanup failure publishes completion and preserves cancellation" $ do+      result <- timeout 2000000 $ try $ withOwnedProcess+        (P.proc "/bin/sh" ["-c", "exit 0"]) {P.std_in = P.CreatePipe}+        $ \input _ _ ph -> case input of+          Nothing -> assertFailure "missing stdin pipe"+          Just pipe -> do+            hSetBuffering pipe (BlockBuffering (Just 4096))+            BS.hPut pipe "pending buffered input"+            void (P.waitForProcess ph)+            -- Releasing stdin flushes to a dead child and fails with EPIPE.+            -- The finalizer must still publish completion and retain cancellation.+            throwIO ThreadKilled+      case result :: Maybe (Either SomeException ()) of+        Just (Left e) -> (fromException e :: Maybe AsyncException) @?= Just ThreadKilled+        _ -> assertFailure "cleanup failure lost cancellation or completion",+    testCase "failed spawn is reported" $ do+      result <- try (withOwnedProcess (P.proc "/nonexistent/baikai-fixture" []) (\_ _ _ _ -> pure ()))+      case result :: Either SomeException () of+        Left _ -> pure ()+        Right () -> assertFailure "spawn unexpectedly succeeded",+    testCase "blocked owned reader is joined before release returns" $ do+      started <- newEmptyMVar+      finished <- newEmptyMVar+      gate <- newEmptyMVar+      withOwnedWorker ((putMVar started () >> readMVar gate) `finally` putMVar finished ()) $ \_ -> readMVar started+      tryReadMVar finished >>= (@?= Just ()),+    testCase "reader error reaches join" $ do+      result <- try (withOwnedWorker (throwIO (userError "reader failed") :: IO ()) id)+      case result :: Either SomeException () of+        Left _ -> pure ()+        Right () -> assertFailure "reader failure swallowed",+    testCase "unrelated sentinel survives group cancellation" $+      withOwnedProcess (P.proc "/bin/sleep" ["30"]) $ \_ _ _ sentinel -> do+        sentinelPid <- P.getPid sentinel+        cancelled False False False+        maybe (assertFailure "sentinel PID missing") (\pid -> processState (fromIntegral pid) >>= assertBool "sentinel stopped" . not . null) sentinelPid,+    testCase "version-probe cancellation owns descendants" $ withFixture False False "" $ \fixture ->+      cancelAndJoin False (executableIdentity (executable fixture)) fixture assertStopped,+    testCase "version-probe timeout owns descendants and reports no version" $ withFixture False False "" $ \fixture -> do+      bounded <- timeout 8000000 (executableIdentity (executable fixture))+      case bounded of+        Nothing -> assertFailure "version timeout failed to finish"+        Just identity -> version identity @?= Nothing+      awaitReady fixture >>= assertStopped+  ]+  where+    spec fixture = (P.proc (executable fixture) [])+      {P.std_in = P.NoStream, P.std_out = P.CreatePipe, P.std_err = P.CreatePipe}+    cancelled resistant early reap = withFixture resistant early "" $ \fixture -> do+      anchorCell <- newEmptyMVar+      let action = withOwnedProcess (spec fixture) $ \_ output err ph -> case (output, err) of+            (Just out, Just errors) -> withOwnedWorker (BS.hGetContents errors) $ \joinErr -> do+              if reap then do+                group <- P.getPid ph >>= maybe (assertFailure "missing group PID") (pure . fromIntegral)+                (_, child) <- awaitReady fixture+                void (P.waitForProcess ph)+                members <- groupPids group+                let anchors = filter (/= child) members+                length anchors @?= 1+                putMVar anchorCell anchors+                writeFile (directory fixture </> "reaped") "ready"+              else pure ()+              _ <- BS.hGetContents out+              void joinErr+            _ -> assertFailure "missing capture handles"+      let readyFixture = if reap then fixture {readyFile = directory fixture </> "reaped"} else fixture+      cancelAndJoin resistant action readyFixture $ \pids -> do+        assertStopped pids+        if reap then readMVar anchorCell >>= mapM_ (\pid -> processState pid >>= (@?= "")) else pure ()++-- Inspect only this invocation's group, and record the anchor while its owner+-- still holds it. Checking it after return proves the private direct child was+-- reaped too, even when the callback reaped the CLI leader itself.+groupPids :: Int -> IO [Int]+groupPids group = do+  (code, out, err) <- P.readProcessWithExitCode "/bin/ps" ["-axo", "pid=,pgid="] ""+  code @?= ExitSuccess+  err @?= ""+  pure [read pid | row <- lines out, [pid, pgid] <- [words row], read pgid == group]+#else+tests = testGroup "CliProcessSpec: Windows direct-child cleanup"+  [testCase "normal direct-child exit" $ do+    code <- withOwnedProcess (P.proc "cmd" ["/c", "exit", "0"]) (\_ _ _ ph -> P.waitForProcess ph)+    code @?= ExitSuccess]+#endif
test/CostSpec.hs view
@@ -290,11 +290,11 @@   testGroup     "fast pricing"     [ testCase "fast premiums compose with context tiers before pricing" $ do-        let m = Models.anthropic_claude_opus_5 & #pricingPolicy .~ Just (PricingPolicy [InputPriceTier 1000 (ModelCost 10 50 1 12.5)] Nothing)+        let m = Models.anthropic_claude_opus_5 & #pricingPolicy .~ Just (PricingPolicy [InputPriceTier 1000 (ModelCost 10 50 1 12.5) Nothing] Nothing)         Cost.usd (computeCostAtSpeed m SpeedFast u) @?= 2 * Cost.usd (computeCost m u),       testCase "invalid premium rates and undefined policy ratios are explicit" $ do         let negative = knownModel & #fastModeCost .~ Just (ModelCost (-1) 10 0.2 2.5)-            undefinedRatio = knownModel & #cost .~ ModelCost 0 5 0.1 1.25 & #fastModeCost .~ Just (ModelCost 10 10 0.2 2.5) & #pricingPolicy .~ Just (PricingPolicy [InputPriceTier 1 (ModelCost 5 5 0.1 1.25)] Nothing)+            undefinedRatio = knownModel & #cost .~ ModelCost 0 5 0.1 1.25 & #fastModeCost .~ Just (ModelCost 10 10 0.2 2.5) & #pricingPolicy .~ Just (PricingPolicy [InputPriceTier 1 (ModelCost 5 5 0.1 1.25) Nothing] Nothing)         mapM_ (\m -> Set.member Cost.InvalidPricingPolicy (Cost.estimateReasons (Cost.basis (computeCostAtSpeed m SpeedFast u))) @?= True) [negative, undefinedRatio],       testCase "contradictory speed observations remain an explicit standard estimate" $ do         let usage = Usage.observeBilling [Usage.BillingSpeed "fast", Usage.BillingSpeed "standard"] u
test/FetchModelsSpec.hs view
@@ -179,9 +179,9 @@         upstream <- loadUpstream         let sample = (upstream Map.! "openai") Map.! "gpt-5.4"         forM_-          [ (openaiSpec, "gpt-6-astra", Model.PricingPolicy [Model.InputPriceTier 272000 (Model.ModelCost 20 75 2 25)] Nothing),-            (openaiSpec, "gpt-6-sol", Model.PricingPolicy [Model.InputPriceTier 272000 (Model.ModelCost 4 15 0.4 5)] Nothing),-            (openaiSpec, "gpt-6-luna", Model.PricingPolicy [Model.InputPriceTier 272000 (Model.ModelCost 0.2 0.75 0.02 0.25)] Nothing),+          [ (openaiSpec, "gpt-6-astra", Model.PricingPolicy [Model.InputPriceTier 272000 (Model.ModelCost 20 75 2 25) Nothing] Nothing),+            (openaiSpec, "gpt-6-sol", Model.PricingPolicy [Model.InputPriceTier 272000 (Model.ModelCost 4 15 0.4 5) Nothing] Nothing),+            (openaiSpec, "gpt-6-luna", Model.PricingPolicy [Model.InputPriceTier 272000 (Model.ModelCost 0.2 0.75 0.02 0.25) Nothing] Nothing),             (anthropicSpec, "claude-fable-5-1", Model.PricingPolicy [] (Just 20)),             (anthropicSpec, "claude-opus-5-5", Model.PricingPolicy [] (Just 8))           ]
test/Main.hs view
@@ -10,6 +10,7 @@ import Baikai.Prelude import CatalogSpec qualified import CliInternalSpec qualified+import CliProcessSpec qualified import ContextSpec qualified import Control.Monad (forM_) import CostSpec qualified@@ -109,6 +110,7 @@         AgentSpec.tests,         CatalogSpec.tests,         CliInternalSpec.tests,+        CliProcessSpec.tests,         ContextSpec.tests,         CostSpec.tests,         EmbeddingSpec.tests,
test/PricingPolicySpec.hs view
@@ -43,8 +43,21 @@             u = U.zeroUsage & #cacheWriteTokens .~ 1000000         (computeCostWith (Just CacheRetentionShort) m u).usd @?= 5 / 2         (computeCostWith (Just CacheRetentionLong) m u).usd @?= 4-        (computeCost m (U.zeroUsage & #cacheReadTokens .~ 1000000)).usd @?= 1 / 5+        -- 2026-10-07: Anthropic lowered Sonnet 5.5 cache reads from $0.20 to $0.10/M.+        -- https://platform.claude.com/docs/en/about-claude/pricing#prompt-caching+        (computeCost m (U.zeroUsage & #cacheReadTokens .~ 1000000)).usd @?= 1 / 10         Set.member (C.UnsupportedSpeed "fast") (computeCostAtSpeed m SpeedFast u).basis.estimateReasons @?= True,+      testCase "a crossed tier prices long cache writes at its own rate" $ do+        let writes n = U.zeroUsage & #cacheWriteTokens .~ n+            prompt n = U.zeroUsage & #inputTokens .~ n & #cacheReadTokens .~ 20000 & #outputTokens .~ 1000+        (computeCostWith (Just CacheRetentionLong) haikuShaped (writes 1000000)).usd @?= 1+        (computeCostWith (Just CacheRetentionShort) haikuShaped (writes 1000000)).usd @?= 5 / 8+        (computeCostWith (Just CacheRetentionLong) haikuShaped (writes 50000)).usd @?= 1 / 100+        (computeCostWith (Just CacheRetentionShort) haikuShaped (writes 50000)).usd @?= 1 / 160+        (computeCost haikuShaped (prompt 90000)).usd @?= 97 / 2000+        (computeCost haikuShaped (prompt 80000)).usd @?= 87 / 10000+        Models.anthropic_claude_haiku_5_5.cost @?= haikuShaped.cost+        Models.anthropic_claude_haiku_5_5.pricingPolicy @?= haikuShaped.pricingPolicy,       testCase "requested tiers never substitute for observed service" $ do         let unknown = N.normalizeUsage N.InclusiveInput (N.ReportedUsage (Just 1000) (Just 0) (Just 0) (Just 0) Nothing)             standard = U.observeBilling [U.BillingServiceTier "default"] unknown@@ -95,11 +108,21 @@         let flat = astra & #pricingPolicy .~ Nothing         resolveRates (Just CacheRetentionLong) flat (U.zeroUsage & #inputTokens .~ 900000) @?= Right base,       testCase "duplicate, unordered and negative policy data are rejected" $ do-        forM_ [M.PricingPolicy [M.InputPriceTier 2 high, M.InputPriceTier 2 base] Nothing, M.PricingPolicy [M.InputPriceTier 2 high, M.InputPriceTier 1 base] Nothing, M.PricingPolicy [] (Just (-1)), M.PricingPolicy [M.InputPriceTier 1 (base & #inputCost .~ (-1))] Nothing] $ \p -> do+        forM_ [M.PricingPolicy [M.InputPriceTier 2 high Nothing, M.InputPriceTier 2 base Nothing] Nothing, M.PricingPolicy [M.InputPriceTier 2 high Nothing, M.InputPriceTier 1 base Nothing] Nothing, M.PricingPolicy [] (Just (-1)), M.PricingPolicy [M.InputPriceTier 1 (base & #inputCost .~ (-1)) Nothing] Nothing] $ \p -> do           assertBool "pure validation rejects" (case M.validatePricingPolicy p of Left _ -> True; _ -> False)           case Aeson.fromJSON (Aeson.toJSON p) :: Aeson.Result M.PricingPolicy of Aeson.Error _ -> pure (); _ -> assertFailure "invalid policy decoded"         let negative = Aeson.object ["inputTiers" Aeson..= [Aeson.object ["inputAbove" Aeson..= (-1 :: Int), "rates" Aeson..= base]]]         case Aeson.fromJSON negative :: Aeson.Result M.PricingPolicy of Aeson.Error _ -> pure (); _ -> assertFailure "negative threshold decoded",+      testCase "a policy-level long rate requires a long rate on every tier" $ do+        let missing = M.PricingPolicy [M.InputPriceTier 2 high Nothing] (Just 1)+        M.validatePricingPolicy missing @?= Left "Every input tier must state its long cache-write rate when the policy has one"+        case Aeson.fromJSON (Aeson.toJSON missing) :: Aeson.Result M.PricingPolicy of Aeson.Error _ -> pure (); _ -> assertFailure "policy with an unpriced tier decoded"+        assertBool "negative tier long rate rejects" (M.validatePricingPolicy (M.PricingPolicy [M.InputPriceTier 2 high (Just (-1))] (Just 1)) /= Right ())+        M.validatePricingPolicy (M.PricingPolicy [M.InputPriceTier 2 high (Just 2)] Nothing) @?= Right (),+      testCase "a tier without a long-rate key decodes to Nothing" $ do+        let old = Aeson.object ["inputTiers" Aeson..= [Aeson.object ["inputAbove" Aeson..= (272000 :: Int), "rates" Aeson..= high]]]+        Aeson.fromJSON old @?= Aeson.Success (M.PricingPolicy [M.InputPriceTier 272000 high Nothing] Nothing)+        case Aeson.fromJSON (Aeson.toJSON haikuShaped) of Aeson.Success m -> (m :: M.Model) @?= haikuShaped; Aeson.Error err -> assertFailure err,       testCase "old model JSON without a policy decodes and new policy round trips" $ do         let old = case Aeson.toJSON M.emptyModel of Aeson.Object o -> Aeson.Object (KM.delete "pricingPolicy" o); v -> v         case Aeson.fromJSON old of Aeson.Success m -> (m :: M.Model).pricingPolicy @?= Nothing; Aeson.Error err -> assertFailure err@@ -131,3 +154,11 @@  fable :: M.Model fable = Models.anthropic_claude_fable_5_1++-- | Claude Haiku 5.5's published rates, built by hand so the pricing rule+-- is tested independently of the catalog entry.+haikuShaped :: M.Model+haikuShaped =+  M.emptyModel+    & #cost .~ M.ModelCost (1 / 10) (1 / 2) (1 / 100) (1 / 8)+    & #pricingPolicy .~ Just (M.PricingPolicy [M.InputPriceTier 100000 (M.ModelCost (1 / 2) (5 / 2) (1 / 20) (5 / 8)) (Just 1)] (Just (1 / 5)))
+ test/fixtures/claude-cli-result.json view
@@ -0,0 +1,58 @@+[+  {+    "type": "system",+    "subtype": "init",+    "session_id": "01890000-0000-4000-8000-000000000001",+    "model": "claude-opus-5[1m]",+    "claude_code_version": "2.1.222"+  },+  {+    "type": "assistant",+    "session_id": "01890000-0000-4000-8000-000000000001",+    "message": {+      "id": "msg_recorded_0001",+      "model": "claude-opus-5",+      "role": "assistant",+      "content": [{ "type": "text", "text": "ok" }],+      "usage": {+        "input_tokens": 2,+        "cache_creation_input_tokens": 7455,+        "cache_read_input_tokens": 15185,+        "output_tokens": 6+      }+    }+  },+  {+    "type": "result",+    "subtype": "success",+    "is_error": false,+    "result": "ok",+    "session_id": "01890000-0000-4000-8000-000000000001",+    "uuid": "01890000-0000-4000-8000-000000000002",+    "stop_reason": "end_turn",+    "duration_ms": 4212,+    "duration_api_ms": 3890,+    "num_turns": 1,+    "total_cost_usd": 0.0823025,+    "permission_denials": [],+    "usage": {+      "input_tokens": 2,+      "cache_creation_input_tokens": 7455,+      "cache_read_input_tokens": 15185,+      "output_tokens": 6,+      "service_tier": "standard",+      "server_tool_use": { "web_search_requests": 0, "web_fetch_requests": 0 }+    },+    "modelUsage": {+      "claude-opus-5[1m]": {+        "inputTokens": 2,+        "outputTokens": 6,+        "cacheReadInputTokens": 15185,+        "cacheCreationInputTokens": 7455,+        "costUSD": 0.0823025,+        "canonicalModel": "claude-opus-5",+        "provider": "firstParty"+      }+    }+  }+]
+ test/fixtures/codex-events.jsonl view
@@ -0,0 +1,4 @@+{"type":"thread.started","thread_id":"019fd471-4a48-7c83-be67-6b7c49646e43"}+{"type":"turn.started"}+{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"ok"}}+{"type":"turn.completed","usage":{"input_tokens":16071,"cached_input_tokens":6912,"cache_write_input_tokens":0,"output_tokens":5,"reasoning_output_tokens":0}}
+ test/fixtures/evidence-request.json view
@@ -0,0 +1,86 @@+{+  "model": "claude-opus-4-6",+  "max_tokens": 4096,+  "temperature": 0.7,+  "top_p": 0.95,+  "stream": true,+  "thinking": {+    "type": "enabled",+    "budget_tokens": 16384+  },+  "metadata": {+    "user_id": "u-123"+  },+  "extra_headers": {+    "x-api-key": "sk-baikai-fixture-secret-key"+  },+  "system": "You are a careful assistant. SYSTEM-PROMPT-BODY-MARKER.",+  "messages": [+    {+      "role": "user",+      "content": "PROMPT-BODY-MARKER: summarise the attached quarterly report."+    },+    {+      "role": "assistant",+      "content": [+        {+          "type": "thinking",+          "thinking": "REASONING-TEXT-MARKER: the user wants a short summary."+        },+        {+          "type": "tool_use",+          "id": "toolu_1",+          "name": "fetch_report",+          "input": {+            "query": "TOOL-PAYLOAD-MARKER"+          }+        }+      ]+    }+  ],+  "tools": [+    {+      "name": "fetch_report",+      "description": "Fetch a quarterly report by identifier.",+      "input_schema": {+        "type": "object",+        "properties": {+          "query": {+            "type": "string"+          }+        }+      }+    }+  ],+  "output_config": {+    "effort": "high",+    "format": {+      "type": "json_schema",+      "schema": {+        "type": "object",+        "description": "OUTPUT-SCHEMA-MARKER: the shape a quarterly report answer must take.",+        "properties": {+          "revenue": {+            "type": "number"+          }+        }+      }+    }+  },+  "response_format": {+    "type": "json_schema",+    "json_schema": {+      "name": "quarterly_report",+      "strict": true,+      "schema": {+        "type": "object",+        "description": "RESPONSE-SCHEMA-MARKER: the same shape, spelled the OpenAI way.",+        "properties": {+          "revenue": {+            "type": "number"+          }+        }+      }+    }+  }+}
+ test/fixtures/models-dev-sample.json view
@@ -0,0 +1,59 @@+{+  "openai": {+    "id": "openai",+    "name": "OpenAI",+    "models": {+      "gpt-5.4": {+        "id": "gpt-5.4",+        "name": "GPT-5.4",+        "tool_call": true,+        "reasoning": true,+        "limit": { "context": 1050000, "output": 128000 },+        "cost": { "input": 2.5, "output": 15, "cache_read": 0.25, "cache_write": 0 },+        "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }+      },+      "gpt-5-nano": {+        "id": "gpt-5-nano",+        "name": "GPT-5 Nano",+        "tool_call": true,+        "reasoning": true,+        "limit": { "context": 400000, "output": 128000 },+        "cost": { "input": 0.05, "output": 0.4 },+        "modalities": { "input": ["text", "image"], "output": ["text"] }+      },+      "gpt-legacy-nontool": {+        "id": "gpt-legacy-nontool",+        "name": "Legacy non-tool model",+        "tool_call": false,+        "reasoning": false,+        "limit": { "context": 8192, "output": 4096 },+        "cost": { "input": 1, "output": 2 },+        "modalities": { "input": ["text"], "output": ["text"] }+      },+      "gpt-5-pro": {+        "id": "gpt-5-pro",+        "name": "GPT-5 Pro",+        "tool_call": true,+        "reasoning": true,+        "limit": { "context": 400000, "output": 128000 },+        "cost": { "input": 15, "output": 120 },+        "modalities": { "input": ["text", "image"], "output": ["text"] }+      }+    }+  },+  "anthropic": {+    "id": "anthropic",+    "name": "Anthropic",+    "models": {+      "claude-opus-4-5": {+        "id": "claude-opus-4-5",+        "name": "Claude Opus 4.5 (latest)",+        "tool_call": true,+        "reasoning": true,+        "limit": { "context": 200000, "output": 64000 },+        "cost": { "input": 5, "output": 25, "cache_read": 1.5, "cache_write": 6.25 },+        "modalities": { "input": ["text", "image"], "output": ["text"] }+      }+    }+  }+}
+ test/fixtures/trace-opt-out.jsonl view
@@ -0,0 +1,2 @@+{"kind":"call_started","eventId":"<id>","timestamp":"<ts>","provider":"stub.trace","model":"stub-1","maxTokens":16,"promptSummary":"hello"}+{"kind":"call_finished","eventId":"<id>","timestamp":"<ts>","provider":"stub.trace","model":"stub-1","latencyMs":0,"inputTokens":0,"outputTokens":0,"cachedInputTokens":0,"cacheWriteTokens":0,"totalTokens":0,"usd":0}