handcode 0.3.0rc1__py3-none-any.whl

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 (67) hide show
  1. agentctl/__init__.py +0 -0
  2. agentctl/adapters/__init__.py +0 -0
  3. agentctl/adapters/litellm/__init__.py +9 -0
  4. agentctl/adapters/litellm/hook.py +49 -0
  5. agentctl/adapters/litellm/recorder.py +187 -0
  6. agentctl/adapters/openhands/__init__.py +169 -0
  7. agentctl/adapters/openhands/handoff.py +155 -0
  8. agentctl/adapters/openhands/seam_b.py +259 -0
  9. agentctl/adapters/openhands/seam_c.py +209 -0
  10. agentctl/cli.py +1450 -0
  11. agentctl/control/__init__.py +0 -0
  12. agentctl/control/cost/__init__.py +4 -0
  13. agentctl/control/cost/ledger.py +210 -0
  14. agentctl/control/dash.py +697 -0
  15. agentctl/control/keys.py +440 -0
  16. agentctl/control/matrix/__init__.py +0 -0
  17. agentctl/control/matrix/data/tools.yaml +149 -0
  18. agentctl/control/policy/__init__.py +10 -0
  19. agentctl/control/policy/compile.py +258 -0
  20. agentctl/control/policy/data/policy.compiled.json +38 -0
  21. agentctl/control/policy/data/policy.yaml +46 -0
  22. agentctl/control/probe.py +399 -0
  23. agentctl/control/providers.py +293 -0
  24. agentctl/control/proxy.py +536 -0
  25. agentctl/control/proxyenv.py +309 -0
  26. agentctl/control/replay/__init__.py +14 -0
  27. agentctl/control/replay/cassette.py +281 -0
  28. agentctl/control/replay/server.py +109 -0
  29. agentctl/demo/__init__.py +214 -0
  30. agentctl/demo/child.py +84 -0
  31. agentctl/demo/mock.py +79 -0
  32. agentctl/demo/tool.py +62 -0
  33. agentctl/gha.py +488 -0
  34. agentctl/kernel/__init__.py +0 -0
  35. agentctl/kernel/classify.py +170 -0
  36. agentctl/kernel/gate.py +391 -0
  37. agentctl/kernel/hook.py +229 -0
  38. agentctl/kernel/ledger/__init__.py +0 -0
  39. agentctl/kernel/ledger/models.py +160 -0
  40. agentctl/kernel/ledger/schema.sql +62 -0
  41. agentctl/kernel/ledger/store.py +596 -0
  42. agentctl/kernel/paths.py +203 -0
  43. agentctl/kernel/policy.py +160 -0
  44. agentctl/kernel/reconcile/__init__.py +31 -0
  45. agentctl/kernel/reconcile/base.py +106 -0
  46. agentctl/kernel/reconcile/external.py +137 -0
  47. agentctl/kernel/reconcile/filesystem.py +162 -0
  48. agentctl/kernel/reconcile/git.py +162 -0
  49. agentctl/runtime/__init__.py +20 -0
  50. agentctl/runtime/citations.py +179 -0
  51. agentctl/runtime/config.py +97 -0
  52. agentctl/runtime/doctor.py +335 -0
  53. agentctl/runtime/init.py +148 -0
  54. agentctl/runtime/lease.py +143 -0
  55. agentctl/runtime/orchestrate.py +187 -0
  56. agentctl/runtime/plugins.py +130 -0
  57. agentctl/runtime/report.py +361 -0
  58. agentctl/runtime/runner.py +787 -0
  59. agentctl/runtime/runs.py +191 -0
  60. agentctl/runtime/subagent.py +274 -0
  61. agentctl/runtime/tools.py +350 -0
  62. handcode-0.3.0rc1.dist-info/METADATA +659 -0
  63. handcode-0.3.0rc1.dist-info/RECORD +67 -0
  64. handcode-0.3.0rc1.dist-info/WHEEL +5 -0
  65. handcode-0.3.0rc1.dist-info/entry_points.txt +3 -0
  66. handcode-0.3.0rc1.dist-info/licenses/LICENSE +21 -0
  67. handcode-0.3.0rc1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,293 @@
1
+ r"""One registry of providers. The single source of truth.
2
+
3
+ `doctor` listed providers. `proxy` listed providers. Two lists, maintained
4
+ separately, which is precisely the defect `docs/0029` §4 recorded: they drift,
5
+ and drift here means a key you added is checked by one command and ignored by
6
+ another. Everything now reads this module.
7
+
8
+ ## What is recorded, and what deliberately is not
9
+
10
+ Recorded: the env var, the console URL where a key is made, the litellm model
11
+ prefix, and a known-good model id. Those are stable.
12
+
13
+ **Not recorded: rate limits, token allowances, or whether a card is required.**
14
+ Those change constantly — a survey in September 2026 found Cerebras had moved
15
+ to a card-backed trial, GitHub Models had shut down, and Groq had dropped Llama
16
+ from its free plan, all since June. Writing today's numbers into source would
17
+ produce a file that is confidently wrong within weeks, and a user trusting it
18
+ would plan around a quota that no longer exists.
19
+
20
+ So the registry says where to get a key and what to call it. What the key is
21
+ worth, you find out from the provider, and `agentctl doctor` reports what it
22
+ can actually observe.
23
+
24
+ Sources for the console URLs, checked against primary documentation:
25
+ Gemini https://ai.google.dev/gemini-api/docs/api-key
26
+ Cerebras https://inference-docs.cerebras.ai/introduction
27
+ Groq https://console.groq.com/docs/quickstart
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import os
32
+ from dataclasses import dataclass, field
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Provider:
37
+ key: str # environment variable
38
+ name: str # short name used in output
39
+ console: str # where a key is created
40
+ prefix: str # litellm model prefix
41
+ models: tuple[str, ...] # known-good ids, cheapest/freest first
42
+ free_tier: bool # has offered a no-card free tier
43
+ #: Does a second key here buy a second quota?
44
+ #:
45
+ #: True everywhere except Gemini, and the exception is the point. The
46
+ #: multi-account design assumes a cap is per credential (`docs/0033`), so
47
+ #: a second key is a second allowance. Google bills per PROJECT, and keys
48
+ #: minted in one project share one limit -- measured 2026-09-21 against
49
+ #: this owner's six, all in a single project.
50
+ quota_per_key: bool = True
51
+ #: Request parameters this provider REJECTS but litellm forwards anyway,
52
+ #: because its config for the provider inherits OpenAI's parameter list.
53
+ #: `drop_params: true` cannot catch these; each deployment gets them as
54
+ #: `additional_drop_params`. Only what a live 400 has shown (`docs/0047`).
55
+ reject_params: tuple[str, ...] = ()
56
+ note: str = ""
57
+ steps: tuple[str, ...] = field(default_factory=tuple)
58
+
59
+ @property
60
+ def configured(self) -> bool:
61
+ return bool(os.environ.get(self.key))
62
+
63
+ @property
64
+ def default_model(self) -> str:
65
+ return f"{self.prefix}{self.models[0]}" if self.models else ""
66
+
67
+
68
+ PROVIDERS: tuple[Provider, ...] = (
69
+ Provider(
70
+ key="OPENROUTER_API_KEY", name="openrouter",
71
+ console="https://openrouter.ai/settings/keys",
72
+ prefix="openrouter/",
73
+ # `deepseek/deepseek-chat-v3.1:free` was here and is gone: absent from
74
+ # `/api/v1/models` entirely, and a call returns 404 "This model is
75
+ # unavailable for free. The paid version is available now". Six of the
76
+ # eighteen OpenRouter deployments pointed at it (2026-09-21).
77
+ #
78
+ # Its replacement was chosen by a real completion, not by the
79
+ # catalogue — `thinkingmachines/inkling:free` is listed and 403s,
80
+ # which is `docs/0034`'s "a catalogue is not what you can call"
81
+ # happening again on the same provider.
82
+ #
83
+ # `nex-agi/nex-n2.5-pro:free` was first, and on 2026-10-01 a live
84
+ # `keys --check` found it no longer served (`docs/0044` §10). Moved to
85
+ # last rather than removed: `default_model` is the first entry, and
86
+ # `agentctl init` derives a user's default from it, but whether it is
87
+ # gone for good is the owner's call. `proxy --verify` drops it anyway.
88
+ models=("nvidia/nemotron-3-super-120b-a12b:free",
89
+ "nvidia/nemotron-3-ultra-550b-a55b:free",
90
+ "nex-agi/nex-n2.5-pro:free"),
91
+ free_tier=True,
92
+ note="One account-wide cap covers every `:free` model, so extra "
93
+ "OpenRouter models add resilience to outages but not to the "
94
+ "daily limit.",
95
+ steps=("Sign in at openrouter.ai (Google/GitHub works).",
96
+ "Open Settings -> Keys.",
97
+ "Create Key, name it `agentctl`, copy it once — it is not "
98
+ "shown again.",
99
+ "Leave the credit limit blank to stay on free models only."),
100
+ ),
101
+ Provider(
102
+ key="GEMINI_API_KEY", name="gemini",
103
+ console="https://aistudio.google.com/apikey",
104
+ prefix="gemini/",
105
+ # Verified by a real completion, not by the model list: `models`
106
+ # still advertises gemini-2.5-flash, and calling it returns "no
107
+ # longer available to new users" (`docs/0034` §5).
108
+ models=("gemini-3.6-flash",),
109
+ # UNVERIFIED FOR THIS ACCOUNT SET, and it matters more here than
110
+ # anywhere else in this file. Google's rate-limit documentation
111
+ # (ai.google.dev/gemini-api/docs/rate-limits, read 2026-09-21) states:
112
+ #
113
+ # "Rate limits are applied per project, not per API key."
114
+ #
115
+ # Every other provider in this registry bills a quota per KEY, which
116
+ # is the entire premise of the multi-account design (`docs/0033`): a
117
+ # second key at the same provider buys a second quota. For Gemini that
118
+ # premise may simply be false. Six keys minted inside ONE AI Studio
119
+ # project share ONE quota, and `accounts_for()` would then report six
120
+ # where there is one -- overstating failover by 6x on the dashboard
121
+ # and filling the pool with six deployments that all die together.
122
+ #
123
+ # This is not asserted either way, because it is not measured. It
124
+ # depends on how the keys were created, which only the owner can see:
125
+ # aistudio.google.com/apikey lists each key's project. The free tier's
126
+ # per-model numbers are no longer published at all -- the docs now
127
+ # say to read them at aistudio.google.com/rate-limit, which needs a
128
+ # Google sign-in.
129
+ free_tier=True,
130
+ # Six keys, one project, one quota. Confirmed by the owner in AI
131
+ # Studio on 2026-09-21 after Google's docs stated the rule.
132
+ quota_per_key=False,
133
+ note="Independent of OpenRouter's quota, so it survives that outage "
134
+ "— but all keys in ONE Google project share ONE limit, so extra "
135
+ "Gemini keys do not add allowance the way a second key elsewhere "
136
+ "does.",
137
+ steps=("Open aistudio.google.com/apikey and accept the terms.",
138
+ "A default Google Cloud project is created for you.",
139
+ "Create API key, then copy it.",
140
+ "Quotas are per-model; check the console for current limits."),
141
+ ),
142
+ Provider(
143
+ key="MISTRAL_API_KEY", name="mistral",
144
+ console="https://console.mistral.ai/api-keys",
145
+ prefix="mistral/",
146
+ models=("ministral-3b-latest", "mistral-small-latest"),
147
+ free_tier=True,
148
+ note="The free tier has historically required opting in to data "
149
+ "training. Read the consent screen before accepting.",
150
+ steps=("Sign up at console.mistral.ai.",
151
+ "Activate the free/Experiment plan if prompted.",
152
+ "Open API Keys -> Create new key."),
153
+ ),
154
+ Provider(
155
+ key="CEREBRAS_API_KEY", name="cerebras",
156
+ console="https://cloud.cerebras.ai",
157
+ prefix="cerebras/",
158
+ models=("gpt-oss-120b",),
159
+ free_tier=True,
160
+ note="Very fast inference. Whether a card is required has changed at "
161
+ "least once in 2026 — check at signup.",
162
+ steps=("Sign up at cloud.cerebras.ai.",
163
+ "Open API Keys -> Create API Key."),
164
+ ),
165
+ Provider(
166
+ key="GROQ_API_KEY", name="groq",
167
+ console="https://console.groq.com/keys",
168
+ prefix="groq/",
169
+ # Groq no longer serves Llama on this tier; these are what the
170
+ # account actually lists AND answers.
171
+ models=("openai/gpt-oss-20b", "openai/gpt-oss-120b"),
172
+ free_tier=True,
173
+ # Live, 2026-10-02, through the pool: the SDK sends OpenAI's
174
+ # `prompt_cache_key` (the run's model is `openai/pool`), litellm's
175
+ # Groq config inherits it as supported, and Groq answers 400
176
+ # "property 'prompt_cache_key' is unsupported" -- which ended the run.
177
+ reject_params=("prompt_cache_key",),
178
+ note="Model availability on the free plan has changed during 2026; "
179
+ "confirm the model id in the console before relying on it.",
180
+ steps=("Sign up at console.groq.com.",
181
+ "Open Keys -> Create API Key."),
182
+ ),
183
+ Provider(
184
+ key="ANTHROPIC_API_KEY", name="anthropic",
185
+ console="https://console.anthropic.com/settings/keys",
186
+ prefix="anthropic/",
187
+ models=("claude-sonnet-5",),
188
+ free_tier=False,
189
+ note="Paid. Listed last everywhere so a fallback degrades toward "
190
+ "slower, never silently toward billed (`docs/0002` §5).",
191
+ steps=("Sign in at console.anthropic.com.",
192
+ "Add billing, then Settings -> API keys -> Create key."),
193
+ ),
194
+ Provider(
195
+ key="OPENAI_API_KEY", name="openai",
196
+ console="https://platform.openai.com/api-keys",
197
+ prefix="openai/",
198
+ models=("gpt-4o-mini",),
199
+ free_tier=False,
200
+ note="Paid.",
201
+ steps=("Sign in at platform.openai.com.",
202
+ "Add billing, then API keys -> Create new secret key."),
203
+ ),
204
+ )
205
+
206
+ BY_KEY = {p.key: p for p in PROVIDERS}
207
+ BY_NAME = {p.name: p for p in PROVIDERS}
208
+
209
+
210
+ @dataclass(frozen=True)
211
+ class Account:
212
+ """One credential. Not one provider — a provider can have several.
213
+
214
+ This is the unit the whole project turns on. `docs/0002` asked for many
215
+ keys across many accounts, and an account-wide daily cap is beaten by a
216
+ second *account*, not by a second model. Counting providers instead of
217
+ credentials would report failover as ready when it is not.
218
+ """
219
+ provider: Provider
220
+ env: str # the variable actually holding it
221
+ label: str # "openrouter" or "openrouter#2"
222
+
223
+ @property
224
+ def value(self) -> str:
225
+ return os.environ.get(self.env, "")
226
+
227
+
228
+ def accounts_for(p: Provider, env: dict | None = None) -> list[Account]:
229
+ r"""Every credential for one provider, in a stable order.
230
+
231
+ Recognised shapes, so a second account costs one line in `keys.env` and no
232
+ code change:
233
+
234
+ OPENROUTER_API_KEY -> openrouter
235
+ OPENROUTER_API_KEY_2 -> openrouter#2
236
+ OPENROUTER_API_KEY_WORK -> openrouter#work
237
+
238
+ Matching is on the exact name or the name followed by `_`, never a bare
239
+ prefix: `ANTHROPIC_BASE_URL` must not be mistaken for an Anthropic key, and
240
+ a stray match would send requests with a URL where a credential belongs.
241
+ """
242
+ e = env if env is not None else os.environ
243
+ out: list[Account] = []
244
+ if e.get(p.key):
245
+ out.append(Account(p, p.key, p.name))
246
+ for name in sorted(e):
247
+ if not name.startswith(p.key + "_") or not e.get(name):
248
+ continue
249
+ suffix = name[len(p.key) + 1:].lower()
250
+ out.append(Account(p, name, f"{p.name}#{suffix}"))
251
+ return out
252
+
253
+
254
+ def quotas_for(p: Provider, env: dict | None = None) -> int:
255
+ """Independent allowances behind a provider — not credentials.
256
+
257
+ `Account`'s own docstring warns that counting PROVIDERS instead of
258
+ credentials "would report failover as ready when it is not". Gemini is
259
+ that same error one level down: counting credentials reports six
260
+ allowances where the project has one.
261
+
262
+ So the unit the project turns on is narrower than a key. It is a quota,
263
+ and only the provider knows which keys share one.
264
+ """
265
+ n = len(accounts_for(p, env))
266
+ return n if p.quota_per_key else min(n, 1)
267
+
268
+
269
+ def all_accounts(env: dict | None = None) -> list[Account]:
270
+ """Every credential, free tiers first, paid last."""
271
+ out: list[Account] = []
272
+ for p in sorted(PROVIDERS, key=lambda x: (not x.free_tier, PROVIDERS.index(x))):
273
+ out.extend(accounts_for(p, env))
274
+ return out
275
+
276
+
277
+ def configured() -> list[Provider]:
278
+ """Providers with at least one key present, free tiers first."""
279
+ got = [p for p in PROVIDERS if p.configured]
280
+ return sorted(got, key=lambda p: (not p.free_tier, PROVIDERS.index(p)))
281
+
282
+
283
+ def missing() -> list[Provider]:
284
+ return [p for p in PROVIDERS if not p.configured]
285
+
286
+
287
+ def accounts(env: dict | None = None) -> int:
288
+ """How many distinct credentials exist. The failover number.
289
+
290
+ Counts CREDENTIALS, never providers and never deployments: three models
291
+ behind one key share one quota, and two keys at one provider do not.
292
+ """
293
+ return len(all_accounts(env))