convene 0.1.0__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.
- convene/__init__.py +184 -0
- convene/__main__.py +8 -0
- convene/auth.py +239 -0
- convene/cli.py +581 -0
- convene/config.py +112 -0
- convene/doctor.py +283 -0
- convene/errors.py +43 -0
- convene/experts.py +360 -0
- convene/logging_.py +55 -0
- convene/py.typed +0 -0
- convene/runtime.py +352 -0
- convene/sessions.py +637 -0
- convene-0.1.0.dist-info/METADATA +389 -0
- convene-0.1.0.dist-info/RECORD +17 -0
- convene-0.1.0.dist-info/WHEEL +4 -0
- convene-0.1.0.dist-info/entry_points.txt +2 -0
- convene-0.1.0.dist-info/licenses/LICENSE +21 -0
convene/__init__.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""convene -- run Claude Code headlessly as a local inference layer.
|
|
2
|
+
|
|
3
|
+
Wraps the official ``claude`` CLI in non-interactive mode, stripped back from a
|
|
4
|
+
coding agent to a plain inference endpoint, and adds the things you need to run
|
|
5
|
+
real workloads on it: named experts, multi-turn sessions, bounded concurrency,
|
|
6
|
+
and diagnostics that tell you which account is about to be billed.
|
|
7
|
+
|
|
8
|
+
Quick start::
|
|
9
|
+
|
|
10
|
+
from convene import ask, ask_json
|
|
11
|
+
|
|
12
|
+
ask("Name the capital of France in one word.")
|
|
13
|
+
# 'Paris'
|
|
14
|
+
|
|
15
|
+
ask_json("Extract: Ada Lovelace, born 1815.", {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": {"name": {"type": "string"}, "born": {"type": "integer"}},
|
|
18
|
+
"required": ["name", "born"],
|
|
19
|
+
"additionalProperties": False,
|
|
20
|
+
})
|
|
21
|
+
# {'name': 'Ada Lovelace', 'born': 1815}
|
|
22
|
+
|
|
23
|
+
This runs the first-party Claude Code CLI on your own machine, under your own
|
|
24
|
+
login. It is not a proxy, it does not expose a network endpoint, and it does
|
|
25
|
+
not extract or replay your credentials. See the README on scope and terms
|
|
26
|
+
before building anything that changes those three facts.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from .auth import (
|
|
34
|
+
DEFAULT_AUTH,
|
|
35
|
+
AuthMode,
|
|
36
|
+
assert_account,
|
|
37
|
+
binary_available,
|
|
38
|
+
cli_version,
|
|
39
|
+
is_authed,
|
|
40
|
+
login_status,
|
|
41
|
+
ready,
|
|
42
|
+
whoami,
|
|
43
|
+
)
|
|
44
|
+
from .config import (
|
|
45
|
+
CHEAP_MODEL,
|
|
46
|
+
DEFAULT_CONCURRENCY,
|
|
47
|
+
DEFAULT_MODEL,
|
|
48
|
+
FAST_MODEL,
|
|
49
|
+
STATE_ROOT,
|
|
50
|
+
)
|
|
51
|
+
from .errors import (
|
|
52
|
+
AuthError,
|
|
53
|
+
BudgetError,
|
|
54
|
+
CLIError,
|
|
55
|
+
ConveneError,
|
|
56
|
+
ExpertNotFound,
|
|
57
|
+
SessionError,
|
|
58
|
+
)
|
|
59
|
+
from .experts import (
|
|
60
|
+
Expert,
|
|
61
|
+
Registry,
|
|
62
|
+
ask_expert,
|
|
63
|
+
ask_expert_async,
|
|
64
|
+
consult,
|
|
65
|
+
default_registry,
|
|
66
|
+
load_experts,
|
|
67
|
+
map_expert,
|
|
68
|
+
register,
|
|
69
|
+
)
|
|
70
|
+
from .runtime import Call, Result, run, run_sync
|
|
71
|
+
from .sessions import (
|
|
72
|
+
AsyncLiveSession,
|
|
73
|
+
LiveSession,
|
|
74
|
+
Session,
|
|
75
|
+
SessionPool,
|
|
76
|
+
Turn,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
__version__ = "0.1.0"
|
|
80
|
+
|
|
81
|
+
# Grouped by concept rather than sorted: this list doubles as the shape of the
|
|
82
|
+
# public API, and alphabetising it would scatter each group.
|
|
83
|
+
__all__ = [ # noqa: RUF022
|
|
84
|
+
# engine
|
|
85
|
+
"Call",
|
|
86
|
+
"Result",
|
|
87
|
+
"run",
|
|
88
|
+
"run_sync",
|
|
89
|
+
"ask",
|
|
90
|
+
"ask_json",
|
|
91
|
+
"ask_async",
|
|
92
|
+
# experts
|
|
93
|
+
"Expert",
|
|
94
|
+
"Registry",
|
|
95
|
+
"register",
|
|
96
|
+
"load_experts",
|
|
97
|
+
"default_registry",
|
|
98
|
+
"ask_expert",
|
|
99
|
+
"ask_expert_async",
|
|
100
|
+
"consult",
|
|
101
|
+
"map_expert",
|
|
102
|
+
# sessions
|
|
103
|
+
"Session",
|
|
104
|
+
"LiveSession",
|
|
105
|
+
"AsyncLiveSession",
|
|
106
|
+
"SessionPool",
|
|
107
|
+
"Turn",
|
|
108
|
+
# auth
|
|
109
|
+
"AuthMode",
|
|
110
|
+
"DEFAULT_AUTH",
|
|
111
|
+
"whoami",
|
|
112
|
+
"assert_account",
|
|
113
|
+
"login_status",
|
|
114
|
+
"is_authed",
|
|
115
|
+
"ready",
|
|
116
|
+
"binary_available",
|
|
117
|
+
"cli_version",
|
|
118
|
+
# config
|
|
119
|
+
"DEFAULT_MODEL",
|
|
120
|
+
"CHEAP_MODEL",
|
|
121
|
+
"FAST_MODEL",
|
|
122
|
+
"DEFAULT_CONCURRENCY",
|
|
123
|
+
"STATE_ROOT",
|
|
124
|
+
# errors
|
|
125
|
+
"ConveneError",
|
|
126
|
+
"CLIError",
|
|
127
|
+
"AuthError",
|
|
128
|
+
"BudgetError",
|
|
129
|
+
"ExpertNotFound",
|
|
130
|
+
"SessionError",
|
|
131
|
+
"__version__",
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
_DEFAULT_SYSTEM = "You are a concise, accurate assistant."
|
|
135
|
+
_EXTRACT_SYSTEM = "You extract structured data. Follow the schema exactly."
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def ask(
|
|
139
|
+
user_prompt: str,
|
|
140
|
+
*,
|
|
141
|
+
system_prompt: str = _DEFAULT_SYSTEM,
|
|
142
|
+
auth_mode: AuthMode = DEFAULT_AUTH,
|
|
143
|
+
**kwargs: Any,
|
|
144
|
+
) -> str:
|
|
145
|
+
"""Plain text in, plain text out."""
|
|
146
|
+
call = Call(user_prompt=user_prompt, system_prompt=system_prompt, **kwargs)
|
|
147
|
+
return run_sync(call, auth_mode=auth_mode).text or ""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def ask_async(
|
|
151
|
+
user_prompt: str,
|
|
152
|
+
*,
|
|
153
|
+
system_prompt: str = _DEFAULT_SYSTEM,
|
|
154
|
+
auth_mode: AuthMode = DEFAULT_AUTH,
|
|
155
|
+
**kwargs: Any,
|
|
156
|
+
) -> str:
|
|
157
|
+
"""Async :func:`ask`."""
|
|
158
|
+
call = Call(user_prompt=user_prompt, system_prompt=system_prompt, **kwargs)
|
|
159
|
+
return (await run(call, auth_mode=auth_mode)).text or ""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def ask_json(
|
|
163
|
+
user_prompt: str,
|
|
164
|
+
schema: dict,
|
|
165
|
+
*,
|
|
166
|
+
system_prompt: str = _EXTRACT_SYSTEM,
|
|
167
|
+
auth_mode: AuthMode = DEFAULT_AUTH,
|
|
168
|
+
**kwargs: Any,
|
|
169
|
+
) -> dict[str, Any]:
|
|
170
|
+
"""Schema-validated dict out.
|
|
171
|
+
|
|
172
|
+
The CLI enforces the schema server-side and auto-retries invalid JSON, so
|
|
173
|
+
no defensive parsing is needed on this side.
|
|
174
|
+
"""
|
|
175
|
+
call = Call(
|
|
176
|
+
user_prompt=user_prompt,
|
|
177
|
+
system_prompt=system_prompt,
|
|
178
|
+
json_schema=schema,
|
|
179
|
+
**kwargs,
|
|
180
|
+
)
|
|
181
|
+
data = run_sync(call, auth_mode=auth_mode).structured_output
|
|
182
|
+
if not isinstance(data, dict):
|
|
183
|
+
raise CLIError("claude CLI returned no structured_output")
|
|
184
|
+
return data
|
convene/__main__.py
ADDED
convene/auth.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Which account gets billed, and how to be sure before you spend anything.
|
|
2
|
+
|
|
3
|
+
The failure this module exists to prevent: a run that quietly bills Anthropic
|
|
4
|
+
API credits instead of the subscription you meant to use. It is a real and
|
|
5
|
+
expensive mistake -- anthropics/claude-code#37686 reports $1,800 in two days
|
|
6
|
+
from exactly this -- and nothing downstream notices, because a key-billed call
|
|
7
|
+
succeeds identically to a subscription-billed one.
|
|
8
|
+
|
|
9
|
+
Two defences, both here:
|
|
10
|
+
|
|
11
|
+
1. :func:`subprocess_env` strips ``ANTHROPIC_API_KEY`` and
|
|
12
|
+
``ANTHROPIC_AUTH_TOKEN`` from the child environment in both subscription
|
|
13
|
+
modes. Either variable silently outranks the OAuth login.
|
|
14
|
+
2. :func:`assert_account` refuses to start when ``authMethod`` is not
|
|
15
|
+
``claude.ai``, or when the logged-in email is not the one you named.
|
|
16
|
+
|
|
17
|
+
:class:`AuthMode.API_KEY` exists and works, but is never selected implicitly.
|
|
18
|
+
You have to ask for it by name.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import contextlib
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
from enum import StrEnum
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from .config import (
|
|
32
|
+
SANDBOX_CREDENTIALS,
|
|
33
|
+
SANDBOX_DIR,
|
|
34
|
+
SANDBOX_HOME,
|
|
35
|
+
SANDBOX_TOKEN_FILE,
|
|
36
|
+
SANDBOX_WORKDIR,
|
|
37
|
+
)
|
|
38
|
+
from .errors import AuthError
|
|
39
|
+
|
|
40
|
+
# Either of these outranks both the claude.ai login and CLAUDE_CODE_OAUTH_TOKEN.
|
|
41
|
+
API_KEY_VARS = ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AuthMode(StrEnum):
|
|
45
|
+
"""How the subprocess authenticates."""
|
|
46
|
+
|
|
47
|
+
#: Reuse the login you already did with ``claude /login``. Billed to your
|
|
48
|
+
#: subscription. Nothing to set up; the credentials refresh themselves.
|
|
49
|
+
LOGIN = "login"
|
|
50
|
+
|
|
51
|
+
#: An isolated sandbox with its own long-lived token from
|
|
52
|
+
#: ``claude setup-token``, its own ``HOME`` and its own config dir. Also
|
|
53
|
+
#: subscription-billed. Use for cron and servers, or to stay unaffected by
|
|
54
|
+
#: logging out of interactive Claude Code.
|
|
55
|
+
SANDBOX_TOKEN = "sandbox_token"
|
|
56
|
+
|
|
57
|
+
#: Anthropic API credits, per token. Never chosen implicitly.
|
|
58
|
+
API_KEY = "api_key"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class CLIMissing(AuthError):
|
|
62
|
+
"""The ``claude`` binary is not installed or not on PATH."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
DEFAULT_AUTH = AuthMode.LOGIN
|
|
66
|
+
|
|
67
|
+
#: Modes that draw on a Claude subscription rather than API credits.
|
|
68
|
+
SUBSCRIPTION_MODES = frozenset({AuthMode.LOGIN, AuthMode.SANDBOX_TOKEN})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def binary_available() -> bool:
|
|
72
|
+
"""Is the ``claude`` CLI on PATH?"""
|
|
73
|
+
return shutil.which("claude") is not None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cli_version() -> str | None:
|
|
77
|
+
"""Version string reported by ``claude --version``, or None if unavailable."""
|
|
78
|
+
if not binary_available():
|
|
79
|
+
return None
|
|
80
|
+
try:
|
|
81
|
+
proc = subprocess.run(
|
|
82
|
+
["claude", "--version"],
|
|
83
|
+
capture_output=True,
|
|
84
|
+
text=True,
|
|
85
|
+
timeout=30,
|
|
86
|
+
check=False,
|
|
87
|
+
)
|
|
88
|
+
except (OSError, subprocess.SubprocessError):
|
|
89
|
+
return None
|
|
90
|
+
return (proc.stdout or "").strip() or None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def login_status() -> dict[str, Any]:
|
|
94
|
+
"""Ask the CLI who it thinks it is.
|
|
95
|
+
|
|
96
|
+
Shells out, so it costs ~0.5s. Callers should cache; :func:`is_authed`
|
|
97
|
+
already does.
|
|
98
|
+
"""
|
|
99
|
+
if not binary_available():
|
|
100
|
+
return {}
|
|
101
|
+
env = {k: v for k, v in os.environ.items() if k not in API_KEY_VARS}
|
|
102
|
+
try:
|
|
103
|
+
proc = subprocess.run(
|
|
104
|
+
["claude", "auth", "status", "--json"],
|
|
105
|
+
env=env,
|
|
106
|
+
capture_output=True,
|
|
107
|
+
text=True,
|
|
108
|
+
timeout=30,
|
|
109
|
+
check=False,
|
|
110
|
+
)
|
|
111
|
+
except (OSError, subprocess.SubprocessError):
|
|
112
|
+
return {}
|
|
113
|
+
try:
|
|
114
|
+
return json.loads(proc.stdout)
|
|
115
|
+
except json.JSONDecodeError:
|
|
116
|
+
return {}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
_login_ok: bool | None = None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def is_authed(auth_mode: AuthMode = DEFAULT_AUTH, *, recheck: bool = False) -> bool:
|
|
123
|
+
"""Can this mode authenticate?
|
|
124
|
+
|
|
125
|
+
The LOGIN answer is cached, because it shells out and would otherwise add
|
|
126
|
+
~0.5s to every single inference call.
|
|
127
|
+
"""
|
|
128
|
+
global _login_ok
|
|
129
|
+
if auth_mode is AuthMode.API_KEY:
|
|
130
|
+
return any(os.environ.get(v) for v in API_KEY_VARS)
|
|
131
|
+
if auth_mode is AuthMode.SANDBOX_TOKEN:
|
|
132
|
+
return SANDBOX_TOKEN_FILE.exists() or SANDBOX_CREDENTIALS.exists()
|
|
133
|
+
if _login_ok is None or recheck:
|
|
134
|
+
_login_ok = bool(login_status().get("loggedIn"))
|
|
135
|
+
return _login_ok
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def ready(auth_mode: AuthMode = DEFAULT_AUTH) -> bool:
|
|
139
|
+
"""Cheap gate for "should a router pick this backend?"."""
|
|
140
|
+
return binary_available() and is_authed(auth_mode)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def whoami() -> str:
|
|
144
|
+
"""One-line summary of which account the CLI will bill."""
|
|
145
|
+
s = login_status()
|
|
146
|
+
if not s.get("loggedIn"):
|
|
147
|
+
return "not logged in"
|
|
148
|
+
return (
|
|
149
|
+
f"{s.get('email') or 'unknown'} "
|
|
150
|
+
f"({s.get('subscriptionType') or 'no subscription'}, "
|
|
151
|
+
f"via {s.get('authMethod') or '?'})"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def assert_account(expected_email: str | None = None) -> dict[str, Any]:
|
|
156
|
+
"""Fail loudly, before a batch, if the wrong account would be billed.
|
|
157
|
+
|
|
158
|
+
Rejects three things: not logged in; an ``authMethod`` that is not
|
|
159
|
+
``claude.ai`` (an API key has taken over); and the wrong email.
|
|
160
|
+
|
|
161
|
+
Call this at the top of any job that will make more than a handful of
|
|
162
|
+
calls. Nothing further down the pipeline will notice a billing mistake.
|
|
163
|
+
"""
|
|
164
|
+
s = login_status()
|
|
165
|
+
if not s.get("loggedIn"):
|
|
166
|
+
raise AuthError("Not logged in. Run `claude /login`.")
|
|
167
|
+
method = s.get("authMethod")
|
|
168
|
+
if method != "claude.ai":
|
|
169
|
+
raise AuthError(
|
|
170
|
+
f"Auth is {method!r}, not a claude.ai subscription login. "
|
|
171
|
+
f"An API key is probably set, and would bill API credits. "
|
|
172
|
+
f"Unset {' / '.join(API_KEY_VARS)} to fall back to the login."
|
|
173
|
+
)
|
|
174
|
+
if expected_email and s.get("email") != expected_email:
|
|
175
|
+
raise AuthError(
|
|
176
|
+
f"Logged in as {s.get('email')!r}, expected {expected_email!r}. "
|
|
177
|
+
"Run `claude auth logout` then `claude auth login`."
|
|
178
|
+
)
|
|
179
|
+
return s
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def ensure_dirs() -> None:
|
|
183
|
+
"""Create the sandbox tree. Idempotent."""
|
|
184
|
+
for d in (SANDBOX_DIR, SANDBOX_HOME, SANDBOX_WORKDIR):
|
|
185
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
with contextlib.suppress(OSError):
|
|
187
|
+
SANDBOX_DIR.chmod(0o700)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def subprocess_env(auth_mode: AuthMode = DEFAULT_AUTH) -> dict[str, str]:
|
|
191
|
+
"""Environment for the child process.
|
|
192
|
+
|
|
193
|
+
LOGIN keeps your real ``HOME`` so the CLI can reach the claude.ai
|
|
194
|
+
credentials you already have. SANDBOX_TOKEN redirects ``HOME``,
|
|
195
|
+
``XDG_CONFIG_HOME`` and ``CLAUDE_CONFIG_DIR`` at an isolated directory and
|
|
196
|
+
supplies its own token.
|
|
197
|
+
"""
|
|
198
|
+
strip: set[str] = set()
|
|
199
|
+
if auth_mode in SUBSCRIPTION_MODES:
|
|
200
|
+
strip = set(API_KEY_VARS)
|
|
201
|
+
|
|
202
|
+
env = {k: v for k, v in os.environ.items() if k not in strip}
|
|
203
|
+
|
|
204
|
+
if auth_mode is AuthMode.SANDBOX_TOKEN:
|
|
205
|
+
env["CLAUDE_CONFIG_DIR"] = str(SANDBOX_DIR)
|
|
206
|
+
env["HOME"] = str(SANDBOX_HOME)
|
|
207
|
+
env["XDG_CONFIG_HOME"] = str(SANDBOX_HOME / ".config")
|
|
208
|
+
if SANDBOX_TOKEN_FILE.exists():
|
|
209
|
+
env["CLAUDE_CODE_OAUTH_TOKEN"] = SANDBOX_TOKEN_FILE.read_text(
|
|
210
|
+
encoding="utf-8"
|
|
211
|
+
).strip()
|
|
212
|
+
return env
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def preflight(auth_mode: AuthMode = DEFAULT_AUTH) -> None:
|
|
216
|
+
"""Raise before spending anything if this mode cannot work."""
|
|
217
|
+
if not binary_available():
|
|
218
|
+
raise CLIMissing(
|
|
219
|
+
"`claude` is not on PATH. Install Claude Code: "
|
|
220
|
+
"https://code.claude.com/docs/en/quickstart"
|
|
221
|
+
)
|
|
222
|
+
if not is_authed(auth_mode):
|
|
223
|
+
if auth_mode is AuthMode.API_KEY:
|
|
224
|
+
raise AuthError(
|
|
225
|
+
f"AuthMode.API_KEY selected but none of {', '.join(API_KEY_VARS)} "
|
|
226
|
+
"is set. Note that this mode bills Anthropic API credits, not "
|
|
227
|
+
"your subscription -- AuthMode.LOGIN is the default for a reason."
|
|
228
|
+
)
|
|
229
|
+
if auth_mode is AuthMode.SANDBOX_TOKEN:
|
|
230
|
+
raise AuthError(
|
|
231
|
+
f"Sandbox is not authed. Run `convene setup-token` to mint one "
|
|
232
|
+
f"(looked for {SANDBOX_TOKEN_FILE} and {SANDBOX_CREDENTIALS})."
|
|
233
|
+
)
|
|
234
|
+
raise AuthError(
|
|
235
|
+
"Not logged in to Claude Code. Run `claude /login` in a terminal, "
|
|
236
|
+
"then retry. Do not set an ANTHROPIC_API_KEY to work around this -- "
|
|
237
|
+
"that bills API credits instead of your subscription."
|
|
238
|
+
)
|
|
239
|
+
ensure_dirs()
|