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.
- agentctl/__init__.py +0 -0
- agentctl/adapters/__init__.py +0 -0
- agentctl/adapters/litellm/__init__.py +9 -0
- agentctl/adapters/litellm/hook.py +49 -0
- agentctl/adapters/litellm/recorder.py +187 -0
- agentctl/adapters/openhands/__init__.py +169 -0
- agentctl/adapters/openhands/handoff.py +155 -0
- agentctl/adapters/openhands/seam_b.py +259 -0
- agentctl/adapters/openhands/seam_c.py +209 -0
- agentctl/cli.py +1450 -0
- agentctl/control/__init__.py +0 -0
- agentctl/control/cost/__init__.py +4 -0
- agentctl/control/cost/ledger.py +210 -0
- agentctl/control/dash.py +697 -0
- agentctl/control/keys.py +440 -0
- agentctl/control/matrix/__init__.py +0 -0
- agentctl/control/matrix/data/tools.yaml +149 -0
- agentctl/control/policy/__init__.py +10 -0
- agentctl/control/policy/compile.py +258 -0
- agentctl/control/policy/data/policy.compiled.json +38 -0
- agentctl/control/policy/data/policy.yaml +46 -0
- agentctl/control/probe.py +399 -0
- agentctl/control/providers.py +293 -0
- agentctl/control/proxy.py +536 -0
- agentctl/control/proxyenv.py +309 -0
- agentctl/control/replay/__init__.py +14 -0
- agentctl/control/replay/cassette.py +281 -0
- agentctl/control/replay/server.py +109 -0
- agentctl/demo/__init__.py +214 -0
- agentctl/demo/child.py +84 -0
- agentctl/demo/mock.py +79 -0
- agentctl/demo/tool.py +62 -0
- agentctl/gha.py +488 -0
- agentctl/kernel/__init__.py +0 -0
- agentctl/kernel/classify.py +170 -0
- agentctl/kernel/gate.py +391 -0
- agentctl/kernel/hook.py +229 -0
- agentctl/kernel/ledger/__init__.py +0 -0
- agentctl/kernel/ledger/models.py +160 -0
- agentctl/kernel/ledger/schema.sql +62 -0
- agentctl/kernel/ledger/store.py +596 -0
- agentctl/kernel/paths.py +203 -0
- agentctl/kernel/policy.py +160 -0
- agentctl/kernel/reconcile/__init__.py +31 -0
- agentctl/kernel/reconcile/base.py +106 -0
- agentctl/kernel/reconcile/external.py +137 -0
- agentctl/kernel/reconcile/filesystem.py +162 -0
- agentctl/kernel/reconcile/git.py +162 -0
- agentctl/runtime/__init__.py +20 -0
- agentctl/runtime/citations.py +179 -0
- agentctl/runtime/config.py +97 -0
- agentctl/runtime/doctor.py +335 -0
- agentctl/runtime/init.py +148 -0
- agentctl/runtime/lease.py +143 -0
- agentctl/runtime/orchestrate.py +187 -0
- agentctl/runtime/plugins.py +130 -0
- agentctl/runtime/report.py +361 -0
- agentctl/runtime/runner.py +787 -0
- agentctl/runtime/runs.py +191 -0
- agentctl/runtime/subagent.py +274 -0
- agentctl/runtime/tools.py +350 -0
- handcode-0.3.0rc1.dist-info/METADATA +659 -0
- handcode-0.3.0rc1.dist-info/RECORD +67 -0
- handcode-0.3.0rc1.dist-info/WHEEL +5 -0
- handcode-0.3.0rc1.dist-info/entry_points.txt +3 -0
- handcode-0.3.0rc1.dist-info/licenses/LICENSE +21 -0
- 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))
|