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 ADDED
@@ -0,0 +1,2 @@
1
+ """Small diagnostics for OpenAI-compatible endpoints."""
2
+ __version__ = "0.1.0"
apiwells/__main__.py ADDED
@@ -0,0 +1,2 @@
1
+ from .cli import main
2
+ raise SystemExit(main())
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ apiwells = apiwells.cli:main
@@ -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