tsugite-cli 0.17.0__tar.gz → 0.17.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/PKG-INFO +1 -1
  2. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/pyproject.toml +1 -1
  3. tsugite_cli-0.17.2/tsugite/builtin_agents/job_verifier.md +71 -0
  4. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/cli/daemon.py +23 -6
  5. tsugite_cli-0.17.2/tsugite/core/record_store.py +316 -0
  6. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/providers/anthropic.py +48 -28
  7. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/providers/base.py +5 -0
  8. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/providers/openai_compat.py +28 -57
  9. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/tools/http.py +37 -9
  10. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/tools/jobs.py +7 -1
  11. tsugite_cli-0.17.0/tsugite/builtin_agents/job_verifier.md +0 -58
  12. tsugite_cli-0.17.0/tsugite/core/record_store.py +0 -146
  13. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/.gitignore +0 -0
  14. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/AGENTS.md +0 -0
  15. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/CONTRIBUTING.md +0 -0
  16. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/LICENSE +0 -0
  17. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/README.md +0 -0
  18. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/__init__.py +0 -0
  19. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/agent_inheritance.py +0 -0
  20. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/agent_preparation.py +0 -0
  21. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/agent_runner/__init__.py +0 -0
  22. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/agent_runner/exec_directives.py +0 -0
  23. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/agent_runner/exec_runner.py +0 -0
  24. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/agent_runner/helpers.py +0 -0
  25. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/agent_runner/history_integration.py +0 -0
  26. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/agent_runner/metrics.py +0 -0
  27. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/agent_runner/models.py +0 -0
  28. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/agent_runner/runner.py +0 -0
  29. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/agent_runner/validation.py +0 -0
  30. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/agent_utils.py +0 -0
  31. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/attachments/__init__.py +0 -0
  32. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/attachments/auto_context.py +0 -0
  33. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/attachments/base.py +0 -0
  34. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/attachments/file.py +0 -0
  35. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/attachments/inline.py +0 -0
  36. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/attachments/storage.py +0 -0
  37. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/attachments/url.py +0 -0
  38. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/builtin_agents/.gitkeep +0 -0
  39. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/builtin_agents/code_searcher.md +0 -0
  40. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/builtin_agents/default.md +0 -0
  41. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/builtin_agents/file_searcher.md +0 -0
  42. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/builtin_agents/job_worker.md +0 -0
  43. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/builtin_agents/onboard.md +0 -0
  44. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/builtin_skills/.gitkeep +0 -0
  45. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/builtin_skills/codebase-exploration/SKILL.md +0 -0
  46. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/builtin_skills/external-agent-bridge/SKILL.md +0 -0
  47. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/builtin_skills/python-math/SKILL.md +0 -0
  48. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/builtin_skills/response-patterns/SKILL.md +0 -0
  49. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/builtin_skills/scheduling/SKILL.md +0 -0
  50. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/builtin_skills/skill-authoring/SKILL.md +0 -0
  51. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/builtin_skills/tsugite-agent-basics/SKILL.md +0 -0
  52. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/builtin_skills/tsugite-jinja-reference/SKILL.md +0 -0
  53. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/builtin_skills/tsugite-skill-basics/SKILL.md +0 -0
  54. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/cache.py +0 -0
  55. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/cancellation.py +0 -0
  56. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/cli/__init__.py +0 -0
  57. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/cli/agents.py +0 -0
  58. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/cli/attachments.py +0 -0
  59. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/cli/cache.py +0 -0
  60. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/cli/chat.py +0 -0
  61. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/cli/config.py +0 -0
  62. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/cli/exec.py +0 -0
  63. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/cli/helpers.py +0 -0
  64. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/cli/history.py +0 -0
  65. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/cli/init.py +0 -0
  66. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/cli/models.py +0 -0
  67. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/cli/plugins.py +0 -0
  68. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/cli/render.py +0 -0
  69. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/cli/run.py +0 -0
  70. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/cli/secrets.py +0 -0
  71. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/cli/skills.py +0 -0
  72. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/cli/tools.py +0 -0
  73. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/cli/usage.py +0 -0
  74. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/cli/validate.py +0 -0
  75. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/cli/workspace.py +0 -0
  76. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/config.py +0 -0
  77. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/console.py +0 -0
  78. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/constants.py +0 -0
  79. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/core/__init__.py +0 -0
  80. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/core/agent.py +0 -0
  81. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/core/content_blocks.py +0 -0
  82. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/core/executor.py +0 -0
  83. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/core/executor_registry.py +0 -0
  84. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/core/memory.py +0 -0
  85. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/core/proxy.py +0 -0
  86. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/core/sandbox.py +0 -0
  87. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/core/state.py +0 -0
  88. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/core/subprocess_executor.py +0 -0
  89. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/core/tools.py +0 -0
  90. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/events/__init__.py +0 -0
  91. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/events/base.py +0 -0
  92. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/events/bus.py +0 -0
  93. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/events/events.py +0 -0
  94. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/events/helpers.py +0 -0
  95. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/exceptions.py +0 -0
  96. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/history/__init__.py +0 -0
  97. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/history/base.py +0 -0
  98. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/history/models.py +0 -0
  99. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/history/reconstruction.py +0 -0
  100. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/history/registry.py +0 -0
  101. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/history/sqlite_backend.py +0 -0
  102. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/history/sqlite_conn.py +0 -0
  103. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/history/sqlite_schema.py +0 -0
  104. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/history/storage.py +0 -0
  105. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/history/ui_events.py +0 -0
  106. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/hooks.py +0 -0
  107. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/interaction.py +0 -0
  108. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/md_agents.py +0 -0
  109. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/models.py +0 -0
  110. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/options.py +0 -0
  111. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/plugins.py +0 -0
  112. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/providers/__init__.py +0 -0
  113. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/providers/model_cache.py +0 -0
  114. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/providers/model_registry.py +0 -0
  115. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/providers/ollama.py +0 -0
  116. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/providers/openrouter.py +0 -0
  117. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/py.typed +0 -0
  118. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/renderer.py +0 -0
  119. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/schemas/__init__.py +0 -0
  120. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/schemas/agent.schema.json +0 -0
  121. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/secrets/__init__.py +0 -0
  122. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/secrets/backend.py +0 -0
  123. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/secrets/env.py +0 -0
  124. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/secrets/exec.py +0 -0
  125. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/secrets/file.py +0 -0
  126. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/secrets/masking.py +0 -0
  127. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/secrets/registry.py +0 -0
  128. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/secrets/sqlite.py +0 -0
  129. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/shell_tool_config.py +0 -0
  130. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/skill_discovery.py +0 -0
  131. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/templates/AGENTS.md +0 -0
  132. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/templates/IDENTITY.md +0 -0
  133. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/templates/MEMORY.md +0 -0
  134. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/templates/USER.md +0 -0
  135. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/templates/personas/casual-technical.md +0 -0
  136. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/templates/personas/marvin.md +0 -0
  137. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/templates/personas/minimal.md +0 -0
  138. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/tools/__init__.py +0 -0
  139. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/tools/agents.py +0 -0
  140. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/tools/fs.py +0 -0
  141. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/tools/history.py +0 -0
  142. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/tools/interactive.py +0 -0
  143. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/tools/notify.py +0 -0
  144. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/tools/schedule.py +0 -0
  145. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/tools/scratchpad.py +0 -0
  146. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/tools/secrets.py +0 -0
  147. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/tools/sessions.py +0 -0
  148. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/tools/shell.py +0 -0
  149. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/tools/shell_tools.py +0 -0
  150. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/tools/skills.py +0 -0
  151. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/tools/time.py +0 -0
  152. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/tsugite.py +0 -0
  153. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/ui/__init__.py +0 -0
  154. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/ui/base.py +0 -0
  155. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/ui/chat.py +0 -0
  156. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/ui/helpers.py +0 -0
  157. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/ui/jsonl.py +0 -0
  158. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/ui/plain.py +0 -0
  159. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/ui/repl_chat.py +0 -0
  160. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/ui/repl_commands.py +0 -0
  161. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/ui/repl_completer.py +0 -0
  162. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/ui/repl_handler.py +0 -0
  163. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/ui_context.py +0 -0
  164. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/usage/__init__.py +0 -0
  165. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/usage/store.py +0 -0
  166. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/user_agent.py +0 -0
  167. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/utils.py +0 -0
  168. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/workspace/__init__.py +0 -0
  169. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/workspace/context.py +0 -0
  170. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/workspace/models.py +0 -0
  171. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/workspace/session.py +0 -0
  172. {tsugite_cli-0.17.0 → tsugite_cli-0.17.2}/tsugite/workspace/templates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tsugite-cli
3
- Version: 0.17.0
3
+ Version: 0.17.2
4
4
  Summary: Micro-agent runner for task automation using markdown definitions
5
5
  Author: Justyn Shull
6
6
  License: GNU AFFERO GENERAL PUBLIC LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tsugite-cli"
3
- version = "0.17.0"
3
+ version = "0.17.2"
4
4
  description = "Micro-agent runner for task automation using markdown definitions"
5
5
  authors = [{ name = "Justyn Shull" }]
6
6
  requires-python = ">=3.11"
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: job_verifier
3
+ description: Reasoning-blind verifier — judges a Job's worker output against its acceptance criteria, inspecting workspace file artifacts before deciding. Returns strict JSON. Spawned fresh per round with no parent context.
4
+ extends: none
5
+ max_turns: 10
6
+ tools: [read_file, run]
7
+ ---
8
+
9
+ # Job Verifier
10
+
11
+ You are a reasoning-blind verifier. You receive a list of acceptance criteria
12
+ and a work-output blob (the worker's structured summary, optionally with a
13
+ `git diff`). You run in the same working directory the worker ran in, so the
14
+ files it produced are directly inspectable.
15
+
16
+ You do not see the worker's reasoning. You do not see the parent chat. Claims
17
+ are not evidence: judge each criterion against the visible artifacts, or by
18
+ inspecting the workspace yourself.
19
+
20
+ ## Task
21
+
22
+ {{ user_prompt }}
23
+
24
+ ## How to verify
25
+
26
+ - Read the worker's `## Summary`, `## Output` (if present), `## Acceptance
27
+ criteria`, and `## Artifacts` sections.
28
+ - When the deliverable is inline text (poem, snippet, written answer), it
29
+ lives in `## Output`. Judge ACs about the content (length, format, contains
30
+ word X, syllable count, etc.) against the verbatim Output text.
31
+ - When a criterion concerns a file's existence, contents, or structure, read
32
+ the actual file before deciding: `read_file(path=...)` resolves against your
33
+ working directory; use `run` for listings or diffs. Only fail such a
34
+ criterion after inspection confirms the miss or the file is genuinely
35
+ absent/unreadable - never just because the worker's summary doesn't inline
36
+ the contents.
37
+ - If a PR URL or commit SHA is provided, you may inspect it via `run` (e.g.
38
+ `git show <sha>`, `gh pr view <url>`) when that materially affects the
39
+ verdict.
40
+ - Do not run long-running commands or perform setup; you have a turn budget.
41
+ - Be skeptical, not pedantic. A criterion like "tests pass" is met if the
42
+ worker reports a passing test run; you don't need to re-run unless evidence
43
+ is contradictory.
44
+
45
+ ## Tool use
46
+
47
+ To inspect, reply with a single ```python code block calling a tool:
48
+
49
+ ```python
50
+ read_file(path="docs/page.md")
51
+ ```
52
+
53
+ The result comes back as an observation; keep inspecting or emit the verdict.
54
+ Never mix the final verdict into a tool-call turn.
55
+
56
+ ## Output contract
57
+
58
+ When you have enough evidence, reply with ONLY a strictly-valid JSON object as
59
+ plain text - no code block, no markdown fences, no prose before or after:
60
+
61
+ ```
62
+ {
63
+ "ac_results": [
64
+ {"ac_text": "<verbatim AC>", "pass": true|false, "reason": "<one sentence>"}
65
+ ],
66
+ "overall_pass": true|false
67
+ }
68
+ ```
69
+
70
+ Include one entry per acceptance criterion, in the given order. `overall_pass`
71
+ is `true` only if every `ac_results[i].pass` is `true`.
@@ -716,20 +716,36 @@ token_app = typer.Typer(help="Manage API authentication tokens")
716
716
  daemon_app.add_typer(token_app, name="token")
717
717
 
718
718
 
719
- def _get_tokens_path() -> Path:
719
+ def _get_tokens_path(config_path: Optional[Path] = None) -> Path:
720
+ """Resolve the daemon's token storage from its config so the CLI writes
721
+ where the daemon reads. Previously the CLI always used the XDG default,
722
+ so a daemon with a custom `state_dir` never saw CLI-created tokens.
723
+ Falls back to the default state dir when no daemon config exists yet.
724
+ """
720
725
  from tsugite_daemon.auth import TOKENS_FILENAME
726
+ from tsugite_daemon.config import load_daemon_config
727
+
728
+ try:
729
+ state_dir = load_daemon_config(config_path).state_dir
730
+ except ValueError:
731
+ state_dir = get_xdg_data_path("daemon")
732
+ return state_dir / TOKENS_FILENAME
733
+
721
734
 
722
- return get_xdg_data_path("daemon") / TOKENS_FILENAME
735
+ _TOKEN_CONFIG_OPTION = typer.Option(
736
+ None, "--config", "-c", help="Daemon config to resolve state_dir from (default: ~/.config/tsugite/daemon.yaml)"
737
+ )
723
738
 
724
739
 
725
740
  @token_app.command("create")
726
741
  def token_create(
727
742
  name: str = typer.Option("", "--name", "-n", help="Name for the token (for identification)"),
743
+ config: Optional[Path] = _TOKEN_CONFIG_OPTION,
728
744
  ):
729
745
  """Create a new admin API token."""
730
746
  from tsugite_daemon.auth import TokenStore
731
747
 
732
- store = TokenStore(_get_tokens_path())
748
+ store = TokenStore(_get_tokens_path(config))
733
749
  t, raw = store.create_admin_token(name=name)
734
750
  print(raw)
735
751
  err = Console(stderr=True)
@@ -740,11 +756,11 @@ def token_create(
740
756
 
741
757
 
742
758
  @token_app.command("list")
743
- def token_list():
759
+ def token_list(config: Optional[Path] = _TOKEN_CONFIG_OPTION):
744
760
  """List all admin API tokens."""
745
761
  from tsugite_daemon.auth import TokenStore
746
762
 
747
- store = TokenStore(_get_tokens_path())
763
+ store = TokenStore(_get_tokens_path(config))
748
764
  tokens = store.list_admin_tokens()
749
765
  if not tokens:
750
766
  console.print("[dim]No tokens configured.[/dim]")
@@ -757,11 +773,12 @@ def token_list():
757
773
  @token_app.command("revoke")
758
774
  def token_revoke(
759
775
  name_or_prefix: str = typer.Argument(help="Token name or prefix to revoke"),
776
+ config: Optional[Path] = _TOKEN_CONFIG_OPTION,
760
777
  ):
761
778
  """Revoke an admin API token."""
762
779
  from tsugite_daemon.auth import TokenStore
763
780
 
764
- store = TokenStore(_get_tokens_path())
781
+ store = TokenStore(_get_tokens_path(config))
765
782
  if store.revoke_admin_token(name_or_prefix):
766
783
  console.print(f"[green]Token '{name_or_prefix}' revoked.[/green]")
767
784
  else:
@@ -0,0 +1,316 @@
1
+ """Shared base for the daemon's record stores.
2
+
3
+ One copy of the locking, persistence, and guarded state-machine logic that
4
+ JobStore and TerminalSessionStore both need - the two had already started to
5
+ drift (one locked its read accessors, the other didn't).
6
+
7
+ Persistence is a per-collection table in ``<dir>/daemon.db`` (SQLite, WAL,
8
+ write-through): each mutation upserts or deletes exactly the affected row, so
9
+ nothing is lost on a crash and a large store isn't re-serialized wholesale per
10
+ save. The legacy per-store ``*.json`` file remains the constructor argument:
11
+ it is read once as a migration source when the db collection is empty, and
12
+ left untouched afterwards as a backup.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ import os
20
+ import re
21
+ import sqlite3
22
+ import threading
23
+ from dataclasses import asdict
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ DAEMON_DB_FILENAME = "daemon.db"
30
+
31
+ _BUSY_TIMEOUT_MS = 5000
32
+ _COLLECTION_NAME_RE = re.compile(r"^[a-z_][a-z0-9_]*$")
33
+
34
+
35
+ def now_iso() -> str:
36
+ return datetime.now(timezone.utc).isoformat()
37
+
38
+
39
+ class SqliteCollectionStorage:
40
+ """One collection (table) of ``id -> JSON document`` rows in a shared SQLite db.
41
+
42
+ Rows are opaque JSON documents rather than per-field columns so dataclass
43
+ schema growth never needs a db migration; queries stay in the stores'
44
+ in-memory dicts. The connection is NOT internally synchronized - callers
45
+ serialize access with their own lock (every store method already runs
46
+ under one).
47
+ """
48
+
49
+ def __init__(self, db_path: Path, collection: str):
50
+ if not _COLLECTION_NAME_RE.match(collection):
51
+ raise ValueError(f"Invalid collection name: {collection!r}")
52
+ # Reject non-paths outright: a test's MagicMock would otherwise
53
+ # materialize junk directories/db files named after the mock's repr in
54
+ # the CWD (Path(mock) "works" via the mock's auto-generated __fspath__).
55
+ if not isinstance(db_path, (str, Path)):
56
+ raise TypeError(f"db_path must be a Path, got {type(db_path).__name__}")
57
+ db_path = Path(db_path)
58
+ self._collection = collection
59
+ db_path.parent.mkdir(parents=True, exist_ok=True)
60
+ self._conn = sqlite3.connect(str(db_path), check_same_thread=False, isolation_level=None)
61
+ self._conn.execute("PRAGMA journal_mode=WAL")
62
+ self._conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
63
+ self._conn.execute("PRAGMA synchronous=NORMAL")
64
+ self._conn.execute(f'CREATE TABLE IF NOT EXISTS "{collection}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)')
65
+ # Tracks which collections have completed their one-time legacy-JSON
66
+ # import. Row-count can't stand in for this: a collection legitimately
67
+ # emptied at runtime must NOT re-import the stale legacy file on the
68
+ # next start.
69
+ self._conn.execute('CREATE TABLE IF NOT EXISTS "_migrated" (collection TEXT PRIMARY KEY, at TEXT NOT NULL)')
70
+ # Owner-only, like tokens.json was: the db holds session content and
71
+ # auth token hashes. SQLite propagates the mode to -wal/-shm siblings.
72
+ try:
73
+ os.chmod(db_path, 0o600)
74
+ except OSError:
75
+ pass
76
+
77
+ @classmethod
78
+ def for_state_file(cls, legacy_path: Path, collection: str) -> "SqliteCollectionStorage":
79
+ """Storage for a store whose legacy JSON lived at ``legacy_path``:
80
+ the shared daemon.db sits next to it."""
81
+ return cls(legacy_path.parent / DAEMON_DB_FILENAME, collection)
82
+
83
+ def load_or_migrate(self, legacy_path: Path, collection_key: str) -> tuple[list[dict], bool]:
84
+ """Entries from the db, falling back to a one-time legacy JSON import.
85
+
86
+ The import runs at most once per collection (tracked in the _migrated
87
+ table), so a collection emptied at runtime stays empty across restarts
88
+ instead of resurrecting the stale legacy file.
89
+
90
+ Returns (entries, migrating). When migrating is True the caller must
91
+ persist its parsed records back (replace_all / _save) so the import
92
+ becomes durable; the legacy file is left untouched as a backup.
93
+ """
94
+ entries = self.load_all()
95
+ if entries or self._is_migrated():
96
+ self._mark_migrated() # stamp dbs created before the marker existed
97
+ return entries, False
98
+ entries = load_legacy_json_entries(legacy_path, collection_key)
99
+ if entries:
100
+ logger.info(
101
+ "Migrating %d %s record(s) from %s into daemon.db (legacy file kept as backup)",
102
+ len(entries),
103
+ collection_key,
104
+ legacy_path.name,
105
+ )
106
+ self._mark_migrated()
107
+ return entries, bool(entries)
108
+
109
+ def _is_migrated(self) -> bool:
110
+ row = self._conn.execute('SELECT 1 FROM "_migrated" WHERE collection = ?', (self._collection,)).fetchone()
111
+ return row is not None
112
+
113
+ def _mark_migrated(self) -> None:
114
+ self._conn.execute(
115
+ 'INSERT OR IGNORE INTO "_migrated" (collection, at) VALUES (?, ?)',
116
+ (self._collection, datetime.now(timezone.utc).isoformat()),
117
+ )
118
+
119
+ def exists_any(self) -> bool:
120
+ """True when the collection has at least one row (no full scan)."""
121
+ return self._conn.execute(f'SELECT 1 FROM "{self._collection}" LIMIT 1').fetchone() is not None
122
+
123
+ def get(self, record_id: str) -> dict | None:
124
+ row = self._conn.execute(f'SELECT data FROM "{self._collection}" WHERE id = ?', (record_id,)).fetchone()
125
+ if row is None:
126
+ return None
127
+ try:
128
+ return json.loads(row[0])
129
+ except json.JSONDecodeError as e:
130
+ logger.error("Corrupt %s row %s: %s", self._collection, record_id, e)
131
+ return None
132
+
133
+ def load_all(self) -> list[dict]:
134
+ rows = self._conn.execute(f'SELECT data FROM "{self._collection}"').fetchall()
135
+ entries = []
136
+ for (data,) in rows:
137
+ try:
138
+ entries.append(json.loads(data))
139
+ except json.JSONDecodeError as e:
140
+ logger.error("Skipping corrupt %s row: %s", self._collection, e)
141
+ return entries
142
+
143
+ def upsert(self, record_id: str, entry: dict) -> None:
144
+ # default=str: a stray non-JSON value in a record field must degrade to
145
+ # its string form, not crash the mutation that carries it.
146
+ self._conn.execute(
147
+ f'INSERT INTO "{self._collection}" (id, data) VALUES (?, ?) '
148
+ "ON CONFLICT(id) DO UPDATE SET data = excluded.data",
149
+ (record_id, json.dumps(entry, default=str)),
150
+ )
151
+
152
+ def delete(self, record_id: str) -> None:
153
+ self._conn.execute(f'DELETE FROM "{self._collection}" WHERE id = ?', (record_id,))
154
+
155
+ def replace_all(self, entries: dict[str, dict]) -> None:
156
+ """Atomically make the collection equal to ``entries``."""
157
+ self._conn.execute("BEGIN IMMEDIATE")
158
+ try:
159
+ self._conn.execute(f'DELETE FROM "{self._collection}"')
160
+ self._conn.executemany(
161
+ f'INSERT INTO "{self._collection}" (id, data) VALUES (?, ?)',
162
+ [(rid, json.dumps(entry, default=str)) for rid, entry in entries.items()],
163
+ )
164
+ self._conn.execute("COMMIT")
165
+ except Exception:
166
+ self._conn.execute("ROLLBACK")
167
+ raise
168
+
169
+ def close(self) -> None:
170
+ self._conn.close()
171
+
172
+
173
+ def load_legacy_json_entries(path: Path, collection_key: str) -> list[dict]:
174
+ """Entries from a legacy whole-file JSON store, tolerating the list shape
175
+ (``{key: [...]}``), the dict shape (``{key: {id: {...}}}``), and the bare
176
+ top-level array some stores used (tokens.json, push_subscriptions.json).
177
+ Returns [] when the file is absent or unreadable."""
178
+ if not path.exists():
179
+ return []
180
+ try:
181
+ data = json.loads(path.read_text())
182
+ except (json.JSONDecodeError, OSError) as e:
183
+ logger.error("Failed to load legacy %s from %s: %s", collection_key, path, e)
184
+ return []
185
+ raw = data.get(collection_key, []) if isinstance(data, dict) else data
186
+ if isinstance(raw, dict):
187
+ return list(raw.values())
188
+ if isinstance(raw, list):
189
+ return [e for e in raw if isinstance(e, dict)]
190
+ return []
191
+
192
+
193
+ class RecordStore:
194
+ """In-memory dict of dataclass records + write-through SQLite persistence.
195
+
196
+ Records must have: id, state, updated_at, resolved_at, parent_session_id.
197
+ Subclasses define the class attributes below and may override `_load_entry`
198
+ (per-entry migrations) and `_after_load` (post-load reconciliation; return
199
+ True to persist the result).
200
+ """
201
+
202
+ record_cls: type
203
+ collection_key: str # table name / legacy top-level JSON key, e.g. "jobs"
204
+ record_label: str # for error messages, e.g. "job"
205
+ valid_transitions: dict[str, frozenset[str]]
206
+ terminal_states: frozenset[str]
207
+ transition_error_cls: type[ValueError] = ValueError
208
+
209
+ def __init__(self, path: Path):
210
+ self._path = path # legacy JSON location; migration source only
211
+ self._storage = SqliteCollectionStorage.for_state_file(path, self.collection_key)
212
+ self._records: dict = {}
213
+ self._lock = threading.Lock()
214
+ self._load()
215
+
216
+ def _persist(self, record) -> None:
217
+ """Write-through one record's row. Caller holds the lock."""
218
+ self._storage.upsert(record.id, asdict(record))
219
+
220
+ def add(self, record):
221
+ with self._lock:
222
+ if record.id in self._records:
223
+ raise ValueError(f"{self.record_cls.__name__} already exists: {record.id}")
224
+ self._records[record.id] = record
225
+ self._persist(record)
226
+ return record
227
+
228
+ def get(self, record_id: str):
229
+ with self._lock:
230
+ return self._records.get(record_id)
231
+
232
+ def list_all(self) -> list:
233
+ with self._lock:
234
+ return list(self._records.values())
235
+
236
+ def list_active(self) -> list:
237
+ with self._lock:
238
+ return [r for r in self._records.values() if r.state not in self.terminal_states]
239
+
240
+ def list_for_parent(self, parent_session_id: str) -> list:
241
+ with self._lock:
242
+ return [r for r in self._records.values() if r.parent_session_id == parent_session_id]
243
+
244
+ def update_state(self, record_id: str, new_state: str):
245
+ with self._lock:
246
+ record = self._records.get(record_id)
247
+ if record is None:
248
+ raise KeyError(f"Unknown {self.record_label}: {record_id}")
249
+ allowed = self.valid_transitions.get(record.state, frozenset())
250
+ if new_state not in allowed:
251
+ raise self.transition_error_cls(
252
+ f"Invalid {self.record_cls.__name__} state transition: "
253
+ f"{record.state} -> {new_state} ({self.record_label} {record_id})"
254
+ )
255
+ record.state = new_state
256
+ record.updated_at = now_iso()
257
+ if new_state in self.terminal_states and not record.resolved_at:
258
+ record.resolved_at = record.updated_at
259
+ self._on_state_updated(record)
260
+ self._persist(record)
261
+ return record
262
+
263
+ def _on_state_updated(self, record) -> None:
264
+ """Hook called (under the lock) after a state transition, before persist.
265
+ Hooks that drop other records must go through `_delete_record` so the
266
+ deletion is durable."""
267
+
268
+ def _delete_record(self, record_id: str) -> None:
269
+ """Remove a record from memory AND storage. Caller holds the lock."""
270
+ self._records.pop(record_id, None)
271
+ self._storage.delete(record_id)
272
+
273
+ def update(self, record_id: str, **fields):
274
+ with self._lock:
275
+ record = self._records.get(record_id)
276
+ if record is None:
277
+ raise KeyError(f"Unknown {self.record_label}: {record_id}")
278
+ for key, value in fields.items():
279
+ if not hasattr(record, key):
280
+ raise ValueError(f"Unknown {self.record_cls.__name__} field: {key}")
281
+ setattr(record, key, self._coerce_update_value(key, value))
282
+ record.updated_at = now_iso()
283
+ self._persist(record)
284
+ return record
285
+
286
+ def _coerce_update_value(self, key: str, value):
287
+ """Hook for per-field validation/coercion in update()."""
288
+ return value
289
+
290
+ def _load_entry(self, entry: dict):
291
+ """Build a record from a raw persisted entry; return None to drop it.
292
+ Subclasses apply schema migrations here."""
293
+ try:
294
+ return self.record_cls(**entry)
295
+ except TypeError as e:
296
+ logger.error("Skipping malformed %s record: %s (%s)", self.record_cls.__name__, entry.get("id"), e)
297
+ return None
298
+
299
+ def _after_load(self) -> bool:
300
+ """Post-load reconciliation hook. Return True to persist the result."""
301
+ return False
302
+
303
+ def _load(self) -> None:
304
+ entries, migrating = self._storage.load_or_migrate(self._path, self.collection_key)
305
+ for entry in entries:
306
+ record = self._load_entry(entry)
307
+ if record is not None:
308
+ self._records[record.id] = record
309
+ if self._after_load() or migrating:
310
+ self._storage.replace_all({r.id: asdict(r) for r in self._records.values()})
311
+
312
+
313
+ # Deprecated alias - external plugins may still import the old name.
314
+ JsonRecordStore = RecordStore
315
+
316
+ __all__ = ["RecordStore", "JsonRecordStore", "SqliteCollectionStorage", "load_legacy_json_entries", "now_iso"]
@@ -35,27 +35,30 @@ _EFFORT_TO_BUDGET = {
35
35
  # Auto-generated by scripts/update_model_registry.py — do not edit manually
36
36
  # fmt: off
37
37
  _ANTHROPIC_MODELS: dict[str, ModelInfo] = {
38
- "anthropic/claude-3-7-sonnet-20250219": ModelInfo(max_input_tokens=200_000, max_output_tokens=64_000, input_cost_per_million=3.0, output_cost_per_million=15.0, supports_vision=True),
38
+ "anthropic/claude-3-5-sonnet-20240620": ModelInfo(max_input_tokens=200_000, max_output_tokens=8_192, input_cost_per_million=3.0, output_cost_per_million=15.0, supports_vision=True),
39
+ "anthropic/claude-3-5-sonnet-20241022": ModelInfo(max_input_tokens=200_000, max_output_tokens=8_192, input_cost_per_million=3.0, output_cost_per_million=15.0, supports_vision=True),
40
+ "anthropic/claude-3-7-sonnet-20250219": ModelInfo(max_input_tokens=200_000, max_output_tokens=64_000, input_cost_per_million=3.0, output_cost_per_million=15.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "max"], thinking_style="budget_tokens"),
39
41
  "anthropic/claude-3-haiku-20240307": ModelInfo(max_input_tokens=200_000, max_output_tokens=4_096, input_cost_per_million=0.25, output_cost_per_million=1.25, supports_vision=True),
40
42
  "anthropic/claude-3-opus-20240229": ModelInfo(max_input_tokens=200_000, max_output_tokens=4_096, input_cost_per_million=15.0, output_cost_per_million=75.0, supports_vision=True),
41
- "anthropic/claude-4-opus-20250514": ModelInfo(max_input_tokens=200_000, max_output_tokens=32_000, input_cost_per_million=15.0, output_cost_per_million=75.0, supports_vision=True),
42
- "anthropic/claude-4-sonnet-20250514": ModelInfo(max_input_tokens=1_000_000, max_output_tokens=64_000, input_cost_per_million=3.0, output_cost_per_million=15.0, supports_vision=True),
43
- "anthropic/claude-haiku-4-5": ModelInfo(max_input_tokens=200_000, max_output_tokens=64_000, input_cost_per_million=1.0, output_cost_per_million=5.0, supports_vision=True, supported_effort_levels=["low", "medium", "high", "max"]),
44
- "anthropic/claude-haiku-4-5-20251001": ModelInfo(max_input_tokens=200_000, max_output_tokens=64_000, input_cost_per_million=1.0, output_cost_per_million=5.0, supports_vision=True, supported_effort_levels=["low", "medium", "high", "max"]),
45
- "anthropic/claude-opus-4-1": ModelInfo(max_input_tokens=200_000, max_output_tokens=32_000, input_cost_per_million=15.0, output_cost_per_million=75.0, supports_vision=True, supported_effort_levels=["low", "medium", "high", "max"]),
46
- "anthropic/claude-opus-4-1-20250805": ModelInfo(max_input_tokens=200_000, max_output_tokens=32_000, input_cost_per_million=15.0, output_cost_per_million=75.0, supports_vision=True, supported_effort_levels=["low", "medium", "high", "max"]),
47
- "anthropic/claude-opus-4-20250514": ModelInfo(max_input_tokens=200_000, max_output_tokens=32_000, input_cost_per_million=15.0, output_cost_per_million=75.0, supports_vision=True, supported_effort_levels=["low", "medium", "high", "max"]),
48
- "anthropic/claude-opus-4-5": ModelInfo(max_input_tokens=200_000, max_output_tokens=64_000, input_cost_per_million=5.0, output_cost_per_million=25.0, supports_vision=True, supported_effort_levels=["low", "medium", "high", "max"]),
49
- "anthropic/claude-opus-4-5-20251101": ModelInfo(max_input_tokens=200_000, max_output_tokens=64_000, input_cost_per_million=5.0, output_cost_per_million=25.0, supports_vision=True, supported_effort_levels=["low", "medium", "high", "max"]),
50
- "anthropic/claude-opus-4-6": ModelInfo(max_input_tokens=1_000_000, max_output_tokens=128_000, input_cost_per_million=5.0, output_cost_per_million=25.0, supports_vision=True, supported_effort_levels=["low", "medium", "high", "max"]),
51
- "anthropic/claude-opus-4-6-20260205": ModelInfo(max_input_tokens=1_000_000, max_output_tokens=128_000, input_cost_per_million=5.0, output_cost_per_million=25.0, supports_vision=True, supported_effort_levels=["low", "medium", "high", "max"]),
52
- "anthropic/claude-opus-4-7": ModelInfo(max_input_tokens=1_000_000, max_output_tokens=128_000, input_cost_per_million=5.0, output_cost_per_million=25.0, supports_vision=True, supported_effort_levels=["low", "medium", "high", "max"]),
53
- "anthropic/claude-opus-4-7-20260416": ModelInfo(max_input_tokens=1_000_000, max_output_tokens=128_000, input_cost_per_million=5.0, output_cost_per_million=25.0, supports_vision=True, supported_effort_levels=["low", "medium", "high", "max"]),
54
- "anthropic/claude-opus-4-8": ModelInfo(max_input_tokens=1_000_000, max_output_tokens=128_000, input_cost_per_million=5.0, output_cost_per_million=25.0, supports_vision=True, supported_effort_levels=["low", "medium", "high", "max"]),
55
- "anthropic/claude-sonnet-4-20250514": ModelInfo(max_input_tokens=1_000_000, max_output_tokens=64_000, input_cost_per_million=3.0, output_cost_per_million=15.0, supports_vision=True, supported_effort_levels=["low", "medium", "high", "max"]),
56
- "anthropic/claude-sonnet-4-5": ModelInfo(max_input_tokens=200_000, max_output_tokens=64_000, input_cost_per_million=3.0, output_cost_per_million=15.0, supports_vision=True, supported_effort_levels=["low", "medium", "high", "max"]),
57
- "anthropic/claude-sonnet-4-5-20250929": ModelInfo(max_input_tokens=200_000, max_output_tokens=64_000, input_cost_per_million=3.0, output_cost_per_million=15.0, supports_vision=True, supported_effort_levels=["low", "medium", "high", "max"]),
58
- "anthropic/claude-sonnet-4-6": ModelInfo(max_input_tokens=1_000_000, max_output_tokens=64_000, input_cost_per_million=3.0, output_cost_per_million=15.0, supports_vision=True, supported_effort_levels=["low", "medium", "high", "max"]),
43
+ "anthropic/claude-3-sonnet-20240229": ModelInfo(max_input_tokens=200_000, max_output_tokens=4_096, input_cost_per_million=3.0, output_cost_per_million=15.0, supports_vision=True),
44
+ "anthropic/claude-fable-5": ModelInfo(max_input_tokens=1_000_000, max_output_tokens=128_000, input_cost_per_million=10.0, output_cost_per_million=50.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "xhigh", "max"], thinking_style="adaptive"),
45
+ "anthropic/claude-haiku-4-5": ModelInfo(max_input_tokens=200_000, max_output_tokens=64_000, input_cost_per_million=1.0, output_cost_per_million=5.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "max"], thinking_style="budget_tokens"),
46
+ "anthropic/claude-haiku-4-5-20251001": ModelInfo(max_input_tokens=200_000, max_output_tokens=64_000, input_cost_per_million=1.0, output_cost_per_million=5.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "max"], thinking_style="budget_tokens"),
47
+ "anthropic/claude-opus-4-0": ModelInfo(max_input_tokens=200_000, max_output_tokens=32_000, input_cost_per_million=15.0, output_cost_per_million=75.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "max"], thinking_style="budget_tokens"),
48
+ "anthropic/claude-opus-4-1": ModelInfo(max_input_tokens=200_000, max_output_tokens=32_000, input_cost_per_million=15.0, output_cost_per_million=75.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "max"], thinking_style="budget_tokens"),
49
+ "anthropic/claude-opus-4-1-20250805": ModelInfo(max_input_tokens=200_000, max_output_tokens=32_000, input_cost_per_million=15.0, output_cost_per_million=75.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "max"], thinking_style="budget_tokens"),
50
+ "anthropic/claude-opus-4-20250514": ModelInfo(max_input_tokens=200_000, max_output_tokens=32_000, input_cost_per_million=15.0, output_cost_per_million=75.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "max"], thinking_style="budget_tokens"),
51
+ "anthropic/claude-opus-4-5": ModelInfo(max_input_tokens=200_000, max_output_tokens=64_000, input_cost_per_million=5.0, output_cost_per_million=25.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "max"], thinking_style="budget_tokens"),
52
+ "anthropic/claude-opus-4-5-20251101": ModelInfo(max_input_tokens=200_000, max_output_tokens=64_000, input_cost_per_million=5.0, output_cost_per_million=25.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "max"], thinking_style="budget_tokens"),
53
+ "anthropic/claude-opus-4-6": ModelInfo(max_input_tokens=1_000_000, max_output_tokens=128_000, input_cost_per_million=5.0, output_cost_per_million=25.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "max"], thinking_style="budget_tokens"),
54
+ "anthropic/claude-opus-4-7": ModelInfo(max_input_tokens=1_000_000, max_output_tokens=128_000, input_cost_per_million=5.0, output_cost_per_million=25.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "xhigh", "max"], thinking_style="adaptive"),
55
+ "anthropic/claude-opus-4-8": ModelInfo(max_input_tokens=1_000_000, max_output_tokens=128_000, input_cost_per_million=5.0, output_cost_per_million=25.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "xhigh", "max"], thinking_style="adaptive"),
56
+ "anthropic/claude-sonnet-4-0": ModelInfo(max_input_tokens=200_000, max_output_tokens=64_000, input_cost_per_million=3.0, output_cost_per_million=15.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "max"], thinking_style="budget_tokens"),
57
+ "anthropic/claude-sonnet-4-20250514": ModelInfo(max_input_tokens=200_000, max_output_tokens=64_000, input_cost_per_million=3.0, output_cost_per_million=15.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "max"], thinking_style="budget_tokens"),
58
+ "anthropic/claude-sonnet-4-5": ModelInfo(max_input_tokens=200_000, max_output_tokens=64_000, input_cost_per_million=3.0, output_cost_per_million=15.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "max"], thinking_style="budget_tokens"),
59
+ "anthropic/claude-sonnet-4-5-20250929": ModelInfo(max_input_tokens=200_000, max_output_tokens=64_000, input_cost_per_million=3.0, output_cost_per_million=15.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "max"], thinking_style="budget_tokens"),
60
+ "anthropic/claude-sonnet-4-6": ModelInfo(max_input_tokens=1_000_000, max_output_tokens=64_000, input_cost_per_million=3.0, output_cost_per_million=15.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "max"], thinking_style="budget_tokens"),
61
+ "anthropic/claude-sonnet-5": ModelInfo(max_input_tokens=1_000_000, max_output_tokens=128_000, input_cost_per_million=2.0, output_cost_per_million=10.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "xhigh", "max"], thinking_style="adaptive"),
59
62
  }
60
63
  # fmt: on
61
64
  # --- END GENERATED MODELS (anthropic) --- #
@@ -114,20 +117,37 @@ class AnthropicProvider:
114
117
  if system:
115
118
  body["system"] = system
116
119
 
120
+ # The adaptive-thinking generation (Opus 4.7+, Sonnet 5, Fable 5)
121
+ # rejects budget_tokens thinking AND sampling params with 400; effort
122
+ # rides output_config there instead. Unknown models fall back to the
123
+ # budget_tokens path (pre-adaptive behavior).
124
+ info = self.get_model_info(model)
125
+ adaptive = info is not None and info.thinking_style == "adaptive"
126
+
117
127
  for key in ("temperature", "top_p", "top_k", "stop_sequences", "metadata"):
118
- if key in kwargs:
119
- body[key] = kwargs[key]
128
+ if key not in kwargs:
129
+ continue
130
+ if adaptive and key in ("temperature", "top_p", "top_k"):
131
+ logger.warning(
132
+ "Model %s rejects sampling param %s (adaptive-thinking generation); dropping", model, key
133
+ )
134
+ continue
135
+ body[key] = kwargs[key]
120
136
 
121
137
  effort = kwargs.get("reasoning_effort")
122
138
  if effort:
123
- budget = _EFFORT_TO_BUDGET.get(effort)
124
- if budget is None:
125
- logger.warning("Unknown reasoning_effort=%r for Anthropic; dropping", effort)
139
+ if adaptive:
140
+ body["thinking"] = {"type": "adaptive"}
141
+ body["output_config"] = {"effort": effort}
126
142
  else:
127
- # Extended thinking requires max_tokens > budget_tokens.
128
- if body["max_tokens"] <= budget:
129
- body["max_tokens"] = budget + 1024
130
- body["thinking"] = {"type": "enabled", "budget_tokens": budget}
143
+ budget = _EFFORT_TO_BUDGET.get(effort)
144
+ if budget is None:
145
+ logger.warning("Unknown reasoning_effort=%r for Anthropic; dropping", effort)
146
+ else:
147
+ # Extended thinking requires max_tokens > budget_tokens.
148
+ if body["max_tokens"] <= budget:
149
+ body["max_tokens"] = budget + 1024
150
+ body["thinking"] = {"type": "enabled", "budget_tokens": budget}
131
151
 
132
152
  url = f"{self.api_base}/v1/messages"
133
153
  headers = self._build_headers()
@@ -49,6 +49,11 @@ class ModelInfo:
49
49
  supports_reasoning: bool = False
50
50
  supports_streaming: bool = True
51
51
  supported_effort_levels: list[str] | None = None
52
+ # How the model exposes reasoning control: "budget_tokens" (extended
53
+ # thinking via thinking budgets) or "adaptive" (native effort parameter;
54
+ # budget_tokens and sampling params are rejected with 400). None when the
55
+ # model has neither or the style is unknown.
56
+ thinking_style: str | None = None
52
57
 
53
58
 
54
59
  @runtime_checkable