askai-py 0.1.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.
- askai_py-0.1.0/LICENSE +21 -0
- askai_py-0.1.0/PKG-INFO +89 -0
- askai_py-0.1.0/README.md +72 -0
- askai_py-0.1.0/pyproject.toml +55 -0
- askai_py-0.1.0/src/askai/.gitkeep +0 -0
- askai_py-0.1.0/src/askai/__init__.py +8 -0
- askai_py-0.1.0/src/askai/__main__.py +3 -0
- askai_py-0.1.0/src/askai/backends/__init__.py +152 -0
- askai_py-0.1.0/src/askai/backends/codex.py +156 -0
- askai_py-0.1.0/src/askai/backends/groq.py +237 -0
- askai_py-0.1.0/src/askai/cli.py +332 -0
- askai_py-0.1.0/src/askai/commands/.gitkeep +0 -0
- askai_py-0.1.0/src/askai/config.py +234 -0
- askai_py-0.1.0/src/askai/policy.py +81 -0
- askai_py-0.1.0/src/askai/utils/.gitkeep +0 -0
askai_py-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Peconi Federico
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
askai_py-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: askai-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for one-shot, stateless questions to an LLM.
|
|
5
|
+
Author: Peconi Federico
|
|
6
|
+
Author-email: Peconi Federico <fpswe@tuta.io>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: platformdirs>=4.9.6
|
|
10
|
+
Requires-Dist: typer>=0.24.1
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Project-URL: Homepage, https://codeberg.org/peconif/askai
|
|
13
|
+
Project-URL: Repository, https://codeberg.org/peconif/askai
|
|
14
|
+
Project-URL: Issues, https://codeberg.org/peconif/askai/issues
|
|
15
|
+
Project-URL: GitHub Mirror, https://github.com/arbiter1elegantiae/askai
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Askai
|
|
19
|
+
|
|
20
|
+
> Development happens on [Codeberg](https://codeberg.org/peconif/askai).
|
|
21
|
+
> GitHub is a read-only mirror for discovery — please open issues and pull requests on Codeberg.
|
|
22
|
+
|
|
23
|
+
CLI for one-shot, stateless questions to an LLM.
|
|
24
|
+
|
|
25
|
+
Meant for engineers living inside terminals, `askai` aims to fill those small information gaps that typically pop up while working, such as:
|
|
26
|
+
- commands syntax / flag names;
|
|
27
|
+
- tiny code examples;
|
|
28
|
+
- quick conceptual clarifications.
|
|
29
|
+
Instead of wasting time switching to a browser or a dedicated AI app, simply:
|
|
30
|
+
```bash
|
|
31
|
+
askai "What does set -euo pipefail do?"
|
|
32
|
+
```
|
|
33
|
+
will return a concise, fluff-free answer from your favorite model, minimizing distractions and speeding up your workflow.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
Install with [`pipx`](https://pypa.github.io/pipx/) (recommended):
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pipx install askai-py
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Or with [`uv`](https://docs.astral.sh/uv/):
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv tool install askai-py
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Then set your Groq API key (Groq is the default backend):
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
export GROQ_API_KEY="your-key"
|
|
53
|
+
askai "How to rename a branch in Git?"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Backends
|
|
57
|
+
|
|
58
|
+
askai supports two backends:
|
|
59
|
+
|
|
60
|
+
- **`groq`** (default) — API adapter calling Groq's OpenAI-compatible endpoint. Requires `GROQ_API_KEY`.
|
|
61
|
+
- **`codex`** — CLI adapter wrapping the Codex CLI. Requires a local Codex installation.
|
|
62
|
+
|
|
63
|
+
Switch backend:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
askai config set backend codex
|
|
67
|
+
askai doctor
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Adding a Backend
|
|
71
|
+
|
|
72
|
+
Contributions for new backends are welcome. To add one, you need to:
|
|
73
|
+
|
|
74
|
+
1. Implement the `BackendAdapter` protocol in `src/askai/backends/`
|
|
75
|
+
2. Register it in `build_backend_adapters()` (`src/askai/backends/__init__.py`)
|
|
76
|
+
3. Add the backend name to `KNOWN_BACKENDS` (`src/askai/config.py`)
|
|
77
|
+
4. Add tests under `tests/`
|
|
78
|
+
5. Document it in `docs/CLI_CONTRACT.md`
|
|
79
|
+
|
|
80
|
+
## Corporate VPN / Proxy (Self-Signed Certificates)
|
|
81
|
+
|
|
82
|
+
If you are behind a corporate VPN or intercepting proxy, set `ASKAI_CA_BUNDLE` to your company's CA certificate so askai trusts the TLS connection:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
export ASKAI_CA_BUNDLE=/path/to/company-ca.pem
|
|
86
|
+
askai "List Tmux useful commands"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
This adds your CA as a trust root alongside the system defaults, so normal HTTPS still works.
|
askai_py-0.1.0/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Askai
|
|
2
|
+
|
|
3
|
+
> Development happens on [Codeberg](https://codeberg.org/peconif/askai).
|
|
4
|
+
> GitHub is a read-only mirror for discovery — please open issues and pull requests on Codeberg.
|
|
5
|
+
|
|
6
|
+
CLI for one-shot, stateless questions to an LLM.
|
|
7
|
+
|
|
8
|
+
Meant for engineers living inside terminals, `askai` aims to fill those small information gaps that typically pop up while working, such as:
|
|
9
|
+
- commands syntax / flag names;
|
|
10
|
+
- tiny code examples;
|
|
11
|
+
- quick conceptual clarifications.
|
|
12
|
+
Instead of wasting time switching to a browser or a dedicated AI app, simply:
|
|
13
|
+
```bash
|
|
14
|
+
askai "What does set -euo pipefail do?"
|
|
15
|
+
```
|
|
16
|
+
will return a concise, fluff-free answer from your favorite model, minimizing distractions and speeding up your workflow.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
Install with [`pipx`](https://pypa.github.io/pipx/) (recommended):
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pipx install askai-py
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or with [`uv`](https://docs.astral.sh/uv/):
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uv tool install askai-py
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Then set your Groq API key (Groq is the default backend):
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
export GROQ_API_KEY="your-key"
|
|
36
|
+
askai "How to rename a branch in Git?"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Backends
|
|
40
|
+
|
|
41
|
+
askai supports two backends:
|
|
42
|
+
|
|
43
|
+
- **`groq`** (default) — API adapter calling Groq's OpenAI-compatible endpoint. Requires `GROQ_API_KEY`.
|
|
44
|
+
- **`codex`** — CLI adapter wrapping the Codex CLI. Requires a local Codex installation.
|
|
45
|
+
|
|
46
|
+
Switch backend:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
askai config set backend codex
|
|
50
|
+
askai doctor
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Adding a Backend
|
|
54
|
+
|
|
55
|
+
Contributions for new backends are welcome. To add one, you need to:
|
|
56
|
+
|
|
57
|
+
1. Implement the `BackendAdapter` protocol in `src/askai/backends/`
|
|
58
|
+
2. Register it in `build_backend_adapters()` (`src/askai/backends/__init__.py`)
|
|
59
|
+
3. Add the backend name to `KNOWN_BACKENDS` (`src/askai/config.py`)
|
|
60
|
+
4. Add tests under `tests/`
|
|
61
|
+
5. Document it in `docs/CLI_CONTRACT.md`
|
|
62
|
+
|
|
63
|
+
## Corporate VPN / Proxy (Self-Signed Certificates)
|
|
64
|
+
|
|
65
|
+
If you are behind a corporate VPN or intercepting proxy, set `ASKAI_CA_BUNDLE` to your company's CA certificate so askai trusts the TLS connection:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
export ASKAI_CA_BUNDLE=/path/to/company-ca.pem
|
|
69
|
+
askai "List Tmux useful commands"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
This adds your CA as a trust root alongside the system defaults, so normal HTTPS still works.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "askai-py"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "CLI for one-shot, stateless questions to an LLM."
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "Peconi Federico", email = "fpswe@tuta.io"},
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"platformdirs>=4.9.6",
|
|
12
|
+
"typer>=0.24.1",
|
|
13
|
+
]
|
|
14
|
+
license = "MIT"
|
|
15
|
+
license-files = ["LICENSE"]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://codeberg.org/peconif/askai"
|
|
19
|
+
Repository = "https://codeberg.org/peconif/askai"
|
|
20
|
+
Issues = "https://codeberg.org/peconif/askai/issues"
|
|
21
|
+
"GitHub Mirror" = "https://github.com/arbiter1elegantiae/askai"
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
askai = "askai.cli:run"
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["uv_build>=0.11.8,<0.12"]
|
|
28
|
+
build-backend = "uv_build"
|
|
29
|
+
|
|
30
|
+
[tool.uv.build-backend]
|
|
31
|
+
module-name = "askai"
|
|
32
|
+
|
|
33
|
+
[dependency-groups]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=9.0.3",
|
|
36
|
+
"ruff>=0.15.11",
|
|
37
|
+
"ty>=0.0.32",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[tool.ruff]
|
|
41
|
+
target-version = "py312"
|
|
42
|
+
line-length = 88
|
|
43
|
+
src = ["src"]
|
|
44
|
+
|
|
45
|
+
[tool.ruff.lint]
|
|
46
|
+
select = ["E4", "E7", "E9", "F", "I", "UP", "B"]
|
|
47
|
+
|
|
48
|
+
[tool.ty.environment]
|
|
49
|
+
python-version = "3.12"
|
|
50
|
+
root = ["./src"]
|
|
51
|
+
|
|
52
|
+
[tool.ty.src]
|
|
53
|
+
include = ["src", "tests"]
|
|
54
|
+
[tool.ty.terminal]
|
|
55
|
+
output-format = "concise"
|
|
File without changes
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from askai.policy import AskaiPrompt
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class BackendRequest:
|
|
10
|
+
prompt: AskaiPrompt
|
|
11
|
+
model: str | None = None
|
|
12
|
+
max_lines: int | None = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class BackendResponse:
|
|
17
|
+
raw_output: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class BackendAvailability:
|
|
22
|
+
available: bool
|
|
23
|
+
executable: str | None = None
|
|
24
|
+
message: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BackendAdapter(Protocol):
|
|
28
|
+
name: str
|
|
29
|
+
|
|
30
|
+
def check_availability(self) -> BackendAvailability: ...
|
|
31
|
+
|
|
32
|
+
def execute(self, request: BackendRequest) -> BackendResponse: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BackendError(Exception):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BackendNotConfiguredError(BackendError):
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
super().__init__(
|
|
42
|
+
"Backend is not configured. Run `askai config set backend BACKEND`."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class BackendExecutableNotFoundError(BackendError):
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
backend: str,
|
|
50
|
+
*,
|
|
51
|
+
executable: str | None = None,
|
|
52
|
+
detail: str | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
target = (
|
|
55
|
+
f"Backend executable '{executable}' for backend '{backend}'"
|
|
56
|
+
if executable is not None
|
|
57
|
+
else f"Backend executable for backend '{backend}'"
|
|
58
|
+
)
|
|
59
|
+
detail_text = f": {detail}" if detail else ""
|
|
60
|
+
super().__init__(f"{target} is unavailable{detail_text}. Run `askai doctor`.")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class BackendCredentialsNotFoundError(BackendError):
|
|
64
|
+
def __init__(self, backend: str, *, detail: str | None = None) -> None:
|
|
65
|
+
detail_text = f": {detail}" if detail else ""
|
|
66
|
+
super().__init__(
|
|
67
|
+
f"Backend '{backend}' credentials are missing or rejected{detail_text}."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class BackendUnsupportedModelError(BackendError):
|
|
72
|
+
def __init__(self, backend: str, model: str | None = None) -> None:
|
|
73
|
+
if model is None:
|
|
74
|
+
message = f"Backend '{backend}' does not support model overrides."
|
|
75
|
+
else:
|
|
76
|
+
message = f"Backend '{backend}' does not support model override '{model}'."
|
|
77
|
+
|
|
78
|
+
super().__init__(message)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class BackendTimeoutError(BackendError):
|
|
82
|
+
def __init__(self, backend: str) -> None:
|
|
83
|
+
super().__init__(f"Backend '{backend}' timed out.")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class BackendInvocationError(BackendError):
|
|
87
|
+
def __init__(self, backend: str, detail: str | None = None) -> None:
|
|
88
|
+
detail_text = f": {detail}" if detail else ""
|
|
89
|
+
super().__init__(f"Backend '{backend}' invocation failed{detail_text}.")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_backend_adapters() -> dict[str, BackendAdapter]:
|
|
93
|
+
from askai.backends.codex import CodexBackendAdapter
|
|
94
|
+
from askai.backends.groq import GroqBackendAdapter
|
|
95
|
+
|
|
96
|
+
codex_adapter = CodexBackendAdapter()
|
|
97
|
+
groq_adapter = GroqBackendAdapter()
|
|
98
|
+
return {
|
|
99
|
+
codex_adapter.name: codex_adapter,
|
|
100
|
+
groq_adapter.name: groq_adapter,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
BACKEND_ADAPTERS: dict[str, BackendAdapter] = build_backend_adapters()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def resolve_backend_adapter(
|
|
108
|
+
backend: str | None,
|
|
109
|
+
*,
|
|
110
|
+
adapters: Mapping[str, BackendAdapter] | None = None,
|
|
111
|
+
) -> BackendAdapter:
|
|
112
|
+
if backend is None:
|
|
113
|
+
raise BackendNotConfiguredError()
|
|
114
|
+
|
|
115
|
+
adapter_registry = BACKEND_ADAPTERS if adapters is None else adapters
|
|
116
|
+
adapter = adapter_registry.get(backend)
|
|
117
|
+
if adapter is None:
|
|
118
|
+
raise BackendInvocationError(backend, "no adapter is registered")
|
|
119
|
+
|
|
120
|
+
return adapter
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def check_backend_availability(
|
|
124
|
+
backend: str | None,
|
|
125
|
+
*,
|
|
126
|
+
adapters: Mapping[str, BackendAdapter] | None = None,
|
|
127
|
+
) -> BackendAvailability:
|
|
128
|
+
adapter = resolve_backend_adapter(backend, adapters=adapters)
|
|
129
|
+
return adapter.check_availability()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def execute_backend(
|
|
133
|
+
backend: str | None,
|
|
134
|
+
request: BackendRequest,
|
|
135
|
+
*,
|
|
136
|
+
adapters: Mapping[str, BackendAdapter] | None = None,
|
|
137
|
+
) -> BackendResponse:
|
|
138
|
+
adapter = resolve_backend_adapter(backend, adapters=adapters)
|
|
139
|
+
availability = adapter.check_availability()
|
|
140
|
+
if not availability.available:
|
|
141
|
+
if availability.executable is not None:
|
|
142
|
+
raise BackendExecutableNotFoundError(
|
|
143
|
+
backend or adapter.name,
|
|
144
|
+
executable=availability.executable,
|
|
145
|
+
detail=availability.message,
|
|
146
|
+
)
|
|
147
|
+
raise BackendCredentialsNotFoundError(
|
|
148
|
+
backend or adapter.name,
|
|
149
|
+
detail=availability.message,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return adapter.execute(request)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import subprocess
|
|
3
|
+
import tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from askai.backends import (
|
|
7
|
+
BackendAvailability,
|
|
8
|
+
BackendExecutableNotFoundError,
|
|
9
|
+
BackendInvocationError,
|
|
10
|
+
BackendRequest,
|
|
11
|
+
BackendResponse,
|
|
12
|
+
BackendTimeoutError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
CODEX_BACKEND_NAME = "codex"
|
|
16
|
+
CODEX_EXECUTABLE = "codex"
|
|
17
|
+
CODEX_TIMEOUT_SECONDS = 60
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CodexBackendAdapter:
|
|
21
|
+
name = CODEX_BACKEND_NAME
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
executable: str = CODEX_EXECUTABLE,
|
|
27
|
+
timeout_seconds: int = CODEX_TIMEOUT_SECONDS,
|
|
28
|
+
) -> None:
|
|
29
|
+
self.executable = executable
|
|
30
|
+
self.timeout_seconds = timeout_seconds
|
|
31
|
+
|
|
32
|
+
def check_availability(self) -> BackendAvailability:
|
|
33
|
+
executable_path = shutil.which(self.executable)
|
|
34
|
+
if executable_path is None:
|
|
35
|
+
return BackendAvailability(
|
|
36
|
+
available=False,
|
|
37
|
+
executable=self.executable,
|
|
38
|
+
message=f"{self.executable} executable not found on PATH",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
return BackendAvailability(available=True, executable=executable_path)
|
|
42
|
+
|
|
43
|
+
def execute(self, request: BackendRequest) -> BackendResponse:
|
|
44
|
+
availability = self.check_availability()
|
|
45
|
+
if not availability.available:
|
|
46
|
+
raise BackendExecutableNotFoundError(
|
|
47
|
+
self.name,
|
|
48
|
+
executable=availability.executable,
|
|
49
|
+
detail=availability.message,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
executable_path = availability.executable or self.executable
|
|
53
|
+
with tempfile.TemporaryDirectory(prefix="askai-codex-") as temp_dir:
|
|
54
|
+
answer_path = Path(temp_dir) / "answer.txt"
|
|
55
|
+
command = build_codex_command(
|
|
56
|
+
executable_path,
|
|
57
|
+
work_dir=temp_dir,
|
|
58
|
+
answer_path=answer_path,
|
|
59
|
+
model=request.model,
|
|
60
|
+
)
|
|
61
|
+
run_codex_command(
|
|
62
|
+
command,
|
|
63
|
+
prompt=request.prompt.render_for_single_prompt(),
|
|
64
|
+
work_dir=temp_dir,
|
|
65
|
+
timeout_seconds=self.timeout_seconds,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return BackendResponse(raw_output=read_codex_answer(answer_path))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def build_codex_command(
|
|
72
|
+
executable: str,
|
|
73
|
+
*,
|
|
74
|
+
work_dir: str,
|
|
75
|
+
answer_path: Path,
|
|
76
|
+
model: str | None,
|
|
77
|
+
) -> list[str]:
|
|
78
|
+
command = [
|
|
79
|
+
executable,
|
|
80
|
+
"exec",
|
|
81
|
+
"--cd",
|
|
82
|
+
work_dir,
|
|
83
|
+
"--skip-git-repo-check",
|
|
84
|
+
"--ephemeral",
|
|
85
|
+
"--ignore-rules",
|
|
86
|
+
"--ignore-user-config",
|
|
87
|
+
"--sandbox",
|
|
88
|
+
"read-only",
|
|
89
|
+
"--color",
|
|
90
|
+
"never",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
if model is not None:
|
|
94
|
+
command.extend(["--model", model])
|
|
95
|
+
|
|
96
|
+
command.extend(["--output-last-message", str(answer_path), "-"])
|
|
97
|
+
return command
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def run_codex_command(
|
|
101
|
+
command: list[str],
|
|
102
|
+
*,
|
|
103
|
+
prompt: str,
|
|
104
|
+
work_dir: str,
|
|
105
|
+
timeout_seconds: int,
|
|
106
|
+
) -> None:
|
|
107
|
+
try:
|
|
108
|
+
completed_process = subprocess.run(
|
|
109
|
+
command,
|
|
110
|
+
input=prompt,
|
|
111
|
+
capture_output=True,
|
|
112
|
+
text=True,
|
|
113
|
+
shell=False,
|
|
114
|
+
cwd=work_dir,
|
|
115
|
+
timeout=timeout_seconds,
|
|
116
|
+
check=False,
|
|
117
|
+
)
|
|
118
|
+
except subprocess.TimeoutExpired as error:
|
|
119
|
+
raise BackendTimeoutError(CODEX_BACKEND_NAME) from error
|
|
120
|
+
except OSError as error:
|
|
121
|
+
raise BackendInvocationError(CODEX_BACKEND_NAME, str(error)) from error
|
|
122
|
+
|
|
123
|
+
if completed_process.returncode != 0:
|
|
124
|
+
detail = summarize_process_failure(
|
|
125
|
+
completed_process.stderr,
|
|
126
|
+
completed_process.stdout,
|
|
127
|
+
)
|
|
128
|
+
raise BackendInvocationError(CODEX_BACKEND_NAME, detail)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def summarize_process_failure(stderr: str, stdout: str) -> str:
|
|
132
|
+
for output in (stderr, stdout):
|
|
133
|
+
detail = first_non_empty_line(output)
|
|
134
|
+
if detail:
|
|
135
|
+
return detail
|
|
136
|
+
|
|
137
|
+
return "process exited with no diagnostic output"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def first_non_empty_line(value: str) -> str | None:
|
|
141
|
+
for line in value.splitlines():
|
|
142
|
+
detail = line.strip()
|
|
143
|
+
if detail:
|
|
144
|
+
return detail
|
|
145
|
+
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def read_codex_answer(answer_path: Path) -> str:
|
|
150
|
+
try:
|
|
151
|
+
return answer_path.read_text(encoding="utf-8")
|
|
152
|
+
except OSError as error:
|
|
153
|
+
raise BackendInvocationError(
|
|
154
|
+
CODEX_BACKEND_NAME,
|
|
155
|
+
f"could not read final answer file: {error}",
|
|
156
|
+
) from error
|