apiwells 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apiwells-0.1.0/CHANGELOG.md +6 -0
- apiwells-0.1.0/LICENSE +21 -0
- apiwells-0.1.0/MANIFEST.in +3 -0
- apiwells-0.1.0/PKG-INFO +118 -0
- apiwells-0.1.0/README.md +101 -0
- apiwells-0.1.0/docs/PUBLISH_ZH.md +290 -0
- apiwells-0.1.0/pyproject.toml +27 -0
- apiwells-0.1.0/setup.cfg +4 -0
- apiwells-0.1.0/src/apiwells/__init__.py +2 -0
- apiwells-0.1.0/src/apiwells/__main__.py +2 -0
- apiwells-0.1.0/src/apiwells/cli.py +178 -0
- apiwells-0.1.0/src/apiwells.egg-info/PKG-INFO +118 -0
- apiwells-0.1.0/src/apiwells.egg-info/SOURCES.txt +15 -0
- apiwells-0.1.0/src/apiwells.egg-info/dependency_links.txt +1 -0
- apiwells-0.1.0/src/apiwells.egg-info/entry_points.txt +2 -0
- apiwells-0.1.0/src/apiwells.egg-info/top_level.txt +1 -0
- apiwells-0.1.0/tests/test_doctor.py +126 -0
apiwells-0.1.0/LICENSE
ADDED
|
@@ -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.
|
apiwells-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
apiwells-0.1.0/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# ApiWells Endpoint Doctor
|
|
2
|
+
|
|
3
|
+
A small, dependency-free CLI that checks an OpenAI-compatible model API from
|
|
4
|
+
your machine. Useful when onboarding a gateway customer or checking a deployment.
|
|
5
|
+
Python 3.10+. Initial alpha release; not a full SDK or a service monitor.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
After the release is published to PyPI:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
python -m pip install apiwells
|
|
13
|
+
apiwells --version
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
For an unpublished local checkout: `python -m pip install .`
|
|
17
|
+
|
|
18
|
+
## Check a model API
|
|
19
|
+
|
|
20
|
+
Set `APIWELLS_API_KEY` in your environment using your shell's secret-input
|
|
21
|
+
mechanism. Do not put a real key in a command, screenshot, issue or repository.
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
apiwells doctor --base-url https://YOUR-API-HOST/v1
|
|
25
|
+
apiwells doctor --base-url https://YOUR-API-HOST/v1 --json
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Replace the host with your actual API base. `/v1` is **not** added automatically.
|
|
29
|
+
For a gateway using `/openai/v1`, supply that exact prefix. Do not supply the
|
|
30
|
+
full `/models` or `/chat/completions` URL. This command sends one GET to
|
|
31
|
+
`BASE/models`; it checks JSON `data` entries for nonempty string model IDs.
|
|
32
|
+
An empty model list passes the shape check and reports `model_count: 0`.
|
|
33
|
+
A model list does not prove that inference works or is available to this key.
|
|
34
|
+
|
|
35
|
+
For one potentially billable inference request, opt in explicitly:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
apiwells doctor --base-url https://YOUR-API-HOST/v1 --chat --model YOUR-MODEL-ID
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This sends `Reply OK.` to `BASE/chat/completions`, with `stream: false` and
|
|
42
|
+
`max_tokens: 8`. A pass means a nonempty assistant text response was returned;
|
|
43
|
+
it does not require exactly `OK`. Some reasoning models require a different
|
|
44
|
+
parameter or larger output budget; this release does not support those variants.
|
|
45
|
+
`--max-tokens 32` increases the budget, not a guaranteed cost ceiling.
|
|
46
|
+
There are no automatic retries or fallback models.
|
|
47
|
+
|
|
48
|
+
For a local unauthenticated development server:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
apiwells doctor --base-url http://127.0.0.1:3000/v1 --anonymous
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Use `--api-key-env NAME` to select another environment variable.
|
|
55
|
+
`--timeout 15` is a socket-operation timeout, not an overall deadline; DNS or
|
|
56
|
+
slow continuous delivery may make total runtime longer. `elapsed_ms` measures
|
|
57
|
+
this client's request/response time, not server inference time or streaming TTFT.
|
|
58
|
+
|
|
59
|
+
## Results
|
|
60
|
+
|
|
61
|
+
Exit codes: `0` minimal check passed, `1` endpoint check failed, `2` local usage
|
|
62
|
+
or configuration error. `--json` writes one JSON object to stdout for completed
|
|
63
|
+
checks; usage errors go to stderr and do not produce a JSON report.
|
|
64
|
+
|
|
65
|
+
HTTP categories include authentication (401), forbidden (403), not_found (404),
|
|
66
|
+
rate_or_quota (429), server_error (5xx), and redirect (3xx). These are diagnostic
|
|
67
|
+
hints, not definitive root-cause identification. Transport categories include
|
|
68
|
+
network, DNS, TLS and timeout. HTTP 200 with HTML, malformed JSON or an invalid
|
|
69
|
+
response shape fails. Response reading is capped at 2 MiB plus one sentinel byte.
|
|
70
|
+
|
|
71
|
+
## Security and limitations
|
|
72
|
+
|
|
73
|
+
- Keys are sent only to the supplied endpoint. Verify the host before running.
|
|
74
|
+
- HTTPS certificate verification stays enabled. Remote HTTP requires `--allow-http`;
|
|
75
|
+
literal loopback addresses and localhost are allowed for development.
|
|
76
|
+
- Redirects are blocked, including same-host redirects.
|
|
77
|
+
- No telemetry, files, raw response bodies, model IDs, URL or keys in reports.
|
|
78
|
+
- Proxy settings are ignored unless `--use-env-proxy` is explicitly supplied.
|
|
79
|
+
- This is a local CLI for endpoints you may test. Do not expose it as an unrestricted
|
|
80
|
+
server-side URL-fetching service: private/local destinations are intentionally allowed.
|
|
81
|
+
- No streaming, embeddings, images, tool calling, Responses API, native Anthropic
|
|
82
|
+
or native Gemini protocol coverage in this version.
|
|
83
|
+
- No live provider integration has been certified by the bundled local tests.
|
|
84
|
+
|
|
85
|
+
## Development
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
python -m venv .venv
|
|
89
|
+
# macOS/Linux: source .venv/bin/activate
|
|
90
|
+
# Windows PowerShell: .\.venv\Scripts\Activate.ps1
|
|
91
|
+
python -m pip install -e .
|
|
92
|
+
python -m unittest discover -s tests -v
|
|
93
|
+
python -m pip install build twine
|
|
94
|
+
python -m build
|
|
95
|
+
python -m twine check --strict dist/*
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
See `docs/PUBLISH_ZH.md` for the release walkthrough, sources and maintenance
|
|
99
|
+
plan. License: MIT. Public source URL and maintainer contact can be added to
|
|
100
|
+
project metadata once their real identities are confirmed; no fictitious links
|
|
101
|
+
or authors are included.
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# ApiWells 0.1.0:从本地验证到 PyPI 正式发布
|
|
2
|
+
|
|
3
|
+
核查日期:2026-09-07。适用对象:第一次发布 Python 工具的维护者。
|
|
4
|
+
|
|
5
|
+
## 1. 本次交付和完成边界
|
|
6
|
+
|
|
7
|
+
交付是真实的 Endpoint Doctor:源码、测试、说明书、MIT 许可证、wheel 安装包和 sdist 源码包。
|
|
8
|
+
本次已经完成的检查以压缩包根目录 VALIDATION.md 和验证日志为准。
|
|
9
|
+
尚未完成:你的电脑测试、真实上游带密钥测试、TestPyPI 上传、正式 PyPI 上传。
|
|
10
|
+
没有访问你的 PyPI 账户,也没有代替你创建或使用发布凭据。
|
|
11
|
+
|
|
12
|
+
这是 OpenAI 兼容协议的基础诊断工具,不依赖 ApiWells 平台已上线。
|
|
13
|
+
不包含 New API 的代码;不是 New API 的分发版本。
|
|
14
|
+
可将它用于客户接入、网关部署验收和支持排障,已有独立用途。
|
|
15
|
+
|
|
16
|
+
## 2. 名称与规则:事实、判断和边界
|
|
17
|
+
|
|
18
|
+
事实:PEP 541 将无功能或空包式名称占用列为无效项目;同时还有垃圾信息、侵权、滥用等其他移除依据。
|
|
19
|
+
事实:本次直接查询 https://pypi.org/pypi/apiwells/json 得到 HTTP 404。
|
|
20
|
+
判断:目前未查到该名称的公开项目,可以尝试上传;但保留名、禁止名或之后发生的注册都可能阻止上传。
|
|
21
|
+
边界:工具有功能并不等于 PyPI 事前认可,也不保证永久拥有名称。以首次正式上传成功且项目归属于你的账户为验收条件。
|
|
22
|
+
来源:[PEP 541](https://peps.python.org/pep-0541/)。
|
|
23
|
+
|
|
24
|
+
PyPI 用户名、Python 项目名、导入名和终端命令分别是不同概念:
|
|
25
|
+
|
|
26
|
+
| 项目 | 本次值 | 如何产生 |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| PyPI 账户 | 你的实际账号 | 注册并验证邮箱,不要求叫 apiwells |
|
|
29
|
+
| 发行项目名 | apiwells | pyproject.toml 中的 name,首次成功上传创建 |
|
|
30
|
+
| Python 导入名 | apiwells | src/apiwells 目录 |
|
|
31
|
+
| 终端命令 | apiwells | project.scripts 显式配置,安装时生成 |
|
|
32
|
+
|
|
33
|
+
因此 CLI 并非仅靠 PyPI 名称自动获得,交付件已写好入口配置。它只在安装该包的环境中出现,不构成跨软件全局唯一命令权利。
|
|
34
|
+
|
|
35
|
+
## 3. 下载和准备目录(约 10 分钟)
|
|
36
|
+
|
|
37
|
+
下载 apiwells-release-kit-0.1.0.zip,解压。目录包含:
|
|
38
|
+
|
|
39
|
+
- apiwells/:项目源码。后面的构建命令都在该目录运行。
|
|
40
|
+
- apiwells/dist/:已验证的 .whl 和 .tar.gz 文件。
|
|
41
|
+
- VALIDATION.md、validation-*.txt:本次验证记录。
|
|
42
|
+
- SHA256SUMS.txt:压缩包内部交付文件的校验值。
|
|
43
|
+
|
|
44
|
+
不要直接在压缩包预览窗口运行代码。先完整解压。
|
|
45
|
+
使用 Python 3.10 或以上;本次实测版本是 Python 3.12.13,其他版本/系统未实机验证。
|
|
46
|
+
|
|
47
|
+
macOS:打开“终端”,输入 `cd `(末尾有空格),把解压后的 apiwells 文件夹拖入终端,再回车。
|
|
48
|
+
Windows:在 apiwells 文件夹的空白处右键,选择“在终端中打开”。
|
|
49
|
+
|
|
50
|
+
macOS/Linux:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
python3 --version
|
|
54
|
+
python3 -m venv .venv
|
|
55
|
+
source .venv/bin/activate
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Windows PowerShell:
|
|
59
|
+
|
|
60
|
+
```powershell
|
|
61
|
+
py --version
|
|
62
|
+
py -m venv .venv
|
|
63
|
+
.\.venv\Scripts\Activate.ps1
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
若 PowerShell 禁止运行激活脚本,不必修改全局策略:后文所有 `python` 改为 `.\.venv\Scripts\python.exe`,`apiwells` 改为 `.\.venv\Scripts\apiwells.exe`。
|
|
67
|
+
激活后的命令均使用 `python`,以确保 pip、测试和构建使用同一个环境。
|
|
68
|
+
虚拟环境把该项目的工具与系统 Python 隔离开。
|
|
69
|
+
|
|
70
|
+
## 4. 先安装交付的成品,验证确实能用(约 5 分钟)
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
python -m pip install --no-deps ./dist/apiwells-0.1.0-py3-none-any.whl
|
|
74
|
+
python -m apiwells --version
|
|
75
|
+
python -m apiwells doctor --help
|
|
76
|
+
python -m unittest discover -s tests -v
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
预期版本:`apiwells 0.1.0`;测试结尾应为 `OK`。
|
|
80
|
+
测试会启动临时本地 HTTP 服务并自动关闭;无需真实密钥,也不会调用付费模型。
|
|
81
|
+
这里安装的是 wheel,不是开发目录,因此能发现“源码正常、打包漏文件”的问题。
|
|
82
|
+
`--no-deps` 可用,因为首版无第三方运行依赖;构建和发布工具仍需要安装。
|
|
83
|
+
|
|
84
|
+
## 5. 真实端点验收(约 10 分钟)
|
|
85
|
+
|
|
86
|
+
准备两个信息:上游/你自己的网关的完整 API Base URL,以及在该平台创建的 API Key。
|
|
87
|
+
不要把管理后台网址当 API Base;按对方文档确定 `/v1` 或其他路径。
|
|
88
|
+
不要在这里填写未经确认的 apiwells 品牌域名。
|
|
89
|
+
|
|
90
|
+
macOS 默认 zsh 安全输入密钥(输入时不显示字符):
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
read -s 'APIWELLS_API_KEY?API Key: '
|
|
94
|
+
export APIWELLS_API_KEY
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
bash 用户使用:
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
read -r -s -p 'API Key: ' APIWELLS_API_KEY
|
|
101
|
+
export APIWELLS_API_KEY
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Windows PowerShell:
|
|
105
|
+
|
|
106
|
+
```powershell
|
|
107
|
+
$endpointSecret = Read-Host 'API Key' -AsSecureString
|
|
108
|
+
$env:APIWELLS_API_KEY = [System.Net.NetworkCredential]::new('', $endpointSecret).Password
|
|
109
|
+
Remove-Variable endpointSecret
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
用真实地址替换下面的 YOUR-API-HOST:
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
python -m apiwells doctor --base-url https://YOUR-API-HOST/v1
|
|
116
|
+
python -m apiwells doctor --base-url https://YOUR-API-HOST/v1 --json
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
预期:PASS、HTTP=200、model_count 为平台返回的数量。空数组也会通过结构检查,但没有可见模型。
|
|
120
|
+
默认只做 GET /models。它不主动产生推理 Token,但是否对请求收费仍由服务商决定。
|
|
121
|
+
如果平台不支持模型列表,这项检查可能失败,但对话接口仍可能正常。
|
|
122
|
+
|
|
123
|
+
在平台文档或控制台找到你有权限的完整模型 ID,再执行一次:
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
python -m apiwells doctor --base-url https://YOUR-API-HOST/v1 --chat --model YOUR-MODEL-ID
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
这会发送一次真实、可能收费的对话请求。成功标准是收到非空 assistant 文本。
|
|
130
|
+
默认 max_tokens=8,适合基础文本模型;不保证所有推理模型支持。若返回400,检查模型与参数,而非立刻断定平台故障。
|
|
131
|
+
本版不测流式、工具调用、多模态、Responses API,也不证明模型身份或生成质量。
|
|
132
|
+
|
|
133
|
+
macOS/Linux 结束后:`unset APIWELLS_API_KEY`。
|
|
134
|
+
PowerShell 结束后:`Remove-Item Env:APIWELLS_API_KEY`。
|
|
135
|
+
密钥不会写入报告,但环境变量仍应按凭据保护。
|
|
136
|
+
|
|
137
|
+
## 6. 发布前检查项目身份和许可证(约 5 分钟)
|
|
138
|
+
|
|
139
|
+
打开 pyproject.toml,确认 name=apiwells、version=0.1.0。
|
|
140
|
+
本交付默认采用 MIT:允许其他人使用、修改和再分发这份小工具。若这不是你希望的代码授权方式,先更改许可证和元数据,再重新构建。
|
|
141
|
+
品牌名称使用权与代码许可不是同一件事。
|
|
142
|
+
|
|
143
|
+
没有填写虚构作者邮箱或 GitHub 地址;这些字段不是本包构建所必需的。
|
|
144
|
+
建议在你拥有的 GitHub 账户下建立 apiwells 仓库,上传源码,建立真实问题反馈渠道后,再补充项目链接。
|
|
145
|
+
只添加真实且你控制的信息,不必为了首次发布填写不存在的网址。
|
|
146
|
+
|
|
147
|
+
如果源码/README/元数据有任何改动,应重新测试和构建;不能继续发布旧 dist 文件。
|
|
148
|
+
若未改动,可以直接上传本次验证的两个成品。
|
|
149
|
+
|
|
150
|
+
## 7. 如需自行重新构建(约 5–10 分钟)
|
|
151
|
+
|
|
152
|
+
安装构建工具。build 负责生成分发文件;twine 负责校验元数据和上传。
|
|
153
|
+
|
|
154
|
+
```sh
|
|
155
|
+
python -m pip install --upgrade build twine
|
|
156
|
+
python -m unittest discover -s tests -v
|
|
157
|
+
python -m build
|
|
158
|
+
python -m twine check --strict dist/apiwells-0.1.0-py3-none-any.whl dist/apiwells-0.1.0.tar.gz
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
build 默认先构建源码包,再从源码包生成 wheel。检查两项 PASSED。
|
|
162
|
+
如果测试源代码发生变化,应先 `python -m pip install -e .` 再运行测试,随后重新构建,并在另一个新虚拟环境安装新 wheel 复核。
|
|
163
|
+
不要把 PASSED 理解成安全审计:twine check 主要检查元数据和说明文档。
|
|
164
|
+
来源:[PyPA 打包教程](https://packaging.python.org/en/latest/tutorials/packaging-projects/)。
|
|
165
|
+
|
|
166
|
+
## 8. 创建两个发布账户(约 10–20 分钟)
|
|
167
|
+
|
|
168
|
+
在 [TestPyPI](https://test.pypi.org/account/register/) 和 [PyPI](https://pypi.org/account/register/) 分别注册、验证邮箱,设置双因素认证并保存恢复码。
|
|
169
|
+
这两个站点的账户和 Token 分开管理,不能混用。
|
|
170
|
+
|
|
171
|
+
在对应站点 Account settings → API tokens → Add API token,生成首次发布所用 Token。
|
|
172
|
+
首次发布新项目时项目尚不存在,手工 Token 路线使用该账户范围的 Token;上传完成后换成仅 apiwells 项目的 Token,并撤销首次用的广范围 Token。
|
|
173
|
+
|
|
174
|
+
Token 在终端密码提示中粘贴,不要发给任何聊天助手,不写入源码、命令历史或提交到 Git。
|
|
175
|
+
发布身份固定使用 `__token__`,密码是完整的 `pypi-...` Token。
|
|
176
|
+
来源:[PyPI 账户、2FA 与 Token 帮助](https://pypi.org/help/)。
|
|
177
|
+
|
|
178
|
+
## 9. 先上传 TestPyPI(约 5–10 分钟)
|
|
179
|
+
|
|
180
|
+
在已激活虚拟环境、且位于 apiwells 源码目录时:
|
|
181
|
+
|
|
182
|
+
```sh
|
|
183
|
+
python -m twine upload --repository testpypi --username __token__ dist/apiwells-0.1.0-py3-none-any.whl dist/apiwells-0.1.0.tar.gz
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
提示密码时粘贴 TestPyPI Token;终端不显示字符是正常现象。
|
|
187
|
+
若你已有本机凭据管理配置,Twine 可能直接取已保存凭据;务必核对目标站点。
|
|
188
|
+
上传成功后打开 https://test.pypi.org/project/apiwells/0.1.0/,检查简介和文件。
|
|
189
|
+
|
|
190
|
+
用新环境验证下载,macOS/Linux:
|
|
191
|
+
|
|
192
|
+
```sh
|
|
193
|
+
python -m venv ../apiwells-test-install
|
|
194
|
+
../apiwells-test-install/bin/python -m pip install --index-url https://test.pypi.org/simple/ --no-deps apiwells==0.1.0
|
|
195
|
+
../apiwells-test-install/bin/python -m apiwells --version
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Windows PowerShell:
|
|
199
|
+
|
|
200
|
+
```powershell
|
|
201
|
+
python -m venv ..\apiwells-test-install
|
|
202
|
+
..\apiwells-test-install\Scripts\python.exe -m pip install --index-url https://test.pypi.org/simple/ --no-deps apiwells==0.1.0
|
|
203
|
+
..\apiwells-test-install\Scripts\python.exe -m apiwells --version
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
TestPyPI 的成功不会占有正式 PyPI 名称;测试站数据也可能被清理。
|
|
207
|
+
如果测试站 apiwells 已被别人使用,不表示正式站也被使用,不要声称该项目属于你;可以依靠已完成的本地 wheel 安装验收再走正式发布。
|
|
208
|
+
本项目无运行依赖,因此无需混用测试/生产两个下载索引。
|
|
209
|
+
来源:[TestPyPI 官方指南](https://packaging.python.org/en/latest/guides/using-testpypi/)。
|
|
210
|
+
社区实践参考:[Python 打包社区对全新环境 wheel 测试的讨论](https://discuss.python.org/t/update-packaging-tutorial-to-allow-installing-dependencies-when-testing-installation/47999)。这是实践建议,不是上传规则。
|
|
211
|
+
|
|
212
|
+
## 10. 正式发布(约 5 分钟)
|
|
213
|
+
|
|
214
|
+
确保真实端点验收达到你的要求,且确认 MIT 和公开文件内容。
|
|
215
|
+
再次打开 https://pypi.org/project/apiwells/ 检查名称状态。404 仍不保证可注册。
|
|
216
|
+
在构建环境执行下列命令;这一步会把工具公开到正式 PyPI:
|
|
217
|
+
|
|
218
|
+
```sh
|
|
219
|
+
python -m twine upload --repository-url https://upload.pypi.org/legacy/ --username __token__ dist/apiwells-0.1.0-py3-none-any.whl dist/apiwells-0.1.0.tar.gz
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
密码提示中使用正式 PyPI Token。
|
|
223
|
+
上传结束后,打开 https://pypi.org/project/apiwells/0.1.0/,并在登录后的 Your projects 确认该项目由你管理。
|
|
224
|
+
只看到公网项目页面不足以证明你是所有者。
|
|
225
|
+
|
|
226
|
+
如果 wheel 成功而 sdist 失败,只重传失败的那个文件,不要删除整个项目。
|
|
227
|
+
若看到已用文件名错误,先查站点是否已经收到了文件。
|
|
228
|
+
已上传文件不能用同名文件覆盖,删除后也不能重用;需要修复代码时升到 0.1.1 并重建。
|
|
229
|
+
不要用 `--skip-existing` 掩盖第一次发布的异常。来源:[PyPI 文件名不可重用说明](https://pypi.org/help/#file-name-reuse)。
|
|
230
|
+
|
|
231
|
+
## 11. 从正式 PyPI 验证“pip install apiwells”
|
|
232
|
+
|
|
233
|
+
必须用另一个新环境,以免本地已装版本造成假成功。
|
|
234
|
+
macOS/Linux:
|
|
235
|
+
|
|
236
|
+
```sh
|
|
237
|
+
python -m venv ../apiwells-prod-install
|
|
238
|
+
../apiwells-prod-install/bin/python -m pip install --index-url https://pypi.org/simple/ --no-cache-dir apiwells==0.1.0
|
|
239
|
+
../apiwells-prod-install/bin/apiwells --version
|
|
240
|
+
../apiwells-prod-install/bin/python -m unittest discover -s tests -v
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Windows PowerShell:
|
|
244
|
+
|
|
245
|
+
```powershell
|
|
246
|
+
python -m venv ..\apiwells-prod-install
|
|
247
|
+
..\apiwells-prod-install\Scripts\python.exe -m pip install --index-url https://pypi.org/simple/ --no-cache-dir apiwells==0.1.0
|
|
248
|
+
..\apiwells-prod-install\Scripts\apiwells.exe --version
|
|
249
|
+
..\apiwells-prod-install\Scripts\python.exe -m unittest discover -s tests -v
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
随后使用这个环境里的 Python 重做第5节真实端点测试。
|
|
253
|
+
最终客户在支持的 Python 环境、且 pip 指向正式 PyPI 时,可以使用 `pip install apiwells`。
|
|
254
|
+
文档推荐 `python -m pip install apiwells`,以减少多 Python 环境装错位置的问题。
|
|
255
|
+
如果国内镜像尚未同步,先用上面的正式索引验证,不能立即判断发布失败。
|
|
256
|
+
|
|
257
|
+
## 12. 排错表
|
|
258
|
+
|
|
259
|
+
| 现象 | 先查什么 | 处理动作 |
|
|
260
|
+
|---|---|---|
|
|
261
|
+
| doctor 缺少密钥 | 当前终端是否设置变量 | 重新输入变量,或仅对无鉴权端点加 --anonymous |
|
|
262
|
+
| 401 | 密钥、平台、过期状态 | 到对应平台核对,不反复盲试 |
|
|
263
|
+
| 403 | 权限、IP、WAF | 查平台访问策略及日志 |
|
|
264
|
+
| 404 | /v1 前缀、端点、模型ID | 使用官方 Base URL,区分 models 与 chat 支持 |
|
|
265
|
+
| 429 | 速率、余额、配额 | 查账户与限额;不是自动认定某一个原因 |
|
|
266
|
+
| TLS/network | 证书、DNS、代理 | 修复证书/网络;需要系统代理时明确加 --use-env-proxy |
|
|
267
|
+
| timeout | 上游响应、网络 | 先查状态;必要时提高 --timeout,不自动重试收费请求 |
|
|
268
|
+
| twine 认证失败 | 是否用了错误站点 Token | __token__ + 对应站点完整 Token |
|
|
269
|
+
| 名称不可用/无权限 | 保留名、已有项目、Token范围 | 看服务端错误;不要声明名称已经锁定 |
|
|
270
|
+
| command not found | 虚拟环境/命令PATH | 用 python -m apiwells,或执行该环境的绝对路径 |
|
|
271
|
+
|
|
272
|
+
## 13. 长期维护与可证伪目标
|
|
273
|
+
|
|
274
|
+
判断:这个工具适合作为接入支持资产;仅凭它不能推断未来 MaaS 的获客或盈利能力。
|
|
275
|
+
建议第一周实际诊断至少3个你有权限的端点组合,保留脱敏的成功/失败类别,并记录一次实际排障用途。
|
|
276
|
+
基准情况:安装可复现,至少一个真实模型列表和一个对话检查通过,能用于客户接入说明。
|
|
277
|
+
最佳情况:客户能用报告自行定位常见接入错误,减少你重复支持时间。
|
|
278
|
+
失败情况:目标客户主要用不兼容协议,或工具经常把可用端点判为失败;先修正覆盖范围/提示,不为了版本活动随意发包。
|
|
279
|
+
验证指标:安装成功率、真实已知故障分类是否正确、误报数量、客户独立完成检查比例。
|
|
280
|
+
首周失败条件:无法完成真实端点调用或出现密钥泄漏;在修复前不对客户宣传已通过验收。
|
|
281
|
+
|
|
282
|
+
建议每季度实际检查一次安装、核心功能和反馈渠道;发现缺陷时发布修复并写 CHANGELOG。
|
|
283
|
+
PEP 541 的“被遗弃”并非只看12个月没发版,而是还要求不可联系等条件同时满足。不要机械发空更新;保持真实维护和邮箱可达。
|
|
284
|
+
未来有稳定 GitHub 发布流程后,可升级为 [Trusted Publishing](https://docs.pypi.org/trusted-publishers/),使用短期身份凭据,减少长期 Token 管理。
|
|
285
|
+
|
|
286
|
+
## 14. 设计依据
|
|
287
|
+
|
|
288
|
+
- [Python urllib.request 官方文档](https://docs.python.org/3/library/urllib.request.html):HTTP、TLS、代理和重定向的实现基础。
|
|
289
|
+
- [HTTPie 官方 CLI 文档](https://httpie.io/docs/cli/HEAD):状态码影响退出码、显式超时和重定向控制的成熟 CLI 实践。本工具并非复制 HTTPie 功能,退出码也不同。
|
|
290
|
+
- 本项目使用标准库以降低首版安装成本;这是本次工程判断,不是 PyPI 对项目大小或依赖数量的要求。
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "apiwells"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Endpoint Doctor for OpenAI-compatible model APIs"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
keywords = ["api", "diagnostics", "llm", "endpoint"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Topic :: Software Development :: Testing",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
apiwells = "apiwells.cli:main"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
where = ["src"]
|
apiwells-0.1.0/setup.cfg
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,15 @@
|
|
|
1
|
+
CHANGELOG.md
|
|
2
|
+
LICENSE
|
|
3
|
+
MANIFEST.in
|
|
4
|
+
README.md
|
|
5
|
+
pyproject.toml
|
|
6
|
+
docs/PUBLISH_ZH.md
|
|
7
|
+
src/apiwells/__init__.py
|
|
8
|
+
src/apiwells/__main__.py
|
|
9
|
+
src/apiwells/cli.py
|
|
10
|
+
src/apiwells.egg-info/PKG-INFO
|
|
11
|
+
src/apiwells.egg-info/SOURCES.txt
|
|
12
|
+
src/apiwells.egg-info/dependency_links.txt
|
|
13
|
+
src/apiwells.egg-info/entry_points.txt
|
|
14
|
+
src/apiwells.egg-info/top_level.txt
|
|
15
|
+
tests/test_doctor.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
apiwells
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import unittest
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
from apiwells.cli import diagnose, main, endpoint
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Handler(BaseHTTPRequestHandler):
|
|
14
|
+
calls = []
|
|
15
|
+
def log_message(self, *args):
|
|
16
|
+
pass
|
|
17
|
+
def do_GET(self):
|
|
18
|
+
self.handle_check()
|
|
19
|
+
def do_POST(self):
|
|
20
|
+
self.handle_check()
|
|
21
|
+
def handle_check(self):
|
|
22
|
+
body = self.rfile.read(int(self.headers.get('Content-Length', 0)))
|
|
23
|
+
Handler.calls.append((self.path, self.headers.get('Authorization'), body))
|
|
24
|
+
prefix = self.path.split('/')[1]
|
|
25
|
+
code = int(prefix) if prefix.isdigit() else 200
|
|
26
|
+
self.send_response(code)
|
|
27
|
+
if code == 302:
|
|
28
|
+
self.send_header('Location', '/v1/models')
|
|
29
|
+
self.end_headers()
|
|
30
|
+
if prefix == 'slow':
|
|
31
|
+
time.sleep(.15)
|
|
32
|
+
if prefix == 'invalid':
|
|
33
|
+
raw = b'<html>login</html>'
|
|
34
|
+
elif prefix == 'large':
|
|
35
|
+
raw = b'x' * (2 * 1024 * 1024 + 1)
|
|
36
|
+
elif prefix == 'schema':
|
|
37
|
+
raw = b'{"data":[{}]}'
|
|
38
|
+
elif prefix == 'error':
|
|
39
|
+
raw = b'{"error":{"message":"secret-test-key"}}'
|
|
40
|
+
elif self.path.endswith('/chat/completions'):
|
|
41
|
+
raw = b'{"choices":[{"message":{"role":"assistant","content":"OK"}}]}'
|
|
42
|
+
else:
|
|
43
|
+
raw = b'{"data":[{"id":"secret-test-key"}]}'
|
|
44
|
+
try:
|
|
45
|
+
self.wfile.write(raw)
|
|
46
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DoctorTests(unittest.TestCase):
|
|
51
|
+
@classmethod
|
|
52
|
+
def setUpClass(cls):
|
|
53
|
+
cls.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
|
54
|
+
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
|
|
55
|
+
cls.thread.start()
|
|
56
|
+
cls.base = 'http://127.0.0.1:' + str(cls.server.server_port)
|
|
57
|
+
@classmethod
|
|
58
|
+
def tearDownClass(cls):
|
|
59
|
+
cls.server.shutdown()
|
|
60
|
+
cls.server.server_close()
|
|
61
|
+
cls.thread.join()
|
|
62
|
+
def test_models_and_redaction(self):
|
|
63
|
+
r = diagnose(self.base + '/v1/', 'secret-test-key')
|
|
64
|
+
self.assertTrue(r['ok'])
|
|
65
|
+
self.assertEqual(r['model_count'], 1)
|
|
66
|
+
self.assertNotIn('secret-test-key', json.dumps(r))
|
|
67
|
+
self.assertEqual(Handler.calls[-1][:2], ('/v1/models', 'Bearer secret-test-key'))
|
|
68
|
+
def test_chat_payload(self):
|
|
69
|
+
r = diagnose(self.base + '/v1', 'key', model='demo')
|
|
70
|
+
self.assertTrue(r['ok'])
|
|
71
|
+
body = json.loads(Handler.calls[-1][2])
|
|
72
|
+
self.assertEqual(body['model'], 'demo')
|
|
73
|
+
self.assertEqual(body['max_tokens'], 8)
|
|
74
|
+
self.assertFalse(body['stream'])
|
|
75
|
+
def test_http_failures_no_retry(self):
|
|
76
|
+
for status, cat in [(400,'bad_request'), (401,'authentication'), (403,'forbidden'),
|
|
77
|
+
(404,'not_found'), (405,'method_not_allowed'), (429,'rate_or_quota'), (500,'server_error')]:
|
|
78
|
+
with self.subTest(status=status):
|
|
79
|
+
n = len(Handler.calls)
|
|
80
|
+
r = diagnose(self.base + '/' + str(status))
|
|
81
|
+
self.assertEqual(r['category'], cat)
|
|
82
|
+
self.assertEqual(len(Handler.calls), n + 1)
|
|
83
|
+
def test_redirect_not_followed(self):
|
|
84
|
+
n = len(Handler.calls)
|
|
85
|
+
self.assertEqual(diagnose(self.base + '/302', 'key')['category'], 'redirect')
|
|
86
|
+
self.assertEqual(len(Handler.calls), n + 1)
|
|
87
|
+
def test_response_validation(self):
|
|
88
|
+
for path, cat in [('invalid', 'invalid_json'), ('schema', 'unexpected_schema'),
|
|
89
|
+
('error','unexpected_schema'), ('large', 'response_too_large')]:
|
|
90
|
+
self.assertEqual(diagnose(self.base + '/' + path)['category'], cat)
|
|
91
|
+
def test_timeout(self):
|
|
92
|
+
self.assertEqual(diagnose(self.base + '/slow', timeout=.03)['category'], 'timeout')
|
|
93
|
+
def test_invalid_config(self):
|
|
94
|
+
for url in ['ftp://example.com', 'https://u:p@example.com', 'https://example.com?key=x',
|
|
95
|
+
'https://example.com#x', 'https://example.com:bad', 'http://example.com',
|
|
96
|
+
'https://example.com/v1/models', 'https://exam\nple.com']:
|
|
97
|
+
with self.subTest(url=url), self.assertRaises(ValueError):
|
|
98
|
+
endpoint(url)
|
|
99
|
+
for timeout in [0, -1, float('nan'), float('inf'), 301]:
|
|
100
|
+
with self.assertRaises(ValueError):
|
|
101
|
+
diagnose(self.base, timeout=timeout)
|
|
102
|
+
with self.assertRaises(ValueError):
|
|
103
|
+
diagnose(self.base, 'key\r\nInjected: x')
|
|
104
|
+
def test_proxy_ignored_by_default(self):
|
|
105
|
+
with patch.dict(os.environ, {'http_proxy':'http://127.0.0.1:1', 'HTTP_PROXY':'http://127.0.0.1:1', 'no_proxy':'', 'NO_PROXY':''}):
|
|
106
|
+
self.assertTrue(diagnose(self.base + '/v1')['ok'])
|
|
107
|
+
def test_cli_json_exit_codes(self):
|
|
108
|
+
for path, expected in [('v1', 0), ('401', 1)]:
|
|
109
|
+
out = io.StringIO()
|
|
110
|
+
with contextlib.redirect_stdout(out):
|
|
111
|
+
code = main(['doctor','--base-url',self.base+'/'+path,'--anonymous','--json'])
|
|
112
|
+
self.assertEqual(code, expected)
|
|
113
|
+
self.assertEqual(json.loads(out.getvalue())['ok'], expected == 0)
|
|
114
|
+
def test_cli_usage_error(self):
|
|
115
|
+
for extra in [['--chat'], ['--model','demo'], ['--timeout','nan']]:
|
|
116
|
+
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as e:
|
|
117
|
+
main(['doctor','--base-url',self.base,'--anonymous'] + extra)
|
|
118
|
+
self.assertEqual(e.exception.code, 2)
|
|
119
|
+
def test_missing_key(self):
|
|
120
|
+
with patch.dict(os.environ, {}, clear=True), contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as e:
|
|
121
|
+
main(['doctor','--base-url',self.base])
|
|
122
|
+
self.assertEqual(e.exception.code, 2)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == '__main__':
|
|
126
|
+
unittest.main()
|