agentshim 0.19.0__tar.gz → 0.21.0__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 (139) hide show
  1. {agentshim-0.19.0 → agentshim-0.21.0}/CHANGELOG.md +34 -0
  2. {agentshim-0.19.0 → agentshim-0.21.0}/PKG-INFO +1 -1
  3. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/__init__.py +31 -14
  4. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/agent.py +6 -4
  5. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/core/__init__.py +9 -7
  6. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/core/auth.py +38 -0
  7. agentshim-0.21.0/agentshim/core/models.py +209 -0
  8. agentshim-0.21.0/agentshim/core/plugin.py +172 -0
  9. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/core/pricing.py +2 -159
  10. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/core/profile.py +5 -1
  11. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/core/usage.py +1 -1
  12. agentshim-0.21.0/agentshim/execution/readiness.py +76 -0
  13. agentshim-0.21.0/agentshim/models.py +91 -0
  14. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/oneshot.py +9 -2
  15. agentshim-0.21.0/agentshim/pricing.py +109 -0
  16. agentshim-0.21.0/agentshim/probe.py +73 -0
  17. agentshim-0.21.0/agentshim/providers/__init__.py +139 -0
  18. agentshim-0.21.0/agentshim/providers/_testing.py +25 -0
  19. agentshim-0.21.0/agentshim/providers/catalog.py +150 -0
  20. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/claude/__init__.py +2 -0
  21. agentshim-0.21.0/agentshim/providers/claude/model_data.py +95 -0
  22. agentshim-0.21.0/agentshim/providers/claude/plugin.py +32 -0
  23. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/claude/provider.py +20 -10
  24. agentshim-0.21.0/agentshim/providers/claude/testing.py +48 -0
  25. agentshim-0.21.0/agentshim/providers/claude/tools.py +113 -0
  26. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/codex/app_server/transport.py +2 -2
  27. agentshim-0.21.0/agentshim/providers/codex/model_data.py +106 -0
  28. agentshim-0.21.0/agentshim/providers/codex/plugin.py +32 -0
  29. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/codex/provider.py +5 -1
  30. agentshim-0.21.0/agentshim/providers/codex/testing.py +37 -0
  31. agentshim-0.21.0/agentshim/providers/copilot/plugin.py +24 -0
  32. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/copilot/provider.py +3 -1
  33. agentshim-0.21.0/agentshim/providers/copilot/testing.py +28 -0
  34. agentshim-0.21.0/agentshim/providers/gemini/plugin.py +24 -0
  35. agentshim-0.21.0/agentshim/providers/gemini/testing.py +42 -0
  36. agentshim-0.21.0/agentshim/providers/opencode/model_data.py +31 -0
  37. agentshim-0.21.0/agentshim/providers/opencode/plugin.py +27 -0
  38. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/opencode/provider.py +3 -0
  39. agentshim-0.21.0/agentshim/providers/opencode/testing.py +41 -0
  40. agentshim-0.21.0/agentshim/providers/static_plugin.py +162 -0
  41. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/runtime.py +17 -3
  42. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/testing/__init__.py +39 -89
  43. agentshim-0.21.0/agentshim/testing/probe.py +34 -0
  44. {agentshim-0.19.0 → agentshim-0.21.0}/pyproject.toml +1 -1
  45. agentshim-0.19.0/agentshim/core/models.py +0 -335
  46. agentshim-0.19.0/agentshim/probe.py +0 -117
  47. agentshim-0.19.0/agentshim/providers/__init__.py +0 -273
  48. agentshim-0.19.0/agentshim/testing/probe.py +0 -63
  49. {agentshim-0.19.0 → agentshim-0.21.0}/.gitignore +0 -0
  50. {agentshim-0.19.0 → agentshim-0.21.0}/LICENSE +0 -0
  51. {agentshim-0.19.0 → agentshim-0.21.0}/README.md +0 -0
  52. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/core/_files.py +0 -0
  53. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/core/checkpoints.py +0 -0
  54. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/core/clock.py +0 -0
  55. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/core/conversation.py +0 -0
  56. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/core/env.py +0 -0
  57. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/core/errors.py +0 -0
  58. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/core/events.py +0 -0
  59. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/core/home.py +0 -0
  60. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/core/ids.py +0 -0
  61. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/core/mcp.py +0 -0
  62. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/core/permissions.py +0 -0
  63. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/core/provider.py +0 -0
  64. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/core/schema.py +0 -0
  65. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/core/session_policy.py +0 -0
  66. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/core/skills.py +0 -0
  67. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/core/status.py +0 -0
  68. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/core/stream.py +0 -0
  69. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/core/turn.py +0 -0
  70. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/execution/__init__.py +0 -0
  71. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/execution/confinement.py +0 -0
  72. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/execution/docker.py +0 -0
  73. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/execution/executor.py +0 -0
  74. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/execution/host.py +0 -0
  75. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/execution/process.py +0 -0
  76. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/execution/transform.py +0 -0
  77. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/claude/events.py +0 -0
  78. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/claude/failures.py +0 -0
  79. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/claude/hooks/__init__.py +0 -0
  80. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
  81. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/claude/parser.py +0 -0
  82. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/claude/probe.py +0 -0
  83. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/claude/rate_limits.py +0 -0
  84. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/claude/sandbox.py +0 -0
  85. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/claude/scripted.py +0 -0
  86. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/claude/stream_argv.py +0 -0
  87. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/claude/stream_transport.py +0 -0
  88. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/claude/user_hooks.py +0 -0
  89. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/codex/__init__.py +0 -0
  90. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/codex/_toml.py +0 -0
  91. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/codex/app_server/__init__.py +0 -0
  92. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/codex/app_server/_wire.py +0 -0
  93. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/codex/app_server/approvals.py +0 -0
  94. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/codex/app_server/channel.py +0 -0
  95. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/codex/app_server/errors.py +0 -0
  96. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/codex/app_server/items.py +0 -0
  97. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/codex/app_server/mcp.py +0 -0
  98. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/codex/app_server/permissions.py +0 -0
  99. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/codex/app_server/profile.py +0 -0
  100. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/codex/app_server/protocol.py +0 -0
  101. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/codex/app_server/rate_limits.py +0 -0
  102. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/codex/app_server/usage.py +0 -0
  103. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/codex/events.py +0 -0
  104. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/codex/failures.py +0 -0
  105. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/codex/mcp_scope.py +0 -0
  106. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/codex/parser.py +0 -0
  107. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/codex/probe.py +0 -0
  108. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/codex/rules.py +0 -0
  109. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/codex/sandbox.py +0 -0
  110. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/codex/scripted.py +0 -0
  111. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/codex/skills.py +0 -0
  112. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/copilot/__init__.py +0 -0
  113. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/copilot/events.py +0 -0
  114. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/copilot/parser.py +0 -0
  115. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/copilot/probe.py +0 -0
  116. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/copilot/scripted.py +0 -0
  117. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/gemini/__init__.py +0 -0
  118. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/gemini/events.py +0 -0
  119. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/gemini/parser.py +0 -0
  120. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/gemini/probe.py +0 -0
  121. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/gemini/provider.py +0 -0
  122. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/gemini/scripted.py +0 -0
  123. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/opencode/__init__.py +0 -0
  124. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/opencode/events.py +0 -0
  125. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/opencode/parser.py +0 -0
  126. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/opencode/probe.py +0 -0
  127. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/providers/opencode/scripted.py +0 -0
  128. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/py.typed +0 -0
  129. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/session.py +0 -0
  130. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/testing/claude_stream.py +0 -0
  131. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/testing/clock.py +0 -0
  132. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/testing/codex_app_server.py +0 -0
  133. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/testing/confinement.py +0 -0
  134. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/testing/contracts.py +0 -0
  135. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/testing/conversation.py +0 -0
  136. {agentshim-0.19.0 → agentshim-0.21.0}/agentshim/testing/process.py +0 -0
  137. {agentshim-0.19.0 → agentshim-0.21.0}/tests/fixtures/claude_stream/README.md +0 -0
  138. {agentshim-0.19.0 → agentshim-0.21.0}/tests/fixtures/codex/README.md +0 -0
  139. {agentshim-0.19.0 → agentshim-0.21.0}/tests/fixtures/codex_app_server/README.md +0 -0
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.21.0 (2026-10-18)
4
+
5
+ ### Added
6
+
7
+ - `ModelSpec` ranks a model's generation among its vendor's: `generation_id` (`"5.5"`, shared
8
+ by every model of that generation), `generation` (`"Claude 5.5"`, `"GPT-6"`) and
9
+ `generation_rank` (`1` the newest). The three are set together, default to `None`, and are
10
+ filled for every shipped model. `ModelTable` rejects a vendor whose generations disagree on a
11
+ name or rank, or share a rank. Newest first: Claude 5.5, 5.1, 5, 4.8, 4.7, 4.6, 4.5; GPT-6.1,
12
+ 6, 5.6, 5.5; Gemini 3.
13
+ - `ProviderProfile.login` is a `LoginSpec`: the CLI's own command for a person to sign in
14
+ (`claude auth login`, `codex login` with `--device-auth` as its device variant,
15
+ `copilot login`, `opencode auth login`), and whether it needs a terminal.
16
+ `LoginSpec.command(binary, device=False)` is the full command line. Gemini CLI has no
17
+ sign-in command (`login` is `None`). Signed-in state stays in `ProbeSpec.auth` and
18
+ `probe_provider`.
19
+
20
+ ## 0.20.0 (2026-10-18)
21
+
22
+ ### Added
23
+
24
+ - `ClaudeProvider(tools=ClaudeToolPolicy(...))`: run Claude Code with only the built-in tools
25
+ and allow rules the policy names, under `--permission-mode dontAsk`, instead of the
26
+ always-on `--dangerously-skip-permissions`. `ClaudeToolPolicy.only(["mcp__rs"])` gives a
27
+ turn no built-in tools and lets it call only that MCP server; `builtin=()` renders
28
+ `--tools ""`, and each allow rule is its own `--allowedTools` argument so a scoped rule
29
+ such as `Bash(git log:*)` arrives whole. A turn with an output schema also allows
30
+ `StructuredOutput`. Without a policy the argv is unchanged.
31
+
32
+ ### Changed
33
+
34
+ - Provider integrations are resolved through an extensible `ProviderCatalog`; `CliAgent`,
35
+ `Agent`, `probe_provider` and the consumer test helpers take `catalog=` (#70).
36
+
3
37
  ## 0.19.0 (2026-10-17)
4
38
 
5
39
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentshim
3
- Version: 0.19.0
3
+ Version: 0.21.0
4
4
  Summary: Provider-agnostic coding-agent CLI shims
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -29,6 +29,7 @@ from .core import (
29
29
  CompositeEventHandler,
30
30
  ConfigFileInstallation,
31
31
  ConfigScope,
32
+ ConnectionOptions,
32
33
  ConsoleEventHandler,
33
34
  ContextualStreamParser,
34
35
  Continuity,
@@ -50,15 +51,13 @@ from .core import (
50
51
  IdAllocator,
51
52
  InMemoryCheckpointStore,
52
53
  Lifecycle,
54
+ LoginSpec,
53
55
  McpConfigError,
54
56
  McpInstallation,
55
57
  McpMechanism,
56
58
  McpScope,
57
59
  McpServer,
58
60
  McpTransport,
59
- ModelPricing,
60
- ModelSpec,
61
- ModelTable,
62
61
  NativeMode,
63
62
  NativePermissions,
64
63
  NoopInstallation,
@@ -69,7 +68,7 @@ from .core import (
69
68
  ParsedTurn,
70
69
  ParserContext,
71
70
  PolicyConfig,
72
- PricingTable,
71
+ PricingContext,
73
72
  ProcessClosedError,
74
73
  Provider,
75
74
  ProviderCapabilityError,
@@ -126,22 +125,16 @@ from .core import (
126
125
  UsageReport,
127
126
  compact_json,
128
127
  compose_event_handlers,
129
- context_window,
130
- cost_usd,
131
- default_models,
132
- default_pricing,
133
128
  dialect_problems,
134
129
  host_auth_plan,
135
130
  install_config_file,
136
131
  interactive_env,
137
132
  materialize,
138
- model_spec,
139
133
  normalize,
140
134
  normalized_usage,
141
135
  parse_json_object,
142
136
  plan_auth,
143
137
  prepare_config_home,
144
- price_for,
145
138
  )
146
139
  from .execution import (
147
140
  CallbackCommandStreamSink,
@@ -164,10 +157,26 @@ from .execution import (
164
157
  TransformingExecutor,
165
158
  confine,
166
159
  )
160
+ from .models import ModelSpec, ModelTable, context_window, default_models, model_spec
167
161
  from .oneshot import OneShotTransport
162
+ from .pricing import ModelPricing, PricingTable, cost_usd, default_pricing, price_for
168
163
  from .probe import probe_provider
169
- from .providers import get_provider, provider_names, stream_provider_names
170
- from .providers.claude import ClaudeHook, ClaudeProvider, ClaudeStreamTransport, SandboxConfig
164
+ from .providers import (
165
+ DEFAULT_CATALOG,
166
+ ProviderCatalog,
167
+ ProviderPlugin,
168
+ ProviderTestSupport,
169
+ get_provider,
170
+ provider_names,
171
+ stream_provider_names,
172
+ )
173
+ from .providers.claude import (
174
+ ClaudeHook,
175
+ ClaudeProvider,
176
+ ClaudeStreamTransport,
177
+ ClaudeToolPolicy,
178
+ SandboxConfig,
179
+ )
171
180
  from .providers.codex import CodexProvider, CodexSandboxConfig
172
181
  from .providers.codex.app_server import CodexAppServerTransport
173
182
  from .providers.copilot import CopilotProvider
@@ -176,9 +185,9 @@ from .providers.opencode import OpencodeProvider
176
185
  from .runtime import Agent
177
186
  from .session import Session, Turn, TurnTicket, map_mcp_servers
178
187
 
179
- __version__ = "0.19.0"
188
+ __version__ = "0.21.0"
180
189
 
181
- __all__ = [
190
+ __all__ = [ # noqa: RUF022 - public API test uses Python's case-sensitive order
182
191
  "Agent",
183
192
  "AgentEvent",
184
193
  "AgentEventHandler",
@@ -198,6 +207,7 @@ __all__ = [
198
207
  "ClaudeHook",
199
208
  "ClaudeProvider",
200
209
  "ClaudeStreamTransport",
210
+ "ClaudeToolPolicy",
201
211
  "CliAgent",
202
212
  "CliCheckError",
203
213
  "CliExitError",
@@ -216,6 +226,7 @@ __all__ = [
216
226
  "ConfigFileInstallation",
217
227
  "ConfigScope",
218
228
  "Confinement",
229
+ "ConnectionOptions",
219
230
  "ConsoleEventHandler",
220
231
  "ContextualStreamParser",
221
232
  "Continuity",
@@ -225,6 +236,7 @@ __all__ = [
225
236
  "CopilotProvider",
226
237
  "CredentialKind",
227
238
  "CredentialSource",
239
+ "DEFAULT_CATALOG",
228
240
  "DockerExecConfinement",
229
241
  "EndpointSpec",
230
242
  "EnvCredential",
@@ -242,6 +254,7 @@ __all__ = [
242
254
  "IdAllocator",
243
255
  "InMemoryCheckpointStore",
244
256
  "Lifecycle",
257
+ "LoginSpec",
245
258
  "McpConfigError",
246
259
  "McpInstallation",
247
260
  "McpMechanism",
@@ -264,6 +277,7 @@ __all__ = [
264
277
  "ParsedTurn",
265
278
  "ParserContext",
266
279
  "PolicyConfig",
280
+ "PricingContext",
267
281
  "PricingTable",
268
282
  "Process",
269
283
  "ProcessClosedError",
@@ -271,9 +285,12 @@ __all__ = [
271
285
  "ProcessOutput",
272
286
  "Provider",
273
287
  "ProviderCapabilityError",
288
+ "ProviderCatalog",
274
289
  "ProviderError",
290
+ "ProviderPlugin",
275
291
  "ProviderProfile",
276
292
  "ProviderStatus",
293
+ "ProviderTestSupport",
277
294
  "ProviderUsage",
278
295
  "RandomIds",
279
296
  "RateLimitStatus",
@@ -38,7 +38,7 @@ from agentshim.core.skills import SkillTracker
38
38
  from agentshim.core.turn import TurnRequest, TurnResult, coerce_request
39
39
  from agentshim.execution.executor import CommandRequest
40
40
  from agentshim.execution.host import HostCommandExecutor
41
- from agentshim.providers import get_provider
41
+ from agentshim.providers import DEFAULT_CATALOG, ProviderCatalog, get_provider
42
42
 
43
43
  if TYPE_CHECKING:
44
44
  from collections.abc import Callable, Mapping, Sequence
@@ -51,14 +51,14 @@ if TYPE_CHECKING:
51
51
  from agentshim.execution.executor import CommandExecutor, CommandHandle, CommandResult
52
52
 
53
53
 
54
- def _resolve_provider(provider: str | Provider) -> Provider:
54
+ def _resolve_provider(provider: str | Provider, catalog: ProviderCatalog) -> Provider:
55
55
  """Resolve a provider name to a provider instance.
56
56
 
57
57
  This module sits above ``providers`` precisely so the documented
58
58
  ``CliAgent("claude")`` spelling can be a plain module-level import.
59
59
  """
60
60
  if isinstance(provider, str):
61
- return get_provider(provider)
61
+ return get_provider(provider, catalog=catalog)
62
62
  return provider
63
63
 
64
64
 
@@ -82,6 +82,7 @@ class CliAgent:
82
82
  event_handlers: Sequence[AgentEventHandler] = (),
83
83
  check_timeout: float = 15.0,
84
84
  log: Callable[[str], None] | None = None,
85
+ catalog: ProviderCatalog = DEFAULT_CATALOG,
85
86
  ) -> None:
86
87
  """Configure a provider CLI and prove it is usable before any turn runs.
87
88
 
@@ -104,8 +105,9 @@ class CliAgent:
104
105
  check_timeout: Seconds allowed for the one-off health check. It is
105
106
  not a budget for a turn.
106
107
  log: Sink for agentshim's own progress lines; dropped by default.
108
+ catalog: Provider names and implementations to use; defaults to the built-ins.
107
109
  """
108
- self.provider: Provider = _resolve_provider(provider)
110
+ self.provider: Provider = _resolve_provider(provider, catalog)
109
111
  self.profile: ProviderProfile = self.provider.profile
110
112
  self.model = model
111
113
  self.executor: CommandExecutor = executor if executor is not None else HostCommandExecutor()
@@ -19,6 +19,7 @@ from .auth import (
19
19
  FileCredential,
20
20
  HostAuthGap,
21
21
  HostAuthUnplannableError,
22
+ LoginSpec,
22
23
  SettingsSpec,
23
24
  StagedCredentialFile,
24
25
  host_auth_plan,
@@ -95,9 +96,10 @@ from .mcp import (
95
96
  StdioMcpServer,
96
97
  install_config_file,
97
98
  )
98
- from .models import ModelSpec, ModelTable, context_window, default_models, model_spec
99
+ from .models import ModelSpec, ModelTable
99
100
  from .permissions import ApprovalPolicy, NativeMode, NativePermissions
100
- from .pricing import ModelPricing, PricingTable, cost_usd, default_pricing, price_for
101
+ from .plugin import ConnectionOptions, PricingContext, ProviderPlugin, ProviderTestSupport
102
+ from .pricing import ModelPricing, PricingTable, cost_usd
101
103
  from .profile import (
102
104
  ConfigScope,
103
105
  McpMechanism,
@@ -148,6 +150,7 @@ __all__ = [
148
150
  "CompositeEventHandler",
149
151
  "ConfigFileInstallation",
150
152
  "ConfigScope",
153
+ "ConnectionOptions",
151
154
  "ConsoleEventHandler",
152
155
  "ContextualStreamParser",
153
156
  "Continuity",
@@ -169,6 +172,7 @@ __all__ = [
169
172
  "IdAllocator",
170
173
  "InMemoryCheckpointStore",
171
174
  "Lifecycle",
175
+ "LoginSpec",
172
176
  "McpConfigError",
173
177
  "McpInstallation",
174
178
  "McpMechanism",
@@ -188,13 +192,16 @@ __all__ = [
188
192
  "ParsedTurn",
189
193
  "ParserContext",
190
194
  "PolicyConfig",
195
+ "PricingContext",
191
196
  "PricingTable",
192
197
  "ProcessClosedError",
193
198
  "Provider",
194
199
  "ProviderCapabilityError",
195
200
  "ProviderError",
201
+ "ProviderPlugin",
196
202
  "ProviderProfile",
197
203
  "ProviderStatus",
204
+ "ProviderTestSupport",
198
205
  "ProviderUsage",
199
206
  "RandomIds",
200
207
  "RateLimitStatus",
@@ -245,20 +252,15 @@ __all__ = [
245
252
  "UsageReport",
246
253
  "compact_json",
247
254
  "compose_event_handlers",
248
- "context_window",
249
255
  "cost_usd",
250
- "default_models",
251
- "default_pricing",
252
256
  "dialect_problems",
253
257
  "host_auth_plan",
254
258
  "install_config_file",
255
259
  "interactive_env",
256
260
  "materialize",
257
- "model_spec",
258
261
  "normalize",
259
262
  "normalized_usage",
260
263
  "parse_json_object",
261
264
  "plan_auth",
262
265
  "prepare_config_home",
263
- "price_for",
264
266
  ]
@@ -151,6 +151,44 @@ class EndpointSpec:
151
151
  credentials: frozenset[CredentialKind] = frozenset({CredentialKind.API_KEY})
152
152
 
153
153
 
154
+ @dataclass(frozen=True)
155
+ class LoginSpec:
156
+ """How a person signs a CLI in, as the CLI itself does it.
157
+
158
+ ``argv`` follows the binary and starts the CLI's own subscription login
159
+ (a browser page, or a URL to open). ``device_argv`` is the variant for a
160
+ machine without a usable browser, where the CLI prints a code to enter on
161
+ another device, or ``None`` when the CLI has none. ``needs_terminal`` is
162
+ true when the command prompts the person (a menu, a pasted code), so it
163
+ must run attached to a terminal; false when it runs unattended and only
164
+ prints a URL. Whether a CLI is signed in is not here: the provider's
165
+ ``ProbeSpec.auth`` reads that, where the CLI has a status command.
166
+ """
167
+
168
+ argv: tuple[str, ...]
169
+ device_argv: tuple[str, ...] | None = None
170
+ needs_terminal: bool = False
171
+
172
+ def __post_init__(self) -> None:
173
+ """Reject an empty command."""
174
+ if not self.argv or (self.device_argv is not None and not self.device_argv):
175
+ msg = "LoginSpec commands need at least one argument"
176
+ raise ValueError(msg)
177
+
178
+ def command(self, binary: str, *, device: bool = False) -> tuple[str, ...]:
179
+ """The full command line, with *binary* first.
180
+
181
+ Raises:
182
+ ValueError: *device* is asked for and the CLI has no device login.
183
+ """
184
+ if not device:
185
+ return (binary, *self.argv)
186
+ if self.device_argv is None:
187
+ msg = f"{binary} has no device login"
188
+ raise ValueError(msg)
189
+ return (binary, *self.device_argv)
190
+
191
+
154
192
  @dataclass(frozen=True)
155
193
  class AuthPolicy:
156
194
  """Which credentials a caller allows, and the endpoint to use.
@@ -0,0 +1,209 @@
1
+ """Provider-independent model facts and tables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ from dataclasses import dataclass, field, replace
7
+ from typing import TYPE_CHECKING, cast
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Iterable
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ModelSpec:
15
+ """One model's context window, with where and when it was read.
16
+
17
+ Attributes:
18
+ vendor: Who serves the model (``"openai"``, ``"anthropic"``).
19
+ model: The model id as the vendor names it.
20
+ context_window: The model's context window in tokens (input and
21
+ output together), at the vendor's default for that id.
22
+ source: URL of the official page the number was read from.
23
+ checked: Date the number was last checked against ``source``.
24
+ display_name: A readable name for the model (``"Opus 5.5"``), or
25
+ ``None`` for a caller-built entry that has none.
26
+ family: The model line the model belongs to (``"Claude"``, ``"GPT"``,
27
+ ``"Gemini"``); set together with ``display_name``.
28
+ tier: The model's rank within its family, ``1`` the smallest and
29
+ cheapest; versions of one tier share a rank. ``None`` for a model
30
+ that sits on no tier ladder.
31
+ tier_name: The tier's readable name (``"Opus"``); set together with
32
+ ``tier``.
33
+ generation_id: A short stable token naming the model's generation
34
+ within its vendor (``"5.5"``); every model of one generation
35
+ shares it, and no two generations of a vendor do.
36
+ generation: The generation as people call it (``"Claude 5.5"``,
37
+ ``"GPT-6"``); set together with ``generation_id``.
38
+ generation_rank: The generation's rank among its vendor's, ``1`` the
39
+ newest; set together with ``generation_id``. ``ModelTable``
40
+ checks that one vendor's generations have distinct ranks.
41
+ """
42
+
43
+ vendor: str
44
+ model: str
45
+ context_window: int
46
+ source: str
47
+ checked: dt.date
48
+ display_name: str | None = None
49
+ family: str | None = None
50
+ tier: int | None = None
51
+ tier_name: str | None = None
52
+ generation_id: str | None = None
53
+ generation: str | None = None
54
+ generation_rank: int | None = None
55
+
56
+ def __post_init__(self) -> None:
57
+ """Reject a missing name, source or date, and a window that is not a positive int."""
58
+ for name in ("vendor", "model"):
59
+ value: object = getattr(self, name)
60
+ if not isinstance(value, str) or not value.strip():
61
+ msg = f"{name} must be a non-empty string"
62
+ raise ValueError(msg)
63
+ source = cast("object", self.source)
64
+ if not isinstance(source, str) or not source.startswith("https://"):
65
+ msg = f"{self.vendor}/{self.model}: source must be an https:// URL"
66
+ raise ValueError(msg)
67
+ checked = cast("object", self.checked)
68
+ if not isinstance(checked, dt.date) or isinstance(checked, dt.datetime):
69
+ msg = f"{self.vendor}/{self.model}: checked must be a datetime.date"
70
+ raise TypeError(msg)
71
+ window = cast("object", self.context_window)
72
+ if isinstance(window, bool) or not isinstance(window, int) or window <= 0:
73
+ msg = f"{self.vendor}/{self.model}: context_window must be a positive int"
74
+ raise ValueError(msg)
75
+ self._check_presentation()
76
+ self._check_generation()
77
+
78
+ def _check_generation(self) -> None:
79
+ """Reject a half-set generation triple, a blank name, and a rank that is not a positive int."""
80
+ parts = (self.generation_id, self.generation, self.generation_rank)
81
+ if any(part is None for part in parts) and any(part is not None for part in parts):
82
+ msg = (
83
+ f"{self.vendor}/{self.model}: "
84
+ "generation_id, generation and generation_rank are set together"
85
+ )
86
+ raise ValueError(msg)
87
+ for name in ("generation_id", "generation"):
88
+ value: object = getattr(self, name)
89
+ if value is not None and (not isinstance(value, str) or not value.strip()):
90
+ msg = f"{self.vendor}/{self.model}: {name} must be a non-empty string"
91
+ raise ValueError(msg)
92
+ rank = cast("object", self.generation_rank)
93
+ if rank is not None and (isinstance(rank, bool) or not isinstance(rank, int) or rank < 1):
94
+ msg = f"{self.vendor}/{self.model}: generation_rank must be a positive int"
95
+ raise ValueError(msg)
96
+
97
+ def _check_presentation(self) -> None:
98
+ """Reject a half-set name pair or tier pair, and a tier that is not a positive int."""
99
+ for first, second in (("display_name", "family"), ("tier", "tier_name")):
100
+ if (getattr(self, first) is None) != (getattr(self, second) is None):
101
+ msg = f"{self.vendor}/{self.model}: {first} and {second} are set together"
102
+ raise ValueError(msg)
103
+ for name in ("display_name", "family", "tier_name"):
104
+ value: object = getattr(self, name)
105
+ if value is not None and (not isinstance(value, str) or not value.strip()):
106
+ msg = f"{self.vendor}/{self.model}: {name} must be a non-empty string"
107
+ raise ValueError(msg)
108
+ tier = cast("object", self.tier)
109
+ if tier is not None and (isinstance(tier, bool) or not isinstance(tier, int) or tier < 1):
110
+ msg = f"{self.vendor}/{self.model}: tier must be a positive int"
111
+ raise ValueError(msg)
112
+ if self.tier is not None and self.family is None:
113
+ msg = f"{self.vendor}/{self.model}: tier needs a family"
114
+ raise ValueError(msg)
115
+
116
+ @property
117
+ def key(self) -> tuple[str, str]:
118
+ """The ``(vendor, model)`` this entry describes."""
119
+ return (self.vendor, self.model)
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class ModelTable:
124
+ """A versioned set of :class:`ModelSpec` entries.
125
+
126
+ Attributes:
127
+ version: Names this set of facts; change it whenever a fact changes.
128
+ last_updated: Date the table as a whole was last updated. No entry
129
+ may have been checked later.
130
+ entries: One entry per ``(vendor, model)``.
131
+ """
132
+
133
+ version: str
134
+ last_updated: dt.date
135
+ entries: tuple[ModelSpec, ...] = field(default=())
136
+
137
+ def __post_init__(self) -> None:
138
+ """Reject a missing version, duplicate keys, and entries checked after ``last_updated``."""
139
+ version = cast("object", self.version)
140
+ if not isinstance(version, str) or not version.strip():
141
+ msg = "version must be a non-empty string"
142
+ raise ValueError(msg)
143
+ object.__setattr__(self, "entries", tuple(self.entries))
144
+ seen: set[tuple[str, str]] = set()
145
+ for entry in self.entries:
146
+ if not isinstance(cast("object", entry), ModelSpec):
147
+ msg = f"entries must be ModelSpec, got {entry!r}"
148
+ raise TypeError(msg)
149
+ if entry.key in seen:
150
+ msg = f"duplicate model entry for {entry.vendor}/{entry.model}"
151
+ raise ValueError(msg)
152
+ seen.add(entry.key)
153
+ if entry.checked > self.last_updated:
154
+ msg = (
155
+ f"{entry.vendor}/{entry.model} was checked {entry.checked}, "
156
+ f"after the table's last_updated {self.last_updated}"
157
+ )
158
+ raise ValueError(msg)
159
+ self._check_generations()
160
+
161
+ def _check_generations(self) -> None:
162
+ """Reject a vendor whose generations disagree on a name or rank, or share a rank."""
163
+ by_id: dict[tuple[str, str], tuple[str | None, int | None]] = {}
164
+ by_rank: dict[tuple[str, int], str] = {}
165
+ for entry in self.entries:
166
+ if entry.generation_id is None or entry.generation_rank is None:
167
+ continue
168
+ known = by_id.setdefault(
169
+ (entry.vendor, entry.generation_id), (entry.generation, entry.generation_rank)
170
+ )
171
+ if known != (entry.generation, entry.generation_rank):
172
+ msg = (
173
+ f"{entry.vendor}/{entry.model}: generation {entry.generation_id!r} "
174
+ "has another name or rank on another model"
175
+ )
176
+ raise ValueError(msg)
177
+ holder = by_rank.setdefault((entry.vendor, entry.generation_rank), entry.generation_id)
178
+ if holder != entry.generation_id:
179
+ msg = (
180
+ f"{entry.vendor}: generations {holder!r} and {entry.generation_id!r} "
181
+ f"share rank {entry.generation_rank}"
182
+ )
183
+ raise ValueError(msg)
184
+
185
+ def get(self, vendor: str, model: str) -> ModelSpec | None:
186
+ """The entry for exactly ``(vendor, model)``, or ``None``."""
187
+ for entry in self.entries:
188
+ if entry.key == (vendor, model):
189
+ return entry
190
+ return None
191
+
192
+ def with_entries(
193
+ self,
194
+ entries: Iterable[ModelSpec],
195
+ *,
196
+ version: str,
197
+ last_updated: dt.date | None = None,
198
+ ) -> ModelTable:
199
+ """A new table with *entries* added, replacing any with the same key."""
200
+ added = tuple(entries)
201
+ by_key = {entry.key: entry for entry in self.entries}
202
+ by_key.update({entry.key: entry for entry in added})
203
+ latest = max([self.last_updated, *(entry.checked for entry in added)])
204
+ return replace(
205
+ self,
206
+ version=version,
207
+ last_updated=last_updated or latest,
208
+ entries=tuple(by_key.values()),
209
+ )