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.
- {spawnllm-0.16.0 → spawnllm-0.17.0}/PKG-INFO +23 -2
- {spawnllm-0.16.0 → spawnllm-0.17.0}/README.md +22 -1
- {spawnllm-0.16.0 → spawnllm-0.17.0}/pyproject.toml +1 -1
- {spawnllm-0.16.0 → spawnllm-0.17.0}/pyproject.toml.orig +1 -1
- {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/__init__.py +36 -3
- spawnllm-0.17.0/spawnllm/_core/spawnllm_core.wasm +0 -0
- {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/cli.py +15 -0
- spawnllm-0.17.0/spawnllm/decide.py +489 -0
- spawnllm-0.16.0/spawnllm/_core/spawnllm_core.wasm +0 -0
- {spawnllm-0.16.0 → spawnllm-0.17.0}/LICENSE +0 -0
- {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/__main__.py +0 -0
- {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/_core/__init__.py +0 -0
- {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/backends/__init__.py +0 -0
- {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/backends/apple.py +0 -0
- {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/backends/base.py +0 -0
- {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/backends/claude.py +0 -0
- {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/backends/claude_sdk.py +0 -0
- {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/backends/codex.py +0 -0
- {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/backends/gemini.py +0 -0
- {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/backends/mlx.py +0 -0
- {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/backends/openai_endpoint.py +0 -0
- {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/backends/registry.py +0 -0
- {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/call.py +0 -0
- {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/extract.py +0 -0
- {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/mlx/__init__.py +0 -0
- {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/mlx/codec.py +0 -0
- {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/mlx/engine.py +0 -0
- {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/mlx/fuse.py +0 -0
- {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/mlx/patches.py +0 -0
- {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/proc.py +0 -0
- {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/py.typed +0 -0
- {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/response.py +0 -0
- {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/run.py +0 -0
- {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/spec.py +0 -0
- {spawnllm-0.16.0 → spawnllm-0.17.0}/spawnllm/structured.py +0 -0
- {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.
|
|
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 `
|
|
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 `
|
|
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,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.
|
|
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
|
|
6
|
-
|
|
7
|
-
`
|
|
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",
|
|
Binary file
|
|
@@ -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)
|
|
Binary file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|