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 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
@@ -0,0 +1,8 @@
1
+ """``python -m convene``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
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()