apiwells 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apiwells/__init__.py +2 -0
- apiwells/__main__.py +2 -0
- apiwells/cli.py +178 -0
- apiwells-0.1.0.dist-info/METADATA +118 -0
- apiwells-0.1.0.dist-info/RECORD +9 -0
- apiwells-0.1.0.dist-info/WHEEL +5 -0
- apiwells-0.1.0.dist-info/entry_points.txt +2 -0
- apiwells-0.1.0.dist-info/licenses/LICENSE +21 -0
- apiwells-0.1.0.dist-info/top_level.txt +1 -0
apiwells/__init__.py
ADDED
apiwells/__main__.py
ADDED
apiwells/cli.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Dependency-free, single-request endpoint diagnostics."""
|
|
2
|
+
import argparse
|
|
3
|
+
import http.client
|
|
4
|
+
import ipaddress
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import socket
|
|
9
|
+
import ssl
|
|
10
|
+
import time
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
|
|
17
|
+
LIMIT = 2 * 1024 * 1024
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
21
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def endpoint(base, allow_http=False):
|
|
26
|
+
if not base or any(ord(c) <= 32 or ord(c) == 127 for c in base):
|
|
27
|
+
raise ValueError("Base URL must not contain whitespace/control characters.")
|
|
28
|
+
p = urllib.parse.urlsplit(base)
|
|
29
|
+
if p.scheme not in ("http", "https") or not p.hostname:
|
|
30
|
+
raise ValueError("Use an absolute http(s) API base URL ending in /v1 if required.")
|
|
31
|
+
if p.username is not None or p.password is not None or p.query or p.fragment:
|
|
32
|
+
raise ValueError("Do not put credentials, query strings or fragments in the base URL.")
|
|
33
|
+
try:
|
|
34
|
+
p.port
|
|
35
|
+
local = ipaddress.ip_address(p.hostname).is_loopback
|
|
36
|
+
except ValueError:
|
|
37
|
+
local = p.hostname == "localhost"
|
|
38
|
+
# Validate malformed ports separately from non-IP hostnames.
|
|
39
|
+
p.port
|
|
40
|
+
if p.scheme == "http" and not local and not allow_http:
|
|
41
|
+
raise ValueError("Remote HTTP is unencrypted; use HTTPS or explicitly --allow-http.")
|
|
42
|
+
path = p.path.rstrip("/")
|
|
43
|
+
if path.endswith(("/models", "/chat/completions")):
|
|
44
|
+
raise ValueError("Supply the API base, not the /models or /chat/completions endpoint.")
|
|
45
|
+
return urllib.parse.urlunsplit((p.scheme, p.netloc, path, "", ""))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def hint(status):
|
|
49
|
+
if 300 <= status < 400:
|
|
50
|
+
return "redirect", "Redirect blocked. Verify the final API base URL."
|
|
51
|
+
return {
|
|
52
|
+
400: ("bad_request", "Check model support and request parameters."),
|
|
53
|
+
401: ("authentication", "Check API key validity and the intended endpoint."),
|
|
54
|
+
403: ("forbidden", "Check permissions, IP policy and gateway/WAF rules."),
|
|
55
|
+
404: ("not_found", "Check base path and model name; this does not prove the service is offline."),
|
|
56
|
+
405: ("method_not_allowed", "Check route and protocol compatibility."),
|
|
57
|
+
429: ("rate_or_quota", "Check rate limits, quota and account balance; status alone cannot distinguish them."),
|
|
58
|
+
}.get(status, ("server_error" if status >= 500 else "http_error", "Inspect gateway/upstream logs for this request."))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def diagnose(base_url, key="", model=None, timeout=15.0, max_tokens=8,
|
|
62
|
+
allow_http=False, use_env_proxy=False):
|
|
63
|
+
"""Return a sanitized report. Raises ValueError for invalid local configuration."""
|
|
64
|
+
base = endpoint(base_url, allow_http)
|
|
65
|
+
if not math.isfinite(timeout) or timeout <= 0 or timeout > 300:
|
|
66
|
+
raise ValueError("Timeout must be finite and in (0, 300] seconds.")
|
|
67
|
+
if not isinstance(max_tokens, int) or not 1 <= max_tokens <= 4096:
|
|
68
|
+
raise ValueError("max-tokens must be an integer from 1 to 4096.")
|
|
69
|
+
if any(ord(c) < 33 or ord(c) > 126 for c in key):
|
|
70
|
+
raise ValueError("API key must contain printable ASCII without spaces.")
|
|
71
|
+
if model is not None and (not isinstance(model, str) or not model.strip()):
|
|
72
|
+
raise ValueError("A nonempty model is required for a chat check.")
|
|
73
|
+
kind = "chat" if model is not None else "models"
|
|
74
|
+
route = "/chat/completions" if model is not None else "/models"
|
|
75
|
+
headers = {"Accept": "application/json", "User-Agent": "apiwells/" + __version__}
|
|
76
|
+
if key:
|
|
77
|
+
headers["Authorization"] = "Bearer " + key
|
|
78
|
+
body = None
|
|
79
|
+
if model is not None:
|
|
80
|
+
headers["Content-Type"] = "application/json"
|
|
81
|
+
body = json.dumps({"model": model, "messages": [{"role": "user", "content": "Reply OK."}],
|
|
82
|
+
"max_tokens": max_tokens, "stream": False}).encode()
|
|
83
|
+
req = urllib.request.Request(base + route, data=body, headers=headers)
|
|
84
|
+
# No redirects, .netrc authentication, retries or implicit environment proxy.
|
|
85
|
+
opener = urllib.request.build_opener(NoRedirect(), urllib.request.ProxyHandler(
|
|
86
|
+
None if use_env_proxy else {}))
|
|
87
|
+
report = {"schema_version": 1, "version": __version__, "check": kind,
|
|
88
|
+
"ok": False, "http_status": None, "category": "network", "elapsed_ms": 0}
|
|
89
|
+
start = time.monotonic()
|
|
90
|
+
try:
|
|
91
|
+
try:
|
|
92
|
+
response = opener.open(req, timeout=timeout)
|
|
93
|
+
except urllib.error.HTTPError as exc:
|
|
94
|
+
response = exc
|
|
95
|
+
with response:
|
|
96
|
+
report["http_status"] = response.code
|
|
97
|
+
if not 200 <= response.code < 300:
|
|
98
|
+
report["category"], report["hint"] = hint(response.code)
|
|
99
|
+
return report
|
|
100
|
+
raw = response.read(LIMIT + 1)
|
|
101
|
+
if len(raw) > LIMIT:
|
|
102
|
+
report.update(category="response_too_large", hint="Response exceeded the 2 MiB limit.")
|
|
103
|
+
return report
|
|
104
|
+
try:
|
|
105
|
+
data = json.loads(raw)
|
|
106
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
107
|
+
report.update(category="invalid_json", hint="Expected JSON; check for an HTML login or proxy page.")
|
|
108
|
+
return report
|
|
109
|
+
valid = False
|
|
110
|
+
if isinstance(data, dict) and "error" not in data:
|
|
111
|
+
if kind == "models":
|
|
112
|
+
items = data.get("data")
|
|
113
|
+
valid = isinstance(items, list) and all(
|
|
114
|
+
isinstance(x, dict) and isinstance(x.get("id"), str) and bool(x["id"])
|
|
115
|
+
for x in items)
|
|
116
|
+
if valid:
|
|
117
|
+
report["model_count"] = len(items)
|
|
118
|
+
else:
|
|
119
|
+
choices = data.get("choices")
|
|
120
|
+
if isinstance(choices, list) and choices and isinstance(choices[0], dict):
|
|
121
|
+
message = choices[0].get("message")
|
|
122
|
+
valid = (isinstance(message, dict) and message.get("role") == "assistant"
|
|
123
|
+
and isinstance(message.get("content"), str) and bool(message["content"].strip()))
|
|
124
|
+
report.update(ok=valid, category="ok" if valid else "unexpected_schema",
|
|
125
|
+
hint=("Minimal response check passed; this is not a full compatibility or quality certification."
|
|
126
|
+
if valid else "Expected models data or nonempty assistant text. Inspect upstream protocol/model support."))
|
|
127
|
+
return report
|
|
128
|
+
except (urllib.error.URLError, OSError, http.client.HTTPException) as exc:
|
|
129
|
+
reason = exc.reason if isinstance(exc, urllib.error.URLError) else exc
|
|
130
|
+
category = "network"
|
|
131
|
+
if isinstance(reason, (TimeoutError, socket.timeout)):
|
|
132
|
+
category = "timeout"
|
|
133
|
+
elif isinstance(reason, ssl.SSLError):
|
|
134
|
+
category = "tls"
|
|
135
|
+
elif isinstance(reason, socket.gaierror):
|
|
136
|
+
category = "dns"
|
|
137
|
+
report.update(category=category, hint="Check DNS, certificate trust, connectivity and timeout. Raw errors are omitted to protect credentials.")
|
|
138
|
+
return report
|
|
139
|
+
finally:
|
|
140
|
+
report["elapsed_ms"] = round((time.monotonic() - start) * 1000, 2)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def main(argv=None):
|
|
144
|
+
parser = argparse.ArgumentParser(description="ApiWells Endpoint Doctor: one diagnostic request, no retries.")
|
|
145
|
+
parser.add_argument("--version", action="version", version="apiwells " + __version__)
|
|
146
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
147
|
+
p = sub.add_parser("doctor", help="Check an OpenAI-compatible API base")
|
|
148
|
+
p.add_argument("--base-url", required=True, help="Exact API base, e.g. https://host.example/v1; no automatic /v1")
|
|
149
|
+
p.add_argument("--api-key-env", default="APIWELLS_API_KEY", help="Environment variable containing the key")
|
|
150
|
+
p.add_argument("--anonymous", action="store_true", help="Send no authentication header")
|
|
151
|
+
p.add_argument("--chat", action="store_true", help="Opt into one potentially billable chat request")
|
|
152
|
+
p.add_argument("--model", help="Exact model ID; required with --chat")
|
|
153
|
+
p.add_argument("--max-tokens", type=int, default=8)
|
|
154
|
+
p.add_argument("--timeout", type=float, default=15, help="Socket operation timeout in seconds, not total wall-clock deadline")
|
|
155
|
+
p.add_argument("--allow-http", action="store_true", help="Explicitly allow unencrypted remote HTTP")
|
|
156
|
+
p.add_argument("--use-env-proxy", action="store_true", help="Opt into system/environment proxy settings")
|
|
157
|
+
p.add_argument("--json", action="store_true", help="Print sanitized JSON to stdout")
|
|
158
|
+
args = parser.parse_args(argv)
|
|
159
|
+
if args.chat != (args.model is not None):
|
|
160
|
+
p.error("Use --chat and --model together.")
|
|
161
|
+
key = "" if args.anonymous else os.environ.get(args.api_key_env, "")
|
|
162
|
+
if not args.anonymous and not key:
|
|
163
|
+
p.error("API key environment variable is missing/empty; set it or use --anonymous.")
|
|
164
|
+
try:
|
|
165
|
+
result = diagnose(args.base_url, key, args.model, args.timeout, args.max_tokens,
|
|
166
|
+
args.allow_http, args.use_env_proxy)
|
|
167
|
+
except (ValueError, UnicodeError):
|
|
168
|
+
p.error("Invalid configuration. Check URL, port, HTTPS, key characters, model, timeout and token limit.")
|
|
169
|
+
if args.json:
|
|
170
|
+
print(json.dumps(result, ensure_ascii=True, allow_nan=False))
|
|
171
|
+
else:
|
|
172
|
+
print("{} {} HTTP={} {:.2f}ms [{}]".format(
|
|
173
|
+
"PASS" if result["ok"] else "FAIL", result["check"],
|
|
174
|
+
result["http_status"], result["elapsed_ms"], result["category"]))
|
|
175
|
+
print(result["hint"])
|
|
176
|
+
if "model_count" in result:
|
|
177
|
+
print("Models returned:", result["model_count"])
|
|
178
|
+
return 0 if result["ok"] else 1
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: apiwells
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Endpoint Doctor for OpenAI-compatible model APIs
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Keywords: api,diagnostics,llm,endpoint
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Topic :: Software Development :: Testing
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# ApiWells Endpoint Doctor
|
|
19
|
+
|
|
20
|
+
A small, dependency-free CLI that checks an OpenAI-compatible model API from
|
|
21
|
+
your machine. Useful when onboarding a gateway customer or checking a deployment.
|
|
22
|
+
Python 3.10+. Initial alpha release; not a full SDK or a service monitor.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
After the release is published to PyPI:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
python -m pip install apiwells
|
|
30
|
+
apiwells --version
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
For an unpublished local checkout: `python -m pip install .`
|
|
34
|
+
|
|
35
|
+
## Check a model API
|
|
36
|
+
|
|
37
|
+
Set `APIWELLS_API_KEY` in your environment using your shell's secret-input
|
|
38
|
+
mechanism. Do not put a real key in a command, screenshot, issue or repository.
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
apiwells doctor --base-url https://YOUR-API-HOST/v1
|
|
42
|
+
apiwells doctor --base-url https://YOUR-API-HOST/v1 --json
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Replace the host with your actual API base. `/v1` is **not** added automatically.
|
|
46
|
+
For a gateway using `/openai/v1`, supply that exact prefix. Do not supply the
|
|
47
|
+
full `/models` or `/chat/completions` URL. This command sends one GET to
|
|
48
|
+
`BASE/models`; it checks JSON `data` entries for nonempty string model IDs.
|
|
49
|
+
An empty model list passes the shape check and reports `model_count: 0`.
|
|
50
|
+
A model list does not prove that inference works or is available to this key.
|
|
51
|
+
|
|
52
|
+
For one potentially billable inference request, opt in explicitly:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
apiwells doctor --base-url https://YOUR-API-HOST/v1 --chat --model YOUR-MODEL-ID
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
This sends `Reply OK.` to `BASE/chat/completions`, with `stream: false` and
|
|
59
|
+
`max_tokens: 8`. A pass means a nonempty assistant text response was returned;
|
|
60
|
+
it does not require exactly `OK`. Some reasoning models require a different
|
|
61
|
+
parameter or larger output budget; this release does not support those variants.
|
|
62
|
+
`--max-tokens 32` increases the budget, not a guaranteed cost ceiling.
|
|
63
|
+
There are no automatic retries or fallback models.
|
|
64
|
+
|
|
65
|
+
For a local unauthenticated development server:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
apiwells doctor --base-url http://127.0.0.1:3000/v1 --anonymous
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Use `--api-key-env NAME` to select another environment variable.
|
|
72
|
+
`--timeout 15` is a socket-operation timeout, not an overall deadline; DNS or
|
|
73
|
+
slow continuous delivery may make total runtime longer. `elapsed_ms` measures
|
|
74
|
+
this client's request/response time, not server inference time or streaming TTFT.
|
|
75
|
+
|
|
76
|
+
## Results
|
|
77
|
+
|
|
78
|
+
Exit codes: `0` minimal check passed, `1` endpoint check failed, `2` local usage
|
|
79
|
+
or configuration error. `--json` writes one JSON object to stdout for completed
|
|
80
|
+
checks; usage errors go to stderr and do not produce a JSON report.
|
|
81
|
+
|
|
82
|
+
HTTP categories include authentication (401), forbidden (403), not_found (404),
|
|
83
|
+
rate_or_quota (429), server_error (5xx), and redirect (3xx). These are diagnostic
|
|
84
|
+
hints, not definitive root-cause identification. Transport categories include
|
|
85
|
+
network, DNS, TLS and timeout. HTTP 200 with HTML, malformed JSON or an invalid
|
|
86
|
+
response shape fails. Response reading is capped at 2 MiB plus one sentinel byte.
|
|
87
|
+
|
|
88
|
+
## Security and limitations
|
|
89
|
+
|
|
90
|
+
- Keys are sent only to the supplied endpoint. Verify the host before running.
|
|
91
|
+
- HTTPS certificate verification stays enabled. Remote HTTP requires `--allow-http`;
|
|
92
|
+
literal loopback addresses and localhost are allowed for development.
|
|
93
|
+
- Redirects are blocked, including same-host redirects.
|
|
94
|
+
- No telemetry, files, raw response bodies, model IDs, URL or keys in reports.
|
|
95
|
+
- Proxy settings are ignored unless `--use-env-proxy` is explicitly supplied.
|
|
96
|
+
- This is a local CLI for endpoints you may test. Do not expose it as an unrestricted
|
|
97
|
+
server-side URL-fetching service: private/local destinations are intentionally allowed.
|
|
98
|
+
- No streaming, embeddings, images, tool calling, Responses API, native Anthropic
|
|
99
|
+
or native Gemini protocol coverage in this version.
|
|
100
|
+
- No live provider integration has been certified by the bundled local tests.
|
|
101
|
+
|
|
102
|
+
## Development
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
python -m venv .venv
|
|
106
|
+
# macOS/Linux: source .venv/bin/activate
|
|
107
|
+
# Windows PowerShell: .\.venv\Scripts\Activate.ps1
|
|
108
|
+
python -m pip install -e .
|
|
109
|
+
python -m unittest discover -s tests -v
|
|
110
|
+
python -m pip install build twine
|
|
111
|
+
python -m build
|
|
112
|
+
python -m twine check --strict dist/*
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
See `docs/PUBLISH_ZH.md` for the release walkthrough, sources and maintenance
|
|
116
|
+
plan. License: MIT. Public source URL and maintainer contact can be added to
|
|
117
|
+
project metadata once their real identities are confirmed; no fictitious links
|
|
118
|
+
or authors are included.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
apiwells/__init__.py,sha256=zDgBwe59SKAoLUeABoQYh1uIbd6Lg7Xhn7mAQA06Y0U,79
|
|
2
|
+
apiwells/__main__.py,sha256=ee5vE0xcUZM3fPmxicvcw-IJXkm6nOwxARREHBWpF8Q,47
|
|
3
|
+
apiwells/cli.py,sha256=ew3RFfQMCxszmSyfnFVw5g4hJnSma6FgMDc6yA4eG8Q,9491
|
|
4
|
+
apiwells-0.1.0.dist-info/licenses/LICENSE,sha256=Q9ns_jyx1GkfQczrUe0nfKbcQUE51Dx_cJcZpv3gfcM,1078
|
|
5
|
+
apiwells-0.1.0.dist-info/METADATA,sha256=GYqqbo1IJmWj-f04xOj8yz6KOriFU-fYx_XHpCDZloo,4961
|
|
6
|
+
apiwells-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
apiwells-0.1.0.dist-info/entry_points.txt,sha256=8wSP_AzjrMO1_TdRiTRuwWSe2sfplzcxll4sLB-7t8E,47
|
|
8
|
+
apiwells-0.1.0.dist-info/top_level.txt,sha256=qVwURZwqLzsvtBeMUbFVyZinuzFnrECS6ZIXVQWj6Gs,9
|
|
9
|
+
apiwells-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ApiWells contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
apiwells
|