askllm-cli 0.1.1__tar.gz → 0.1.2__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.1 → askllm_cli-0.1.2}/PKG-INFO +35 -2
- {askllm_cli-0.1.1 → askllm_cli-0.1.2}/README.md +34 -1
- {askllm_cli-0.1.1 → askllm_cli-0.1.2}/pyproject.toml +1 -1
- {askllm_cli-0.1.1 → askllm_cli-0.1.2}/src/askllm/__init__.py +1 -1
- {askllm_cli-0.1.1 → askllm_cli-0.1.2}/src/askllm/cli.py +33 -5
- {askllm_cli-0.1.1 → askllm_cli-0.1.2}/src/askllm/executor.py +1 -0
- askllm_cli-0.1.2/src/askllm/web.py +256 -0
- {askllm_cli-0.1.1 → askllm_cli-0.1.2}/src/askllm_cli.egg-info/PKG-INFO +35 -2
- {askllm_cli-0.1.1 → askllm_cli-0.1.2}/src/askllm_cli.egg-info/SOURCES.txt +3 -1
- askllm_cli-0.1.2/tests/test_web.py +82 -0
- {askllm_cli-0.1.1 → askllm_cli-0.1.2}/setup.cfg +0 -0
- {askllm_cli-0.1.1 → askllm_cli-0.1.2}/src/askllm/__main__.py +0 -0
- {askllm_cli-0.1.1 → askllm_cli-0.1.2}/src/askllm_cli.egg-info/dependency_links.txt +0 -0
- {askllm_cli-0.1.1 → askllm_cli-0.1.2}/src/askllm_cli.egg-info/entry_points.txt +0 -0
- {askllm_cli-0.1.1 → askllm_cli-0.1.2}/src/askllm_cli.egg-info/requires.txt +0 -0
- {askllm_cli-0.1.1 → askllm_cli-0.1.2}/src/askllm_cli.egg-info/top_level.txt +0 -0
- {askllm_cli-0.1.1 → askllm_cli-0.1.2}/tests/test_askllm.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: askllm-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Lightweight OpenAI-compatible terminal REPL
|
|
5
5
|
Author: AskLLM
|
|
6
6
|
License: MIT
|
|
@@ -201,11 +201,42 @@ Inside the REPL, type `/` to access built-in commands:
|
|
|
201
201
|
|
|
202
202
|
---
|
|
203
203
|
|
|
204
|
+
## Declarative Web Request Tools
|
|
205
|
+
|
|
206
|
+
Each `-w`/`--web-request` value creates a separate model tool. A request has a
|
|
207
|
+
name, description, defaults, and `params`. Each parameter supplies the model
|
|
208
|
+
facing name and description, a JSON Schema snippet, and a dotted (or JSON
|
|
209
|
+
Pointer) path into `defaults` to replace:
|
|
210
|
+
|
|
211
|
+
```json
|
|
212
|
+
{
|
|
213
|
+
"name": "lookup",
|
|
214
|
+
"description": "Look up a value.",
|
|
215
|
+
"defaults": {
|
|
216
|
+
"uri": "https://example.test/lookup",
|
|
217
|
+
"query": {"page": 1},
|
|
218
|
+
"method": "GET"
|
|
219
|
+
},
|
|
220
|
+
"params": [
|
|
221
|
+
{
|
|
222
|
+
"name": "page",
|
|
223
|
+
"description": "Result page number.",
|
|
224
|
+
"schema": {"type": "integer", "minimum": 1},
|
|
225
|
+
"path": "query.page"
|
|
226
|
+
}
|
|
227
|
+
]
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
The tool exposed to the model contains only the declared parameters; request
|
|
232
|
+
fields not declared in `params` remain fixed at their defaults. `-w` may be
|
|
233
|
+
repeated, or its value may be a JSON array of request definitions.
|
|
234
|
+
|
|
204
235
|
## Command-Line Options
|
|
205
236
|
|
|
206
237
|
```
|
|
207
238
|
usage: askllm [-h] [-v] [-m MODEL] [-e ENDPOINT] [-x] [-k API_KEY] [-s SYSTEM]
|
|
208
|
-
[-H HISTORY_FILE] [--api-mode {auto,chat,responses}]
|
|
239
|
+
[-H HISTORY_FILE] [-w JSON] [--api-mode {auto,chat,responses}]
|
|
209
240
|
|
|
210
241
|
options:
|
|
211
242
|
-h, --help show this help message and exit
|
|
@@ -222,6 +253,8 @@ options:
|
|
|
222
253
|
System prompt (env: ASKLLM_SYSTEM_PROMPT, OPENAI_SYSTEM_PROMPT)
|
|
223
254
|
-H HISTORY_FILE, --history-file HISTORY_FILE
|
|
224
255
|
Path to history file (env: ASKLLM_HISTORY_FILE, OPENAI_HISTORY_FILE, default: ~/.askllm_history)
|
|
256
|
+
-w JSON, --web-request JSON
|
|
257
|
+
Expose a declarative web request tool; repeat the option or provide a JSON array
|
|
225
258
|
--api-mode {auto,chat,responses}
|
|
226
259
|
API mode: auto (probe /v1/responses then fallback to /v1/chat/completions),
|
|
227
260
|
chat, or responses (env: ASKLLM_API_MODE, OPENAI_API_MODE, default: auto)
|
|
@@ -174,11 +174,42 @@ Inside the REPL, type `/` to access built-in commands:
|
|
|
174
174
|
|
|
175
175
|
---
|
|
176
176
|
|
|
177
|
+
## Declarative Web Request Tools
|
|
178
|
+
|
|
179
|
+
Each `-w`/`--web-request` value creates a separate model tool. A request has a
|
|
180
|
+
name, description, defaults, and `params`. Each parameter supplies the model
|
|
181
|
+
facing name and description, a JSON Schema snippet, and a dotted (or JSON
|
|
182
|
+
Pointer) path into `defaults` to replace:
|
|
183
|
+
|
|
184
|
+
```json
|
|
185
|
+
{
|
|
186
|
+
"name": "lookup",
|
|
187
|
+
"description": "Look up a value.",
|
|
188
|
+
"defaults": {
|
|
189
|
+
"uri": "https://example.test/lookup",
|
|
190
|
+
"query": {"page": 1},
|
|
191
|
+
"method": "GET"
|
|
192
|
+
},
|
|
193
|
+
"params": [
|
|
194
|
+
{
|
|
195
|
+
"name": "page",
|
|
196
|
+
"description": "Result page number.",
|
|
197
|
+
"schema": {"type": "integer", "minimum": 1},
|
|
198
|
+
"path": "query.page"
|
|
199
|
+
}
|
|
200
|
+
]
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The tool exposed to the model contains only the declared parameters; request
|
|
205
|
+
fields not declared in `params` remain fixed at their defaults. `-w` may be
|
|
206
|
+
repeated, or its value may be a JSON array of request definitions.
|
|
207
|
+
|
|
177
208
|
## Command-Line Options
|
|
178
209
|
|
|
179
210
|
```
|
|
180
211
|
usage: askllm [-h] [-v] [-m MODEL] [-e ENDPOINT] [-x] [-k API_KEY] [-s SYSTEM]
|
|
181
|
-
[-H HISTORY_FILE] [--api-mode {auto,chat,responses}]
|
|
212
|
+
[-H HISTORY_FILE] [-w JSON] [--api-mode {auto,chat,responses}]
|
|
182
213
|
|
|
183
214
|
options:
|
|
184
215
|
-h, --help show this help message and exit
|
|
@@ -195,6 +226,8 @@ options:
|
|
|
195
226
|
System prompt (env: ASKLLM_SYSTEM_PROMPT, OPENAI_SYSTEM_PROMPT)
|
|
196
227
|
-H HISTORY_FILE, --history-file HISTORY_FILE
|
|
197
228
|
Path to history file (env: ASKLLM_HISTORY_FILE, OPENAI_HISTORY_FILE, default: ~/.askllm_history)
|
|
229
|
+
-w JSON, --web-request JSON
|
|
230
|
+
Expose a declarative web request tool; repeat the option or provide a JSON array
|
|
198
231
|
--api-mode {auto,chat,responses}
|
|
199
232
|
API mode: auto (probe /v1/responses then fallback to /v1/chat/completions),
|
|
200
233
|
chat, or responses (env: ASKLLM_API_MODE, OPENAI_API_MODE, default: auto)
|
|
@@ -3,7 +3,7 @@ AskLLM - A lightweight Python REPL for asking LLMs (OpenAI-compatible endpoints)
|
|
|
3
3
|
Powered by prompt_toolkit for a modern, cross-platform interactive terminal UX.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
-
__version__ = "0.1.
|
|
6
|
+
__version__ = "0.1.2"
|
|
7
7
|
|
|
8
8
|
from askllm.cli import LLMClient, REPL, main
|
|
9
9
|
from askllm.executor import PythonExecutor, PYTHON_TOOL
|
|
@@ -20,11 +20,12 @@ from prompt_toolkit.history import FileHistory as _PTFileHistory, InMemoryHistor
|
|
|
20
20
|
from prompt_toolkit.key_binding import KeyBindings
|
|
21
21
|
|
|
22
22
|
from askllm.executor import PythonExecutor, PYTHON_TOOL, extract_code_from_args
|
|
23
|
+
from askllm.web import make_tool, execute as execute_web_request, parse_specs
|
|
23
24
|
|
|
24
25
|
try:
|
|
25
26
|
from askllm import __version__
|
|
26
27
|
except ImportError:
|
|
27
|
-
__version__ = "0.1.
|
|
28
|
+
__version__ = "0.1.2"
|
|
28
29
|
|
|
29
30
|
# ANSI color codes
|
|
30
31
|
COLOR_RESET = "\033[0m"
|
|
@@ -568,6 +569,7 @@ class REPL:
|
|
|
568
569
|
code_execution: bool = False,
|
|
569
570
|
history_file: Optional[str] = None,
|
|
570
571
|
api_mode: str = "auto",
|
|
572
|
+
web_requests: Optional[List[Dict[str, Any]]] = None,
|
|
571
573
|
):
|
|
572
574
|
self.api_key = api_key
|
|
573
575
|
self.endpoint = endpoint
|
|
@@ -575,6 +577,7 @@ class REPL:
|
|
|
575
577
|
self.system_prompt = system_prompt
|
|
576
578
|
self.code_execution = code_execution
|
|
577
579
|
self.api_mode = api_mode
|
|
580
|
+
self.web_requests = web_requests or []
|
|
578
581
|
self.executor = PythonExecutor() if self.code_execution else None
|
|
579
582
|
self.client = LLMClient(
|
|
580
583
|
api_key=self.api_key,
|
|
@@ -812,8 +815,11 @@ class REPL:
|
|
|
812
815
|
{"role": "user", "content": user_text}
|
|
813
816
|
]
|
|
814
817
|
|
|
815
|
-
tools = [PYTHON_TOOL] if (self.code_execution and self.executor) else
|
|
816
|
-
|
|
818
|
+
tools = [PYTHON_TOOL] if (self.code_execution and self.executor) else []
|
|
819
|
+
tools.extend(make_tool(spec) for spec in self.web_requests)
|
|
820
|
+
if not tools:
|
|
821
|
+
tools = None
|
|
822
|
+
max_tool_steps = 20
|
|
817
823
|
step = 0
|
|
818
824
|
|
|
819
825
|
try:
|
|
@@ -865,6 +871,16 @@ class REPL:
|
|
|
865
871
|
tc_id = tc.get("id") or f"call_{step}_{i}"
|
|
866
872
|
func_name = tc.get("function", {}).get("name", "run_python")
|
|
867
873
|
raw_args = tc.get("function", {}).get("arguments", "")
|
|
874
|
+
if any(spec.get("name") == func_name for spec in self.web_requests):
|
|
875
|
+
spec = next(spec for spec in self.web_requests if spec.get("name") == func_name)
|
|
876
|
+
output = execute_web_request(spec, raw_args)
|
|
877
|
+
sys.stdout.write(f"\n{COLOR_YELLOW}[Web Request: {func_name}]{COLOR_RESET}\n{output}\n\n")
|
|
878
|
+
sys.stdout.flush()
|
|
879
|
+
tool_msg = {"role": "tool", "tool_call_id": tc_id, "name": func_name, "content": output}
|
|
880
|
+
messages.append(tool_msg)
|
|
881
|
+
turn_messages.append(tool_msg)
|
|
882
|
+
continue
|
|
883
|
+
|
|
868
884
|
code = extract_code_from_args(raw_args)
|
|
869
885
|
|
|
870
886
|
sys.stdout.write(f"\n{COLOR_YELLOW}[Python Execution]{COLOR_RESET}\n")
|
|
@@ -903,7 +919,9 @@ class REPL:
|
|
|
903
919
|
print(f"{COLOR_RED}Unexpected error: {e}{COLOR_RESET}\n")
|
|
904
920
|
return
|
|
905
921
|
finally:
|
|
906
|
-
signal.signal
|
|
922
|
+
# signal.signal may return None under tests/non-main-thread contexts.
|
|
923
|
+
if orig_handler is not None:
|
|
924
|
+
signal.signal(signal.SIGINT, orig_handler)
|
|
907
925
|
|
|
908
926
|
if not stop_event["stop"] and len(turn_messages) > 1:
|
|
909
927
|
self.history.extend(turn_messages)
|
|
@@ -1044,7 +1062,16 @@ def parse_args(args=None):
|
|
|
1044
1062
|
default=get_default_env(["ASKLLM_API_MODE", "OPENAI_API_MODE"], "auto"),
|
|
1045
1063
|
help="API mode: auto (probe /v1/responses then fallback to /v1/chat/completions), chat, or responses (env: ASKLLM_API_MODE, OPENAI_API_MODE, default: auto)",
|
|
1046
1064
|
)
|
|
1047
|
-
|
|
1065
|
+
parser.add_argument(
|
|
1066
|
+
"-w", "--web-request", dest="web_requests", action="append", default=[], metavar="JSON",
|
|
1067
|
+
help="Expose a declarative web request tool with named params; may be repeated or supplied as a JSON array",
|
|
1068
|
+
)
|
|
1069
|
+
parsed = parser.parse_args(args)
|
|
1070
|
+
try:
|
|
1071
|
+
parsed.web_requests = [spec for value in parsed.web_requests for spec in parse_specs(value)]
|
|
1072
|
+
except (ValueError, json.JSONDecodeError) as exc:
|
|
1073
|
+
parser.error(f"invalid --web-request JSON: {exc}")
|
|
1074
|
+
return parsed
|
|
1048
1075
|
|
|
1049
1076
|
|
|
1050
1077
|
def main(argv=None):
|
|
@@ -1057,6 +1084,7 @@ def main(argv=None):
|
|
|
1057
1084
|
code_execution=args.code_execution,
|
|
1058
1085
|
history_file=args.history_file,
|
|
1059
1086
|
api_mode=args.api_mode,
|
|
1087
|
+
web_requests=args.web_requests,
|
|
1060
1088
|
)
|
|
1061
1089
|
repl.run()
|
|
1062
1090
|
|
|
@@ -40,6 +40,7 @@ PYTHON_TOOL: Dict[str, Any] = {
|
|
|
40
40
|
"name": "run_python",
|
|
41
41
|
"description": (
|
|
42
42
|
"Execute Python code in a stateful, persistent REPL session. "
|
|
43
|
+
"Variables persist, be careful with memory management. "
|
|
43
44
|
"Output from print() and any errors/tracebacks are captured and returned. "
|
|
44
45
|
"Use print() to inspect variables or output results."
|
|
45
46
|
),
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Safe, declarative HTTP request tools exposed to the model."""
|
|
2
|
+
import fnmatch
|
|
3
|
+
import json
|
|
4
|
+
import copy
|
|
5
|
+
import urllib.parse
|
|
6
|
+
import urllib.request
|
|
7
|
+
from typing import Any, Dict, List
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _paths(value, prefix=""):
|
|
11
|
+
if isinstance(value, dict):
|
|
12
|
+
for k, v in value.items():
|
|
13
|
+
path = f"{prefix}.{k}" if prefix else k
|
|
14
|
+
yield path
|
|
15
|
+
yield from _paths(v, path)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _allowed(path: str, policy: Any) -> bool:
|
|
19
|
+
"""Policy is data: allow/deny glob patterns over replacement paths."""
|
|
20
|
+
if policy is None:
|
|
21
|
+
return False
|
|
22
|
+
if isinstance(policy, list):
|
|
23
|
+
allow, deny = policy, []
|
|
24
|
+
elif isinstance(policy, dict):
|
|
25
|
+
allow = policy.get("allow", policy.get("replace", []))
|
|
26
|
+
deny = policy.get("deny", [])
|
|
27
|
+
else:
|
|
28
|
+
return False
|
|
29
|
+
if allow is True:
|
|
30
|
+
allow = ["*"]
|
|
31
|
+
if isinstance(allow, str): allow = [allow]
|
|
32
|
+
if isinstance(deny, str): deny = [deny]
|
|
33
|
+
if any(fnmatch.fnmatchcase(path, str(p)) for p in (deny or [])):
|
|
34
|
+
return False
|
|
35
|
+
return any(fnmatch.fnmatchcase(path, str(p)) for p in (allow or []))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _merge(default: Any, replacement: Any, path: str, policy: Any) -> Any:
|
|
39
|
+
"""Apply a replacement while preserving fields not permitted by *policy*.
|
|
40
|
+
|
|
41
|
+
Policies are structural glob rules, rather than HTTP/security switches. A
|
|
42
|
+
rule such as ``query.user_id`` permits only that value; ``body.*`` permits
|
|
43
|
+
direct body fields; and ``headers`` permits replacing the complete header
|
|
44
|
+
list. An exact/parent match permits replacing the complete value.
|
|
45
|
+
"""
|
|
46
|
+
if _allowed(path, policy):
|
|
47
|
+
return replacement
|
|
48
|
+
if isinstance(default, dict) and isinstance(replacement, dict):
|
|
49
|
+
result = dict(default)
|
|
50
|
+
for key, value in replacement.items():
|
|
51
|
+
child = f"{path}.{key}" if path else str(key)
|
|
52
|
+
if key in default:
|
|
53
|
+
result[key] = _merge(default[key], value, child, policy)
|
|
54
|
+
elif _allowed(child, policy):
|
|
55
|
+
result[key] = value
|
|
56
|
+
return result
|
|
57
|
+
# Lists are intentionally atomic unless their parent path is allowed. It
|
|
58
|
+
# avoids surprising partial mutations of headers and array-valued params.
|
|
59
|
+
return default
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _schema(value: Any) -> Dict[str, Any]:
|
|
63
|
+
"""Return a JSON-schema object from a schema snippet."""
|
|
64
|
+
if isinstance(value, dict):
|
|
65
|
+
return value
|
|
66
|
+
if isinstance(value, str):
|
|
67
|
+
parsed = json.loads(value)
|
|
68
|
+
if isinstance(parsed, dict):
|
|
69
|
+
return parsed
|
|
70
|
+
raise ValueError("parameter schema must be a JSON schema object")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _path_parts(path: str) -> List[str]:
|
|
74
|
+
"""Accept the dotted paths used by the CLI and JSON Pointer paths."""
|
|
75
|
+
if path.startswith("/"):
|
|
76
|
+
return [p.replace("~1", "/").replace("~0", "~") for p in path[1:].split("/")]
|
|
77
|
+
if path.startswith("$."):
|
|
78
|
+
path = path[2:]
|
|
79
|
+
elif path == "$":
|
|
80
|
+
return []
|
|
81
|
+
return [part for part in path.split(".") if part]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _replace_path(value: Any, path: str, replacement: Any) -> Any:
|
|
85
|
+
parts = _path_parts(path)
|
|
86
|
+
if not parts:
|
|
87
|
+
return replacement
|
|
88
|
+
result = copy.deepcopy(value)
|
|
89
|
+
if not isinstance(result, (dict, list)):
|
|
90
|
+
raise ValueError(f"cannot replace path {path!r}")
|
|
91
|
+
cursor = result
|
|
92
|
+
for part in parts[:-1]:
|
|
93
|
+
if isinstance(cursor, dict):
|
|
94
|
+
if part not in cursor:
|
|
95
|
+
cursor[part] = {}
|
|
96
|
+
cursor = cursor[part]
|
|
97
|
+
elif isinstance(cursor, list) and part.isdigit():
|
|
98
|
+
cursor = cursor[int(part)]
|
|
99
|
+
else:
|
|
100
|
+
raise ValueError(f"path does not exist: {path}")
|
|
101
|
+
leaf = parts[-1]
|
|
102
|
+
if isinstance(cursor, dict):
|
|
103
|
+
cursor[leaf] = replacement
|
|
104
|
+
elif isinstance(cursor, list) and leaf.isdigit():
|
|
105
|
+
cursor[int(leaf)] = replacement
|
|
106
|
+
else:
|
|
107
|
+
raise ValueError(f"path does not exist: {path}")
|
|
108
|
+
return result
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _params(spec: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
112
|
+
params = spec.get("params")
|
|
113
|
+
if params is None:
|
|
114
|
+
return []
|
|
115
|
+
if not isinstance(params, list):
|
|
116
|
+
raise ValueError("params must be an array")
|
|
117
|
+
# Accept both concise names and the explicit names used by configuration
|
|
118
|
+
# files. Internally all parameters use schema/path.
|
|
119
|
+
normalized = []
|
|
120
|
+
for param in params:
|
|
121
|
+
if not isinstance(param, dict):
|
|
122
|
+
normalized.append(param)
|
|
123
|
+
continue
|
|
124
|
+
item = dict(param)
|
|
125
|
+
if "schema" not in item and "json_schema" in item:
|
|
126
|
+
item["schema"] = item["json_schema"]
|
|
127
|
+
if "path" not in item and "json_path" in item:
|
|
128
|
+
item["path"] = item["json_path"]
|
|
129
|
+
normalized.append(item)
|
|
130
|
+
return normalized
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def make_tool(spec: Dict[str, Any]) -> Dict[str, Any]:
|
|
134
|
+
properties: Dict[str, Any] = {}
|
|
135
|
+
required: List[str] = []
|
|
136
|
+
params = _params(spec)
|
|
137
|
+
if params:
|
|
138
|
+
for param in params:
|
|
139
|
+
schema = dict(_schema(param["schema"]))
|
|
140
|
+
schema.setdefault("description", param["description"])
|
|
141
|
+
properties[param["name"]] = schema
|
|
142
|
+
if param.get("required", False):
|
|
143
|
+
required.append(param["name"])
|
|
144
|
+
else:
|
|
145
|
+
# Compatibility with the original format. New specs should use params.
|
|
146
|
+
properties = {
|
|
147
|
+
"uri": {"type": "string", "description": "Replacement request URI."},
|
|
148
|
+
"query": {"type": "object", "additionalProperties": True},
|
|
149
|
+
"body": {"type": "object", "additionalProperties": True},
|
|
150
|
+
"headers": {"type": "array", "items": {"type": "array", "minItems": 2, "maxItems": 2}},
|
|
151
|
+
"method": {"type": "string"},
|
|
152
|
+
}
|
|
153
|
+
parameters = {"type": "object", "properties": properties, "additionalProperties": False}
|
|
154
|
+
if required:
|
|
155
|
+
parameters["required"] = required
|
|
156
|
+
return {"type": "function", "function": {
|
|
157
|
+
"name": spec["name"], "description": spec["description"], "parameters": parameters
|
|
158
|
+
}}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def execute(spec: Dict[str, Any], raw_args: str) -> str:
|
|
162
|
+
try:
|
|
163
|
+
args = json.loads(raw_args) if raw_args else {}
|
|
164
|
+
if not isinstance(args, dict): raise ValueError("arguments must be a JSON object")
|
|
165
|
+
except Exception as e:
|
|
166
|
+
return json.dumps({"error": f"invalid arguments: {e}"})
|
|
167
|
+
defaults = spec["defaults"]
|
|
168
|
+
params = _params(spec)
|
|
169
|
+
if params:
|
|
170
|
+
request = copy.deepcopy(defaults)
|
|
171
|
+
by_name = {param["name"]: param for param in params}
|
|
172
|
+
for key, value in args.items():
|
|
173
|
+
if key in by_name:
|
|
174
|
+
param = by_name[key]
|
|
175
|
+
request = _replace_path(request, param["path"], value)
|
|
176
|
+
else:
|
|
177
|
+
policy = spec.get("policy", {})
|
|
178
|
+
allowed_keys = ("uri", "query", "body", "headers", "method")
|
|
179
|
+
request = dict(defaults)
|
|
180
|
+
for key, value in args.items():
|
|
181
|
+
if key in allowed_keys:
|
|
182
|
+
request[key] = _merge(defaults.get(key), value, key, policy)
|
|
183
|
+
uri = request.get("uri")
|
|
184
|
+
if not isinstance(uri, str) or not uri:
|
|
185
|
+
return json.dumps({"error": "defaults.uri must be a non-empty string"})
|
|
186
|
+
query = request.get("query") or {}
|
|
187
|
+
if isinstance(query, dict):
|
|
188
|
+
query_text = urllib.parse.urlencode(query, doseq=True)
|
|
189
|
+
else:
|
|
190
|
+
query_text = str(query)
|
|
191
|
+
if query_text:
|
|
192
|
+
uri += ("&" if "?" in uri else "?") + query_text
|
|
193
|
+
body = request.get("body") or {}
|
|
194
|
+
body_type = body.get("type", "raw") if isinstance(body, dict) else "raw"
|
|
195
|
+
body_value = body.get("value", "") if isinstance(body, dict) else body
|
|
196
|
+
if body_type == "json":
|
|
197
|
+
data = json.dumps(body_value).encode()
|
|
198
|
+
elif body_type == "form":
|
|
199
|
+
data = urllib.parse.urlencode(body_value or {}, doseq=True).encode()
|
|
200
|
+
elif body_type == "raw":
|
|
201
|
+
data = str(body_value).encode()
|
|
202
|
+
else:
|
|
203
|
+
return json.dumps({"error": "body.type must be form, json, or raw"})
|
|
204
|
+
headers = {}
|
|
205
|
+
for pair in request.get("headers", []) or []:
|
|
206
|
+
if isinstance(pair, (list, tuple)) and len(pair) == 2:
|
|
207
|
+
headers[str(pair[0])] = str(pair[1])
|
|
208
|
+
if data and body_type == "json" and not any(k.lower() == "content-type" for k in headers):
|
|
209
|
+
headers["Content-Type"] = "application/json"
|
|
210
|
+
elif data and body_type == "form" and not any(k.lower() == "content-type" for k in headers):
|
|
211
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
212
|
+
req = urllib.request.Request(uri, data=data if data else None,
|
|
213
|
+
headers=headers, method=str(request.get("method", "GET")).upper())
|
|
214
|
+
try:
|
|
215
|
+
with urllib.request.urlopen(req, timeout=60) as response:
|
|
216
|
+
result = {"status": response.status, "headers": dict(response.headers),
|
|
217
|
+
"body": response.read().decode("utf-8", "replace")}
|
|
218
|
+
except Exception as e:
|
|
219
|
+
result = {"error": str(e)}
|
|
220
|
+
return json.dumps(result, ensure_ascii=False)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def parse_specs(value: str) -> List[Dict[str, Any]]:
|
|
224
|
+
parsed = json.loads(value)
|
|
225
|
+
items = parsed if isinstance(parsed, list) else [parsed]
|
|
226
|
+
for spec in items:
|
|
227
|
+
if not isinstance(spec, dict) or not all(k in spec for k in ("name", "description", "defaults")):
|
|
228
|
+
raise ValueError("each web request must contain name, description, and defaults")
|
|
229
|
+
if "params" not in spec and "policy" not in spec:
|
|
230
|
+
raise ValueError("each web request must contain params")
|
|
231
|
+
if "params" in spec:
|
|
232
|
+
if not isinstance(spec["params"], list):
|
|
233
|
+
raise ValueError("params must be an array")
|
|
234
|
+
seen = set()
|
|
235
|
+
for param in _params(spec):
|
|
236
|
+
if (not isinstance(param, dict) or
|
|
237
|
+
not all(k in param for k in ("name", "description", "schema", "path"))):
|
|
238
|
+
raise ValueError("each param must contain name, description, schema, and path (or json_schema and json_path)")
|
|
239
|
+
if not isinstance(param["name"], str) or not param["name"] or param["name"] in seen:
|
|
240
|
+
raise ValueError("parameter names must be unique, non-empty strings")
|
|
241
|
+
seen.add(param["name"])
|
|
242
|
+
_schema(param["schema"])
|
|
243
|
+
if not isinstance(param["path"], str) or not param["path"]:
|
|
244
|
+
raise ValueError("parameter path must be a non-empty string")
|
|
245
|
+
if not isinstance(spec["name"], str) or not spec["name"]:
|
|
246
|
+
raise ValueError("web request name must be a non-empty string")
|
|
247
|
+
if not isinstance(spec["defaults"], dict):
|
|
248
|
+
raise ValueError("defaults must be an object")
|
|
249
|
+
defaults = spec["defaults"]
|
|
250
|
+
if "uri" not in defaults or not isinstance(defaults["uri"], str) or not defaults["uri"]:
|
|
251
|
+
raise ValueError("defaults.uri must be a non-empty string")
|
|
252
|
+
if "body" in defaults:
|
|
253
|
+
body = defaults["body"]
|
|
254
|
+
if not isinstance(body, dict) or body.get("type", "raw") not in ("form", "json", "raw"):
|
|
255
|
+
raise ValueError("defaults.body.type must be form, json, or raw")
|
|
256
|
+
return items
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: askllm-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Lightweight OpenAI-compatible terminal REPL
|
|
5
5
|
Author: AskLLM
|
|
6
6
|
License: MIT
|
|
@@ -201,11 +201,42 @@ Inside the REPL, type `/` to access built-in commands:
|
|
|
201
201
|
|
|
202
202
|
---
|
|
203
203
|
|
|
204
|
+
## Declarative Web Request Tools
|
|
205
|
+
|
|
206
|
+
Each `-w`/`--web-request` value creates a separate model tool. A request has a
|
|
207
|
+
name, description, defaults, and `params`. Each parameter supplies the model
|
|
208
|
+
facing name and description, a JSON Schema snippet, and a dotted (or JSON
|
|
209
|
+
Pointer) path into `defaults` to replace:
|
|
210
|
+
|
|
211
|
+
```json
|
|
212
|
+
{
|
|
213
|
+
"name": "lookup",
|
|
214
|
+
"description": "Look up a value.",
|
|
215
|
+
"defaults": {
|
|
216
|
+
"uri": "https://example.test/lookup",
|
|
217
|
+
"query": {"page": 1},
|
|
218
|
+
"method": "GET"
|
|
219
|
+
},
|
|
220
|
+
"params": [
|
|
221
|
+
{
|
|
222
|
+
"name": "page",
|
|
223
|
+
"description": "Result page number.",
|
|
224
|
+
"schema": {"type": "integer", "minimum": 1},
|
|
225
|
+
"path": "query.page"
|
|
226
|
+
}
|
|
227
|
+
]
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
The tool exposed to the model contains only the declared parameters; request
|
|
232
|
+
fields not declared in `params` remain fixed at their defaults. `-w` may be
|
|
233
|
+
repeated, or its value may be a JSON array of request definitions.
|
|
234
|
+
|
|
204
235
|
## Command-Line Options
|
|
205
236
|
|
|
206
237
|
```
|
|
207
238
|
usage: askllm [-h] [-v] [-m MODEL] [-e ENDPOINT] [-x] [-k API_KEY] [-s SYSTEM]
|
|
208
|
-
[-H HISTORY_FILE] [--api-mode {auto,chat,responses}]
|
|
239
|
+
[-H HISTORY_FILE] [-w JSON] [--api-mode {auto,chat,responses}]
|
|
209
240
|
|
|
210
241
|
options:
|
|
211
242
|
-h, --help show this help message and exit
|
|
@@ -222,6 +253,8 @@ options:
|
|
|
222
253
|
System prompt (env: ASKLLM_SYSTEM_PROMPT, OPENAI_SYSTEM_PROMPT)
|
|
223
254
|
-H HISTORY_FILE, --history-file HISTORY_FILE
|
|
224
255
|
Path to history file (env: ASKLLM_HISTORY_FILE, OPENAI_HISTORY_FILE, default: ~/.askllm_history)
|
|
256
|
+
-w JSON, --web-request JSON
|
|
257
|
+
Expose a declarative web request tool; repeat the option or provide a JSON array
|
|
225
258
|
--api-mode {auto,chat,responses}
|
|
226
259
|
API mode: auto (probe /v1/responses then fallback to /v1/chat/completions),
|
|
227
260
|
chat, or responses (env: ASKLLM_API_MODE, OPENAI_API_MODE, default: auto)
|
|
@@ -4,10 +4,12 @@ src/askllm/__init__.py
|
|
|
4
4
|
src/askllm/__main__.py
|
|
5
5
|
src/askllm/cli.py
|
|
6
6
|
src/askllm/executor.py
|
|
7
|
+
src/askllm/web.py
|
|
7
8
|
src/askllm_cli.egg-info/PKG-INFO
|
|
8
9
|
src/askllm_cli.egg-info/SOURCES.txt
|
|
9
10
|
src/askllm_cli.egg-info/dependency_links.txt
|
|
10
11
|
src/askllm_cli.egg-info/entry_points.txt
|
|
11
12
|
src/askllm_cli.egg-info/requires.txt
|
|
12
13
|
src/askllm_cli.egg-info/top_level.txt
|
|
13
|
-
tests/test_askllm.py
|
|
14
|
+
tests/test_askllm.py
|
|
15
|
+
tests/test_web.py
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import unittest
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
from askllm.web import execute, make_tool, parse_specs
|
|
7
|
+
from askllm.cli import parse_args
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestWebRequests(unittest.TestCase):
|
|
11
|
+
def setUp(self):
|
|
12
|
+
self.spec = {
|
|
13
|
+
"name": "lookup",
|
|
14
|
+
"description": "Look up a value.",
|
|
15
|
+
"defaults": {
|
|
16
|
+
"uri": "https://example.test/lookup",
|
|
17
|
+
"query": {"fixed": "yes", "page": 1},
|
|
18
|
+
"body": {"type": "json", "value": {"url": "https://default.test"}},
|
|
19
|
+
"headers": [["X-Default", "yes"]],
|
|
20
|
+
"method": "POST",
|
|
21
|
+
},
|
|
22
|
+
"policy": {"allow": ["query.page", "body.value.url"]},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
def test_parse_single_and_array_specs(self):
|
|
26
|
+
value = json.dumps([self.spec, {**self.spec, "name": "other"}])
|
|
27
|
+
specs = parse_specs(value)
|
|
28
|
+
self.assertEqual([s["name"] for s in specs], ["lookup", "other"])
|
|
29
|
+
|
|
30
|
+
def test_parse_rejects_invalid_body_type(self):
|
|
31
|
+
bad = {**self.spec, "defaults": {**self.spec["defaults"],
|
|
32
|
+
"body": {"type": "xml", "value": "x"}}}
|
|
33
|
+
with self.assertRaises(ValueError):
|
|
34
|
+
parse_specs(json.dumps(bad))
|
|
35
|
+
|
|
36
|
+
def test_tool_schema(self):
|
|
37
|
+
tool = make_tool(self.spec)
|
|
38
|
+
self.assertEqual(tool["function"]["name"], "lookup")
|
|
39
|
+
self.assertEqual(tool["function"]["parameters"]["type"], "object")
|
|
40
|
+
self.assertIn("uri", tool["function"]["parameters"]["properties"])
|
|
41
|
+
|
|
42
|
+
def test_execute_applies_structural_policy(self):
|
|
43
|
+
response = io.BytesIO(b'{"ok":true}')
|
|
44
|
+
response.status = 200
|
|
45
|
+
response.headers = {"Content-Type": "application/json"}
|
|
46
|
+
captured = {}
|
|
47
|
+
|
|
48
|
+
def urlopen(request, timeout=60):
|
|
49
|
+
captured["request"] = request
|
|
50
|
+
return response
|
|
51
|
+
|
|
52
|
+
args = json.dumps({
|
|
53
|
+
"query": {"page": 3, "fixed": "changed"},
|
|
54
|
+
"body": {"value": {"url": "https://changed.test", "extra": "ignored"}},
|
|
55
|
+
"headers": [["X-Evil", "changed"]],
|
|
56
|
+
"method": "GET",
|
|
57
|
+
})
|
|
58
|
+
with patch("urllib.request.urlopen", side_effect=urlopen):
|
|
59
|
+
result = json.loads(execute(self.spec, args))
|
|
60
|
+
|
|
61
|
+
request = captured["request"]
|
|
62
|
+
self.assertEqual(result["status"], 200)
|
|
63
|
+
self.assertEqual(request.method, "POST")
|
|
64
|
+
self.assertIn("fixed=yes", request.full_url)
|
|
65
|
+
self.assertIn("page=3", request.full_url)
|
|
66
|
+
self.assertNotIn("changed", request.full_url)
|
|
67
|
+
self.assertEqual(json.loads(request.data.decode())["url"], "https://changed.test")
|
|
68
|
+
self.assertEqual(request.get_header("X-default"), "yes")
|
|
69
|
+
|
|
70
|
+
def test_execute_invalid_arguments(self):
|
|
71
|
+
result = json.loads(execute(self.spec, "[]"))
|
|
72
|
+
self.assertIn("error", result)
|
|
73
|
+
|
|
74
|
+
def test_parse_args_repeated_web_request(self):
|
|
75
|
+
one = json.dumps(self.spec)
|
|
76
|
+
two = json.dumps({**self.spec, "name": "second"})
|
|
77
|
+
args = parse_args(["-w", one, "--web-request", two])
|
|
78
|
+
self.assertEqual([s["name"] for s in args.web_requests], ["lookup", "second"])
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|