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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: askllm-cli
3
- Version: 0.1.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)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "askllm-cli"
7
- version = "0.1.1"
7
+ version = "0.1.2"
8
8
  description = "Lightweight OpenAI-compatible terminal REPL"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -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.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.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 None
816
- max_tool_steps = 10
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(signal.SIGINT, orig_handler)
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
- return parser.parse_args(args)
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.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