aiscript 1.0.0__tar.gz → 1.2.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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiscript
3
- Version: 1.0.0
4
- Summary: JIT-compile plain-English intents into cached, reviewed shell scripts via an LLM.
3
+ Version: 1.2.0
4
+ Summary: JIT-compile plain-English intents into cached, reviewed shell or Python scripts via an LLM.
5
5
  Author: Daniel Illescas Romero
6
6
  Author-email: Daniel Illescas Romero <illescas.daniel@protonmail.com>
7
7
  License-Expression: MIT
@@ -17,11 +17,14 @@ Description-Content-Type: text/markdown
17
17
  [![PyPI version](https://img.shields.io/pypi/v/aiscript)](https://pypi.org/project/aiscript/)
18
18
  [![Python versions](https://img.shields.io/pypi/pyversions/aiscript)](https://pypi.org/project/aiscript/)
19
19
 
20
- Describe what you want in plain English and `aiscript` asks an LLM to write a shell script
21
- (Bash on Linux/macOS, PowerShell on Windows), caches it, and runs it. The next run is instant:
20
+ Describe what you want in plain English and `aiscript` asks an LLM to write a script, caches
21
+ it, and runs it. The model picks the language: your native shell (Bash on Linux/macOS, PowerShell
22
+ on Windows) or Python when that fits better. Python scripts that need third-party packages get
23
+ their own virtual environment. The next run is instant:
22
24
  no network, no config. Before anything risky is saved or run, every generated script is checked
23
25
  by a built-in static scan and, optionally, by a decision model: [Jev](https://docs.typesafe.ai)
24
- by TypeSafe AI (hosted) or a local model served by [Ollaya](https://ollaya.dev). Risky scripts
26
+ by TypeSafe AI (hosted), [OpenAI Decisions](https://developers.openai.com/api/docs/guides/decisions),
27
+ or a local model served by [Ollaya](https://ollaya.dev). Risky scripts
25
28
  are shown to you and need an explicit `[y/N]` approval.
26
29
 
27
30
  ## Install
@@ -34,7 +37,19 @@ pipx install aiscript
34
37
  pip install aiscript
35
38
  ```
36
39
 
37
- Requires Python 3.10+. To work on aiscript itself, see [docs/development.md](docs/development.md).
40
+ Requires Python 3.10+.
41
+
42
+ To upgrade to the latest version, use the same tool you installed with:
43
+
44
+ ```sh
45
+ uv tool upgrade aiscript
46
+ # or
47
+ pipx upgrade aiscript
48
+ # or
49
+ pip install --upgrade aiscript
50
+ ```
51
+
52
+ Your config, prompts and cached scripts in `~/.config/aiscript/` are kept. To work on aiscript itself, see [docs/development.md](docs/development.md).
38
53
 
39
54
  ## Quickstart
40
55
 
@@ -72,7 +87,7 @@ Every generated script is reviewed **before it is saved**:
72
87
  - A **static regex scan** (always on) flags privilege escalation, recursive or system-wide file
73
88
  changes, networking/firewall changes and remote code execution (`curl | sh`, `iex`).
74
89
  - A **decision model** (`reviewer_provider`) gives a second opinion: **Jev** (hosted; key from
75
- <https://console.typesafe.ai/keys>) or **Ollaya** (local and keyless; set
90
+ <https://console.typesafe.ai/keys>), **OpenAI Decisions** (see below), or **Ollaya** (local and keyless; set
76
91
  `reviewer_provider: ollaya`, default model `jevk5:4b` on `localhost:11435`; see
77
92
  <https://ollaya.dev>). Comment `reviewer_provider` out to use only the static scan.
78
93
 
@@ -87,7 +102,8 @@ a best-effort safety net, not a sandbox: read what you approve. Details: [docs/s
87
102
 
88
103
  ```yaml
89
104
  # aiscript config. Contains API keys: keep it private (chmod 600) and out of git.
90
- # Keys can also come from the environment: OPENAI_API_KEY, TYPESAFE_API_KEY, <PROVIDER>_API_KEY.
105
+ # Keys can also come from the environment: OPENAI_API_KEY, OPENAI_DECISIONS_API_KEY,
106
+ # TYPESAFE_API_KEY, <PROVIDER>_API_KEY.
91
107
 
92
108
  # Provider that writes the scripts (override with $AISCRIPT_PROVIDER).
93
109
  default_provider: openai
@@ -103,6 +119,13 @@ providers:
103
119
  model: "gpt-6-luna"
104
120
  # reasoning_effort: low # none | low | medium | high | xhigh | max (model-dependent)
105
121
 
122
+ # OpenAI Decisions API reviewer provider.
123
+ # openai_decisions:
124
+ # kind: openai
125
+ # api: decisions
126
+ # api_key: "..."
127
+ # model: "gpt-6-luna"
128
+
106
129
  # Jev (TypeSafe AI) safety reviewer. API key: https://console.typesafe.ai/keys
107
130
  jev:
108
131
  api_key: "..."
@@ -121,6 +144,22 @@ Placeholder keys (`"..."`) are ignored, so if you leave them, the environment va
121
144
  All options (provider kinds, `api`, `temperature`, key and model resolution, every environment
122
145
  variable, the legacy `secrets.yaml`): [docs/configuration.md](docs/configuration.md).
123
146
 
147
+ ### OpenAI Decisions reviewer
148
+
149
+ To use OpenAI Decisions instead of Jev, select `reviewer_provider: openai_decisions` and
150
+ uncomment the corresponding provider block above. Supply its own `api_key`, or export
151
+ `OPENAI_DECISIONS_API_KEY`; this named reviewer does not fall back to `OPENAI_API_KEY`.
152
+ The default model is `gpt-6-luna`, and `model` remains configurable for future models.
153
+
154
+ Decisions uses the official OpenAI endpoint only. One request checks the same four hazards,
155
+ flagging probabilities **>= 0.5**. Requests use a 30-second transport timeout with zero retries.
156
+ Errors, refusals and malformed answers require the existing risk confirmation; they are refused
157
+ in noninteractive builds. Static findings and dependency warnings always remain effective.
158
+
159
+ Existing reviewers and cached scripts keep working. To roll back, restore your previous
160
+ `reviewer_provider` before downgrading aiscript. See [configuration](docs/configuration.md#openai-decisions)
161
+ and [safety](docs/safety.md#openai-decisions) for the full contract.
162
+
124
163
  ## Usage
125
164
 
126
165
  | Command | Action |
@@ -135,17 +174,43 @@ variable, the legacy `secrets.yaml`): [docs/configuration.md](docs/configuration
135
174
 
136
175
  Command names must match `[A-Za-z0-9][A-Za-z0-9_-]*`.
137
176
 
177
+ ## Shell or Python
178
+
179
+ The model is told your OS, architecture, native shell, the Python version it will run under and
180
+ whether [uv](https://docs.astral.sh/uv/) is installed, and chooses the language: the shell for thin
181
+ wrappers around CLI tools and pipelines, Python for parsing JSON/CSV/HTML, data processing, HTTP
182
+ APIs and anything that would be fragile in shell. Say "in python" or "as a shell script" in your
183
+ description to ask for one; the model follows it unless it is clearly a poor fit (and then notes
184
+ why in the script header). `aiscript --list` shows each command's language.
185
+
186
+ - **Standard-library Python** is saved as `functions/NAME.py` and run with the system `python3`
187
+ (`py -3` on Windows), or with aiscript's own interpreter if there is none.
188
+ - **Python with dependencies** declares them in an inline
189
+ [PEP 723](https://peps.python.org/pep-0723/) block. After you approve (any dependency requires a
190
+ `[y/N]` approval that lists the packages), aiscript creates `functions/NAME/` with `main.py` and
191
+ a private `.venv`: with uv (`pyproject.toml` + `uv.lock`, `uv sync`) or otherwise with
192
+ `python -m venv` + `pip install -r requirements.txt`. The script always runs with that venv's
193
+ interpreter, so later runs are offline. A rebuild with the same dependencies reuses the venv; a
194
+ failed install keeps the previous version. Without uv, Debian/Ubuntu need `python3-venv`.
195
+
138
196
  ## How it works
139
197
 
140
198
  ```
141
199
  ~/.config/aiscript/ # or $XDG_CONFIG_HOME/aiscript, or $AISCRIPT_HOME
142
200
  ├── config.yaml # providers and keys
143
201
  ├── prompts/<name>.txt # your intent for each command
144
- └── functions/<name>.sh # generated, reviewed scripts (.ps1 on Windows)
202
+ └── functions/
203
+ ├── <name>.sh # generated, reviewed scripts (.ps1 on Windows)
204
+ ├── <name>.py # standard-library Python
205
+ └── <name>/ # Python with dependencies
206
+ ├── main.py
207
+ ├── .venv/
208
+ └── pyproject.toml + uv.lock (or requirements.txt)
145
209
  ```
146
210
 
147
211
  The first run of `NAME` saves your description to `prompts/NAME.txt`, asks the LLM for a script,
148
- reviews it, and stores it in `functions/`. Later runs just execute the cached script. If the
212
+ reviews it, and stores it in `functions/` (only one build per command: switching language
213
+ replaces the old one). Later runs just execute the cached script. If the
149
214
  prompt is newer than the script (after `--edit` or a manual change), the script is rebuilt on the
150
215
  next run. A `--rebuild` that fails or is rejected keeps the previous script.
151
216
 
@@ -3,11 +3,14 @@
3
3
  [![PyPI version](https://img.shields.io/pypi/v/aiscript)](https://pypi.org/project/aiscript/)
4
4
  [![Python versions](https://img.shields.io/pypi/pyversions/aiscript)](https://pypi.org/project/aiscript/)
5
5
 
6
- Describe what you want in plain English and `aiscript` asks an LLM to write a shell script
7
- (Bash on Linux/macOS, PowerShell on Windows), caches it, and runs it. The next run is instant:
6
+ Describe what you want in plain English and `aiscript` asks an LLM to write a script, caches
7
+ it, and runs it. The model picks the language: your native shell (Bash on Linux/macOS, PowerShell
8
+ on Windows) or Python when that fits better. Python scripts that need third-party packages get
9
+ their own virtual environment. The next run is instant:
8
10
  no network, no config. Before anything risky is saved or run, every generated script is checked
9
11
  by a built-in static scan and, optionally, by a decision model: [Jev](https://docs.typesafe.ai)
10
- by TypeSafe AI (hosted) or a local model served by [Ollaya](https://ollaya.dev). Risky scripts
12
+ by TypeSafe AI (hosted), [OpenAI Decisions](https://developers.openai.com/api/docs/guides/decisions),
13
+ or a local model served by [Ollaya](https://ollaya.dev). Risky scripts
11
14
  are shown to you and need an explicit `[y/N]` approval.
12
15
 
13
16
  ## Install
@@ -20,7 +23,19 @@ pipx install aiscript
20
23
  pip install aiscript
21
24
  ```
22
25
 
23
- Requires Python 3.10+. To work on aiscript itself, see [docs/development.md](docs/development.md).
26
+ Requires Python 3.10+.
27
+
28
+ To upgrade to the latest version, use the same tool you installed with:
29
+
30
+ ```sh
31
+ uv tool upgrade aiscript
32
+ # or
33
+ pipx upgrade aiscript
34
+ # or
35
+ pip install --upgrade aiscript
36
+ ```
37
+
38
+ Your config, prompts and cached scripts in `~/.config/aiscript/` are kept. To work on aiscript itself, see [docs/development.md](docs/development.md).
24
39
 
25
40
  ## Quickstart
26
41
 
@@ -58,7 +73,7 @@ Every generated script is reviewed **before it is saved**:
58
73
  - A **static regex scan** (always on) flags privilege escalation, recursive or system-wide file
59
74
  changes, networking/firewall changes and remote code execution (`curl | sh`, `iex`).
60
75
  - A **decision model** (`reviewer_provider`) gives a second opinion: **Jev** (hosted; key from
61
- <https://console.typesafe.ai/keys>) or **Ollaya** (local and keyless; set
76
+ <https://console.typesafe.ai/keys>), **OpenAI Decisions** (see below), or **Ollaya** (local and keyless; set
62
77
  `reviewer_provider: ollaya`, default model `jevk5:4b` on `localhost:11435`; see
63
78
  <https://ollaya.dev>). Comment `reviewer_provider` out to use only the static scan.
64
79
 
@@ -73,7 +88,8 @@ a best-effort safety net, not a sandbox: read what you approve. Details: [docs/s
73
88
 
74
89
  ```yaml
75
90
  # aiscript config. Contains API keys: keep it private (chmod 600) and out of git.
76
- # Keys can also come from the environment: OPENAI_API_KEY, TYPESAFE_API_KEY, <PROVIDER>_API_KEY.
91
+ # Keys can also come from the environment: OPENAI_API_KEY, OPENAI_DECISIONS_API_KEY,
92
+ # TYPESAFE_API_KEY, <PROVIDER>_API_KEY.
77
93
 
78
94
  # Provider that writes the scripts (override with $AISCRIPT_PROVIDER).
79
95
  default_provider: openai
@@ -89,6 +105,13 @@ providers:
89
105
  model: "gpt-6-luna"
90
106
  # reasoning_effort: low # none | low | medium | high | xhigh | max (model-dependent)
91
107
 
108
+ # OpenAI Decisions API reviewer provider.
109
+ # openai_decisions:
110
+ # kind: openai
111
+ # api: decisions
112
+ # api_key: "..."
113
+ # model: "gpt-6-luna"
114
+
92
115
  # Jev (TypeSafe AI) safety reviewer. API key: https://console.typesafe.ai/keys
93
116
  jev:
94
117
  api_key: "..."
@@ -107,6 +130,22 @@ Placeholder keys (`"..."`) are ignored, so if you leave them, the environment va
107
130
  All options (provider kinds, `api`, `temperature`, key and model resolution, every environment
108
131
  variable, the legacy `secrets.yaml`): [docs/configuration.md](docs/configuration.md).
109
132
 
133
+ ### OpenAI Decisions reviewer
134
+
135
+ To use OpenAI Decisions instead of Jev, select `reviewer_provider: openai_decisions` and
136
+ uncomment the corresponding provider block above. Supply its own `api_key`, or export
137
+ `OPENAI_DECISIONS_API_KEY`; this named reviewer does not fall back to `OPENAI_API_KEY`.
138
+ The default model is `gpt-6-luna`, and `model` remains configurable for future models.
139
+
140
+ Decisions uses the official OpenAI endpoint only. One request checks the same four hazards,
141
+ flagging probabilities **>= 0.5**. Requests use a 30-second transport timeout with zero retries.
142
+ Errors, refusals and malformed answers require the existing risk confirmation; they are refused
143
+ in noninteractive builds. Static findings and dependency warnings always remain effective.
144
+
145
+ Existing reviewers and cached scripts keep working. To roll back, restore your previous
146
+ `reviewer_provider` before downgrading aiscript. See [configuration](docs/configuration.md#openai-decisions)
147
+ and [safety](docs/safety.md#openai-decisions) for the full contract.
148
+
110
149
  ## Usage
111
150
 
112
151
  | Command | Action |
@@ -121,17 +160,43 @@ variable, the legacy `secrets.yaml`): [docs/configuration.md](docs/configuration
121
160
 
122
161
  Command names must match `[A-Za-z0-9][A-Za-z0-9_-]*`.
123
162
 
163
+ ## Shell or Python
164
+
165
+ The model is told your OS, architecture, native shell, the Python version it will run under and
166
+ whether [uv](https://docs.astral.sh/uv/) is installed, and chooses the language: the shell for thin
167
+ wrappers around CLI tools and pipelines, Python for parsing JSON/CSV/HTML, data processing, HTTP
168
+ APIs and anything that would be fragile in shell. Say "in python" or "as a shell script" in your
169
+ description to ask for one; the model follows it unless it is clearly a poor fit (and then notes
170
+ why in the script header). `aiscript --list` shows each command's language.
171
+
172
+ - **Standard-library Python** is saved as `functions/NAME.py` and run with the system `python3`
173
+ (`py -3` on Windows), or with aiscript's own interpreter if there is none.
174
+ - **Python with dependencies** declares them in an inline
175
+ [PEP 723](https://peps.python.org/pep-0723/) block. After you approve (any dependency requires a
176
+ `[y/N]` approval that lists the packages), aiscript creates `functions/NAME/` with `main.py` and
177
+ a private `.venv`: with uv (`pyproject.toml` + `uv.lock`, `uv sync`) or otherwise with
178
+ `python -m venv` + `pip install -r requirements.txt`. The script always runs with that venv's
179
+ interpreter, so later runs are offline. A rebuild with the same dependencies reuses the venv; a
180
+ failed install keeps the previous version. Without uv, Debian/Ubuntu need `python3-venv`.
181
+
124
182
  ## How it works
125
183
 
126
184
  ```
127
185
  ~/.config/aiscript/ # or $XDG_CONFIG_HOME/aiscript, or $AISCRIPT_HOME
128
186
  ├── config.yaml # providers and keys
129
187
  ├── prompts/<name>.txt # your intent for each command
130
- └── functions/<name>.sh # generated, reviewed scripts (.ps1 on Windows)
188
+ └── functions/
189
+ ├── <name>.sh # generated, reviewed scripts (.ps1 on Windows)
190
+ ├── <name>.py # standard-library Python
191
+ └── <name>/ # Python with dependencies
192
+ ├── main.py
193
+ ├── .venv/
194
+ └── pyproject.toml + uv.lock (or requirements.txt)
131
195
  ```
132
196
 
133
197
  The first run of `NAME` saves your description to `prompts/NAME.txt`, asks the LLM for a script,
134
- reviews it, and stores it in `functions/`. Later runs just execute the cached script. If the
198
+ reviews it, and stores it in `functions/` (only one build per command: switching language
199
+ replaces the old one). Later runs just execute the cached script. If the
135
200
  prompt is newer than the script (after `--edit` or a manual change), the script is rebuilt on the
136
201
  next run. A `--rebuild` that fails or is rejected keeps the previous script.
137
202
 
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "aiscript"
3
- version = "1.0.0"
4
- description = "JIT-compile plain-English intents into cached, reviewed shell scripts via an LLM."
3
+ version = "1.2.0"
4
+ description = "JIT-compile plain-English intents into cached, reviewed shell or Python scripts via an LLM."
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
7
  license-files = ["LICENSE"]
@@ -33,6 +33,12 @@ dev = [
33
33
  [tool.ruff]
34
34
  line-length = 100
35
35
  target-version = "py310"
36
+ extend-exclude = [
37
+ ".agents/skills",
38
+ ".claude/skills",
39
+ ".cursor/skills",
40
+ ".codex/skills",
41
+ ]
36
42
 
37
43
  [tool.ruff.lint]
38
44
  select = [
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "aiscript"
3
- version = "1.0.0"
4
- description = "JIT-compile plain-English intents into cached, reviewed shell scripts via an LLM."
3
+ version = "1.2.0"
4
+ description = "JIT-compile plain-English intents into cached, reviewed shell or Python scripts via an LLM."
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
7
  license-files = ["LICENSE"]
@@ -32,6 +32,8 @@ dev = [
32
32
  [tool.ruff]
33
33
  line-length = 100
34
34
  target-version = "py310"
35
+ # Installed skills carry supporting code with their own style conventions.
36
+ extend-exclude = [".agents/skills", ".claude/skills", ".cursor/skills", ".codex/skills"]
35
37
 
36
38
  [tool.ruff.lint]
37
39
  select = ["E", "F", "I", "UP", "B", "ANN"]
@@ -1,3 +1,3 @@
1
1
  """aiscript: JIT-compile plain-English intents into cached shell scripts."""
2
2
 
3
- __version__ = "1.0.0"
3
+ __version__ = "1.2.0"
@@ -4,11 +4,12 @@ from __future__ import annotations
4
4
 
5
5
  import contextlib
6
6
  import os
7
+ import shutil
7
8
  import sys
8
9
  import tempfile
9
10
  from pathlib import Path
10
11
 
11
- from aiscript import llm, paths, reviewer
12
+ from aiscript import llm, paths, pysetup, reviewer
12
13
  from aiscript.config import Settings
13
14
  from aiscript.errors import AiscriptError
14
15
 
@@ -44,17 +45,26 @@ def build(name: str, settings: Settings) -> Path:
44
45
  print(f"Generating {name!r} with {gen.name}/{gen.model}...", file=sys.stderr)
45
46
  script = llm.generate_script(gen, name, intent)
46
47
 
47
- shell = paths.shell_kind()
48
- result = reviewer.review(script, shell, settings.reviewer)
49
- if not reviewer.confirm(script, result):
48
+ result = reviewer.review(script.code, script.language, settings.reviewer, script.dependencies)
49
+ if not reviewer.confirm(script.code, result):
50
50
  raise AiscriptError("Script rejected; nothing saved.")
51
51
 
52
52
  paths.ensure_dirs()
53
- target = paths.function_path(name)
53
+ if script.dependencies:
54
+ target = pysetup.install(name, script.code, script.dependencies)
55
+ else:
56
+ target = paths.function_target(name, script.language, has_deps=False)
57
+ _write_file(name, target, script.code)
58
+ _remove_other_artifacts(name, target)
59
+ print(f"Saved {target}", file=sys.stderr)
60
+ return target
61
+
62
+
63
+ def _write_file(name: str, target: Path, code: str) -> None:
54
64
  fd, tmp_name = tempfile.mkstemp(dir=paths.functions_dir(), prefix=f".{name}.", suffix=".tmp")
55
65
  try:
56
66
  with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as fh:
57
- fh.write(script)
67
+ fh.write(code)
58
68
  if not paths.is_windows():
59
69
  os.chmod(tmp_name, 0o755)
60
70
  os.replace(tmp_name, target)
@@ -62,5 +72,17 @@ def build(name: str, settings: Settings) -> Path:
62
72
  with contextlib.suppress(OSError):
63
73
  os.unlink(tmp_name)
64
74
  raise
65
- print(f"Saved {target}", file=sys.stderr)
66
- return target
75
+
76
+
77
+ def _remove_other_artifacts(name: str, target: Path) -> None:
78
+ """Drop builds of ``name`` in another language or layout, so only ``target`` remains."""
79
+ for path in paths.function_artifacts(name):
80
+ if path == target or path in target.parents:
81
+ continue
82
+ try:
83
+ if path.is_dir():
84
+ shutil.rmtree(path)
85
+ else:
86
+ path.unlink()
87
+ except OSError as exc:
88
+ print(f"aiscript: warning: could not remove old build {path}: {exc}", file=sys.stderr)
@@ -22,7 +22,7 @@ usage: aiscript NAME [args...] run a command (created/built on first use)
22
22
  aiscript --list list commands
23
23
  aiscript --edit NAME edit a command's prompt (rebuilds on next run)
24
24
  aiscript --rebuild NAME regenerate a command's script (does not run it)
25
- aiscript --remove NAME delete a command's prompt and script
25
+ aiscript --remove NAME delete a command's prompt and script(s)
26
26
  aiscript --help | --version
27
27
 
28
28
  Everything after NAME is passed untouched to the generated script.
@@ -92,12 +92,15 @@ def _rebuild(name: str) -> int:
92
92
 
93
93
  def _remove(name: str) -> int:
94
94
  paths.validate_command_name(name)
95
- targets = [p for p in (paths.prompt_path(name), paths.function_path(name)) if p.exists()]
95
+ targets = [p for p in (paths.prompt_path(name), *paths.function_artifacts(name)) if p.exists()]
96
96
  if not targets:
97
97
  raise AiscriptError(f"No command named {name!r}.")
98
98
  for path in targets:
99
99
  try:
100
- path.unlink()
100
+ if path.is_dir():
101
+ shutil.rmtree(path)
102
+ else:
103
+ path.unlink()
101
104
  except OSError as exc:
102
105
  raise AiscriptError(f"Could not remove {path}: {exc}") from exc
103
106
  _err(f"Removed {path}")
@@ -21,7 +21,7 @@ class ProviderConfig:
21
21
  base_url: str | None
22
22
  model: str
23
23
  kind: str = "openai" # "openai" (OpenAI-compatible) or "jev" (TypeSafe System One; Ollaya too)
24
- api: str = "chat" # for kind "openai": "responses" or "chat" (completions)
24
+ api: str = "chat" # for kind "openai": "responses", "chat", or reviewer-only "decisions"
25
25
  reasoning_effort: str | None = None # sent only when set
26
26
  temperature: float | None = None # sent only when set (GPT-6 rejects it unless effort=none)
27
27
 
@@ -34,11 +34,12 @@ class Settings:
34
34
 
35
35
  DEFAULT_PROVIDER = "openai"
36
36
  DEFAULT_MODEL = "gpt-6-luna"
37
+ DECISIONS_DEFAULT_MODEL = "gpt-6-luna"
37
38
  JEV_DEFAULT_MODEL = "jev-latest"
38
39
  OLLAYA_DEFAULT_MODEL = "jevk5:4b"
39
40
  OLLAYA_DEFAULT_URL = "http://localhost:11435"
40
41
  _KINDS = {"openai", "jev", "ollaya"}
41
- _APIS = {"responses", "chat"}
42
+ _APIS = {"responses", "chat", "decisions"}
42
43
  _OPENAI_HOSTS = {"api.openai.com"}
43
44
  _JEV_NAMES = {"jev", "typesafe"}
44
45
  _OLLAYA_NAMES = {"ollaya"}
@@ -157,6 +158,50 @@ def _resolve_provider(
157
158
  f"Provider '{name}' in {path} has unknown kind '{kind}' "
158
159
  "(expected: openai, jev, ollaya)."
159
160
  )
161
+ if (_clean(entry.get("api")) or "").lower() == "decisions":
162
+ if generator:
163
+ raise ConfigError(
164
+ f"Provider '{name}' in {path}: Decisions is only supported as a reviewer. "
165
+ "Select it with `reviewer_provider` and use another generator."
166
+ )
167
+ if kind != "openai":
168
+ raise ConfigError(f"Provider '{name}' in {path}: Decisions requires kind 'openai'.")
169
+ for setting in ("temperature", "reasoning_effort"):
170
+ if setting in entry:
171
+ raise ConfigError(
172
+ f"Provider '{name}' in {path}: {setting} is not supported by Decisions. "
173
+ "Remove that setting."
174
+ )
175
+
176
+ official_url = "https://api.openai.com/v1"
177
+ for alias in ("base_url", "endpoint"):
178
+ if alias in entry and _clean(entry[alias]) not in (official_url, official_url + "/"):
179
+ raise ConfigError(
180
+ f"Provider '{name}' in {path}: Decisions {alias} must be "
181
+ f"{official_url} (an optional trailing slash is accepted)."
182
+ )
183
+
184
+ api_key = None
185
+ env_name = _env_key_name(name)
186
+ for candidate in (entry.get("api_key"), os.environ.get(env_name)):
187
+ cleaned_key = _clean(candidate)
188
+ if cleaned_key is not None and not cleaned_key.endswith("..."):
189
+ api_key = cleaned_key
190
+ break
191
+ if api_key is None:
192
+ raise ConfigError(
193
+ f"No API key for provider '{name}'. Set `api_key` for it in {path} "
194
+ f"or export {env_name}; Decisions requires a dedicated reviewer credential."
195
+ )
196
+ return ProviderConfig(
197
+ name=name,
198
+ api_key=api_key,
199
+ base_url=official_url,
200
+ model=_clean(entry.get("model")) or DECISIONS_DEFAULT_MODEL,
201
+ kind="openai",
202
+ api="decisions",
203
+ )
204
+
160
205
  # Ollaya speaks the Jev wire protocol: only its defaults differ, so it becomes kind "jev".
161
206
  ollaya = kind == "ollaya"
162
207
  if ollaya:
@@ -222,7 +267,8 @@ def _resolve_provider(
222
267
  )
223
268
  if api not in _APIS:
224
269
  raise ConfigError(
225
- f"Provider '{name}' in {path} has unknown api '{api}' (expected: responses, chat)."
270
+ f"Provider '{name}' in {path} has unknown api '{api}' "
271
+ "(expected: responses, chat, decisions)."
226
272
  )
227
273
  reasoning_effort = _clean(entry.get("reasoning_effort"))
228
274
  temperature = entry.get("temperature")
@@ -0,0 +1,48 @@
1
+ """Domain values and gateway contract for Decisions safety review."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal, Protocol
7
+
8
+ HazardName = Literal["privilege_escalation", "filesystem", "networking", "remote_code_execution"]
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ReviewInput:
13
+ """Script and metadata supplied as untrusted review data."""
14
+
15
+ script: str
16
+ language: str
17
+ dependencies: tuple[str, ...]
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class HazardQuestion:
22
+ """Fixed hazard definition shared by reviewer adapters."""
23
+
24
+ name: HazardName
25
+ category: str
26
+ instructions: str
27
+ true_criteria: str
28
+ false_criteria: str
29
+
30
+
31
+ @dataclass
32
+ class DecisionsCallError(Exception):
33
+ """Bounded failure metadata without a provider payload."""
34
+
35
+ kind: Literal["client", "connection", "timeout", "http", "unexpected"]
36
+ status_code: int | None = None
37
+
38
+
39
+ class DecisionsGateway(Protocol):
40
+ """Port returning untrusted decoded response data."""
41
+
42
+ def classify(
43
+ self,
44
+ *,
45
+ model: str,
46
+ review_input: ReviewInput,
47
+ questions: tuple[HazardQuestion, ...],
48
+ ) -> object: ...