askai-py 0.1.0__py3-none-any.whl
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/.gitkeep +0 -0
- askai/__init__.py +8 -0
- askai/__main__.py +3 -0
- askai/backends/__init__.py +152 -0
- askai/backends/codex.py +156 -0
- askai/backends/groq.py +237 -0
- askai/cli.py +332 -0
- askai/commands/.gitkeep +0 -0
- askai/config.py +234 -0
- askai/policy.py +81 -0
- askai/utils/.gitkeep +0 -0
- askai_py-0.1.0.dist-info/METADATA +89 -0
- askai_py-0.1.0.dist-info/RECORD +16 -0
- askai_py-0.1.0.dist-info/WHEEL +4 -0
- askai_py-0.1.0.dist-info/entry_points.txt +3 -0
- askai_py-0.1.0.dist-info/licenses/LICENSE +21 -0
askai/.gitkeep
ADDED
|
File without changes
|
askai/__init__.py
ADDED
askai/__main__.py
ADDED
|
@@ -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)
|
askai/backends/codex.py
ADDED
|
@@ -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
|
askai/backends/groq.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import ssl
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.request
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
from askai import __version__
|
|
9
|
+
from askai.backends import (
|
|
10
|
+
BackendAvailability,
|
|
11
|
+
BackendCredentialsNotFoundError,
|
|
12
|
+
BackendError,
|
|
13
|
+
BackendInvocationError,
|
|
14
|
+
BackendRequest,
|
|
15
|
+
BackendResponse,
|
|
16
|
+
BackendTimeoutError,
|
|
17
|
+
)
|
|
18
|
+
from askai.policy import AskaiPrompt
|
|
19
|
+
|
|
20
|
+
GROQ_BACKEND_NAME = "groq"
|
|
21
|
+
GROQ_API_KEY_ENV = "GROQ_API_KEY"
|
|
22
|
+
GROQ_ENDPOINT = "https://api.groq.com/openai/v1/chat/completions"
|
|
23
|
+
GROQ_DEFAULT_MODEL = "llama-3.1-8b-instant"
|
|
24
|
+
GROQ_TIMEOUT_SECONDS = 30
|
|
25
|
+
GROQ_TEMPERATURE = 0.2
|
|
26
|
+
GROQ_TOKENS_PER_LINE = 32
|
|
27
|
+
GROQ_FALLBACK_MAX_LINES = 6
|
|
28
|
+
ASKAI_CA_BUNDLE_ENV = "ASKAI_CA_BUNDLE"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GroqBackendAdapter:
|
|
32
|
+
name = GROQ_BACKEND_NAME
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
endpoint: str = GROQ_ENDPOINT,
|
|
38
|
+
timeout_seconds: int = GROQ_TIMEOUT_SECONDS,
|
|
39
|
+
) -> None:
|
|
40
|
+
self.endpoint = endpoint
|
|
41
|
+
self.timeout_seconds = timeout_seconds
|
|
42
|
+
|
|
43
|
+
def check_availability(self) -> BackendAvailability:
|
|
44
|
+
if not os.environ.get(GROQ_API_KEY_ENV):
|
|
45
|
+
return BackendAvailability(
|
|
46
|
+
available=False,
|
|
47
|
+
message=f"{GROQ_API_KEY_ENV} environment variable is not set",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return BackendAvailability(available=True)
|
|
51
|
+
|
|
52
|
+
def execute(self, request: BackendRequest) -> BackendResponse:
|
|
53
|
+
api_key = os.environ.get(GROQ_API_KEY_ENV)
|
|
54
|
+
if not api_key:
|
|
55
|
+
raise BackendCredentialsNotFoundError(
|
|
56
|
+
GROQ_BACKEND_NAME,
|
|
57
|
+
detail=f"{GROQ_API_KEY_ENV} environment variable is not set",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
model = request.model if request.model is not None else GROQ_DEFAULT_MODEL
|
|
61
|
+
max_tokens = estimate_max_tokens(request.max_lines)
|
|
62
|
+
payload = build_groq_payload(
|
|
63
|
+
prompt=request.prompt,
|
|
64
|
+
model=model,
|
|
65
|
+
max_tokens=max_tokens,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
response_body = send_groq_request(
|
|
69
|
+
self.endpoint,
|
|
70
|
+
api_key=api_key,
|
|
71
|
+
payload=payload,
|
|
72
|
+
timeout_seconds=self.timeout_seconds,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return BackendResponse(raw_output=extract_groq_answer(response_body))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def estimate_max_tokens(max_lines: int | None) -> int:
|
|
79
|
+
effective_lines = max_lines if max_lines is not None else GROQ_FALLBACK_MAX_LINES
|
|
80
|
+
return effective_lines * GROQ_TOKENS_PER_LINE
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def build_groq_payload(
|
|
84
|
+
*,
|
|
85
|
+
prompt: AskaiPrompt,
|
|
86
|
+
model: str,
|
|
87
|
+
max_tokens: int,
|
|
88
|
+
) -> dict[str, Any]:
|
|
89
|
+
return {
|
|
90
|
+
"model": model,
|
|
91
|
+
"messages": [
|
|
92
|
+
{"role": "system", "content": prompt.system},
|
|
93
|
+
{"role": "user", "content": prompt.user},
|
|
94
|
+
],
|
|
95
|
+
"max_tokens": max_tokens,
|
|
96
|
+
"temperature": GROQ_TEMPERATURE,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def build_ssl_context() -> ssl.SSLContext | None:
|
|
101
|
+
ca_bundle = os.environ.get(ASKAI_CA_BUNDLE_ENV)
|
|
102
|
+
if not ca_bundle:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
context = ssl.create_default_context()
|
|
106
|
+
context.load_verify_locations(cafile=ca_bundle)
|
|
107
|
+
return context
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def send_groq_request(
|
|
111
|
+
endpoint: str,
|
|
112
|
+
*,
|
|
113
|
+
api_key: str,
|
|
114
|
+
payload: dict[str, Any],
|
|
115
|
+
timeout_seconds: int,
|
|
116
|
+
) -> dict[str, Any]:
|
|
117
|
+
body = json.dumps(payload).encode("utf-8")
|
|
118
|
+
request = urllib.request.Request(
|
|
119
|
+
endpoint,
|
|
120
|
+
data=body,
|
|
121
|
+
headers={
|
|
122
|
+
"Authorization": f"Bearer {api_key}",
|
|
123
|
+
"Content-Type": "application/json",
|
|
124
|
+
"User-Agent": f"askai/{__version__}",
|
|
125
|
+
},
|
|
126
|
+
method="POST",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
ssl_context = build_ssl_context()
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
with urllib.request.urlopen(
|
|
133
|
+
request, timeout=timeout_seconds, context=ssl_context
|
|
134
|
+
) as response:
|
|
135
|
+
response_text = response.read().decode("utf-8")
|
|
136
|
+
except urllib.error.HTTPError as error:
|
|
137
|
+
error_text = error.read().decode("utf-8", errors="replace")
|
|
138
|
+
raise map_groq_http_error(error.code, error_text) from error
|
|
139
|
+
except TimeoutError as error:
|
|
140
|
+
raise BackendTimeoutError(GROQ_BACKEND_NAME) from error
|
|
141
|
+
except urllib.error.URLError as error:
|
|
142
|
+
if isinstance(error.reason, TimeoutError):
|
|
143
|
+
raise BackendTimeoutError(GROQ_BACKEND_NAME) from error
|
|
144
|
+
raise BackendInvocationError(
|
|
145
|
+
GROQ_BACKEND_NAME,
|
|
146
|
+
str(error.reason),
|
|
147
|
+
) from error
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
response_data: Any = json.loads(response_text)
|
|
151
|
+
except json.JSONDecodeError as error:
|
|
152
|
+
raise BackendInvocationError(
|
|
153
|
+
GROQ_BACKEND_NAME,
|
|
154
|
+
f"could not parse response: {error}",
|
|
155
|
+
) from error
|
|
156
|
+
|
|
157
|
+
if not isinstance(response_data, dict):
|
|
158
|
+
raise BackendInvocationError(
|
|
159
|
+
GROQ_BACKEND_NAME,
|
|
160
|
+
"response was not a JSON object",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
return cast(dict[str, Any], response_data)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def map_groq_http_error(status: int, body: str) -> BackendError:
|
|
167
|
+
detail = extract_groq_error_message(body)
|
|
168
|
+
|
|
169
|
+
if status in (401, 403):
|
|
170
|
+
return BackendCredentialsNotFoundError(
|
|
171
|
+
GROQ_BACKEND_NAME,
|
|
172
|
+
detail="API key was rejected by Groq",
|
|
173
|
+
)
|
|
174
|
+
if status == 429:
|
|
175
|
+
return BackendInvocationError(
|
|
176
|
+
GROQ_BACKEND_NAME,
|
|
177
|
+
detail="rate limit reached, retry shortly",
|
|
178
|
+
)
|
|
179
|
+
if detail:
|
|
180
|
+
return BackendInvocationError(
|
|
181
|
+
GROQ_BACKEND_NAME,
|
|
182
|
+
detail=f"HTTP {status}: {detail}",
|
|
183
|
+
)
|
|
184
|
+
return BackendInvocationError(GROQ_BACKEND_NAME, detail=f"HTTP {status}")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def extract_groq_error_message(body: str) -> str | None:
|
|
188
|
+
try:
|
|
189
|
+
data = json.loads(body)
|
|
190
|
+
except json.JSONDecodeError:
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
error = data.get("error")
|
|
194
|
+
if isinstance(error, dict):
|
|
195
|
+
message = error.get("message")
|
|
196
|
+
if isinstance(message, str):
|
|
197
|
+
return message
|
|
198
|
+
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def extract_groq_answer(response_body: dict[str, Any]) -> str:
|
|
203
|
+
choices = response_body.get("choices")
|
|
204
|
+
if not isinstance(choices, list) or not choices:
|
|
205
|
+
raise BackendInvocationError(
|
|
206
|
+
GROQ_BACKEND_NAME,
|
|
207
|
+
"response contained no choices",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
first_choice = choices[0]
|
|
211
|
+
if not isinstance(first_choice, dict):
|
|
212
|
+
raise BackendInvocationError(
|
|
213
|
+
GROQ_BACKEND_NAME,
|
|
214
|
+
"malformed response choice",
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if first_choice.get("finish_reason") == "length":
|
|
218
|
+
raise BackendInvocationError(
|
|
219
|
+
GROQ_BACKEND_NAME,
|
|
220
|
+
"response hit token limit before completing",
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
message = first_choice.get("message")
|
|
224
|
+
if not isinstance(message, dict):
|
|
225
|
+
raise BackendInvocationError(
|
|
226
|
+
GROQ_BACKEND_NAME,
|
|
227
|
+
"malformed response message",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
content = message.get("content")
|
|
231
|
+
if not isinstance(content, str):
|
|
232
|
+
raise BackendInvocationError(
|
|
233
|
+
GROQ_BACKEND_NAME,
|
|
234
|
+
"response had no text content",
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
return content
|
askai/cli.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
from typing import Annotated, Never
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from askai import __version__
|
|
8
|
+
from askai.backends import (
|
|
9
|
+
BACKEND_ADAPTERS,
|
|
10
|
+
BackendAvailability,
|
|
11
|
+
BackendError,
|
|
12
|
+
BackendRequest,
|
|
13
|
+
check_backend_availability,
|
|
14
|
+
execute_backend,
|
|
15
|
+
)
|
|
16
|
+
from askai.config import (
|
|
17
|
+
MAX_ANSWER_LINES,
|
|
18
|
+
ConfigError,
|
|
19
|
+
get_effective_config,
|
|
20
|
+
set_backend,
|
|
21
|
+
set_max_lines,
|
|
22
|
+
set_model,
|
|
23
|
+
validate_backend,
|
|
24
|
+
validate_max_lines,
|
|
25
|
+
validate_model,
|
|
26
|
+
)
|
|
27
|
+
from askai.policy import build_prompt, normalize_output
|
|
28
|
+
|
|
29
|
+
EXIT_RUNTIME_ERROR = 1
|
|
30
|
+
EXIT_USAGE_ERROR = 2
|
|
31
|
+
|
|
32
|
+
app = typer.Typer(
|
|
33
|
+
help="Ask one-shot, stateless questions to an LLM from the terminal.",
|
|
34
|
+
invoke_without_command=True,
|
|
35
|
+
no_args_is_help=False,
|
|
36
|
+
)
|
|
37
|
+
config_app = typer.Typer(help="Inspect and update local askai configuration.")
|
|
38
|
+
config_set_app = typer.Typer(help="Set local askai configuration values.")
|
|
39
|
+
|
|
40
|
+
app.add_typer(config_app, name="config")
|
|
41
|
+
config_app.add_typer(config_set_app, name="set")
|
|
42
|
+
|
|
43
|
+
ROOT_PASSTHROUGH_ARGS = {
|
|
44
|
+
"--help",
|
|
45
|
+
"-h",
|
|
46
|
+
"--version",
|
|
47
|
+
"--install-completion",
|
|
48
|
+
"--show-completion",
|
|
49
|
+
"config",
|
|
50
|
+
"doctor",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _fail(message: str, code: int = EXIT_RUNTIME_ERROR) -> Never:
|
|
55
|
+
typer.echo(f"Error: {message}", err=True)
|
|
56
|
+
raise typer.Exit(code)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _validate_max_lines(value: int, name: str) -> None:
|
|
60
|
+
try:
|
|
61
|
+
validate_max_lines(value)
|
|
62
|
+
except ConfigError:
|
|
63
|
+
_fail(
|
|
64
|
+
f"{name} must be between 1 and {MAX_ANSWER_LINES}.",
|
|
65
|
+
EXIT_USAGE_ERROR,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _fail_config_error(error: ConfigError, code: int = EXIT_RUNTIME_ERROR) -> Never:
|
|
70
|
+
_fail(str(error), code)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _fail_backend_error(error: BackendError) -> Never:
|
|
74
|
+
_fail(str(error), EXIT_RUNTIME_ERROR)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _validate_backend_option(backend: str | None) -> None:
|
|
78
|
+
if backend is None:
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
validate_backend(backend)
|
|
83
|
+
except ConfigError as error:
|
|
84
|
+
_fail_config_error(error)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _validate_model_option(model: str | None) -> None:
|
|
88
|
+
if model is None:
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
validate_model(model)
|
|
93
|
+
except ConfigError:
|
|
94
|
+
_fail("--model cannot be empty.", EXIT_USAGE_ERROR)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _validate_runtime_overrides(
|
|
98
|
+
*,
|
|
99
|
+
backend: str | None,
|
|
100
|
+
model: str | None,
|
|
101
|
+
max_lines: int | None,
|
|
102
|
+
) -> None:
|
|
103
|
+
_validate_backend_option(backend)
|
|
104
|
+
_validate_model_option(model)
|
|
105
|
+
|
|
106
|
+
if max_lines is not None:
|
|
107
|
+
_validate_max_lines(max_lines, "--max-lines")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def build_typer_args(argv: Sequence[str]) -> list[str]:
|
|
111
|
+
args = list(argv)
|
|
112
|
+
if args and args[0] in ROOT_PASSTHROUGH_ARGS:
|
|
113
|
+
return args
|
|
114
|
+
|
|
115
|
+
return ["query", *args]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@app.callback()
|
|
119
|
+
def main(
|
|
120
|
+
backend: Annotated[
|
|
121
|
+
str | None,
|
|
122
|
+
typer.Option("--backend", help="Override the configured backend."),
|
|
123
|
+
] = None,
|
|
124
|
+
model: Annotated[
|
|
125
|
+
str | None,
|
|
126
|
+
typer.Option("--model", help="Override the configured model."),
|
|
127
|
+
] = None,
|
|
128
|
+
max_lines: Annotated[
|
|
129
|
+
int | None,
|
|
130
|
+
typer.Option("--max-lines", help="Override the maximum answer lines."),
|
|
131
|
+
] = None,
|
|
132
|
+
show_version: Annotated[
|
|
133
|
+
bool,
|
|
134
|
+
typer.Option(
|
|
135
|
+
"--version",
|
|
136
|
+
help="Print the installed askai version.",
|
|
137
|
+
is_eager=True,
|
|
138
|
+
),
|
|
139
|
+
] = False,
|
|
140
|
+
) -> None:
|
|
141
|
+
del backend, model, max_lines
|
|
142
|
+
|
|
143
|
+
if show_version:
|
|
144
|
+
typer.echo(f"askai {__version__}")
|
|
145
|
+
raise typer.Exit()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@app.command("query", hidden=True)
|
|
149
|
+
def query(
|
|
150
|
+
question: Annotated[str, typer.Argument(help="One-shot question to ask.")],
|
|
151
|
+
backend: Annotated[
|
|
152
|
+
str | None,
|
|
153
|
+
typer.Option("--backend", help="Override the configured backend."),
|
|
154
|
+
] = None,
|
|
155
|
+
model: Annotated[
|
|
156
|
+
str | None,
|
|
157
|
+
typer.Option("--model", help="Override the configured model."),
|
|
158
|
+
] = None,
|
|
159
|
+
max_lines: Annotated[
|
|
160
|
+
int | None,
|
|
161
|
+
typer.Option("--max-lines", help="Override the maximum answer lines."),
|
|
162
|
+
] = None,
|
|
163
|
+
) -> None:
|
|
164
|
+
question = question.strip()
|
|
165
|
+
if not question:
|
|
166
|
+
_fail("The question cannot be empty.", EXIT_USAGE_ERROR)
|
|
167
|
+
|
|
168
|
+
_validate_runtime_overrides(
|
|
169
|
+
backend=backend,
|
|
170
|
+
model=model,
|
|
171
|
+
max_lines=max_lines,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
_effective_config = get_effective_config(
|
|
176
|
+
backend=backend,
|
|
177
|
+
model=model,
|
|
178
|
+
max_lines=max_lines,
|
|
179
|
+
)
|
|
180
|
+
except ConfigError as error:
|
|
181
|
+
_fail_config_error(error)
|
|
182
|
+
|
|
183
|
+
prompt = build_prompt(question, max_lines=_effective_config.max_lines)
|
|
184
|
+
request = BackendRequest(
|
|
185
|
+
prompt=prompt,
|
|
186
|
+
model=_effective_config.model,
|
|
187
|
+
max_lines=_effective_config.max_lines,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
response = execute_backend(
|
|
192
|
+
_effective_config.backend,
|
|
193
|
+
request,
|
|
194
|
+
adapters=BACKEND_ADAPTERS,
|
|
195
|
+
)
|
|
196
|
+
except BackendError as error:
|
|
197
|
+
_fail_backend_error(error)
|
|
198
|
+
|
|
199
|
+
answer = normalize_output(response.raw_output, _effective_config.max_lines)
|
|
200
|
+
if answer:
|
|
201
|
+
typer.echo(answer)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _format_backend_availability_status(
|
|
205
|
+
availability: BackendAvailability | None,
|
|
206
|
+
) -> str:
|
|
207
|
+
if availability is None:
|
|
208
|
+
return "not checked"
|
|
209
|
+
|
|
210
|
+
if availability.available:
|
|
211
|
+
return availability.executable or "available"
|
|
212
|
+
|
|
213
|
+
if availability.executable:
|
|
214
|
+
return f"{availability.executable} unavailable"
|
|
215
|
+
|
|
216
|
+
return "unavailable"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _format_backend_availability_problem(
|
|
220
|
+
availability: BackendAvailability,
|
|
221
|
+
) -> str:
|
|
222
|
+
if availability.message:
|
|
223
|
+
return f"backend is unavailable: {availability.message}"
|
|
224
|
+
|
|
225
|
+
return "backend is unavailable"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@config_app.command("show")
|
|
229
|
+
def config_show() -> None:
|
|
230
|
+
try:
|
|
231
|
+
effective_config = get_effective_config()
|
|
232
|
+
except ConfigError as error:
|
|
233
|
+
_fail_config_error(error)
|
|
234
|
+
|
|
235
|
+
typer.echo(f"config file: {effective_config.config_path}")
|
|
236
|
+
typer.echo(f"backend: {effective_config.backend or 'not configured'}")
|
|
237
|
+
typer.echo(f"model: {effective_config.model or 'backend default'}")
|
|
238
|
+
typer.echo(f"max lines: {effective_config.max_lines}")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@config_set_app.command("backend")
|
|
242
|
+
def config_set_backend(
|
|
243
|
+
backend: Annotated[str, typer.Argument(help="Backend name to use by default.")],
|
|
244
|
+
) -> None:
|
|
245
|
+
backend = backend.strip()
|
|
246
|
+
if not backend:
|
|
247
|
+
_fail("backend cannot be empty.", EXIT_USAGE_ERROR)
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
config_path = set_backend(backend)
|
|
251
|
+
except ConfigError as error:
|
|
252
|
+
_fail_config_error(error)
|
|
253
|
+
|
|
254
|
+
typer.echo(f"backend set to '{backend}'")
|
|
255
|
+
typer.echo(f"config file: {config_path}")
|
|
256
|
+
typer.echo("run `askai doctor` after configuring a backend")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@config_set_app.command("model")
|
|
260
|
+
def config_set_model(
|
|
261
|
+
model: Annotated[str, typer.Argument(help="Model name to use by default.")],
|
|
262
|
+
) -> None:
|
|
263
|
+
model = model.strip()
|
|
264
|
+
if not model:
|
|
265
|
+
_fail("model cannot be empty.", EXIT_USAGE_ERROR)
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
config_path = set_model(model)
|
|
269
|
+
except ConfigError as error:
|
|
270
|
+
_fail_config_error(error)
|
|
271
|
+
|
|
272
|
+
typer.echo(f"model set to '{model}'")
|
|
273
|
+
typer.echo(f"config file: {config_path}")
|
|
274
|
+
typer.echo("run `askai doctor` after configuring a model")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@config_set_app.command("max-lines")
|
|
278
|
+
def config_set_max_lines(
|
|
279
|
+
max_lines: Annotated[int, typer.Argument(help="Maximum answer lines.")],
|
|
280
|
+
) -> None:
|
|
281
|
+
_validate_max_lines(max_lines, "max-lines")
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
config_path = set_max_lines(max_lines)
|
|
285
|
+
except ConfigError as error:
|
|
286
|
+
_fail_config_error(error)
|
|
287
|
+
|
|
288
|
+
typer.echo(f"max lines set to {max_lines}")
|
|
289
|
+
typer.echo(f"config file: {config_path}")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@app.command()
|
|
293
|
+
def doctor() -> None:
|
|
294
|
+
try:
|
|
295
|
+
effective_config = get_effective_config()
|
|
296
|
+
except ConfigError as error:
|
|
297
|
+
_fail_config_error(error)
|
|
298
|
+
|
|
299
|
+
backend_availability: BackendAvailability | None = None
|
|
300
|
+
setup_problem: str | None = None
|
|
301
|
+
try:
|
|
302
|
+
backend_availability = check_backend_availability(
|
|
303
|
+
effective_config.backend,
|
|
304
|
+
adapters=BACKEND_ADAPTERS,
|
|
305
|
+
)
|
|
306
|
+
except BackendError as error:
|
|
307
|
+
setup_problem = str(error)
|
|
308
|
+
|
|
309
|
+
if backend_availability is not None and not backend_availability.available:
|
|
310
|
+
setup_problem = _format_backend_availability_problem(backend_availability)
|
|
311
|
+
|
|
312
|
+
typer.echo(f"askai version: {__version__}")
|
|
313
|
+
typer.echo(f"config file: {effective_config.config_path}")
|
|
314
|
+
typer.echo(f"backend: {effective_config.backend or 'not configured'}")
|
|
315
|
+
typer.echo(
|
|
316
|
+
f"backend availability: {_format_backend_availability_status(backend_availability)}"
|
|
317
|
+
)
|
|
318
|
+
typer.echo(f"model: {effective_config.model or 'backend default'}")
|
|
319
|
+
typer.echo(f"max lines: {effective_config.max_lines}")
|
|
320
|
+
|
|
321
|
+
if setup_problem is not None:
|
|
322
|
+
typer.echo(f"setup problem: {setup_problem}")
|
|
323
|
+
raise typer.Exit(EXIT_RUNTIME_ERROR)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def run(argv: Sequence[str] | None = None) -> None:
|
|
327
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
328
|
+
if not args:
|
|
329
|
+
typer.echo("Error: A question is required.", err=True)
|
|
330
|
+
raise SystemExit(EXIT_USAGE_ERROR)
|
|
331
|
+
|
|
332
|
+
app(args=build_typer_args(args), prog_name="askai")
|
askai/commands/.gitkeep
ADDED
|
File without changes
|
askai/config.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import tomllib
|
|
4
|
+
from dataclasses import dataclass, replace
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from platformdirs import user_config_dir
|
|
9
|
+
|
|
10
|
+
APP_NAME = "askai"
|
|
11
|
+
CONFIG_FILENAME = "config.toml"
|
|
12
|
+
CONFIG_FILE_ENV = "ASKAI_CONFIG_FILE"
|
|
13
|
+
|
|
14
|
+
DEFAULT_BACKEND = "groq"
|
|
15
|
+
DEFAULT_MODEL: str | None = None
|
|
16
|
+
DEFAULT_MAX_LINES = 6
|
|
17
|
+
MAX_ANSWER_LINES = 20
|
|
18
|
+
KNOWN_BACKENDS = ("codex", "groq")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConfigError(Exception):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class UserConfig:
|
|
27
|
+
backend: str | None = None
|
|
28
|
+
model: str | None = None
|
|
29
|
+
max_lines: int | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class EffectiveConfig:
|
|
34
|
+
config_path: Path
|
|
35
|
+
backend: str | None
|
|
36
|
+
model: str | None
|
|
37
|
+
max_lines: int
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_config_path(config_path: Path | None = None) -> Path:
|
|
41
|
+
if config_path is not None:
|
|
42
|
+
return config_path
|
|
43
|
+
|
|
44
|
+
env_config_path = os.environ.get(CONFIG_FILE_ENV)
|
|
45
|
+
if env_config_path:
|
|
46
|
+
return Path(env_config_path).expanduser()
|
|
47
|
+
|
|
48
|
+
return Path(user_config_dir(APP_NAME)) / CONFIG_FILENAME
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_user_config(config_path: Path | None = None) -> UserConfig:
|
|
52
|
+
path = get_config_path(config_path)
|
|
53
|
+
if not path.exists():
|
|
54
|
+
return UserConfig()
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
raw_config = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
58
|
+
except tomllib.TOMLDecodeError as error:
|
|
59
|
+
raise ConfigError(f"Invalid config file {path}: {error}") from error
|
|
60
|
+
except OSError as error:
|
|
61
|
+
raise ConfigError(f"Could not read config file {path}: {error}") from error
|
|
62
|
+
|
|
63
|
+
return parse_user_config(raw_config, path)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_effective_config(
|
|
67
|
+
*,
|
|
68
|
+
backend: str | None = None,
|
|
69
|
+
model: str | None = None,
|
|
70
|
+
max_lines: int | None = None,
|
|
71
|
+
config_path: Path | None = None,
|
|
72
|
+
) -> EffectiveConfig:
|
|
73
|
+
path = get_config_path(config_path)
|
|
74
|
+
user_config = load_user_config(path)
|
|
75
|
+
|
|
76
|
+
effective_backend = (
|
|
77
|
+
validate_backend(backend)
|
|
78
|
+
if backend is not None
|
|
79
|
+
else user_config.backend or DEFAULT_BACKEND
|
|
80
|
+
)
|
|
81
|
+
effective_model = (
|
|
82
|
+
validate_model(model)
|
|
83
|
+
if model is not None
|
|
84
|
+
else user_config.model or DEFAULT_MODEL
|
|
85
|
+
)
|
|
86
|
+
effective_max_lines = (
|
|
87
|
+
validate_max_lines(max_lines)
|
|
88
|
+
if max_lines is not None
|
|
89
|
+
else user_config.max_lines or DEFAULT_MAX_LINES
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return EffectiveConfig(
|
|
93
|
+
config_path=path,
|
|
94
|
+
backend=effective_backend,
|
|
95
|
+
model=effective_model,
|
|
96
|
+
max_lines=effective_max_lines,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def set_backend(backend: str, config_path: Path | None = None) -> Path:
|
|
101
|
+
validated_backend = validate_backend(backend)
|
|
102
|
+
user_config = load_user_config(config_path)
|
|
103
|
+
return write_user_config(
|
|
104
|
+
replace(user_config, backend=validated_backend), config_path
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def set_model(model: str, config_path: Path | None = None) -> Path:
|
|
109
|
+
validated_model = validate_model(model)
|
|
110
|
+
user_config = load_user_config(config_path)
|
|
111
|
+
return write_user_config(replace(user_config, model=validated_model), config_path)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def set_max_lines(max_lines: int, config_path: Path | None = None) -> Path:
|
|
115
|
+
validated_max_lines = validate_max_lines(max_lines)
|
|
116
|
+
user_config = load_user_config(config_path)
|
|
117
|
+
return write_user_config(
|
|
118
|
+
replace(user_config, max_lines=validated_max_lines),
|
|
119
|
+
config_path,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def write_user_config(
|
|
124
|
+
user_config: UserConfig,
|
|
125
|
+
config_path: Path | None = None,
|
|
126
|
+
) -> Path:
|
|
127
|
+
path = get_config_path(config_path)
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
path.write_text(format_user_config(user_config), encoding="utf-8")
|
|
132
|
+
except OSError as error:
|
|
133
|
+
raise ConfigError(f"Could not write config file {path}: {error}") from error
|
|
134
|
+
|
|
135
|
+
return path
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def parse_user_config(raw_config: dict[str, Any], path: Path) -> UserConfig:
|
|
139
|
+
backend = parse_backend(raw_config, path)
|
|
140
|
+
model = parse_model(raw_config, path)
|
|
141
|
+
max_lines = parse_max_lines(raw_config, path)
|
|
142
|
+
|
|
143
|
+
return UserConfig(backend=backend, model=model, max_lines=max_lines)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def parse_backend(raw_config: dict[str, Any], path: Path) -> str | None:
|
|
147
|
+
value = raw_config.get("backend")
|
|
148
|
+
if value is None:
|
|
149
|
+
return None
|
|
150
|
+
if not isinstance(value, str):
|
|
151
|
+
raise ConfigError(f"Invalid config file {path}: backend must be a string.")
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
return validate_backend(value)
|
|
155
|
+
except ConfigError as error:
|
|
156
|
+
raise ConfigError(f"Invalid config file {path}: {error}") from error
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def parse_model(raw_config: dict[str, Any], path: Path) -> str | None:
|
|
160
|
+
value = raw_config.get("model")
|
|
161
|
+
if value is None:
|
|
162
|
+
return None
|
|
163
|
+
if not isinstance(value, str):
|
|
164
|
+
raise ConfigError(f"Invalid config file {path}: model must be a string.")
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
return validate_model(value)
|
|
168
|
+
except ConfigError as error:
|
|
169
|
+
raise ConfigError(f"Invalid config file {path}: {error}") from error
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def parse_max_lines(raw_config: dict[str, Any], path: Path) -> int | None:
|
|
173
|
+
value = raw_config.get("max_lines")
|
|
174
|
+
if value is None:
|
|
175
|
+
return None
|
|
176
|
+
if type(value) is not int:
|
|
177
|
+
raise ConfigError(f"Invalid config file {path}: max_lines must be an integer.")
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
return validate_max_lines(value)
|
|
181
|
+
except ConfigError as error:
|
|
182
|
+
raise ConfigError(f"Invalid config file {path}: {error}") from error
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def format_user_config(user_config: UserConfig) -> str:
|
|
186
|
+
lines: list[str] = []
|
|
187
|
+
|
|
188
|
+
if user_config.backend is not None:
|
|
189
|
+
lines.append(f"backend = {json.dumps(user_config.backend)}")
|
|
190
|
+
if user_config.model is not None:
|
|
191
|
+
lines.append(f"model = {json.dumps(user_config.model)}")
|
|
192
|
+
if user_config.max_lines is not None:
|
|
193
|
+
lines.append(f"max_lines = {user_config.max_lines}")
|
|
194
|
+
|
|
195
|
+
if not lines:
|
|
196
|
+
return ""
|
|
197
|
+
|
|
198
|
+
return "\n".join(lines) + "\n"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def validate_backend(backend: str) -> str:
|
|
202
|
+
value = backend.strip()
|
|
203
|
+
if not value:
|
|
204
|
+
raise ConfigError("backend cannot be empty.")
|
|
205
|
+
if not is_supported_backend(value):
|
|
206
|
+
raise ConfigError(
|
|
207
|
+
f"Unsupported backend '{value}'. Supported backends: "
|
|
208
|
+
f"{supported_backends_text()}.",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
return value
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def validate_model(model: str) -> str:
|
|
215
|
+
value = model.strip()
|
|
216
|
+
if not value:
|
|
217
|
+
raise ConfigError("model cannot be empty.")
|
|
218
|
+
|
|
219
|
+
return value
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def validate_max_lines(max_lines: int) -> int:
|
|
223
|
+
if max_lines <= 0 or max_lines > MAX_ANSWER_LINES:
|
|
224
|
+
raise ConfigError(f"max_lines must be between 1 and {MAX_ANSWER_LINES}.")
|
|
225
|
+
|
|
226
|
+
return max_lines
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def is_supported_backend(backend: str) -> bool:
|
|
230
|
+
return backend in KNOWN_BACKENDS
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def supported_backends_text() -> str:
|
|
234
|
+
return ", ".join(KNOWN_BACKENDS)
|
askai/policy.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
ANSI_ESCAPE_PATTERN = re.compile(
|
|
5
|
+
r"\x1b(?:"
|
|
6
|
+
r"[@-Z\\-_]"
|
|
7
|
+
r"|\[[0-?]*[ -/]*[@-~]"
|
|
8
|
+
r"|\][^\x07]*(?:\x07|\x1b\\)"
|
|
9
|
+
r")"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class AskaiPrompt:
|
|
15
|
+
system: str
|
|
16
|
+
user: str
|
|
17
|
+
|
|
18
|
+
def render_for_single_prompt(self) -> str:
|
|
19
|
+
return f"{self.system}\n\nUser question:\n{self.user}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_prompt(question: str, *, max_lines: int) -> AskaiPrompt:
|
|
23
|
+
return AskaiPrompt(
|
|
24
|
+
system=build_system_prompt(max_lines),
|
|
25
|
+
user=question,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_system_prompt(max_lines: int) -> str:
|
|
30
|
+
return "\n".join(
|
|
31
|
+
[
|
|
32
|
+
"You are askai, a terminal-first one-shot AI assistant for engineers.",
|
|
33
|
+
f"Answer in at most {max_lines} lines.",
|
|
34
|
+
"Fit the complete answer within the line limit; do not trail off.",
|
|
35
|
+
"Answer directly. Do not include preambles like 'Sure' or 'Certainly'.",
|
|
36
|
+
"Use plain text by default. Avoid Markdown unless it improves clarity.",
|
|
37
|
+
"Prefer immediately useful commands, flags, or examples when relevant.",
|
|
38
|
+
"Keep explanations concise and terminal-friendly.",
|
|
39
|
+
"If you are unsure, say so briefly instead of guessing.",
|
|
40
|
+
"Do not ask follow-up questions and do not assume conversation history.",
|
|
41
|
+
]
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def normalize_output(raw_output: str, max_lines: int) -> str:
|
|
46
|
+
lines = [line.rstrip() for line in strip_ansi(raw_output).splitlines()]
|
|
47
|
+
lines = trim_blank_edges(lines)
|
|
48
|
+
lines = collapse_blank_lines(lines)
|
|
49
|
+
|
|
50
|
+
return "\n".join(lines[:max_lines])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def strip_ansi(value: str) -> str:
|
|
54
|
+
return ANSI_ESCAPE_PATTERN.sub("", value)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def trim_blank_edges(lines: list[str]) -> list[str]:
|
|
58
|
+
start = 0
|
|
59
|
+
end = len(lines)
|
|
60
|
+
|
|
61
|
+
while start < end and not lines[start].strip():
|
|
62
|
+
start += 1
|
|
63
|
+
while end > start and not lines[end - 1].strip():
|
|
64
|
+
end -= 1
|
|
65
|
+
|
|
66
|
+
return lines[start:end]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def collapse_blank_lines(lines: list[str]) -> list[str]:
|
|
70
|
+
collapsed_lines: list[str] = []
|
|
71
|
+
previous_line_was_blank = False
|
|
72
|
+
|
|
73
|
+
for line in lines:
|
|
74
|
+
line_is_blank = not line.strip()
|
|
75
|
+
if line_is_blank and previous_line_was_blank:
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
collapsed_lines.append("" if line_is_blank else line)
|
|
79
|
+
previous_line_was_blank = line_is_blank
|
|
80
|
+
|
|
81
|
+
return collapsed_lines
|
askai/utils/.gitkeep
ADDED
|
File without changes
|
|
@@ -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.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
askai/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
askai/__init__.py,sha256=GQHazRM8263wjb41pUlMaiZMEWksYe8Pl_liIetbYgo,187
|
|
3
|
+
askai/__main__.py,sha256=GP9PbQQzJ1Fo1qNIZnx7CvjqsefL6APsE9zofZ8v9ZA,33
|
|
4
|
+
askai/backends/__init__.py,sha256=wkeDDT9YnIestW2zLUtUrUSJrmJKhkU6Yr2z3LGjHBs,4394
|
|
5
|
+
askai/backends/codex.py,sha256=0KF2cyjI9F_fYc3JFsbix_nqfmoVU_BvAc5ZgeChBBU,4303
|
|
6
|
+
askai/backends/groq.py,sha256=cie4wE53-IoyZNGNEFGdt5_p1hb76JECNV6g0Xy1S3c,6837
|
|
7
|
+
askai/cli.py,sha256=TRes7doKWCE_CgEX3JPqcSorJ70YcK5q2RrZQ2tp-7w,9014
|
|
8
|
+
askai/commands/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
askai/config.py,sha256=ocp1MSQJ7ReGuUV02RIsupShN9csS-TKseK03c3xMGg,6617
|
|
10
|
+
askai/policy.py,sha256=qqnaR5Fo0LSkV9QZzZjIJUjUhG_O2XeIpZwnRvRt7Yw,2341
|
|
11
|
+
askai/utils/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
askai_py-0.1.0.dist-info/licenses/LICENSE,sha256=dTtuA-ZNd-s3702n3IN-V2uhjyuLMeTn11AmQ3U3Q0E,1072
|
|
13
|
+
askai_py-0.1.0.dist-info/WHEEL,sha256=uOqnPWqgFlbov4NeTCercq7cBQ2UN7xh5fiW55lOnAg,81
|
|
14
|
+
askai_py-0.1.0.dist-info/entry_points.txt,sha256=MeWR8FjjBNOzxf2AAXrHjVbwHX-6qqjQLH8nC_joDhs,41
|
|
15
|
+
askai_py-0.1.0.dist-info/METADATA,sha256=zVBMxC3IoBKB2rj4eqmT1v8fF0wVuDf4qGlm4wA8UF4,2752
|
|
16
|
+
askai_py-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|