askllm-cli 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.
- askllm_cli-0.1.0/PKG-INFO +210 -0
- askllm_cli-0.1.0/README.md +185 -0
- askllm_cli-0.1.0/pyproject.toml +42 -0
- askllm_cli-0.1.0/setup.cfg +4 -0
- askllm_cli-0.1.0/src/askllm/__init__.py +10 -0
- askllm_cli-0.1.0/src/askllm/__main__.py +6 -0
- askllm_cli-0.1.0/src/askllm/cli.py +431 -0
- askllm_cli-0.1.0/src/askllm_cli.egg-info/PKG-INFO +210 -0
- askllm_cli-0.1.0/src/askllm_cli.egg-info/SOURCES.txt +12 -0
- askllm_cli-0.1.0/src/askllm_cli.egg-info/dependency_links.txt +1 -0
- askllm_cli-0.1.0/src/askllm_cli.egg-info/entry_points.txt +2 -0
- askllm_cli-0.1.0/src/askllm_cli.egg-info/requires.txt +3 -0
- askllm_cli-0.1.0/src/askllm_cli.egg-info/top_level.txt +1 -0
- askllm_cli-0.1.0/tests/test_askllm.py +99 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: askllm-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight OpenAI-compatible terminal REPL
|
|
5
|
+
Author: AskLLM
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: llm,repl,cli,openai,terminal,ai
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Communications :: Chat
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Python: >=3.8
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
25
|
+
|
|
26
|
+
# AskLLM
|
|
27
|
+
|
|
28
|
+
A lightweight, zero-dependency Python REPL for chatting with LLMs via any OpenAI-compatible API.
|
|
29
|
+
|
|
30
|
+
Designed to be installed via `pip install askllm-cli` (or `pipx install askllm-cli`), and developed using **Podman** with `python:3.12-slim` (or run directly with Python 3).
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Features
|
|
35
|
+
|
|
36
|
+
- **Zero Third-Party Runtime Dependencies:** Uses standard library only (`urllib.request`, `json`, `readline`).
|
|
37
|
+
- **Standard Python Package:** Modern PEP 517/621 packaging (`pyproject.toml`) published as `askllm-cli` on PyPI.
|
|
38
|
+
- **Podman Development Ready:** Built-in development workflow using `python:3.12-slim` (`./dev.sh`).
|
|
39
|
+
- **Environment Variable Auto-Detection:** Automatically picks up standard OpenAI / Azure / local LLM variables.
|
|
40
|
+
- **Streaming Responses:** Real-time token streaming via Server-Sent Events (SSE).
|
|
41
|
+
- **Graceful Signal Handling:**
|
|
42
|
+
- `Ctrl+C` or `Ctrl+D` at the beginning of the line exits the REPL.
|
|
43
|
+
- `Ctrl+C` with text in the buffer cancels the line.
|
|
44
|
+
- `Ctrl+C` during response streaming halts generation cleanly without exiting.
|
|
45
|
+
- **Multi-Turn Chat History:** Maintains conversation context across turns within the session.
|
|
46
|
+
- **Persistent Input History:** Readline history saved across runs in `~/.askllm_history`.
|
|
47
|
+
- **Multiline Input Support:**
|
|
48
|
+
- Triple quotes `"""` ... `"""`
|
|
49
|
+
- Trailing backslash `\`
|
|
50
|
+
- `/paste` command
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Development with Podman (`python:3.12-slim`)
|
|
55
|
+
|
|
56
|
+
No local Python installation or host dependencies are required. A complete development environment is provided via Podman:
|
|
57
|
+
|
|
58
|
+
### 1. Interactive Development Shell
|
|
59
|
+
Drop into an interactive bash shell running inside `python:3.12-slim` with AskLLM installed in editable mode (`pip install -e .`):
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
./dev.sh
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Inside the shell:
|
|
66
|
+
```bash
|
|
67
|
+
# Run AskLLM directly
|
|
68
|
+
askllm --help
|
|
69
|
+
|
|
70
|
+
# Run tests
|
|
71
|
+
pytest
|
|
72
|
+
|
|
73
|
+
# Test interactive python
|
|
74
|
+
python3 -c "import askllm; print(askllm.__version__)"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Any changes made to files in `src/askllm` on your host are immediately reflected inside the container!
|
|
78
|
+
|
|
79
|
+
### 2. Run Tests in Podman
|
|
80
|
+
Run the test suite inside the `python:3.12-slim` container:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
./dev.sh test
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### 3. Run AskLLM via Podman
|
|
87
|
+
Run AskLLM using Podman directly from the host:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
./askllm
|
|
91
|
+
```
|
|
92
|
+
or with arguments:
|
|
93
|
+
```bash
|
|
94
|
+
./askllm --model llama3.2 --endpoint http://localhost:11434/v1
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### 4. Dev Container (VS Code / IDEs)
|
|
98
|
+
Open this repository in VS Code or any editor supporting Dev Containers to develop seamlessly inside the `python:3.12-slim` container.
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Installation via `pip` / `pipx`
|
|
103
|
+
|
|
104
|
+
Install AskLLM from PyPI:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
# Recommended for CLI tools
|
|
108
|
+
pipx install askllm-cli
|
|
109
|
+
|
|
110
|
+
# Or via standard pip
|
|
111
|
+
pip install askllm-cli
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Or install locally in editable mode in any Python virtual environment:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# Editable install
|
|
118
|
+
pip install -e .
|
|
119
|
+
|
|
120
|
+
# With dev dependencies (pytest)
|
|
121
|
+
pip install -e ".[dev]"
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Once installed, the `askllm` command is directly available:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
askllm --help
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Or run as a module:
|
|
131
|
+
```bash
|
|
132
|
+
python3 -m askllm
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Production Container
|
|
138
|
+
|
|
139
|
+
Build and run the production image using Podman:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
podman build -t askllm .
|
|
143
|
+
podman run --rm -it --network=host -e OPENAI_API_KEY askllm
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Environment Variables
|
|
149
|
+
|
|
150
|
+
AskLLM automatically checks for these environment variables:
|
|
151
|
+
|
|
152
|
+
| Variable | Description | Default |
|
|
153
|
+
| :--- | :--- | :--- |
|
|
154
|
+
| `OPENAI_API_KEY` | Your API key | *(empty / none)* |
|
|
155
|
+
| `OPENAI_ENDPOINT` or `OPENAI_BASE_URL` or `OPENAI_API_BASE` | API base URL or chat endpoint | `https://api.openai.com/v1` |
|
|
156
|
+
| `OPENAI_MODEL` | Default model name | `gpt-4o-mini` |
|
|
157
|
+
| `OPENAI_SYSTEM_PROMPT` | Custom system prompt | `"You are a helpful assistant."` |
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Using with Local LLMs (Ollama, LM Studio, vLLM, etc.)
|
|
162
|
+
|
|
163
|
+
Because `./askllm` and `./dev.sh` use host networking (`--network=host`), you can connect directly to local servers:
|
|
164
|
+
|
|
165
|
+
#### Ollama:
|
|
166
|
+
```bash
|
|
167
|
+
OPENAI_ENDPOINT="http://localhost:11434/v1" OPENAI_MODEL="llama3.2" ./askllm
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
#### LM Studio / LocalAI / vLLM:
|
|
171
|
+
```bash
|
|
172
|
+
OPENAI_ENDPOINT="http://localhost:1234/v1" OPENAI_MODEL="local-model" ./askllm
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## REPL Slash Commands
|
|
178
|
+
|
|
179
|
+
Inside the REPL, type `/` to access built-in commands:
|
|
180
|
+
|
|
181
|
+
| Command | Action |
|
|
182
|
+
| :--- | :--- |
|
|
183
|
+
| `/help` | Display command help and tips |
|
|
184
|
+
| `/clear` or `/reset` | Clear session conversation history |
|
|
185
|
+
| `/model [name]` | Show or dynamically switch model |
|
|
186
|
+
| `/endpoint` | Show currently configured endpoint URL |
|
|
187
|
+
| `/system [prompt]` | Show or update the system prompt |
|
|
188
|
+
| `/history` | Show full message history for the session |
|
|
189
|
+
| `/paste` | Enter multiline paste mode |
|
|
190
|
+
| `/exit` or `/quit` | Exit the REPL |
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Command-Line Options
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
usage: askllm [-h] [-v] [-m MODEL] [-e ENDPOINT] [-k API_KEY] [-s SYSTEM]
|
|
198
|
+
|
|
199
|
+
options:
|
|
200
|
+
-h, --help show this help message and exit
|
|
201
|
+
-v, --version show program's version number and exit
|
|
202
|
+
-m MODEL, --model MODEL
|
|
203
|
+
LLM model name (env: OPENAI_MODEL, default: gpt-4o-mini)
|
|
204
|
+
-e ENDPOINT, --endpoint ENDPOINT
|
|
205
|
+
API Base URL / Endpoint (env: OPENAI_BASE_URL, OPENAI_ENDPOINT, OPENAI_API_BASE)
|
|
206
|
+
-k API_KEY, --api-key API_KEY
|
|
207
|
+
API Key (env: OPENAI_API_KEY)
|
|
208
|
+
-s SYSTEM, --system SYSTEM
|
|
209
|
+
System prompt (env: OPENAI_SYSTEM_PROMPT)
|
|
210
|
+
```
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# AskLLM
|
|
2
|
+
|
|
3
|
+
A lightweight, zero-dependency Python REPL for chatting with LLMs via any OpenAI-compatible API.
|
|
4
|
+
|
|
5
|
+
Designed to be installed via `pip install askllm-cli` (or `pipx install askllm-cli`), and developed using **Podman** with `python:3.12-slim` (or run directly with Python 3).
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Zero Third-Party Runtime Dependencies:** Uses standard library only (`urllib.request`, `json`, `readline`).
|
|
12
|
+
- **Standard Python Package:** Modern PEP 517/621 packaging (`pyproject.toml`) published as `askllm-cli` on PyPI.
|
|
13
|
+
- **Podman Development Ready:** Built-in development workflow using `python:3.12-slim` (`./dev.sh`).
|
|
14
|
+
- **Environment Variable Auto-Detection:** Automatically picks up standard OpenAI / Azure / local LLM variables.
|
|
15
|
+
- **Streaming Responses:** Real-time token streaming via Server-Sent Events (SSE).
|
|
16
|
+
- **Graceful Signal Handling:**
|
|
17
|
+
- `Ctrl+C` or `Ctrl+D` at the beginning of the line exits the REPL.
|
|
18
|
+
- `Ctrl+C` with text in the buffer cancels the line.
|
|
19
|
+
- `Ctrl+C` during response streaming halts generation cleanly without exiting.
|
|
20
|
+
- **Multi-Turn Chat History:** Maintains conversation context across turns within the session.
|
|
21
|
+
- **Persistent Input History:** Readline history saved across runs in `~/.askllm_history`.
|
|
22
|
+
- **Multiline Input Support:**
|
|
23
|
+
- Triple quotes `"""` ... `"""`
|
|
24
|
+
- Trailing backslash `\`
|
|
25
|
+
- `/paste` command
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Development with Podman (`python:3.12-slim`)
|
|
30
|
+
|
|
31
|
+
No local Python installation or host dependencies are required. A complete development environment is provided via Podman:
|
|
32
|
+
|
|
33
|
+
### 1. Interactive Development Shell
|
|
34
|
+
Drop into an interactive bash shell running inside `python:3.12-slim` with AskLLM installed in editable mode (`pip install -e .`):
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
./dev.sh
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Inside the shell:
|
|
41
|
+
```bash
|
|
42
|
+
# Run AskLLM directly
|
|
43
|
+
askllm --help
|
|
44
|
+
|
|
45
|
+
# Run tests
|
|
46
|
+
pytest
|
|
47
|
+
|
|
48
|
+
# Test interactive python
|
|
49
|
+
python3 -c "import askllm; print(askllm.__version__)"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Any changes made to files in `src/askllm` on your host are immediately reflected inside the container!
|
|
53
|
+
|
|
54
|
+
### 2. Run Tests in Podman
|
|
55
|
+
Run the test suite inside the `python:3.12-slim` container:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
./dev.sh test
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 3. Run AskLLM via Podman
|
|
62
|
+
Run AskLLM using Podman directly from the host:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
./askllm
|
|
66
|
+
```
|
|
67
|
+
or with arguments:
|
|
68
|
+
```bash
|
|
69
|
+
./askllm --model llama3.2 --endpoint http://localhost:11434/v1
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### 4. Dev Container (VS Code / IDEs)
|
|
73
|
+
Open this repository in VS Code or any editor supporting Dev Containers to develop seamlessly inside the `python:3.12-slim` container.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Installation via `pip` / `pipx`
|
|
78
|
+
|
|
79
|
+
Install AskLLM from PyPI:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
# Recommended for CLI tools
|
|
83
|
+
pipx install askllm-cli
|
|
84
|
+
|
|
85
|
+
# Or via standard pip
|
|
86
|
+
pip install askllm-cli
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Or install locally in editable mode in any Python virtual environment:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# Editable install
|
|
93
|
+
pip install -e .
|
|
94
|
+
|
|
95
|
+
# With dev dependencies (pytest)
|
|
96
|
+
pip install -e ".[dev]"
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Once installed, the `askllm` command is directly available:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
askllm --help
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Or run as a module:
|
|
106
|
+
```bash
|
|
107
|
+
python3 -m askllm
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Production Container
|
|
113
|
+
|
|
114
|
+
Build and run the production image using Podman:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
podman build -t askllm .
|
|
118
|
+
podman run --rm -it --network=host -e OPENAI_API_KEY askllm
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Environment Variables
|
|
124
|
+
|
|
125
|
+
AskLLM automatically checks for these environment variables:
|
|
126
|
+
|
|
127
|
+
| Variable | Description | Default |
|
|
128
|
+
| :--- | :--- | :--- |
|
|
129
|
+
| `OPENAI_API_KEY` | Your API key | *(empty / none)* |
|
|
130
|
+
| `OPENAI_ENDPOINT` or `OPENAI_BASE_URL` or `OPENAI_API_BASE` | API base URL or chat endpoint | `https://api.openai.com/v1` |
|
|
131
|
+
| `OPENAI_MODEL` | Default model name | `gpt-4o-mini` |
|
|
132
|
+
| `OPENAI_SYSTEM_PROMPT` | Custom system prompt | `"You are a helpful assistant."` |
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Using with Local LLMs (Ollama, LM Studio, vLLM, etc.)
|
|
137
|
+
|
|
138
|
+
Because `./askllm` and `./dev.sh` use host networking (`--network=host`), you can connect directly to local servers:
|
|
139
|
+
|
|
140
|
+
#### Ollama:
|
|
141
|
+
```bash
|
|
142
|
+
OPENAI_ENDPOINT="http://localhost:11434/v1" OPENAI_MODEL="llama3.2" ./askllm
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
#### LM Studio / LocalAI / vLLM:
|
|
146
|
+
```bash
|
|
147
|
+
OPENAI_ENDPOINT="http://localhost:1234/v1" OPENAI_MODEL="local-model" ./askllm
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## REPL Slash Commands
|
|
153
|
+
|
|
154
|
+
Inside the REPL, type `/` to access built-in commands:
|
|
155
|
+
|
|
156
|
+
| Command | Action |
|
|
157
|
+
| :--- | :--- |
|
|
158
|
+
| `/help` | Display command help and tips |
|
|
159
|
+
| `/clear` or `/reset` | Clear session conversation history |
|
|
160
|
+
| `/model [name]` | Show or dynamically switch model |
|
|
161
|
+
| `/endpoint` | Show currently configured endpoint URL |
|
|
162
|
+
| `/system [prompt]` | Show or update the system prompt |
|
|
163
|
+
| `/history` | Show full message history for the session |
|
|
164
|
+
| `/paste` | Enter multiline paste mode |
|
|
165
|
+
| `/exit` or `/quit` | Exit the REPL |
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## Command-Line Options
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
usage: askllm [-h] [-v] [-m MODEL] [-e ENDPOINT] [-k API_KEY] [-s SYSTEM]
|
|
173
|
+
|
|
174
|
+
options:
|
|
175
|
+
-h, --help show this help message and exit
|
|
176
|
+
-v, --version show program's version number and exit
|
|
177
|
+
-m MODEL, --model MODEL
|
|
178
|
+
LLM model name (env: OPENAI_MODEL, default: gpt-4o-mini)
|
|
179
|
+
-e ENDPOINT, --endpoint ENDPOINT
|
|
180
|
+
API Base URL / Endpoint (env: OPENAI_BASE_URL, OPENAI_ENDPOINT, OPENAI_API_BASE)
|
|
181
|
+
-k API_KEY, --api-key API_KEY
|
|
182
|
+
API Key (env: OPENAI_API_KEY)
|
|
183
|
+
-s SYSTEM, --system SYSTEM
|
|
184
|
+
System prompt (env: OPENAI_SYSTEM_PROMPT)
|
|
185
|
+
```
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "askllm-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Lightweight OpenAI-compatible terminal REPL"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "AskLLM" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["llm", "repl", "cli", "openai", "terminal", "ai"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.8",
|
|
23
|
+
"Programming Language :: Python :: 3.9",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Topic :: Communications :: Chat",
|
|
29
|
+
"Topic :: Utilities",
|
|
30
|
+
]
|
|
31
|
+
dependencies = []
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=7.0",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.scripts]
|
|
39
|
+
askllm = "askllm.cli:main"
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.packages.find]
|
|
42
|
+
where = ["src"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AskLLM - A lightweight Python REPL for asking LLMs (OpenAI-compatible endpoints).
|
|
3
|
+
Zero external dependencies (uses Python standard library).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
|
|
8
|
+
from askllm.cli import LLMClient, REPL, main
|
|
9
|
+
|
|
10
|
+
__all__ = ["LLMClient", "REPL", "main", "__version__"]
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
askllm - A lightweight Python REPL for asking LLMs (OpenAI-compatible endpoints).
|
|
4
|
+
Zero external dependencies (uses Python standard library).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import atexit
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import readline
|
|
11
|
+
import signal
|
|
12
|
+
import sys
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.parse
|
|
15
|
+
import urllib.request
|
|
16
|
+
from typing import List, Dict, Generator, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from askllm import __version__
|
|
20
|
+
except ImportError:
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
# ANSI color codes
|
|
24
|
+
COLOR_RESET = "\033[0m"
|
|
25
|
+
COLOR_BOLD = "\033[1m"
|
|
26
|
+
COLOR_DIM = "\033[2m"
|
|
27
|
+
COLOR_CYAN = "\033[36m"
|
|
28
|
+
COLOR_GREEN = "\033[32m"
|
|
29
|
+
COLOR_YELLOW = "\033[33m"
|
|
30
|
+
COLOR_RED = "\033[31m"
|
|
31
|
+
COLOR_MAGENTA = "\033[35m"
|
|
32
|
+
|
|
33
|
+
# Disable colors if not a TTY or NO_COLOR is set
|
|
34
|
+
if not sys.stdout.isatty() or os.environ.get("NO_COLOR"):
|
|
35
|
+
COLOR_RESET = ""
|
|
36
|
+
COLOR_BOLD = ""
|
|
37
|
+
COLOR_DIM = ""
|
|
38
|
+
COLOR_CYAN = ""
|
|
39
|
+
COLOR_GREEN = ""
|
|
40
|
+
COLOR_YELLOW = ""
|
|
41
|
+
COLOR_RED = ""
|
|
42
|
+
COLOR_MAGENTA = ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_default_env(key_names: List[str], default: Optional[str] = None) -> Optional[str]:
|
|
46
|
+
"""Return the first set environment variable from a list of aliases."""
|
|
47
|
+
for key in key_names:
|
|
48
|
+
val = os.environ.get(key)
|
|
49
|
+
if val and val.strip():
|
|
50
|
+
return val.strip()
|
|
51
|
+
return default
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def resolve_chat_url(base_url: str) -> str:
|
|
55
|
+
"""Normalize user-provided base URL or endpoint to chat completions URL."""
|
|
56
|
+
if not base_url:
|
|
57
|
+
return "https://api.openai.com/v1/chat/completions"
|
|
58
|
+
|
|
59
|
+
parsed = urllib.parse.urlparse(base_url)
|
|
60
|
+
path = parsed.path.rstrip("/")
|
|
61
|
+
|
|
62
|
+
if path.endswith("/chat/completions"):
|
|
63
|
+
pass
|
|
64
|
+
elif path.endswith("/v1"):
|
|
65
|
+
path = f"{path}/chat/completions"
|
|
66
|
+
elif not path or path == "/":
|
|
67
|
+
path = "/v1/chat/completions"
|
|
68
|
+
else:
|
|
69
|
+
if "chat/completions" not in path:
|
|
70
|
+
path = f"{path}/chat/completions"
|
|
71
|
+
|
|
72
|
+
return urllib.parse.urlunparse(parsed._replace(path=path))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class LLMClient:
|
|
76
|
+
def __init__(self, api_key: str, endpoint: str, model: str):
|
|
77
|
+
self.api_key = api_key
|
|
78
|
+
self.endpoint_url = resolve_chat_url(endpoint)
|
|
79
|
+
self.model = model
|
|
80
|
+
|
|
81
|
+
def stream_chat(
|
|
82
|
+
self, messages: List[Dict[str, str]], stop_event: Optional[dict] = None
|
|
83
|
+
) -> Generator[str, None, None]:
|
|
84
|
+
"""Stream chat completions from OpenAI-compatible API."""
|
|
85
|
+
headers = {
|
|
86
|
+
"Content-Type": "application/json",
|
|
87
|
+
"Accept": "text/event-stream",
|
|
88
|
+
}
|
|
89
|
+
if self.api_key:
|
|
90
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
91
|
+
headers["api-key"] = self.api_key # For Azure OpenAI compatibility
|
|
92
|
+
|
|
93
|
+
payload = {
|
|
94
|
+
"model": self.model,
|
|
95
|
+
"messages": messages,
|
|
96
|
+
"stream": True,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
data = json.dumps(payload).encode("utf-8")
|
|
100
|
+
req = urllib.request.Request(self.endpoint_url, data=data, headers=headers, method="POST")
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
104
|
+
for line in resp:
|
|
105
|
+
if stop_event and stop_event.get("stop"):
|
|
106
|
+
break
|
|
107
|
+
line = line.decode("utf-8", errors="replace").strip()
|
|
108
|
+
if not line or line.startswith(":"):
|
|
109
|
+
continue
|
|
110
|
+
if line.startswith("data: "):
|
|
111
|
+
content_str = line[6:].strip()
|
|
112
|
+
if content_str == "[DONE]":
|
|
113
|
+
break
|
|
114
|
+
try:
|
|
115
|
+
chunk = json.loads(content_str)
|
|
116
|
+
choices = chunk.get("choices", [])
|
|
117
|
+
if choices:
|
|
118
|
+
delta = choices[0].get("delta", {})
|
|
119
|
+
text = delta.get("content", "")
|
|
120
|
+
if text:
|
|
121
|
+
yield text
|
|
122
|
+
except json.JSONDecodeError:
|
|
123
|
+
continue
|
|
124
|
+
except urllib.error.HTTPError as e:
|
|
125
|
+
err_body = e.read().decode("utf-8", errors="replace")
|
|
126
|
+
try:
|
|
127
|
+
err_json = json.loads(err_body)
|
|
128
|
+
msg = err_json.get("error", {}).get("message", err_body)
|
|
129
|
+
except Exception:
|
|
130
|
+
msg = err_body
|
|
131
|
+
raise RuntimeError(f"HTTP {e.code} Error: {msg}") from e
|
|
132
|
+
except urllib.error.URLError as e:
|
|
133
|
+
raise RuntimeError(f"Connection Error: {e.reason}") from e
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class REPL:
|
|
137
|
+
def __init__(self, api_key: str, endpoint: str, model: str, system_prompt: str):
|
|
138
|
+
self.api_key = api_key
|
|
139
|
+
self.endpoint = endpoint
|
|
140
|
+
self.model = model
|
|
141
|
+
self.system_prompt = system_prompt
|
|
142
|
+
self.client = LLMClient(api_key=self.api_key, endpoint=self.endpoint, model=self.model)
|
|
143
|
+
self.history: List[Dict[str, str]] = []
|
|
144
|
+
self.history_file = os.path.expanduser("~/.askllm_history")
|
|
145
|
+
self._init_readline()
|
|
146
|
+
|
|
147
|
+
def _init_readline(self):
|
|
148
|
+
try:
|
|
149
|
+
readline.parse_and_bind("tab: complete")
|
|
150
|
+
if os.path.exists(self.history_file):
|
|
151
|
+
try:
|
|
152
|
+
readline.read_history_file(self.history_file)
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
readline.set_history_length(1000)
|
|
156
|
+
atexit.register(self._save_history)
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
def _save_history(self):
|
|
161
|
+
try:
|
|
162
|
+
readline.write_history_file(self.history_file)
|
|
163
|
+
except Exception:
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
def reset_conversation(self):
|
|
167
|
+
self.history.clear()
|
|
168
|
+
print(f"{COLOR_DIM}Conversation history reset.{COLOR_RESET}")
|
|
169
|
+
|
|
170
|
+
def print_help(self):
|
|
171
|
+
print(f"""
|
|
172
|
+
{COLOR_BOLD}Commands:{COLOR_RESET}
|
|
173
|
+
{COLOR_CYAN}/help{COLOR_RESET} Show this help message
|
|
174
|
+
{COLOR_CYAN}/clear{COLOR_RESET}, {COLOR_CYAN}/reset{COLOR_RESET} Clear current conversation history
|
|
175
|
+
{COLOR_CYAN}/model [name]{COLOR_RESET} Show or switch current model
|
|
176
|
+
{COLOR_CYAN}/system [prompt]{COLOR_RESET} Show or update system prompt
|
|
177
|
+
{COLOR_CYAN}/history{COLOR_RESET} Show current session history
|
|
178
|
+
{COLOR_CYAN}/endpoint{COLOR_RESET} Show current API endpoint
|
|
179
|
+
{COLOR_CYAN}/paste{COLOR_RESET} Start multiline paste mode (finish with empty line or EOF)
|
|
180
|
+
{COLOR_CYAN}/quit{COLOR_RESET}, {COLOR_CYAN}/exit{COLOR_RESET} Exit REPL (or press Ctrl+C / Ctrl+D on an empty line)
|
|
181
|
+
|
|
182
|
+
{COLOR_BOLD}Tips:{COLOR_RESET}
|
|
183
|
+
- Type {COLOR_CYAN}\\\\{COLOR_RESET} at the end of a line or triple quotes {COLOR_CYAN}\"\"\"{COLOR_RESET} for multiline input.
|
|
184
|
+
- Press {COLOR_CYAN}Ctrl+C{COLOR_RESET} during streaming to cancel generation.
|
|
185
|
+
- Press {COLOR_CYAN}Ctrl+C{COLOR_RESET} or {COLOR_CYAN}Ctrl+D{COLOR_RESET} at the start of a prompt to exit.
|
|
186
|
+
""")
|
|
187
|
+
|
|
188
|
+
def handle_command(self, cmd: str) -> bool:
|
|
189
|
+
"""Handle slash command. Returns True if handled, False otherwise."""
|
|
190
|
+
parts = cmd.strip().split(maxsplit=1)
|
|
191
|
+
action = parts[0].lower()
|
|
192
|
+
arg = parts[1].strip() if len(parts) > 1 else ""
|
|
193
|
+
|
|
194
|
+
if action in ("/quit", "/exit"):
|
|
195
|
+
print(f"{COLOR_DIM}Goodbye!{COLOR_RESET}")
|
|
196
|
+
sys.exit(0)
|
|
197
|
+
elif action in ("/help", "/?"):
|
|
198
|
+
self.print_help()
|
|
199
|
+
return True
|
|
200
|
+
elif action in ("/clear", "/reset"):
|
|
201
|
+
self.reset_conversation()
|
|
202
|
+
return True
|
|
203
|
+
elif action == "/model":
|
|
204
|
+
if arg:
|
|
205
|
+
self.model = arg
|
|
206
|
+
self.client.model = arg
|
|
207
|
+
print(f"{COLOR_DIM}Model switched to:{COLOR_RESET} {COLOR_BOLD}{self.model}{COLOR_RESET}")
|
|
208
|
+
else:
|
|
209
|
+
print(f"{COLOR_DIM}Current model:{COLOR_RESET} {COLOR_BOLD}{self.model}{COLOR_RESET}")
|
|
210
|
+
return True
|
|
211
|
+
elif action == "/endpoint":
|
|
212
|
+
print(f"{COLOR_DIM}Endpoint URL:{COLOR_RESET} {COLOR_BOLD}{self.client.endpoint_url}{COLOR_RESET}")
|
|
213
|
+
return True
|
|
214
|
+
elif action == "/system":
|
|
215
|
+
if arg:
|
|
216
|
+
self.system_prompt = arg
|
|
217
|
+
print(f"{COLOR_DIM}System prompt updated to:{COLOR_RESET}\n{self.system_prompt}")
|
|
218
|
+
else:
|
|
219
|
+
print(f"{COLOR_DIM}Current system prompt:{COLOR_RESET}\n{self.system_prompt}")
|
|
220
|
+
return True
|
|
221
|
+
elif action == "/history":
|
|
222
|
+
if not self.history:
|
|
223
|
+
print(f"{COLOR_DIM}No conversation history.{COLOR_RESET}")
|
|
224
|
+
else:
|
|
225
|
+
print(f"{COLOR_BOLD}--- Session History ---{COLOR_RESET}")
|
|
226
|
+
for msg in self.history:
|
|
227
|
+
role = msg["role"].upper()
|
|
228
|
+
color = COLOR_CYAN if role == "USER" else COLOR_GREEN
|
|
229
|
+
print(f"{color}{role}:{COLOR_RESET} {msg['content']}\n")
|
|
230
|
+
return True
|
|
231
|
+
elif action == "/paste":
|
|
232
|
+
self.read_paste_mode()
|
|
233
|
+
return True
|
|
234
|
+
else:
|
|
235
|
+
print(f"{COLOR_RED}Unknown command: {action}. Type /help for assistance.{COLOR_RESET}")
|
|
236
|
+
return True
|
|
237
|
+
|
|
238
|
+
def read_paste_mode(self):
|
|
239
|
+
print(f"{COLOR_DIM}Entering multiline paste mode. Press Enter twice or Ctrl+D on empty line to submit:{COLOR_RESET}")
|
|
240
|
+
lines = []
|
|
241
|
+
while True:
|
|
242
|
+
try:
|
|
243
|
+
line = input(f"{COLOR_DIM}... {COLOR_RESET}")
|
|
244
|
+
if line == "" and lines and lines[-1] == "":
|
|
245
|
+
lines.pop()
|
|
246
|
+
break
|
|
247
|
+
lines.append(line)
|
|
248
|
+
except EOFError:
|
|
249
|
+
break
|
|
250
|
+
except KeyboardInterrupt:
|
|
251
|
+
print(f"\n{COLOR_DIM}[Paste cancelled]{COLOR_RESET}")
|
|
252
|
+
return
|
|
253
|
+
text = "\n".join(lines).strip()
|
|
254
|
+
if text:
|
|
255
|
+
self.send_prompt(text)
|
|
256
|
+
|
|
257
|
+
def send_prompt(self, user_text: str):
|
|
258
|
+
# Build message history for the request
|
|
259
|
+
messages = []
|
|
260
|
+
if self.system_prompt:
|
|
261
|
+
messages.append({"role": "system", "content": self.system_prompt})
|
|
262
|
+
messages.extend(self.history)
|
|
263
|
+
messages.append({"role": "user", "content": user_text})
|
|
264
|
+
|
|
265
|
+
stop_event = {"stop": False}
|
|
266
|
+
|
|
267
|
+
def sigint_handler(sig, frame):
|
|
268
|
+
stop_event["stop"] = True
|
|
269
|
+
print(f"\n{COLOR_YELLOW}[Interrupted by user]{COLOR_RESET}")
|
|
270
|
+
|
|
271
|
+
orig_handler = signal.signal(signal.SIGINT, sigint_handler)
|
|
272
|
+
|
|
273
|
+
sys.stdout.write(f"\n{COLOR_GREEN}{COLOR_BOLD}AI:{COLOR_RESET} ")
|
|
274
|
+
sys.stdout.flush()
|
|
275
|
+
|
|
276
|
+
ai_response_chunks = []
|
|
277
|
+
try:
|
|
278
|
+
for chunk in self.client.stream_chat(messages, stop_event=stop_event):
|
|
279
|
+
if stop_event["stop"]:
|
|
280
|
+
break
|
|
281
|
+
sys.stdout.write(chunk)
|
|
282
|
+
sys.stdout.flush()
|
|
283
|
+
ai_response_chunks.append(chunk)
|
|
284
|
+
sys.stdout.write("\n\n")
|
|
285
|
+
sys.stdout.flush()
|
|
286
|
+
except RuntimeError as e:
|
|
287
|
+
sys.stdout.write("\n")
|
|
288
|
+
print(f"{COLOR_RED}Error: {e}{COLOR_RESET}\n")
|
|
289
|
+
return
|
|
290
|
+
except Exception as e:
|
|
291
|
+
sys.stdout.write("\n")
|
|
292
|
+
print(f"{COLOR_RED}Unexpected error: {e}{COLOR_RESET}\n")
|
|
293
|
+
return
|
|
294
|
+
finally:
|
|
295
|
+
signal.signal(signal.SIGINT, orig_handler)
|
|
296
|
+
|
|
297
|
+
ai_full_text = "".join(ai_response_chunks).strip()
|
|
298
|
+
if ai_full_text:
|
|
299
|
+
self.history.append({"role": "user", "content": user_text})
|
|
300
|
+
self.history.append({"role": "assistant", "content": ai_full_text})
|
|
301
|
+
|
|
302
|
+
def run(self):
|
|
303
|
+
print(f"{COLOR_BOLD}=== AskLLM REPL ==={COLOR_RESET}")
|
|
304
|
+
print(f"{COLOR_DIM}Endpoint:{COLOR_RESET} {COLOR_CYAN}{self.client.endpoint_url}{COLOR_RESET}")
|
|
305
|
+
print(f"{COLOR_DIM}Model: {COLOR_RESET} {COLOR_CYAN}{self.model}{COLOR_RESET}")
|
|
306
|
+
if not self.api_key:
|
|
307
|
+
print(f"{COLOR_YELLOW}Warning: OPENAI_API_KEY is not set.{COLOR_RESET}")
|
|
308
|
+
print(f"{COLOR_DIM}Type {COLOR_RESET}{COLOR_CYAN}/help{COLOR_RESET}{COLOR_DIM} for commands, {COLOR_RESET}{COLOR_CYAN}Ctrl+C{COLOR_RESET}{COLOR_DIM} or {COLOR_RESET}{COLOR_CYAN}Ctrl+D{COLOR_RESET}{COLOR_DIM} at beginning of line to exit.{COLOR_RESET}\n")
|
|
309
|
+
|
|
310
|
+
while True:
|
|
311
|
+
try:
|
|
312
|
+
prompt_line = input(f"{COLOR_BOLD}{COLOR_CYAN}>>> {COLOR_RESET}")
|
|
313
|
+
except EOFError:
|
|
314
|
+
# Ctrl+D at beginning of line quits
|
|
315
|
+
print(f"\n{COLOR_DIM}Goodbye!{COLOR_RESET}")
|
|
316
|
+
break
|
|
317
|
+
except KeyboardInterrupt:
|
|
318
|
+
# Check readline buffer
|
|
319
|
+
buf = readline.get_line_buffer()
|
|
320
|
+
if not buf:
|
|
321
|
+
# Ctrl+C at beginning of line quits
|
|
322
|
+
print(f"\n{COLOR_DIM}Goodbye!{COLOR_RESET}")
|
|
323
|
+
break
|
|
324
|
+
else:
|
|
325
|
+
# Ctrl+C with text in buffer clears line and gives new prompt
|
|
326
|
+
print("^C")
|
|
327
|
+
continue
|
|
328
|
+
|
|
329
|
+
user_text = prompt_line.strip()
|
|
330
|
+
if not user_text:
|
|
331
|
+
continue
|
|
332
|
+
|
|
333
|
+
# Check if entering triple quotes multiline mode
|
|
334
|
+
if user_text.startswith('"""') or user_text.startswith("'''"):
|
|
335
|
+
quote_type = user_text[:3]
|
|
336
|
+
remaining = user_text[3:]
|
|
337
|
+
lines = []
|
|
338
|
+
if remaining.endswith(quote_type) and len(remaining) >= 3:
|
|
339
|
+
user_text = remaining[:-3].strip()
|
|
340
|
+
else:
|
|
341
|
+
if remaining:
|
|
342
|
+
lines.append(remaining)
|
|
343
|
+
while True:
|
|
344
|
+
try:
|
|
345
|
+
cont_line = input(f"{COLOR_DIM}... {COLOR_RESET}")
|
|
346
|
+
if cont_line.endswith(quote_type):
|
|
347
|
+
lines.append(cont_line[:-3])
|
|
348
|
+
break
|
|
349
|
+
lines.append(cont_line)
|
|
350
|
+
except (EOFError, KeyboardInterrupt):
|
|
351
|
+
print(f"\n{COLOR_DIM}[Input cancelled]{COLOR_RESET}")
|
|
352
|
+
lines = None
|
|
353
|
+
break
|
|
354
|
+
if lines is None:
|
|
355
|
+
continue
|
|
356
|
+
user_text = "\n".join(lines).strip()
|
|
357
|
+
|
|
358
|
+
# Check for trailing backslash continuation
|
|
359
|
+
elif user_text.endswith("\\"):
|
|
360
|
+
lines = [user_text[:-1]]
|
|
361
|
+
cancelled = False
|
|
362
|
+
while True:
|
|
363
|
+
try:
|
|
364
|
+
cont_line = input(f"{COLOR_DIM}... {COLOR_RESET}")
|
|
365
|
+
if cont_line.endswith("\\"):
|
|
366
|
+
lines.append(cont_line[:-1])
|
|
367
|
+
else:
|
|
368
|
+
lines.append(cont_line)
|
|
369
|
+
break
|
|
370
|
+
except (EOFError, KeyboardInterrupt):
|
|
371
|
+
print(f"\n{COLOR_DIM}[Input cancelled]{COLOR_RESET}")
|
|
372
|
+
cancelled = True
|
|
373
|
+
break
|
|
374
|
+
if cancelled:
|
|
375
|
+
continue
|
|
376
|
+
user_text = "\n".join(lines).strip()
|
|
377
|
+
|
|
378
|
+
if not user_text:
|
|
379
|
+
continue
|
|
380
|
+
|
|
381
|
+
if user_text.startswith("/"):
|
|
382
|
+
self.handle_command(user_text)
|
|
383
|
+
continue
|
|
384
|
+
|
|
385
|
+
self.send_prompt(user_text)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def parse_args(args=None):
|
|
389
|
+
import argparse
|
|
390
|
+
parser = argparse.ArgumentParser(prog="askllm", description="AskLLM - Lightweight Python REPL for LLMs")
|
|
391
|
+
parser.add_argument(
|
|
392
|
+
"-v", "--version",
|
|
393
|
+
action="version",
|
|
394
|
+
version=f"%(prog)s {__version__}"
|
|
395
|
+
)
|
|
396
|
+
parser.add_argument(
|
|
397
|
+
"-m", "--model",
|
|
398
|
+
default=get_default_env(["OPENAI_MODEL", "OPENAI_MODEL_NAME", "MODEL"], "gpt-4o-mini"),
|
|
399
|
+
help="LLM model name (env: OPENAI_MODEL, default: gpt-4o-mini)"
|
|
400
|
+
)
|
|
401
|
+
parser.add_argument(
|
|
402
|
+
"-e", "--endpoint",
|
|
403
|
+
default=get_default_env(["OPENAI_BASE_URL", "OPENAI_ENDPOINT", "OPENAI_API_BASE"], "https://api.openai.com/v1"),
|
|
404
|
+
help="API Base URL / Endpoint (env: OPENAI_BASE_URL, OPENAI_ENDPOINT, OPENAI_API_BASE)"
|
|
405
|
+
)
|
|
406
|
+
parser.add_argument(
|
|
407
|
+
"-k", "--api-key",
|
|
408
|
+
default=get_default_env(["OPENAI_API_KEY"], ""),
|
|
409
|
+
help="API Key (env: OPENAI_API_KEY)"
|
|
410
|
+
)
|
|
411
|
+
parser.add_argument(
|
|
412
|
+
"-s", "--system",
|
|
413
|
+
default=get_default_env(["OPENAI_SYSTEM_PROMPT"], "You are a helpful assistant."),
|
|
414
|
+
help="System prompt (env: OPENAI_SYSTEM_PROMPT)"
|
|
415
|
+
)
|
|
416
|
+
return parser.parse_args(args)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def main(argv=None):
|
|
420
|
+
args = parse_args(argv)
|
|
421
|
+
repl = REPL(
|
|
422
|
+
api_key=args.api_key,
|
|
423
|
+
endpoint=args.endpoint,
|
|
424
|
+
model=args.model,
|
|
425
|
+
system_prompt=args.system,
|
|
426
|
+
)
|
|
427
|
+
repl.run()
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
if __name__ == "__main__":
|
|
431
|
+
main()
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: askllm-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight OpenAI-compatible terminal REPL
|
|
5
|
+
Author: AskLLM
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: llm,repl,cli,openai,terminal,ai
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Communications :: Chat
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Python: >=3.8
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
25
|
+
|
|
26
|
+
# AskLLM
|
|
27
|
+
|
|
28
|
+
A lightweight, zero-dependency Python REPL for chatting with LLMs via any OpenAI-compatible API.
|
|
29
|
+
|
|
30
|
+
Designed to be installed via `pip install askllm-cli` (or `pipx install askllm-cli`), and developed using **Podman** with `python:3.12-slim` (or run directly with Python 3).
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Features
|
|
35
|
+
|
|
36
|
+
- **Zero Third-Party Runtime Dependencies:** Uses standard library only (`urllib.request`, `json`, `readline`).
|
|
37
|
+
- **Standard Python Package:** Modern PEP 517/621 packaging (`pyproject.toml`) published as `askllm-cli` on PyPI.
|
|
38
|
+
- **Podman Development Ready:** Built-in development workflow using `python:3.12-slim` (`./dev.sh`).
|
|
39
|
+
- **Environment Variable Auto-Detection:** Automatically picks up standard OpenAI / Azure / local LLM variables.
|
|
40
|
+
- **Streaming Responses:** Real-time token streaming via Server-Sent Events (SSE).
|
|
41
|
+
- **Graceful Signal Handling:**
|
|
42
|
+
- `Ctrl+C` or `Ctrl+D` at the beginning of the line exits the REPL.
|
|
43
|
+
- `Ctrl+C` with text in the buffer cancels the line.
|
|
44
|
+
- `Ctrl+C` during response streaming halts generation cleanly without exiting.
|
|
45
|
+
- **Multi-Turn Chat History:** Maintains conversation context across turns within the session.
|
|
46
|
+
- **Persistent Input History:** Readline history saved across runs in `~/.askllm_history`.
|
|
47
|
+
- **Multiline Input Support:**
|
|
48
|
+
- Triple quotes `"""` ... `"""`
|
|
49
|
+
- Trailing backslash `\`
|
|
50
|
+
- `/paste` command
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Development with Podman (`python:3.12-slim`)
|
|
55
|
+
|
|
56
|
+
No local Python installation or host dependencies are required. A complete development environment is provided via Podman:
|
|
57
|
+
|
|
58
|
+
### 1. Interactive Development Shell
|
|
59
|
+
Drop into an interactive bash shell running inside `python:3.12-slim` with AskLLM installed in editable mode (`pip install -e .`):
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
./dev.sh
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Inside the shell:
|
|
66
|
+
```bash
|
|
67
|
+
# Run AskLLM directly
|
|
68
|
+
askllm --help
|
|
69
|
+
|
|
70
|
+
# Run tests
|
|
71
|
+
pytest
|
|
72
|
+
|
|
73
|
+
# Test interactive python
|
|
74
|
+
python3 -c "import askllm; print(askllm.__version__)"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Any changes made to files in `src/askllm` on your host are immediately reflected inside the container!
|
|
78
|
+
|
|
79
|
+
### 2. Run Tests in Podman
|
|
80
|
+
Run the test suite inside the `python:3.12-slim` container:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
./dev.sh test
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### 3. Run AskLLM via Podman
|
|
87
|
+
Run AskLLM using Podman directly from the host:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
./askllm
|
|
91
|
+
```
|
|
92
|
+
or with arguments:
|
|
93
|
+
```bash
|
|
94
|
+
./askllm --model llama3.2 --endpoint http://localhost:11434/v1
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### 4. Dev Container (VS Code / IDEs)
|
|
98
|
+
Open this repository in VS Code or any editor supporting Dev Containers to develop seamlessly inside the `python:3.12-slim` container.
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Installation via `pip` / `pipx`
|
|
103
|
+
|
|
104
|
+
Install AskLLM from PyPI:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
# Recommended for CLI tools
|
|
108
|
+
pipx install askllm-cli
|
|
109
|
+
|
|
110
|
+
# Or via standard pip
|
|
111
|
+
pip install askllm-cli
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Or install locally in editable mode in any Python virtual environment:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# Editable install
|
|
118
|
+
pip install -e .
|
|
119
|
+
|
|
120
|
+
# With dev dependencies (pytest)
|
|
121
|
+
pip install -e ".[dev]"
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Once installed, the `askllm` command is directly available:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
askllm --help
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Or run as a module:
|
|
131
|
+
```bash
|
|
132
|
+
python3 -m askllm
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Production Container
|
|
138
|
+
|
|
139
|
+
Build and run the production image using Podman:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
podman build -t askllm .
|
|
143
|
+
podman run --rm -it --network=host -e OPENAI_API_KEY askllm
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Environment Variables
|
|
149
|
+
|
|
150
|
+
AskLLM automatically checks for these environment variables:
|
|
151
|
+
|
|
152
|
+
| Variable | Description | Default |
|
|
153
|
+
| :--- | :--- | :--- |
|
|
154
|
+
| `OPENAI_API_KEY` | Your API key | *(empty / none)* |
|
|
155
|
+
| `OPENAI_ENDPOINT` or `OPENAI_BASE_URL` or `OPENAI_API_BASE` | API base URL or chat endpoint | `https://api.openai.com/v1` |
|
|
156
|
+
| `OPENAI_MODEL` | Default model name | `gpt-4o-mini` |
|
|
157
|
+
| `OPENAI_SYSTEM_PROMPT` | Custom system prompt | `"You are a helpful assistant."` |
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Using with Local LLMs (Ollama, LM Studio, vLLM, etc.)
|
|
162
|
+
|
|
163
|
+
Because `./askllm` and `./dev.sh` use host networking (`--network=host`), you can connect directly to local servers:
|
|
164
|
+
|
|
165
|
+
#### Ollama:
|
|
166
|
+
```bash
|
|
167
|
+
OPENAI_ENDPOINT="http://localhost:11434/v1" OPENAI_MODEL="llama3.2" ./askllm
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
#### LM Studio / LocalAI / vLLM:
|
|
171
|
+
```bash
|
|
172
|
+
OPENAI_ENDPOINT="http://localhost:1234/v1" OPENAI_MODEL="local-model" ./askllm
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## REPL Slash Commands
|
|
178
|
+
|
|
179
|
+
Inside the REPL, type `/` to access built-in commands:
|
|
180
|
+
|
|
181
|
+
| Command | Action |
|
|
182
|
+
| :--- | :--- |
|
|
183
|
+
| `/help` | Display command help and tips |
|
|
184
|
+
| `/clear` or `/reset` | Clear session conversation history |
|
|
185
|
+
| `/model [name]` | Show or dynamically switch model |
|
|
186
|
+
| `/endpoint` | Show currently configured endpoint URL |
|
|
187
|
+
| `/system [prompt]` | Show or update the system prompt |
|
|
188
|
+
| `/history` | Show full message history for the session |
|
|
189
|
+
| `/paste` | Enter multiline paste mode |
|
|
190
|
+
| `/exit` or `/quit` | Exit the REPL |
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Command-Line Options
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
usage: askllm [-h] [-v] [-m MODEL] [-e ENDPOINT] [-k API_KEY] [-s SYSTEM]
|
|
198
|
+
|
|
199
|
+
options:
|
|
200
|
+
-h, --help show this help message and exit
|
|
201
|
+
-v, --version show program's version number and exit
|
|
202
|
+
-m MODEL, --model MODEL
|
|
203
|
+
LLM model name (env: OPENAI_MODEL, default: gpt-4o-mini)
|
|
204
|
+
-e ENDPOINT, --endpoint ENDPOINT
|
|
205
|
+
API Base URL / Endpoint (env: OPENAI_BASE_URL, OPENAI_ENDPOINT, OPENAI_API_BASE)
|
|
206
|
+
-k API_KEY, --api-key API_KEY
|
|
207
|
+
API Key (env: OPENAI_API_KEY)
|
|
208
|
+
-s SYSTEM, --system SYSTEM
|
|
209
|
+
System prompt (env: OPENAI_SYSTEM_PROMPT)
|
|
210
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/askllm/__init__.py
|
|
4
|
+
src/askllm/__main__.py
|
|
5
|
+
src/askllm/cli.py
|
|
6
|
+
src/askllm_cli.egg-info/PKG-INFO
|
|
7
|
+
src/askllm_cli.egg-info/SOURCES.txt
|
|
8
|
+
src/askllm_cli.egg-info/dependency_links.txt
|
|
9
|
+
src/askllm_cli.egg-info/entry_points.txt
|
|
10
|
+
src/askllm_cli.egg-info/requires.txt
|
|
11
|
+
src/askllm_cli.egg-info/top_level.txt
|
|
12
|
+
tests/test_askllm.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
askllm
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import unittest
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
from askllm import __version__, LLMClient, REPL
|
|
6
|
+
from askllm.cli import resolve_chat_url, get_default_env, parse_args
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestAskLLM(unittest.TestCase):
|
|
10
|
+
def test_version(self):
|
|
11
|
+
self.assertEqual(__version__, "0.1.0")
|
|
12
|
+
|
|
13
|
+
def test_resolve_chat_url(self):
|
|
14
|
+
self.assertEqual(
|
|
15
|
+
resolve_chat_url(""),
|
|
16
|
+
"https://api.openai.com/v1/chat/completions"
|
|
17
|
+
)
|
|
18
|
+
self.assertEqual(
|
|
19
|
+
resolve_chat_url("https://api.openai.com/v1"),
|
|
20
|
+
"https://api.openai.com/v1/chat/completions"
|
|
21
|
+
)
|
|
22
|
+
self.assertEqual(
|
|
23
|
+
resolve_chat_url("https://api.openai.com/v1/"),
|
|
24
|
+
"https://api.openai.com/v1/chat/completions"
|
|
25
|
+
)
|
|
26
|
+
self.assertEqual(
|
|
27
|
+
resolve_chat_url("https://api.openai.com/v1/chat/completions"),
|
|
28
|
+
"https://api.openai.com/v1/chat/completions"
|
|
29
|
+
)
|
|
30
|
+
self.assertEqual(
|
|
31
|
+
resolve_chat_url("http://localhost:11434/v1"),
|
|
32
|
+
"http://localhost:11434/v1/chat/completions"
|
|
33
|
+
)
|
|
34
|
+
self.assertEqual(
|
|
35
|
+
resolve_chat_url("http://localhost:1234"),
|
|
36
|
+
"http://localhost:1234/v1/chat/completions"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def test_get_default_env(self):
|
|
40
|
+
with patch.dict(os.environ, {"TEST_KEY_B": "val_b"}, clear=True):
|
|
41
|
+
self.assertEqual(
|
|
42
|
+
get_default_env(["TEST_KEY_A", "TEST_KEY_B"], "fallback"),
|
|
43
|
+
"val_b"
|
|
44
|
+
)
|
|
45
|
+
self.assertEqual(
|
|
46
|
+
get_default_env(["TEST_KEY_NONE"], "fallback"),
|
|
47
|
+
"fallback"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def test_parse_args_defaults(self):
|
|
51
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
52
|
+
args = parse_args([])
|
|
53
|
+
self.assertEqual(args.model, "gpt-4o-mini")
|
|
54
|
+
self.assertEqual(args.endpoint, "https://api.openai.com/v1")
|
|
55
|
+
self.assertEqual(args.api_key, "")
|
|
56
|
+
self.assertEqual(args.system, "You are a helpful assistant.")
|
|
57
|
+
|
|
58
|
+
def test_parse_args_custom(self):
|
|
59
|
+
args = parse_args([
|
|
60
|
+
"-m", "llama3.2",
|
|
61
|
+
"-e", "http://localhost:11434/v1",
|
|
62
|
+
"-k", "my-secret-key",
|
|
63
|
+
"-s", "Custom system prompt"
|
|
64
|
+
])
|
|
65
|
+
self.assertEqual(args.model, "llama3.2")
|
|
66
|
+
self.assertEqual(args.endpoint, "http://localhost:11434/v1")
|
|
67
|
+
self.assertEqual(args.api_key, "my-secret-key")
|
|
68
|
+
self.assertEqual(args.system, "Custom system prompt")
|
|
69
|
+
|
|
70
|
+
def test_repl_commands(self):
|
|
71
|
+
repl = REPL(
|
|
72
|
+
api_key="test-key",
|
|
73
|
+
endpoint="http://localhost:1234/v1",
|
|
74
|
+
model="gpt-4o",
|
|
75
|
+
system_prompt="Initial system prompt"
|
|
76
|
+
)
|
|
77
|
+
# Add a fake conversation history
|
|
78
|
+
repl.history.append({"role": "user", "content": "hello"})
|
|
79
|
+
self.assertEqual(len(repl.history), 1)
|
|
80
|
+
|
|
81
|
+
# /reset command
|
|
82
|
+
handled = repl.handle_command("/reset")
|
|
83
|
+
self.assertTrue(handled)
|
|
84
|
+
self.assertEqual(len(repl.history), 0)
|
|
85
|
+
|
|
86
|
+
# /model command
|
|
87
|
+
handled = repl.handle_command("/model claude-3-opus")
|
|
88
|
+
self.assertTrue(handled)
|
|
89
|
+
self.assertEqual(repl.model, "claude-3-opus")
|
|
90
|
+
self.assertEqual(repl.client.model, "claude-3-opus")
|
|
91
|
+
|
|
92
|
+
# /system command
|
|
93
|
+
handled = repl.handle_command("/system New prompt")
|
|
94
|
+
self.assertTrue(handled)
|
|
95
|
+
self.assertEqual(repl.system_prompt, "New prompt")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
unittest.main()
|