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.
@@ -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`). Models are passed through verbatim in
81
- `provider:name` form (e.g. `anthropic:claude-sonnet-4-5`), so Ape supports any
82
- provider Pydantic AI supports without provider-specific code. The model is resolved
83
- solely from the `APE_MODEL` env var the `DEFAULT_MODEL` constant
84
- (`openai-chat:gpt-4.1`); there is no CLI override. The sampling temperature is
85
- resolved by `resolve_model_settings()` from the `APE_TEMPERATURE` env var the
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. Credentials come from each provider's standard env var (e.g. `OPENAI_API_KEY`,
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 `OPENAI_API_KEY`.
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.4.6
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 any provider supported by [Pydantic AI](https://ai.pydantic.dev/models/)
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, set the API key for your provider using its standard environment variable.
45
- For example:
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 OPENAI_API_KEY=key # for OpenAI models
49
- export ANTHROPIC_API_KEY=key # for Anthropic models
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 any provider supported by [Pydantic AI](https://ai.pydantic.dev/models/)
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, set the API key for your provider using its standard environment variable.
25
- For example:
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 OPENAI_API_KEY=key # for OpenAI models
29
- export ANTHROPIC_API_KEY=key # for Anthropic models
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 typing import cast
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 provider:name form
28
- (e.g. anthropic:claude-sonnet-4-5), falling back to {default}. See
29
- https://ai.pydantic.dev/models/. Credentials come from each provider's standard
30
- environment variable (e.g. OPENAI_API_KEY, ANTHROPIC_API_KEY).
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(default=DEFAULT_MODEL, temperature=DEFAULT_TEMPERATURE)
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
- model,
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, falling back to the default.
244
- model = os.environ.get("APE_MODEL") or DEFAULT_MODEL
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)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ape-linux"
3
- version = "0.4.6"
3
+ version = "0.5.0"
4
4
  description = "AI for Linux commands"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT License"}
@@ -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("OPENAI_API_KEY", "key")
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
- monkeypatch.delenv("OPENAI_API_KEY")
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 test_app_uses_default_model_when_unset(mockenv, monkeypatch):
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["model"] = model
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.delenv("APE_MODEL", raising=False)
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["model"] == ape_linux.DEFAULT_MODEL
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
 
@@ -50,7 +50,7 @@ wheels = [
50
50
 
51
51
  [[package]]
52
52
  name = "ape-linux"
53
- version = "0.4.6"
53
+ version = "0.5.0"
54
54
  source = { editable = "." }
55
55
  dependencies = [
56
56
  { name = "pydantic" },
File without changes
File without changes
File without changes
File without changes