ape-linux 0.4.6__tar.gz → 0.5.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.
- {ape_linux-0.4.6 → ape_linux-0.5.0}/CLAUDE.md +61 -10
- {ape_linux-0.4.6 → ape_linux-0.5.0}/PKG-INFO +13 -18
- {ape_linux-0.4.6 → ape_linux-0.5.0}/README.md +12 -17
- {ape_linux-0.4.6 → ape_linux-0.5.0}/ape_linux.py +87 -11
- {ape_linux-0.4.6 → ape_linux-0.5.0}/pyproject.toml +1 -1
- {ape_linux-0.4.6 → ape_linux-0.5.0}/tests/test_app.py +148 -16
- {ape_linux-0.4.6 → ape_linux-0.5.0}/uv.lock +1 -1
- {ape_linux-0.4.6 → ape_linux-0.5.0}/.github/workflows/publish.yaml +0 -0
- {ape_linux-0.4.6 → ape_linux-0.5.0}/.github/workflows/tests.yaml +0 -0
- {ape_linux-0.4.6 → ape_linux-0.5.0}/.gitignore +0 -0
- {ape_linux-0.4.6 → ape_linux-0.5.0}/.python-version +0 -0
- {ape_linux-0.4.6 → ape_linux-0.5.0}/LICENSE +0 -0
- {ape_linux-0.4.6 → ape_linux-0.5.0}/justfile +0 -0
|
@@ -77,18 +77,29 @@ The flow in `ape_linux.py` is intentionally minimal:
|
|
|
77
77
|
3. `call_llm()` wraps `pydantic_ai.Agent`, which is the provider abstraction. It sets
|
|
78
78
|
`output_type=Command | CannotHelp`, so the agent returns one of those structured
|
|
79
79
|
objects instead of raw text (no string-sniffing in `main()`), and forwards a
|
|
80
|
-
`model_settings` mapping (or `None`).
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
(`
|
|
85
|
-
|
|
80
|
+
`model_settings` mapping (or `None`). The model string in `provider:name` form
|
|
81
|
+
(e.g. `anthropic:claude-sonnet-4-5`) is turned into a model object via
|
|
82
|
+
`pydantic_ai.models.infer_model()`, to which `call_llm()` passes a custom
|
|
83
|
+
`provider_factory` that builds the inferred provider with
|
|
84
|
+
`infer_provider_class(provider_name)(api_key=api_key)` — i.e. it injects Ape's own
|
|
85
|
+
API key straight into the provider rather than letting Pydantic AI read the
|
|
86
|
+
provider's standard credential env var. This stays provider-agnostic (any provider
|
|
87
|
+
whose class accepts an `api_key` works with no provider-specific code); a provider
|
|
88
|
+
that lacks an `api_key` parameter raises at call time and surfaces as a one-line
|
|
89
|
+
error. The model is resolved by `resolve_model()` solely from the **required**
|
|
90
|
+
`APE_MODEL` env var (in `provider:name` form); there is no built-in default and no
|
|
91
|
+
CLI override, so the provider is always explicit — a missing/empty `APE_MODEL` exits
|
|
92
|
+
`1` before any LLM call. The API key is resolved by `resolve_api_key()` from the
|
|
93
|
+
**`APE_API_KEY`** env var — Ape's own variable, deliberately *not* a provider's
|
|
94
|
+
standard one (e.g. `OPENAI_API_KEY`), so configuring Ape doesn't force a globally
|
|
95
|
+
named key that other tools also read; a missing/empty `APE_API_KEY` likewise exits
|
|
96
|
+
`1` before any LLM call. The sampling temperature
|
|
97
|
+
is resolved by `resolve_model_settings()` from the `APE_TEMPERATURE` env var → the
|
|
86
98
|
`DEFAULT_TEMPERATURE` constant (`0.2`); the literal `"undefined"` (case-insensitive)
|
|
87
99
|
yields `None` so **no** `model_settings` are sent — deliberately, because a
|
|
88
100
|
hard-coded temperature crashes models that reject sampling settings (some reasoning
|
|
89
101
|
models, Claude Opus 4.7/4.8) — and an unparseable value exits `1` before any LLM
|
|
90
|
-
call.
|
|
91
|
-
`ANTHROPIC_API_KEY`).
|
|
102
|
+
call.
|
|
92
103
|
4. Errors are flattened to one-line stderr messages, raising `SystemExit(1)` —
|
|
93
104
|
`ModelHTTPError` reports status/message; any other exception (bad credentials,
|
|
94
105
|
unknown provider) prints `str(error)`. There is no CLI framework swallowing
|
|
@@ -105,7 +116,17 @@ The flow in `ape_linux.py` is intentionally minimal:
|
|
|
105
116
|
doesn't exit then), asserting on captured stdout/stderr with `capsys`. It monkeypatches
|
|
106
117
|
`ape_linux.call_llm` so no real network/LLM calls happen; the mock returns a `Command`
|
|
107
118
|
(printed to stdout, exit 0) or a `CannotHelp` to exercise the refusal path (printed to
|
|
108
|
-
stderr, exit `2`). The `mockenv` fixture sets a dummy `
|
|
119
|
+
stderr, exit `2`). The `mockenv` fixture sets a dummy `APE_API_KEY` and `APE_MODEL`
|
|
120
|
+
(both required). Tests that assert on `call_llm`'s arguments take its signature
|
|
121
|
+
`(model, api_key, system_prompt, user_prompt, model_settings)`; a missing **or
|
|
122
|
+
blank/whitespace** `APE_MODEL` or `APE_API_KEY` is each checked to exit `1` before
|
|
123
|
+
`call_llm` is ever reached.
|
|
124
|
+
|
|
125
|
+
`build_provider()` is covered directly (not through `main()`): tests construct the
|
|
126
|
+
`openai` and `anthropic` providers with the standard credential vars deleted and assert
|
|
127
|
+
the injected key lands on `provider.client.api_key` (proving the standard var is neither
|
|
128
|
+
needed nor read), and that an unknown provider name raises `ValueError`. Building a
|
|
129
|
+
provider is offline, so these make no network calls.
|
|
109
130
|
|
|
110
131
|
`detect_system_context()` is covered by tests that assert it returns a string without
|
|
111
132
|
crashing, reports the OS, and — importantly — excludes the current username, hostname,
|
|
@@ -121,4 +142,34 @@ line.
|
|
|
121
142
|
uses 3.14 (`.python-version`).
|
|
122
143
|
- Version is read at runtime from package metadata (`importlib.metadata.version`), so
|
|
123
144
|
the single source of truth is `pyproject.toml`'s `version`. Publishing is tag-driven
|
|
124
|
-
(`.github/workflows/publish.yaml`).
|
|
145
|
+
(`.github/workflows/publish.yaml`). See **Releasing** below for the full flow.
|
|
146
|
+
|
|
147
|
+
## Releasing
|
|
148
|
+
|
|
149
|
+
Releases are cut from `main` and published to PyPI by the tag-driven `publish.yaml`
|
|
150
|
+
workflow, which runs `uv build` + `uv publish` on **any** pushed tag. Tags are **bare
|
|
151
|
+
version numbers with no `v` prefix** (e.g. `0.4.6`) and must match `pyproject.toml`'s
|
|
152
|
+
`version`. Follow these steps (this is the process that worked for `0.4.6`):
|
|
153
|
+
|
|
154
|
+
1. **Bump the version** in `pyproject.toml`, then `uv sync` so `uv.lock` records the new
|
|
155
|
+
local package version.
|
|
156
|
+
2. **Verify locally**: `just lint`, `just type-check`, and `just test` must all pass.
|
|
157
|
+
3. **Branch + commit + push**: create a `release-<version>` branch, commit the change
|
|
158
|
+
(bundle whatever feature work is shipping in the release), and push it.
|
|
159
|
+
4. **Open a PR** against `main` (`gh pr create`) and wait for CI to go green with
|
|
160
|
+
`gh pr checks <n> --watch` (the `Tests` workflow runs `Lint` plus the 3.10–3.13 ×
|
|
161
|
+
ubuntu/macos test matrix — nine checks total).
|
|
162
|
+
5. **Squash-merge** once green: `gh pr merge <n> --squash --delete-branch`.
|
|
163
|
+
6. **Tag on `main`**: `git checkout main && git pull`, then `git tag <version> &&
|
|
164
|
+
git push origin <version>`. Pushing the tag is what triggers `publish.yaml` → PyPI.
|
|
165
|
+
7. **Create the GitHub release**: `gh release create <version> --generate-notes
|
|
166
|
+
--verify-tag`. This reuses the already-pushed tag and fills the body with GitHub's
|
|
167
|
+
auto-generated changelog.
|
|
168
|
+
8. **Verify the publish**: watch the run (`gh run watch <id> --exit-status`) and confirm
|
|
169
|
+
PyPI serves the new version, e.g. `uv run --isolated --no-project --refresh-package
|
|
170
|
+
ape-linux --with ape-linux python -c "import importlib.metadata as m;
|
|
171
|
+
print(m.version('ape-linux'))"`.
|
|
172
|
+
|
|
173
|
+
Note on release notes: `--generate-notes` diffs against the previous release tag, so any
|
|
174
|
+
PR merged after that tag is listed — including ones that never got their own release.
|
|
175
|
+
That is expected, not a bug; leave the auto-generated notes as-is unless asked.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ape-linux
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: AI for Linux commands
|
|
5
5
|
Project-URL: Homepage, https://github.com/sbalian/ape
|
|
6
6
|
Project-URL: Repository, https://github.com/sbalian/ape
|
|
@@ -32,8 +32,7 @@ Output:
|
|
|
32
32
|
find ~/user/projects -type f -name "*attention*.pdf" > important_files.txt && mv important_files.txt ~/Documents/
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
Ape works with
|
|
36
|
-
— OpenAI, Anthropic, Google, Groq, Mistral and more.
|
|
35
|
+
Ape works with the following providers supported by [Pydantic AI](https://ai.pydantic.dev/models/): OpenAI, Anthropic, Google, Groq, and Mistral.
|
|
37
36
|
|
|
38
37
|
To install ([`uv`](https://docs.astral.sh/uv/getting-started/installation/) recommended):
|
|
39
38
|
|
|
@@ -41,14 +40,21 @@ To install ([`uv`](https://docs.astral.sh/uv/getting-started/installation/) reco
|
|
|
41
40
|
uv tool install ape-linux
|
|
42
41
|
```
|
|
43
42
|
|
|
44
|
-
Next,
|
|
45
|
-
|
|
43
|
+
Next, choose a model with `APE_MODEL` in `provider:name` form (see the
|
|
44
|
+
[Pydantic AI docs](https://ai.pydantic.dev/models/) for the supported providers and
|
|
45
|
+
models) and set your provider API key in `APE_API_KEY`. Ape infers the provider from
|
|
46
|
+
the model name and passes this key straight to it, so `APE_API_KEY` must be a key for
|
|
47
|
+
that model's provider. Ape uses its own key variable rather than a provider's standard
|
|
48
|
+
one (like `OPENAI_API_KEY`), so setting it up for Ape doesn't affect other tools on
|
|
49
|
+
your system:
|
|
46
50
|
|
|
47
51
|
```bash
|
|
48
|
-
export
|
|
49
|
-
export
|
|
52
|
+
export APE_MODEL=openai:gpt-5.4-nano
|
|
53
|
+
export APE_API_KEY=key
|
|
50
54
|
```
|
|
51
55
|
|
|
56
|
+
Both are required.
|
|
57
|
+
|
|
52
58
|
To run:
|
|
53
59
|
|
|
54
60
|
```bash
|
|
@@ -87,17 +93,6 @@ Commands only ever go to standard output, so a refusal is never mistaken for one
|
|
|
87
93
|
ape: I can only help with Linux and Unix command-line tasks.
|
|
88
94
|
```
|
|
89
95
|
|
|
90
|
-
You can change the model with the `APE_MODEL` environment variable. Models are
|
|
91
|
-
specified in `provider:name` form.
|
|
92
|
-
See [here](https://ai.pydantic.dev/models/) for the supported providers and models.
|
|
93
|
-
For example:
|
|
94
|
-
|
|
95
|
-
```bash
|
|
96
|
-
export APE_MODEL=anthropic:claude-sonnet-4-5
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
If `APE_MODEL` is unset, the default `openai-chat:gpt-4.1` is used.
|
|
100
|
-
|
|
101
96
|
You can set the sampling temperature with the `APE_TEMPERATURE` environment
|
|
102
97
|
variable (default `0.2`). Some models — for example certain reasoning models —
|
|
103
98
|
reject a temperature; set `APE_TEMPERATURE=undefined` to send none at all and let
|
|
@@ -12,8 +12,7 @@ Output:
|
|
|
12
12
|
find ~/user/projects -type f -name "*attention*.pdf" > important_files.txt && mv important_files.txt ~/Documents/
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
Ape works with
|
|
16
|
-
— OpenAI, Anthropic, Google, Groq, Mistral and more.
|
|
15
|
+
Ape works with the following providers supported by [Pydantic AI](https://ai.pydantic.dev/models/): OpenAI, Anthropic, Google, Groq, and Mistral.
|
|
17
16
|
|
|
18
17
|
To install ([`uv`](https://docs.astral.sh/uv/getting-started/installation/) recommended):
|
|
19
18
|
|
|
@@ -21,14 +20,21 @@ To install ([`uv`](https://docs.astral.sh/uv/getting-started/installation/) reco
|
|
|
21
20
|
uv tool install ape-linux
|
|
22
21
|
```
|
|
23
22
|
|
|
24
|
-
Next,
|
|
25
|
-
|
|
23
|
+
Next, choose a model with `APE_MODEL` in `provider:name` form (see the
|
|
24
|
+
[Pydantic AI docs](https://ai.pydantic.dev/models/) for the supported providers and
|
|
25
|
+
models) and set your provider API key in `APE_API_KEY`. Ape infers the provider from
|
|
26
|
+
the model name and passes this key straight to it, so `APE_API_KEY` must be a key for
|
|
27
|
+
that model's provider. Ape uses its own key variable rather than a provider's standard
|
|
28
|
+
one (like `OPENAI_API_KEY`), so setting it up for Ape doesn't affect other tools on
|
|
29
|
+
your system:
|
|
26
30
|
|
|
27
31
|
```bash
|
|
28
|
-
export
|
|
29
|
-
export
|
|
32
|
+
export APE_MODEL=openai:gpt-5.4-nano
|
|
33
|
+
export APE_API_KEY=key
|
|
30
34
|
```
|
|
31
35
|
|
|
36
|
+
Both are required.
|
|
37
|
+
|
|
32
38
|
To run:
|
|
33
39
|
|
|
34
40
|
```bash
|
|
@@ -67,17 +73,6 @@ Commands only ever go to standard output, so a refusal is never mistaken for one
|
|
|
67
73
|
ape: I can only help with Linux and Unix command-line tasks.
|
|
68
74
|
```
|
|
69
75
|
|
|
70
|
-
You can change the model with the `APE_MODEL` environment variable. Models are
|
|
71
|
-
specified in `provider:name` form.
|
|
72
|
-
See [here](https://ai.pydantic.dev/models/) for the supported providers and models.
|
|
73
|
-
For example:
|
|
74
|
-
|
|
75
|
-
```bash
|
|
76
|
-
export APE_MODEL=anthropic:claude-sonnet-4-5
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
If `APE_MODEL` is unset, the default `openai-chat:gpt-4.1` is used.
|
|
80
|
-
|
|
81
76
|
You can set the sampling temperature with the `APE_TEMPERATURE` environment
|
|
82
77
|
variable (default `0.2`). Some models — for example certain reasoning models —
|
|
83
78
|
reject a temperature; set `APE_TEMPERATURE=undefined` to send none at all and let
|
|
@@ -4,13 +4,16 @@ import os
|
|
|
4
4
|
import platform
|
|
5
5
|
import shutil
|
|
6
6
|
import sys
|
|
7
|
-
from
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from functools import partial
|
|
9
|
+
from typing import Any, cast
|
|
8
10
|
|
|
9
11
|
from pydantic import BaseModel
|
|
10
12
|
from pydantic_ai import Agent, ModelSettings
|
|
11
13
|
from pydantic_ai.exceptions import ModelHTTPError
|
|
14
|
+
from pydantic_ai.models import infer_model
|
|
15
|
+
from pydantic_ai.providers import Provider, infer_provider_class
|
|
12
16
|
|
|
13
|
-
DEFAULT_MODEL = "openai-chat:gpt-4.1"
|
|
14
17
|
DEFAULT_TEMPERATURE = 0.2
|
|
15
18
|
|
|
16
19
|
HELP = """\
|
|
@@ -24,16 +27,18 @@ Example:
|
|
|
24
27
|
ape "Create a symbolic link named 'win' pointing to /mnt/c/Users/jdoe"
|
|
25
28
|
ln -s /mnt/c/Users/jdoe win
|
|
26
29
|
|
|
27
|
-
The model is read from the APE_MODEL environment variable in
|
|
28
|
-
(e.g. anthropic:claude-sonnet-4-5)
|
|
29
|
-
https://ai.pydantic.dev/models/.
|
|
30
|
-
|
|
30
|
+
The model is required and read from the APE_MODEL environment variable in
|
|
31
|
+
provider:name form (e.g. anthropic:claude-sonnet-4-5). See
|
|
32
|
+
https://ai.pydantic.dev/models/. Set your provider API key in APE_API_KEY: Ape
|
|
33
|
+
infers the provider from the model name and passes this key straight to it, so
|
|
34
|
+
you don't have to set a provider's standard variable (e.g. OPENAI_API_KEY) that
|
|
35
|
+
other tools on your system also read.
|
|
31
36
|
|
|
32
37
|
The sampling temperature is read from APE_TEMPERATURE (default {temperature}). Set
|
|
33
38
|
it to "undefined" to send no temperature at all, which some models require.
|
|
34
39
|
|
|
35
40
|
Run `ape-system-info` to print the detected system context sent to the model.\
|
|
36
|
-
""".format(
|
|
41
|
+
""".format(temperature=DEFAULT_TEMPERATURE)
|
|
37
42
|
|
|
38
43
|
|
|
39
44
|
class Command(BaseModel):
|
|
@@ -48,14 +53,39 @@ class CannotHelp(BaseModel):
|
|
|
48
53
|
reason: str
|
|
49
54
|
|
|
50
55
|
|
|
56
|
+
def build_provider(provider_name: str, api_key: str) -> Provider[Any]:
|
|
57
|
+
"""Construct the Pydantic AI provider named by ``provider_name`` with ``api_key``.
|
|
58
|
+
|
|
59
|
+
Used (with the key bound via ``functools.partial``) as the ``provider_factory`` for
|
|
60
|
+
``infer_model`` so Ape injects its own ``APE_API_KEY`` straight into the provider
|
|
61
|
+
rather than letting Pydantic AI read the provider's standard credential env var.
|
|
62
|
+
This stays provider-agnostic: any provider whose class accepts an ``api_key`` works
|
|
63
|
+
without provider-specific code. A provider that lacks an ``api_key`` parameter
|
|
64
|
+
raises at call time (surfaced as a one-line error), which is correct — APE_API_KEY
|
|
65
|
+
only fits key-based providers.
|
|
66
|
+
"""
|
|
67
|
+
# infer_provider_class returns the abstract base type[Provider], whose __init__
|
|
68
|
+
# takes no arguments; the concrete `api_key` parameter lives on each subclass and
|
|
69
|
+
# is lost through the return type. Both ty and pyright reject the call without this
|
|
70
|
+
# cast to a callable that accepts it (unlike the output_type cast below, which is a
|
|
71
|
+
# ty-only workaround).
|
|
72
|
+
provider_class = cast(
|
|
73
|
+
Callable[..., Provider[Any]], infer_provider_class(provider_name)
|
|
74
|
+
)
|
|
75
|
+
return provider_class(api_key=api_key)
|
|
76
|
+
|
|
77
|
+
|
|
51
78
|
def call_llm(
|
|
52
79
|
model: str,
|
|
80
|
+
api_key: str,
|
|
53
81
|
system_prompt: str,
|
|
54
82
|
user_prompt: str,
|
|
55
83
|
model_settings: ModelSettings | None,
|
|
56
84
|
) -> Command | CannotHelp:
|
|
57
85
|
agent = Agent(
|
|
58
|
-
|
|
86
|
+
# infer_model parses the provider from the `provider:name` prefix;
|
|
87
|
+
# build_provider (with the key bound) constructs it with our APE_API_KEY.
|
|
88
|
+
infer_model(model, provider_factory=partial(build_provider, api_key=api_key)),
|
|
59
89
|
system_prompt=system_prompt,
|
|
60
90
|
output_type=Command | CannotHelp,
|
|
61
91
|
# None means no settings are sent (see resolve_model_settings), so the model
|
|
@@ -68,6 +98,27 @@ def call_llm(
|
|
|
68
98
|
return cast(Command | CannotHelp, agent.run_sync(user_prompt).output)
|
|
69
99
|
|
|
70
100
|
|
|
101
|
+
def resolve_model() -> str:
|
|
102
|
+
"""Resolve the model from the APE_MODEL environment variable.
|
|
103
|
+
|
|
104
|
+
The model is required and given in ``provider:name`` form (e.g.
|
|
105
|
+
``anthropic:claude-sonnet-4-5``); Ape has no built-in default, so that the
|
|
106
|
+
provider is always explicit (it also determines which API `resolve_api_key`'s key
|
|
107
|
+
is sent to). If unset or empty, the program exits with a one-line error before any
|
|
108
|
+
LLM call.
|
|
109
|
+
"""
|
|
110
|
+
model = os.environ.get("APE_MODEL")
|
|
111
|
+
if model is None or not model.strip():
|
|
112
|
+
print(
|
|
113
|
+
"ape: APE_MODEL is not set. Set it to a model in provider:name form, "
|
|
114
|
+
"e.g. anthropic:claude-sonnet-4-5 (run `ape` with no arguments for "
|
|
115
|
+
"details).",
|
|
116
|
+
file=sys.stderr,
|
|
117
|
+
)
|
|
118
|
+
raise SystemExit(1)
|
|
119
|
+
return model
|
|
120
|
+
|
|
121
|
+
|
|
71
122
|
def resolve_model_settings() -> ModelSettings | None:
|
|
72
123
|
"""Resolve model settings from the APE_TEMPERATURE environment variable.
|
|
73
124
|
|
|
@@ -94,6 +145,26 @@ def resolve_model_settings() -> ModelSettings | None:
|
|
|
94
145
|
raise SystemExit(1)
|
|
95
146
|
|
|
96
147
|
|
|
148
|
+
def resolve_api_key() -> str:
|
|
149
|
+
"""Resolve the provider API key from the APE_API_KEY environment variable.
|
|
150
|
+
|
|
151
|
+
Ape reads its own ``APE_API_KEY`` rather than each provider's standard variable
|
|
152
|
+
(e.g. ``OPENAI_API_KEY``) so that configuring Ape doesn't force a globally named
|
|
153
|
+
key that other tools on the system also pick up. The key is passed straight to the
|
|
154
|
+
provider inferred from the model name (see ``call_llm``). If it is unset or empty,
|
|
155
|
+
the program exits with a one-line error before any LLM call.
|
|
156
|
+
"""
|
|
157
|
+
api_key = os.environ.get("APE_API_KEY")
|
|
158
|
+
if api_key is None or not api_key.strip():
|
|
159
|
+
print(
|
|
160
|
+
"ape: APE_API_KEY is not set. Set it to your provider API key "
|
|
161
|
+
"(run `ape` with no arguments for details).",
|
|
162
|
+
file=sys.stderr,
|
|
163
|
+
)
|
|
164
|
+
raise SystemExit(1)
|
|
165
|
+
return api_key
|
|
166
|
+
|
|
167
|
+
|
|
97
168
|
def detect_system_context() -> str:
|
|
98
169
|
"""Best-effort description of the current system for the LLM.
|
|
99
170
|
|
|
@@ -240,8 +311,13 @@ def main() -> None:
|
|
|
240
311
|
raise SystemExit(1)
|
|
241
312
|
query = " ".join(args)
|
|
242
313
|
|
|
243
|
-
# The model is read from APE_MODEL
|
|
244
|
-
|
|
314
|
+
# The model is read from APE_MODEL (see resolve_model); a missing model exits
|
|
315
|
+
# here before any LLM call.
|
|
316
|
+
model = resolve_model()
|
|
317
|
+
|
|
318
|
+
# The API key is read from APE_API_KEY (see resolve_api_key); a missing key
|
|
319
|
+
# exits here before any LLM call.
|
|
320
|
+
api_key = resolve_api_key()
|
|
245
321
|
|
|
246
322
|
# The sampling temperature is read from APE_TEMPERATURE (see
|
|
247
323
|
# resolve_model_settings); an invalid value exits here before any LLM call.
|
|
@@ -300,7 +376,7 @@ def main() -> None:
|
|
|
300
376
|
Answer:"""
|
|
301
377
|
|
|
302
378
|
try:
|
|
303
|
-
result = call_llm(model, system_prompt, user_prompt, model_settings)
|
|
379
|
+
result = call_llm(model, api_key, system_prompt, user_prompt, model_settings)
|
|
304
380
|
except ModelHTTPError as error:
|
|
305
381
|
print(f"{error.status_code} error: {error.message}", file=sys.stderr)
|
|
306
382
|
raise SystemExit(1)
|
|
@@ -5,13 +5,15 @@ import socket
|
|
|
5
5
|
|
|
6
6
|
import pytest
|
|
7
7
|
from pydantic_ai.exceptions import ModelHTTPError
|
|
8
|
+
from pydantic_ai.models.test import TestModel
|
|
8
9
|
|
|
9
10
|
import ape_linux
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
@pytest.fixture
|
|
13
14
|
def mockenv(monkeypatch):
|
|
14
|
-
monkeypatch.setenv("
|
|
15
|
+
monkeypatch.setenv("APE_API_KEY", "key")
|
|
16
|
+
monkeypatch.setenv("APE_MODEL", "openai:gpt-4.1")
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
def run(monkeypatch, argv):
|
|
@@ -42,7 +44,7 @@ def test_app_for_suggestion(mockenv, monkeypatch, capsys):
|
|
|
42
44
|
def test_app_joins_multiple_args_into_query(mockenv, monkeypatch, capsys):
|
|
43
45
|
captured_prompt = {}
|
|
44
46
|
|
|
45
|
-
def mockreturn(model, system_prompt, user_prompt, model_settings):
|
|
47
|
+
def mockreturn(model, api_key, system_prompt, user_prompt, model_settings):
|
|
46
48
|
captured_prompt["user"] = user_prompt
|
|
47
49
|
return ape_linux.Command(command="ls")
|
|
48
50
|
|
|
@@ -76,9 +78,7 @@ def test_app_reports_when_model_cannot_help(mockenv, monkeypatch, capsys):
|
|
|
76
78
|
|
|
77
79
|
def test_app_with_api_error(mockenv, monkeypatch, capsys):
|
|
78
80
|
def mockreturn(*args, **kwargs):
|
|
79
|
-
raise ModelHTTPError(
|
|
80
|
-
status_code=500, model_name="openai-chat:gpt-4o", body=None
|
|
81
|
-
)
|
|
81
|
+
raise ModelHTTPError(status_code=500, model_name="openai:gpt-4o", body=None)
|
|
82
82
|
|
|
83
83
|
monkeypatch.setattr("ape_linux.call_llm", mockreturn)
|
|
84
84
|
code = run(monkeypatch, ["list all the files"])
|
|
@@ -87,30 +87,91 @@ def test_app_with_api_error(mockenv, monkeypatch, capsys):
|
|
|
87
87
|
assert code == 1
|
|
88
88
|
|
|
89
89
|
|
|
90
|
+
def test_app_with_unexpected_error(mockenv, monkeypatch, capsys):
|
|
91
|
+
# A non-HTTP error (bad credentials, unknown provider, ...) is flattened to a
|
|
92
|
+
# one-line stderr message and exits 1, rather than surfacing a traceback.
|
|
93
|
+
def mockreturn(*args, **kwargs):
|
|
94
|
+
raise RuntimeError("something broke")
|
|
95
|
+
|
|
96
|
+
monkeypatch.setattr("ape_linux.call_llm", mockreturn)
|
|
97
|
+
code = run(monkeypatch, ["list all the files"])
|
|
98
|
+
captured = capsys.readouterr()
|
|
99
|
+
assert captured.out == ""
|
|
100
|
+
assert "something broke" in captured.err
|
|
101
|
+
assert code == 1
|
|
102
|
+
|
|
103
|
+
|
|
90
104
|
def test_app_with_no_api_key(mockenv, monkeypatch, capsys):
|
|
91
|
-
|
|
105
|
+
# With APE_API_KEY unset (but APE_MODEL present via mockenv), ape must exit
|
|
106
|
+
# before ever reaching the LLM.
|
|
107
|
+
monkeypatch.delenv("APE_API_KEY", raising=False)
|
|
108
|
+
monkeypatch.setattr(
|
|
109
|
+
"ape_linux.call_llm",
|
|
110
|
+
lambda *args, **kwargs: pytest.fail("call_llm should not be called"),
|
|
111
|
+
)
|
|
92
112
|
code = run(monkeypatch, ["list all the files"])
|
|
93
113
|
captured = capsys.readouterr()
|
|
94
114
|
assert captured.out == ""
|
|
95
|
-
assert captured.err
|
|
115
|
+
assert "APE_API_KEY" in captured.err
|
|
96
116
|
assert code == 1
|
|
97
117
|
|
|
98
118
|
|
|
99
|
-
def
|
|
119
|
+
def test_app_with_blank_api_key(mockenv, monkeypatch, capsys):
|
|
120
|
+
# A whitespace-only APE_API_KEY is treated as unset: exit before the LLM.
|
|
121
|
+
monkeypatch.setenv("APE_API_KEY", " ")
|
|
122
|
+
monkeypatch.setattr(
|
|
123
|
+
"ape_linux.call_llm",
|
|
124
|
+
lambda *args, **kwargs: pytest.fail("call_llm should not be called"),
|
|
125
|
+
)
|
|
126
|
+
code = run(monkeypatch, ["list all the files"])
|
|
127
|
+
captured = capsys.readouterr()
|
|
128
|
+
assert captured.out == ""
|
|
129
|
+
assert "APE_API_KEY" in captured.err
|
|
130
|
+
assert code == 1
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_app_passes_api_key_to_call_llm(mockenv, monkeypatch):
|
|
100
134
|
captured = {}
|
|
101
135
|
|
|
102
|
-
def mockreturn(model, *args, **kwargs):
|
|
103
|
-
captured["
|
|
136
|
+
def mockreturn(model, api_key, *args, **kwargs):
|
|
137
|
+
captured["api_key"] = api_key
|
|
104
138
|
return ape_linux.Command(command="ls")
|
|
105
139
|
|
|
106
|
-
monkeypatch.
|
|
140
|
+
monkeypatch.setenv("APE_API_KEY", "secret-key")
|
|
107
141
|
monkeypatch.setattr("ape_linux.call_llm", mockreturn)
|
|
108
142
|
code = run(monkeypatch, ["list all the files"])
|
|
109
|
-
assert captured["
|
|
110
|
-
assert ape_linux.DEFAULT_MODEL == "openai-chat:gpt-4.1"
|
|
143
|
+
assert captured["api_key"] == "secret-key"
|
|
111
144
|
assert code == 0
|
|
112
145
|
|
|
113
146
|
|
|
147
|
+
def test_app_with_no_model(mockenv, monkeypatch, capsys):
|
|
148
|
+
# With APE_MODEL unset, ape must exit before ever reaching the LLM.
|
|
149
|
+
monkeypatch.delenv("APE_MODEL", raising=False)
|
|
150
|
+
monkeypatch.setattr(
|
|
151
|
+
"ape_linux.call_llm",
|
|
152
|
+
lambda *args, **kwargs: pytest.fail("call_llm should not be called"),
|
|
153
|
+
)
|
|
154
|
+
code = run(monkeypatch, ["list all the files"])
|
|
155
|
+
captured = capsys.readouterr()
|
|
156
|
+
assert captured.out == ""
|
|
157
|
+
assert "APE_MODEL" in captured.err
|
|
158
|
+
assert code == 1
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_app_with_blank_model(mockenv, monkeypatch, capsys):
|
|
162
|
+
# A whitespace-only APE_MODEL is treated as unset: exit before the LLM.
|
|
163
|
+
monkeypatch.setenv("APE_MODEL", " ")
|
|
164
|
+
monkeypatch.setattr(
|
|
165
|
+
"ape_linux.call_llm",
|
|
166
|
+
lambda *args, **kwargs: pytest.fail("call_llm should not be called"),
|
|
167
|
+
)
|
|
168
|
+
code = run(monkeypatch, ["list all the files"])
|
|
169
|
+
captured = capsys.readouterr()
|
|
170
|
+
assert captured.out == ""
|
|
171
|
+
assert "APE_MODEL" in captured.err
|
|
172
|
+
assert code == 1
|
|
173
|
+
|
|
174
|
+
|
|
114
175
|
def test_app_uses_ape_model_env_var(mockenv, monkeypatch):
|
|
115
176
|
captured = {}
|
|
116
177
|
|
|
@@ -128,7 +189,7 @@ def test_app_uses_ape_model_env_var(mockenv, monkeypatch):
|
|
|
128
189
|
def test_app_uses_default_temperature_when_unset(mockenv, monkeypatch):
|
|
129
190
|
captured = {}
|
|
130
191
|
|
|
131
|
-
def mockreturn(model, system_prompt, user_prompt, model_settings):
|
|
192
|
+
def mockreturn(model, api_key, system_prompt, user_prompt, model_settings):
|
|
132
193
|
captured["settings"] = model_settings
|
|
133
194
|
return ape_linux.Command(command="ls")
|
|
134
195
|
|
|
@@ -143,7 +204,7 @@ def test_app_uses_default_temperature_when_unset(mockenv, monkeypatch):
|
|
|
143
204
|
def test_app_uses_ape_temperature_env_var(mockenv, monkeypatch):
|
|
144
205
|
captured = {}
|
|
145
206
|
|
|
146
|
-
def mockreturn(model, system_prompt, user_prompt, model_settings):
|
|
207
|
+
def mockreturn(model, api_key, system_prompt, user_prompt, model_settings):
|
|
147
208
|
captured["settings"] = model_settings
|
|
148
209
|
return ape_linux.Command(command="ls")
|
|
149
210
|
|
|
@@ -157,7 +218,7 @@ def test_app_uses_ape_temperature_env_var(mockenv, monkeypatch):
|
|
|
157
218
|
def test_app_undefined_temperature_sends_no_settings(mockenv, monkeypatch):
|
|
158
219
|
captured = {}
|
|
159
220
|
|
|
160
|
-
def mockreturn(model, system_prompt, user_prompt, model_settings):
|
|
221
|
+
def mockreturn(model, api_key, system_prompt, user_prompt, model_settings):
|
|
161
222
|
captured["settings"] = model_settings
|
|
162
223
|
return ape_linux.Command(command="ls")
|
|
163
224
|
|
|
@@ -183,6 +244,43 @@ def test_app_invalid_temperature_exits_before_llm(mockenv, monkeypatch, capsys):
|
|
|
183
244
|
assert code == 1
|
|
184
245
|
|
|
185
246
|
|
|
247
|
+
def test_call_llm_returns_structured_output(monkeypatch):
|
|
248
|
+
# Exercise the real call_llm (normally mocked) without a network call by swapping in
|
|
249
|
+
# Pydantic AI's TestModel, which drives the agent and yields structured output
|
|
250
|
+
# offline. This covers the infer_model/Agent wiring and the output cast.
|
|
251
|
+
monkeypatch.setattr(
|
|
252
|
+
"ape_linux.infer_model", lambda model, provider_factory: TestModel()
|
|
253
|
+
)
|
|
254
|
+
result = ape_linux.call_llm(
|
|
255
|
+
"openai:gpt-4.1", "key", "system", "user", {"temperature": 0.2}
|
|
256
|
+
)
|
|
257
|
+
assert isinstance(result, ape_linux.Command)
|
|
258
|
+
assert isinstance(result.command, str)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_build_provider_injects_key_without_standard_env_var(monkeypatch):
|
|
262
|
+
# build_provider constructs the inferred provider with our key injected directly,
|
|
263
|
+
# so the provider's standard credential variable is neither needed nor read.
|
|
264
|
+
# Constructing a provider is offline — no request is made until the agent runs.
|
|
265
|
+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
266
|
+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
|
267
|
+
|
|
268
|
+
openai_provider = ape_linux.build_provider("openai", "injected-key")
|
|
269
|
+
assert openai_provider.name == "openai"
|
|
270
|
+
assert openai_provider.client.api_key == "injected-key"
|
|
271
|
+
|
|
272
|
+
anthropic_provider = ape_linux.build_provider("anthropic", "another-key")
|
|
273
|
+
assert anthropic_provider.name == "anthropic"
|
|
274
|
+
assert anthropic_provider.client.api_key == "another-key"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_build_provider_unknown_provider_raises():
|
|
278
|
+
# An unrecognized provider name surfaces as ValueError (which main() flattens to a
|
|
279
|
+
# one-line error), rather than silently building the wrong provider.
|
|
280
|
+
with pytest.raises(ValueError):
|
|
281
|
+
ape_linux.build_provider("not-a-real-provider", "key")
|
|
282
|
+
|
|
283
|
+
|
|
186
284
|
def test_system_info_entry_point(capsys):
|
|
187
285
|
ape_linux.system_info()
|
|
188
286
|
captured = capsys.readouterr()
|
|
@@ -223,6 +321,40 @@ def test_detect_system_context_reports_operating_system():
|
|
|
223
321
|
assert "Operating system:" in context
|
|
224
322
|
|
|
225
323
|
|
|
324
|
+
def test_detect_system_context_reports_distribution(monkeypatch):
|
|
325
|
+
# On Linux, the distribution name from os-release is reported.
|
|
326
|
+
monkeypatch.setattr(ape_linux.platform, "system", lambda: "Linux")
|
|
327
|
+
monkeypatch.setattr(
|
|
328
|
+
ape_linux.platform,
|
|
329
|
+
"freedesktop_os_release",
|
|
330
|
+
lambda: {"PRETTY_NAME": "Ubuntu 22.04.3 LTS"},
|
|
331
|
+
)
|
|
332
|
+
context = ape_linux.detect_system_context()
|
|
333
|
+
assert "Distribution: Ubuntu 22.04.3 LTS" in context
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_detect_system_context_never_raises_when_probe_fails(monkeypatch):
|
|
337
|
+
# The "never raises" invariant: when a probe raises, that field is omitted and the
|
|
338
|
+
# function still returns a string rather than crashing at startup.
|
|
339
|
+
monkeypatch.setattr(ape_linux.platform, "system", lambda: "Darwin")
|
|
340
|
+
|
|
341
|
+
def boom(*args, **kwargs):
|
|
342
|
+
raise OSError("probe failed")
|
|
343
|
+
|
|
344
|
+
monkeypatch.setattr(ape_linux.platform, "mac_ver", boom)
|
|
345
|
+
context = ape_linux.detect_system_context()
|
|
346
|
+
assert isinstance(context, str)
|
|
347
|
+
assert "macOS version:" not in context
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_detect_system_context_reports_root_privileges(monkeypatch):
|
|
351
|
+
# The root branch changes the guidance (sudo not required); its non-root twin is
|
|
352
|
+
# covered by the real host running these tests unprivileged.
|
|
353
|
+
monkeypatch.setattr(ape_linux.os, "geteuid", lambda: 0)
|
|
354
|
+
context = ape_linux.detect_system_context()
|
|
355
|
+
assert "Privileges: root (sudo not required)" in context
|
|
356
|
+
|
|
357
|
+
|
|
226
358
|
def test_detect_system_context_excludes_identifying_info():
|
|
227
359
|
context = ape_linux.detect_system_context()
|
|
228
360
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|