spawnllm 0.16.0__tar.gz → 0.17.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 (36) hide show
  1. {spawnllm-0.16.0 → spawnllm-0.17.0}/PKG-INFO +23 -2
  2. {spawnllm-0.16.0 → spawnllm-0.17.0}/README.md +22 -1
  3. {spawnllm-0.16.0 → spawnllm-0.17.0}/pyproject.toml +1 -1
  4. {spawnllm-0.16.0 → spawnllm-0.17.0}/pyproject.toml.orig +1 -1
  5. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/__init__.py +36 -3
  6. spawnllm-0.17.0/spawnllm/_core/spawnllm_core.wasm +0 -0
  7. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/cli.py +15 -0
  8. spawnllm-0.17.0/spawnllm/decide.py +489 -0
  9. spawnllm-0.16.0/spawnllm/_core/spawnllm_core.wasm +0 -0
  10. {spawnllm-0.16.0 → spawnllm-0.17.0}/LICENSE +0 -0
  11. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/__main__.py +0 -0
  12. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/_core/__init__.py +0 -0
  13. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/backends/__init__.py +0 -0
  14. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/backends/apple.py +0 -0
  15. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/backends/base.py +0 -0
  16. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/backends/claude.py +0 -0
  17. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/backends/claude_sdk.py +0 -0
  18. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/backends/codex.py +0 -0
  19. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/backends/gemini.py +0 -0
  20. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/backends/mlx.py +0 -0
  21. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/backends/openai_endpoint.py +0 -0
  22. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/backends/registry.py +0 -0
  23. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/call.py +0 -0
  24. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/extract.py +0 -0
  25. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/mlx/__init__.py +0 -0
  26. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/mlx/codec.py +0 -0
  27. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/mlx/engine.py +0 -0
  28. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/mlx/fuse.py +0 -0
  29. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/mlx/patches.py +0 -0
  30. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/proc.py +0 -0
  31. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/py.typed +0 -0
  32. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/response.py +0 -0
  33. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/run.py +0 -0
  34. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/spec.py +0 -0
  35. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/structured.py +0 -0
  36. {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spawnllm
3
- Version: 0.16.0
3
+ Version: 0.17.0
4
4
  Summary: Delete your subprocess wrappers around claude, codex, and gemini.
5
5
  Keywords:
6
6
  Author: Yasyf Mohamedali
@@ -157,6 +157,26 @@ print(call_sync("Reply with just the word: pong", backend=AppleBackend()))
157
157
 
158
158
  Auto-selection tries this backend last and only for `model="small"`; an explicit `backend=AppleBackend()` always reaches it. Session and decoding knobs (`use_case`, `guardrails`, `instructions`, `temperature`, sampling) ride in via `RunSpec(provider_configs={"apple": AppleConfig(...)})`. Structured `extract_sync` works too, nested models included, and schema constraints now bind during decoding: `minimum`/`maximum`, `minItems`/`maxItems`, and string-valued `enum`s are enforced exactly (a non-string `enum` such as `Literal[1, 2]` fails before generation, `extract_sync` raising `BackendCallError`), and a `Field(pattern=...)` constrains the value's shape — length, separators, and character families. Apple's decoder rejects bracket character classes, so the sidecar widens each to the narrowest escape it accepts (`^[A-Z]{3}-\d{4}$` decodes as `\w{3}-\d{4}`), and pydantic stays the exact validator: a value that fits the widened shape but violates your regex raises a plain `ValidationError`. Self-referential models extract cleanly; a mutually recursive pair (A referencing B referencing A) fails cleanly instead, `extract_sync` raising `BackendCallError` and `run` returning an error `Response`. Requires macOS 26+ on Apple Silicon with Apple Intelligence enabled — every other platform gets the pure-Python wheel and reports the backend as not installed.
159
159
 
160
+ ### Ask a yes/no, label, or score question in about 120 ms
161
+
162
+ A classifier that runs on every hook event or every PR comment can't wait seconds for a chat model to write JSON. `decide_sync` asks TypeSafe's Jev or OpenAI Decisions instead. Both return probabilities, not prose, and one question set runs unchanged on either:
163
+
164
+ ```python
165
+ from spawnllm import Binary, Label, decide_sync
166
+
167
+ decision = decide_sync(
168
+ "Our nightly release has failed for 3 days. Please roll it back now.",
169
+ {
170
+ "rollback": Binary("Does the message ask to roll back a release?"),
171
+ "action": Label("Which release action does it ask for?", {"none": None, "release": None, "rollback": None}),
172
+ },
173
+ )
174
+ print(decision.answers["rollback"].p_yes) # 0.99
175
+ print(decision.answers["action"].choice) # rollback
176
+ ```
177
+
178
+ On a warm keep-alive connection from a Mac, a three-question Jev call took 120 to 130 ms end to end. Pass `provider=OPENAI` to ask `gpt-6-luna` instead. Retries cover 408, 429, 5xx, and 529 and stop at `timeout`. Keys come from `TYPESAFE_API_KEY` or `OPENAI_API_KEY`, or on macOS from a Keychain item that `spawnllm key set jev` (or `openai`) writes from stdin.
179
+
160
180
  ### Call the same backends from Go or Rust
161
181
 
162
182
  All three languages run the identical engine: argv planning, output parsing, schema strictification, and retry policy live once in a Rust core — linked natively by the Rust crate, embedded as WASM by the Go module and the Python package — pinned by a shared golden-vector suite and released in lockstep.
@@ -171,9 +191,10 @@ Both expose `Call`/`call` and typed `Extract`/`extract` against your existing CL
171
191
  ## More in the docs
172
192
 
173
193
  - **Spec-driven runs** — a literal model id, per-provider flag passthrough, and envelope-aware retry via `RunSpec` — [Running reference](https://yasyf.github.io/spawnllm/reference/#running)
194
+ - **Decisions** — every question and answer type, the provider pins, and the retry and key-lookup rules — [Deciding reference](https://yasyf.github.io/spawnllm/reference/#deciding)
174
195
  - **Backend selection** — the priority chain, plus `specialty=` routing (`debugging` and `review` go to Codex, `general` to the Claude Agent SDK backend) — [Backends reference](https://yasyf.github.io/spawnllm/reference/#backends)
175
196
  - **Transport helpers** — `run_cli`, `collect_process`, and `map_concurrent`, the subprocess plumbing shared by every CLI backend — [Transport reference](https://yasyf.github.io/spawnllm/reference/#transport)
176
- - **The CLI** — `spawnllm call`, `status`, and `backends` from any shell — [CLI reference](https://yasyf.github.io/spawnllm/reference/cli/)
197
+ - **The CLI** — `spawnllm call`, `status`, `backends`, and `key set` from any shell — [CLI reference](https://yasyf.github.io/spawnllm/reference/cli/)
177
198
  - **MLX internals** — the adapter codec, fuser, and runtime patches behind the local engine — [MLX reference](https://yasyf.github.io/spawnllm/reference/#mlx)
178
199
 
179
200
  Read the [docs](https://yasyf.github.io/spawnllm/) for the full guide and API reference. Licensed under [MIT](https://github.com/yasyf/spawnllm/blob/main/LICENSE).
@@ -105,6 +105,26 @@ print(call_sync("Reply with just the word: pong", backend=AppleBackend()))
105
105
 
106
106
  Auto-selection tries this backend last and only for `model="small"`; an explicit `backend=AppleBackend()` always reaches it. Session and decoding knobs (`use_case`, `guardrails`, `instructions`, `temperature`, sampling) ride in via `RunSpec(provider_configs={"apple": AppleConfig(...)})`. Structured `extract_sync` works too, nested models included, and schema constraints now bind during decoding: `minimum`/`maximum`, `minItems`/`maxItems`, and string-valued `enum`s are enforced exactly (a non-string `enum` such as `Literal[1, 2]` fails before generation, `extract_sync` raising `BackendCallError`), and a `Field(pattern=...)` constrains the value's shape — length, separators, and character families. Apple's decoder rejects bracket character classes, so the sidecar widens each to the narrowest escape it accepts (`^[A-Z]{3}-\d{4}$` decodes as `\w{3}-\d{4}`), and pydantic stays the exact validator: a value that fits the widened shape but violates your regex raises a plain `ValidationError`. Self-referential models extract cleanly; a mutually recursive pair (A referencing B referencing A) fails cleanly instead, `extract_sync` raising `BackendCallError` and `run` returning an error `Response`. Requires macOS 26+ on Apple Silicon with Apple Intelligence enabled — every other platform gets the pure-Python wheel and reports the backend as not installed.
107
107
 
108
+ ### Ask a yes/no, label, or score question in about 120 ms
109
+
110
+ A classifier that runs on every hook event or every PR comment can't wait seconds for a chat model to write JSON. `decide_sync` asks TypeSafe's Jev or OpenAI Decisions instead. Both return probabilities, not prose, and one question set runs unchanged on either:
111
+
112
+ ```python
113
+ from spawnllm import Binary, Label, decide_sync
114
+
115
+ decision = decide_sync(
116
+ "Our nightly release has failed for 3 days. Please roll it back now.",
117
+ {
118
+ "rollback": Binary("Does the message ask to roll back a release?"),
119
+ "action": Label("Which release action does it ask for?", {"none": None, "release": None, "rollback": None}),
120
+ },
121
+ )
122
+ print(decision.answers["rollback"].p_yes) # 0.99
123
+ print(decision.answers["action"].choice) # rollback
124
+ ```
125
+
126
+ On a warm keep-alive connection from a Mac, a three-question Jev call took 120 to 130 ms end to end. Pass `provider=OPENAI` to ask `gpt-6-luna` instead. Retries cover 408, 429, 5xx, and 529 and stop at `timeout`. Keys come from `TYPESAFE_API_KEY` or `OPENAI_API_KEY`, or on macOS from a Keychain item that `spawnllm key set jev` (or `openai`) writes from stdin.
127
+
108
128
  ### Call the same backends from Go or Rust
109
129
 
110
130
  All three languages run the identical engine: argv planning, output parsing, schema strictification, and retry policy live once in a Rust core — linked natively by the Rust crate, embedded as WASM by the Go module and the Python package — pinned by a shared golden-vector suite and released in lockstep.
@@ -119,9 +139,10 @@ Both expose `Call`/`call` and typed `Extract`/`extract` against your existing CL
119
139
  ## More in the docs
120
140
 
121
141
  - **Spec-driven runs** — a literal model id, per-provider flag passthrough, and envelope-aware retry via `RunSpec` — [Running reference](https://yasyf.github.io/spawnllm/reference/#running)
142
+ - **Decisions** — every question and answer type, the provider pins, and the retry and key-lookup rules — [Deciding reference](https://yasyf.github.io/spawnllm/reference/#deciding)
122
143
  - **Backend selection** — the priority chain, plus `specialty=` routing (`debugging` and `review` go to Codex, `general` to the Claude Agent SDK backend) — [Backends reference](https://yasyf.github.io/spawnllm/reference/#backends)
123
144
  - **Transport helpers** — `run_cli`, `collect_process`, and `map_concurrent`, the subprocess plumbing shared by every CLI backend — [Transport reference](https://yasyf.github.io/spawnllm/reference/#transport)
124
- - **The CLI** — `spawnllm call`, `status`, and `backends` from any shell — [CLI reference](https://yasyf.github.io/spawnllm/reference/cli/)
145
+ - **The CLI** — `spawnllm call`, `status`, `backends`, and `key set` from any shell — [CLI reference](https://yasyf.github.io/spawnllm/reference/cli/)
125
146
  - **MLX internals** — the adapter codec, fuser, and runtime patches behind the local engine — [MLX reference](https://yasyf.github.io/spawnllm/reference/#mlx)
126
147
 
127
148
  Read the [docs](https://yasyf.github.io/spawnllm/) for the full guide and API reference. Licensed under [MIT](https://github.com/yasyf/spawnllm/blob/main/LICENSE).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spawnllm"
3
- version = "0.16.0"
3
+ version = "0.17.0"
4
4
  description = "Delete your subprocess wrappers around claude, codex, and gemini."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "spawnllm"
3
3
  # Inert sentinel: the real version is set from the release tag (uv version --frozen).
4
- version = "0.16.0"
4
+ version = "0.17.0"
5
5
  description = "Delete your subprocess wrappers around claude, codex, and gemini."
6
6
  readme = "README.md"
7
7
  license = "MIT"
@@ -2,9 +2,10 @@
2
2
 
3
3
  The top-level namespace exposes the three primitives — `run`/`call`/`extract`
4
4
  and their `_sync` companions — over a `Backend` family that fully encapsulates
5
- execution and returns one shared `Response`. The MLX engine lives under
6
- `spawnllm.mlx`, whose imports are lazy so that `import spawnllm` never pulls
7
- `mlx_lm`/`zstandard`.
5
+ execution and returns one shared `Response`, plus `decide`/`decide_sync` for
6
+ typed yes/no, label, and score decisions over TypeSafe Jev and OpenAI
7
+ Decisions. The MLX engine lives under `spawnllm.mlx`, whose imports are lazy so
8
+ that `import spawnllm` never pulls `mlx_lm`/`zstandard`.
8
9
  """
9
10
 
10
11
  from __future__ import annotations
@@ -30,6 +31,23 @@ from spawnllm.backends import (
30
31
  select_backend,
31
32
  )
32
33
  from spawnllm.call import call, call_sync
34
+ from spawnllm.decide import (
35
+ JEV,
36
+ OPENAI,
37
+ Binary,
38
+ BinaryAnswer,
39
+ DecideError,
40
+ DecideKeyMissing,
41
+ Decision,
42
+ Label,
43
+ LabelAnswer,
44
+ Provider,
45
+ Refused,
46
+ Score,
47
+ ScoreAnswer,
48
+ decide,
49
+ decide_sync,
50
+ )
33
51
  from spawnllm.extract import extract, extract_sync
34
52
  from spawnllm.response import DiscardedAttempt, Error, Output, Response, Result
35
53
  from spawnllm.run import run, run_sync
@@ -37,6 +55,8 @@ from spawnllm.spec import AppleConfig, ClaudeConfig, CodexConfig, GeminiConfig,
37
55
  from spawnllm.types import ProviderName, TModel, TReasoningEffort, TSpecialty
38
56
 
39
57
  __all__ = [
58
+ "JEV",
59
+ "OPENAI",
40
60
  "AntigravityCliBackend",
41
61
  "AppleBackend",
42
62
  "AppleConfig",
@@ -46,30 +66,43 @@ __all__ = [
46
66
  "BackendReady",
47
67
  "BackendStatus",
48
68
  "BackendUnavailable",
69
+ "Binary",
70
+ "BinaryAnswer",
49
71
  "ClaudeCliBackend",
50
72
  "ClaudeConfig",
51
73
  "ClaudeSdkBackend",
52
74
  "CliBackend",
53
75
  "CodexCliBackend",
54
76
  "CodexConfig",
77
+ "DecideError",
78
+ "DecideKeyMissing",
79
+ "Decision",
55
80
  "DiscardedAttempt",
56
81
  "Error",
57
82
  "GeminiCliBackend",
58
83
  "GeminiConfig",
84
+ "Label",
85
+ "LabelAnswer",
59
86
  "LlmBackend",
60
87
  "LlmBackends",
61
88
  "MlxBackend",
62
89
  "OpenAiEndpointBackend",
63
90
  "Output",
91
+ "Provider",
64
92
  "ProviderName",
93
+ "Refused",
65
94
  "Response",
66
95
  "Result",
67
96
  "RunSpec",
97
+ "Score",
98
+ "ScoreAnswer",
68
99
  "TModel",
69
100
  "TReasoningEffort",
70
101
  "TSpecialty",
71
102
  "call",
72
103
  "call_sync",
104
+ "decide",
105
+ "decide_sync",
73
106
  "extract",
74
107
  "extract_sync",
75
108
  "run",
@@ -15,8 +15,10 @@ from spawnllm.backends import (
15
15
  )
16
16
  from spawnllm.backends.registry import BACKENDS_BY_NAME, PRIORITY, select_backend
17
17
  from spawnllm.call import DEFAULT_MODEL, call_sync
18
+ from spawnllm.decide import JEV, OPENAI, Provider, store_key
18
19
 
19
20
  BACKEND_NAMES = tuple(BACKENDS_BY_NAME)
21
+ DECIDE_PROVIDERS: dict[str, Provider] = {provider.name: provider for provider in (JEV, OPENAI)}
20
22
 
21
23
 
22
24
  @click.group()
@@ -69,3 +71,16 @@ def status() -> None:
69
71
  except BackendUnavailable:
70
72
  click.echo("selected: none available")
71
73
  click.echo(f"core: {(v := _core.version())['core_version']}@{v['source_hash'][:12]}")
74
+
75
+
76
+ @main.group()
77
+ def key() -> None:
78
+ """Manage the decision providers' API keys in the macOS Keychain."""
79
+
80
+
81
+ @key.command("set")
82
+ @click.argument("provider", type=click.Choice(list(DECIDE_PROVIDERS)))
83
+ def key_set(provider: str) -> None:
84
+ """Store PROVIDER's API key, read from stdin, in the macOS Keychain, where `decide` reads it."""
85
+ store_key(DECIDE_PROVIDERS[provider], sys.stdin.read().strip())
86
+ click.echo(f"stored the {provider} key in the Keychain item spawnllm-{provider}-api-key")
@@ -0,0 +1,489 @@
1
+ """Typed yes/no, label, and score decisions over TypeSafe Jev and OpenAI Decisions.
2
+
3
+ The Rust core maps one question set onto either provider's wire format and resolves the
4
+ answer, retry, or failure each response means; this module only moves bytes over one
5
+ keep-alive HTTP client per process and keeps every attempt inside the caller's deadline.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import os
12
+ import re
13
+ import subprocess
14
+ import sys
15
+ import threading
16
+ import time
17
+ from collections.abc import Mapping, Sequence
18
+ from dataclasses import dataclass
19
+ from typing import Any, Literal
20
+
21
+ import httpx
22
+
23
+ from spawnllm import _core
24
+
25
+ __all__ = [
26
+ "JEV",
27
+ "OPENAI",
28
+ "Binary",
29
+ "BinaryAnswer",
30
+ "DecideError",
31
+ "DecideKeyMissing",
32
+ "Decision",
33
+ "Label",
34
+ "LabelAnswer",
35
+ "Provider",
36
+ "Refused",
37
+ "Score",
38
+ "ScoreAnswer",
39
+ "TDecideProvider",
40
+ "api_key",
41
+ "decide",
42
+ "decide_sync",
43
+ "store_key",
44
+ ]
45
+
46
+ TDecideProvider = Literal["jev", "openai"]
47
+ """A decision provider: TypeSafe's Jev (`jev`) or OpenAI Decisions (`openai`)."""
48
+
49
+ KEY_ENV: dict[TDecideProvider, str] = {"jev": "TYPESAFE_API_KEY", "openai": "OPENAI_API_KEY"}
50
+ KEY_PATTERN = re.compile(r"[A-Za-z0-9._-]+")
51
+ DEFAULT_TIMEOUT = 10.0
52
+
53
+ CLIENT_LOCK = threading.Lock()
54
+ CLIENT: httpx.Client | None = None
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class Provider:
59
+ """A decision provider and the model it runs.
60
+
61
+ Attributes:
62
+ name: `jev` posts to TypeSafe's System One endpoint, `openai` to OpenAI's `/v1/decisions`.
63
+ model: The literal model id sent with every request; pin a versioned id once a
64
+ threshold is tuned against it.
65
+
66
+ Example:
67
+ >>> Provider("jev", "jev-1.13.0")
68
+ """
69
+
70
+ name: TDecideProvider
71
+ model: str
72
+
73
+
74
+ JEV = Provider("jev", "jev-1.13.0")
75
+ """TypeSafe Jev pinned to `jev-1.13.0`, the model `jev-latest` resolves to today."""
76
+
77
+ OPENAI = Provider("openai", "gpt-6-luna")
78
+ """OpenAI Decisions on `gpt-6-luna`, its only model."""
79
+
80
+
81
+ @dataclass(frozen=True, slots=True)
82
+ class Binary:
83
+ """A yes/no question; its answer is the probability of yes.
84
+
85
+ Attributes:
86
+ instructions: The question, about one observable fact.
87
+ yes: What a yes means, when the question alone leaves it open.
88
+ no: What a no means, when the question alone leaves it open.
89
+
90
+ Example:
91
+ >>> Binary("Does the message ask to roll back a release?")
92
+ """
93
+
94
+ instructions: str
95
+ yes: str | None = None
96
+ no: str | None = None
97
+
98
+
99
+ @dataclass(frozen=True, slots=True)
100
+ class Label:
101
+ """Pick one option from 2 to 255; put the fail-safe option first, since Jev leans toward it.
102
+
103
+ Attributes:
104
+ instructions: What to decide.
105
+ options: Each option value mapped to its description, or `None`, in declared order.
106
+
107
+ Example:
108
+ >>> Label("Which release action does the message ask for?", {"none": None, "rollback": "Undo a release"})
109
+ """
110
+
111
+ instructions: str
112
+ options: Mapping[str, str | None]
113
+
114
+
115
+ @dataclass(frozen=True, slots=True)
116
+ class Score:
117
+ """Rate along 2 to 10 ordered levels, lowest first; the score is the probability-weighted level index.
118
+
119
+ Attributes:
120
+ instructions: What to rate.
121
+ levels: Each level label mapped to its description, or `None`, lowest first.
122
+
123
+ Example:
124
+ >>> Score("How urgent is the message?", {"Not urgent": None, "Somewhat urgent": None, "Blocking": None})
125
+ """
126
+
127
+ instructions: str
128
+ levels: Mapping[str, str | None]
129
+
130
+
131
+ type Question = Binary | Label | Score
132
+
133
+
134
+ @dataclass(frozen=True, slots=True)
135
+ class BinaryAnswer:
136
+ """The answer to a `Binary` question.
137
+
138
+ Attributes:
139
+ p_yes: Probability the answer is yes.
140
+ confidence: `abs(2 * p_yes - 1)`, 0 at a coin flip and 1 at certainty.
141
+ """
142
+
143
+ p_yes: float
144
+ confidence: float
145
+
146
+
147
+ @dataclass(frozen=True, slots=True)
148
+ class LabelAnswer:
149
+ """The answer to a `Label` question.
150
+
151
+ Attributes:
152
+ choice: The most likely option value.
153
+ probabilities: Every option value mapped to its probability, in declared order.
154
+ confidence: How far the distribution sits from uniform, 0 to 1.
155
+ """
156
+
157
+ choice: str
158
+ probabilities: Mapping[str, float]
159
+ confidence: float
160
+
161
+
162
+ @dataclass(frozen=True, slots=True)
163
+ class ScoreAnswer:
164
+ """The answer to a `Score` question.
165
+
166
+ Attributes:
167
+ score: The probability-weighted level index; it can land between levels.
168
+ probabilities: Every level label mapped to its probability, lowest first.
169
+ confidence: How concentrated the distribution is around its top level, 0 to 1.
170
+ """
171
+
172
+ score: float
173
+ probabilities: Mapping[str, float]
174
+ confidence: float
175
+
176
+
177
+ @dataclass(frozen=True, slots=True)
178
+ class Refused:
179
+ """A question the provider declined to answer while it answered the others."""
180
+
181
+
182
+ type Answer = BinaryAnswer | LabelAnswer | ScoreAnswer | Refused
183
+
184
+
185
+ @dataclass(frozen=True, slots=True)
186
+ class Decision:
187
+ """One provider response: an answer per question id, in the order asked.
188
+
189
+ Attributes:
190
+ answers: Each question id mapped to its answer.
191
+ model: The model that answered, as the provider reports it.
192
+ input_tokens: Billed input tokens.
193
+ latency_ms: Wall time of the attempt that answered, request to parsed response.
194
+ """
195
+
196
+ answers: Mapping[str, Answer]
197
+ model: str
198
+ input_tokens: int
199
+ latency_ms: float
200
+
201
+
202
+ class DecideError(Exception):
203
+ """The provider rejected a decision request, or answered one with a body that matches no question.
204
+
205
+ Attributes:
206
+ status: The HTTP status of the failed response.
207
+ message: The provider's error body, or what about its answer failed to match.
208
+ """
209
+
210
+ def __init__(self, status: int, message: str) -> None:
211
+ super().__init__(f"decision failed with HTTP {status}: {message}")
212
+ self.status = status
213
+ self.message = message
214
+
215
+
216
+ class DecideKeyMissing(LookupError):
217
+ """No API key for the provider: none was passed, its variable is unset, and the Keychain holds none."""
218
+
219
+
220
+ def keychain_item(name: TDecideProvider) -> tuple[str, str]:
221
+ return f"spawnllm-{name}-api-key", name
222
+
223
+
224
+ def api_key(provider: Provider, *, timeout: float = DEFAULT_TIMEOUT) -> str:
225
+ """Return the provider's API key from its environment variable, else from the macOS Keychain.
226
+
227
+ Jev reads `TYPESAFE_API_KEY` and OpenAI reads `OPENAI_API_KEY`; on macOS an unset variable falls
228
+ through to the Keychain item `store_key` wrote, read when the call is made.
229
+
230
+ Raises:
231
+ DecideKeyMissing: When neither source holds a key.
232
+ TimeoutError: When the Keychain read outlasts `timeout`.
233
+ """
234
+ if key := os.environ.get(KEY_ENV[provider.name]):
235
+ return key
236
+ service, account = keychain_item(provider.name)
237
+ if sys.platform == "darwin":
238
+ try:
239
+ found = subprocess.run(
240
+ ["security", "find-generic-password", "-s", service, "-a", account, "-w"],
241
+ capture_output=True,
242
+ text=True,
243
+ check=False,
244
+ timeout=timeout,
245
+ )
246
+ except subprocess.TimeoutExpired as exc:
247
+ raise TimeoutError(f"the Keychain read for the {provider.name} key outlasted {timeout:g}s") from exc
248
+ if found.returncode == 0:
249
+ return found.stdout.strip()
250
+ raise DecideKeyMissing(
251
+ f"no {provider.name} API key: set {KEY_ENV[provider.name]} or run `spawnllm key set {provider.name}`"
252
+ )
253
+
254
+
255
+ def store_key(provider: Provider, key: str) -> None:
256
+ """Store `key` as the provider's macOS Keychain item, replacing any earlier one.
257
+
258
+ The key reaches `security` over stdin, never its argv, so no process listing shows it.
259
+
260
+ Raises:
261
+ ValueError: When the key holds characters outside `[A-Za-z0-9._-]`.
262
+ subprocess.CalledProcessError: When `security` refuses the write.
263
+ """
264
+ if not KEY_PATTERN.fullmatch(key):
265
+ raise ValueError(f"a {provider.name} API key holds only letters, digits, '.', '_', and '-'")
266
+ service, account = keychain_item(provider.name)
267
+ subprocess.run(
268
+ ["security", "-i"],
269
+ input=f"add-generic-password -U -s {service} -a {account} -w {key}\n",
270
+ capture_output=True,
271
+ text=True,
272
+ check=True,
273
+ )
274
+
275
+
276
+ def sync_client() -> httpx.Client:
277
+ global CLIENT
278
+ with CLIENT_LOCK:
279
+ if CLIENT is None:
280
+ CLIENT = httpx.Client()
281
+ return CLIENT
282
+
283
+
284
+ def fork_reset() -> None:
285
+ global CLIENT, CLIENT_LOCK
286
+ CLIENT_LOCK = threading.Lock()
287
+ CLIENT = None
288
+
289
+
290
+ os.register_at_fork(after_in_child=fork_reset)
291
+
292
+
293
+ def wire_question(id: str, question: Question) -> dict[str, Any]:
294
+ match question:
295
+ case Binary(instructions=instructions, yes=yes, no=no):
296
+ return {"type": "binary", "id": id, "instructions": instructions, "yes": yes, "no": no}
297
+ case Label(instructions=instructions, options=options):
298
+ return {
299
+ "type": "label",
300
+ "id": id,
301
+ "instructions": instructions,
302
+ "options": [{"value": value, "description": text} for value, text in options.items()],
303
+ }
304
+ case Score(instructions=instructions, levels=levels):
305
+ return {
306
+ "type": "score",
307
+ "id": id,
308
+ "instructions": instructions,
309
+ "levels": [{"label": label, "description": text} for label, text in levels.items()],
310
+ }
311
+
312
+
313
+ def keys(question: Question) -> Sequence[str]:
314
+ match question:
315
+ case Label(options=options):
316
+ return tuple(options)
317
+ case Score(levels=levels):
318
+ return tuple(levels)
319
+ case Binary():
320
+ return ()
321
+
322
+
323
+ def answer(question: Question, wire: dict[str, Any]) -> Answer:
324
+ match wire:
325
+ case {"type": "binary", "p_yes": p_yes, "confidence": confidence}:
326
+ return BinaryAnswer(p_yes, confidence)
327
+ case {"type": "label", "choice": choice, "probabilities": probabilities, "confidence": confidence}:
328
+ return LabelAnswer(choice, dict(zip(keys(question), probabilities, strict=True)), confidence)
329
+ case {"type": "score", "score": score, "probabilities": probabilities, "confidence": confidence}:
330
+ return ScoreAnswer(score, dict(zip(keys(question), probabilities, strict=True)), confidence)
331
+ case {"type": "refused"}:
332
+ return Refused()
333
+ raise ValueError(f"the core resolved an unknown answer: {wire}")
334
+
335
+
336
+ @dataclass(frozen=True, slots=True)
337
+ class Call:
338
+ provider: Provider
339
+ questions: Mapping[str, Question]
340
+ wired: list[dict[str, Any]]
341
+ request: dict[str, Any]
342
+ deadline: float
343
+ timeout: float
344
+
345
+ @classmethod
346
+ def plan(
347
+ cls,
348
+ state: str | Mapping[str, Any] | Sequence[Any],
349
+ questions: Mapping[str, Question],
350
+ provider: Provider,
351
+ timeout: float,
352
+ key: str | None,
353
+ ) -> Call:
354
+ deadline = time.monotonic() + timeout
355
+ wired = [wire_question(id, question) for id, question in questions.items()]
356
+ planned = {
357
+ "provider": {"name": provider.name, "model": provider.model},
358
+ "api_key": key or api_key(provider, timeout=timeout),
359
+ "state": state,
360
+ "questions": wired,
361
+ }
362
+ try:
363
+ request = _core.dispatch("decide_plan", planned)
364
+ except _core.CoreError as error:
365
+ raise ValueError(error.msg) from error
366
+ return cls(provider, questions, wired, request, deadline, timeout)
367
+
368
+ def remaining(self) -> float:
369
+ if (left := self.deadline - time.monotonic()) <= 0:
370
+ raise TimeoutError(f"no {self.provider.name} decision within {self.timeout:g}s")
371
+ return left
372
+
373
+ def outcome(self, response: httpx.Response | None, attempt: int, started: float) -> Decision | float:
374
+ resolved = _core.dispatch(
375
+ "decide_resolve",
376
+ {
377
+ "provider": {"name": self.provider.name, "model": self.provider.model},
378
+ "questions": self.wired,
379
+ "status": None if response is None else response.status_code,
380
+ "body": "" if response is None else response.text,
381
+ "retry_after": None if response is None else response.headers.get("retry-after"),
382
+ "retry_after_ms": None if response is None else response.headers.get("retry-after-ms"),
383
+ "attempt": attempt,
384
+ },
385
+ )
386
+ match resolved:
387
+ case {"kind": "decision", "model": model, "input_tokens": tokens, "answers": answers}:
388
+ return Decision(
389
+ {id: answer(self.questions[id], wire) for id, wire in answers.items()},
390
+ model,
391
+ tokens,
392
+ (time.monotonic() - started) * 1000,
393
+ )
394
+ case {"kind": "retry", "sleep_s": sleep_s} if time.monotonic() + sleep_s < self.deadline:
395
+ return sleep_s
396
+ case {"kind": "retry"}:
397
+ last = "no response" if response is None else f"HTTP {response.status_code}"
398
+ raise TimeoutError(
399
+ f"no {self.provider.name} decision within {self.timeout:g}s; the last try got {last}"
400
+ )
401
+ case {"kind": "failed", "status": status, "message": message}:
402
+ raise DecideError(status, message)
403
+ raise ValueError(f"the core resolved an unknown outcome: {resolved}")
404
+
405
+
406
+ def decide_sync(
407
+ state: str | Mapping[str, Any] | Sequence[Any],
408
+ questions: Mapping[str, Question],
409
+ *,
410
+ provider: Provider = JEV,
411
+ timeout: float = DEFAULT_TIMEOUT,
412
+ api_key: str | None = None,
413
+ ) -> Decision:
414
+ """Ask every question about `state` in one request and return the typed answers.
415
+
416
+ Keep each question to one observable fact and combine the answers in code; put counting,
417
+ dates, and arithmetic in code too. Retries 408, 429, 5xx, 529, and a lost connection with
418
+ backoff from 0.5s doubling to 5s, honoring `retry-after`, and only while the retry still fits
419
+ inside `timeout`. The Keychain read counts against `timeout` too, and an answer that arrives
420
+ after it raises `TimeoutError` rather than returning late.
421
+
422
+ Args:
423
+ state: The text to judge, or JSON data; Jev reads structure natively and OpenAI receives
424
+ it as compact JSON text.
425
+ questions: Each question id mapped to its question, in the order to ask them.
426
+ provider: `JEV` or `OPENAI`, or a `Provider` pinning another model.
427
+ timeout: Seconds for the whole call, retries included.
428
+ api_key: The provider key; `None` reads it through `api_key`.
429
+
430
+ Returns:
431
+ The decision, with a `Refused` answer for any question the provider declined.
432
+
433
+ Raises:
434
+ TimeoutError: When no answer arrives within `timeout`.
435
+ DecideError: When the provider rejects the request.
436
+ DecideKeyMissing: When no key is passed or found.
437
+ ValueError: When the question set breaks a limit both providers share, such as a label
438
+ with fewer than 2 options or a score with more than 10 levels.
439
+
440
+ Example:
441
+ >>> decision = decide_sync("Please roll back last night's release.", {
442
+ ... "rollback": Binary("Does the message ask to roll back a release?"),
443
+ ... })
444
+ >>> decision.answers["rollback"].p_yes > 0.5
445
+ True
446
+ """
447
+ call = Call.plan(state, questions, provider, timeout, api_key)
448
+ tries = 0
449
+ while True:
450
+ started = time.monotonic()
451
+ try:
452
+ response = sync_client().post(
453
+ call.request["url"],
454
+ headers=call.request["headers"],
455
+ json=call.request["body"],
456
+ timeout=call.remaining(),
457
+ )
458
+ except httpx.TimeoutException as exc:
459
+ raise TimeoutError(f"no {provider.name} decision within {timeout:g}s") from exc
460
+ except httpx.TransportError:
461
+ response = None
462
+ call.remaining()
463
+ match call.outcome(response, tries, started):
464
+ case Decision() as decision:
465
+ return decision
466
+ case float() as sleep_s:
467
+ time.sleep(sleep_s)
468
+ tries += 1
469
+
470
+
471
+ async def decide(
472
+ state: str | Mapping[str, Any] | Sequence[Any],
473
+ questions: Mapping[str, Question],
474
+ *,
475
+ provider: Provider = JEV,
476
+ timeout: float = DEFAULT_TIMEOUT,
477
+ api_key: str | None = None,
478
+ ) -> Decision:
479
+ """Ask every question about `state` in one request, asynchronously; `decide_sync` documents the contract.
480
+
481
+ The call runs `decide_sync` on a worker thread, so it shares the process's keep-alive client and
482
+ holds no connection to the event loop.
483
+
484
+ Example:
485
+ >>> decision = await decide({"title": "Revert the release"}, {
486
+ ... "kind": Label("What does the title ask for?", {"other": None, "rollback": None}),
487
+ ... }, provider=OPENAI)
488
+ """
489
+ return await asyncio.to_thread(decide_sync, state, questions, provider=provider, timeout=timeout, api_key=api_key)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes