quicksilverpro 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.
- quicksilverpro-0.1.0/.github/workflows/ci.yml +26 -0
- quicksilverpro-0.1.0/.github/workflows/publish.yml +92 -0
- quicksilverpro-0.1.0/.gitignore +13 -0
- quicksilverpro-0.1.0/LICENSE +21 -0
- quicksilverpro-0.1.0/PKG-INFO +136 -0
- quicksilverpro-0.1.0/README.md +103 -0
- quicksilverpro-0.1.0/pyproject.toml +51 -0
- quicksilverpro-0.1.0/src/quicksilverpro/__init__.py +6 -0
- quicksilverpro-0.1.0/src/quicksilverpro/__main__.py +5 -0
- quicksilverpro-0.1.0/src/quicksilverpro/cli.py +599 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
smoke:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python: ["3.9", "3.11", "3.13"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python }}
|
|
19
|
+
- name: Install
|
|
20
|
+
run: pip install -e .
|
|
21
|
+
- name: Smoke — version / help / public models
|
|
22
|
+
run: |
|
|
23
|
+
qsp --version
|
|
24
|
+
qsp --help
|
|
25
|
+
qsp models
|
|
26
|
+
qsp status --json > /dev/null
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
name: Publish to PyPI & Homebrew
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
id-token: write # Trusted Publishing (no long-lived PyPI token)
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
build:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.13"
|
|
18
|
+
|
|
19
|
+
- name: Install build tools
|
|
20
|
+
run: |
|
|
21
|
+
python -m pip install --upgrade pip
|
|
22
|
+
pip install build
|
|
23
|
+
|
|
24
|
+
- name: Build sdist + wheel
|
|
25
|
+
run: python -m build
|
|
26
|
+
|
|
27
|
+
- name: Upload build artifacts
|
|
28
|
+
uses: actions/upload-artifact@v4
|
|
29
|
+
with:
|
|
30
|
+
name: dist
|
|
31
|
+
path: dist/
|
|
32
|
+
|
|
33
|
+
publish:
|
|
34
|
+
needs: build
|
|
35
|
+
runs-on: ubuntu-latest
|
|
36
|
+
environment: pypi
|
|
37
|
+
steps:
|
|
38
|
+
- name: Download build artifacts
|
|
39
|
+
uses: actions/download-artifact@v4
|
|
40
|
+
with:
|
|
41
|
+
name: dist
|
|
42
|
+
path: dist/
|
|
43
|
+
|
|
44
|
+
- name: Publish to PyPI
|
|
45
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
46
|
+
|
|
47
|
+
update-homebrew:
|
|
48
|
+
needs: publish
|
|
49
|
+
runs-on: ubuntu-latest
|
|
50
|
+
steps:
|
|
51
|
+
- name: Wait for PyPI to propagate
|
|
52
|
+
run: sleep 30
|
|
53
|
+
|
|
54
|
+
- name: Resolve release version
|
|
55
|
+
id: version
|
|
56
|
+
# GITHUB_REF_NAME is e.g. "v0.1.0"; strip the leading "v" for PyPI.
|
|
57
|
+
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
|
58
|
+
|
|
59
|
+
- name: Fetch sdist URL and SHA256 from PyPI
|
|
60
|
+
id: pypi
|
|
61
|
+
run: |
|
|
62
|
+
VERSION="${{ steps.version.outputs.version }}"
|
|
63
|
+
URL=""
|
|
64
|
+
# Poll up to ~5 minutes for the new version to show on PyPI.
|
|
65
|
+
for i in $(seq 1 10); do
|
|
66
|
+
URL=$(curl -fsSL "https://pypi.org/pypi/quicksilverpro/${VERSION}/json" 2>/dev/null \
|
|
67
|
+
| python3 -c "import json,sys; d=json.load(sys.stdin); print([u['url'] for u in d['urls'] if u['packagetype']=='sdist'][0])" 2>/dev/null) && break
|
|
68
|
+
echo "Waiting for PyPI... (attempt $i)"
|
|
69
|
+
sleep 30
|
|
70
|
+
done
|
|
71
|
+
if [ -z "$URL" ]; then echo "Timed out waiting for PyPI sdist"; exit 1; fi
|
|
72
|
+
|
|
73
|
+
curl -fsSL "$URL" -o /tmp/pkg.tar.gz
|
|
74
|
+
SHA256=$(sha256sum /tmp/pkg.tar.gz | cut -d' ' -f1)
|
|
75
|
+
rm /tmp/pkg.tar.gz
|
|
76
|
+
|
|
77
|
+
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
|
78
|
+
echo "sha256=$SHA256" >> "$GITHUB_OUTPUT"
|
|
79
|
+
echo "Resolved: $URL (sha256: $SHA256)"
|
|
80
|
+
|
|
81
|
+
- name: Dispatch formula update to homebrew tap
|
|
82
|
+
uses: peter-evans/repository-dispatch@v3
|
|
83
|
+
with:
|
|
84
|
+
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
|
85
|
+
repository: machinefi/homebrew-qspro
|
|
86
|
+
event-type: update-formula
|
|
87
|
+
client-payload: |
|
|
88
|
+
{
|
|
89
|
+
"version": "${{ steps.version.outputs.version }}",
|
|
90
|
+
"url": "${{ steps.pypi.outputs.url }}",
|
|
91
|
+
"sha256": "${{ steps.pypi.outputs.sha256 }}"
|
|
92
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MachineFi Inc.
|
|
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,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: quicksilverpro
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for QuickSilver Pro — OpenAI-compatible API for DeepSeek V3, DeepSeek R1, and Qwen 3.5 at 20% below resellers.
|
|
5
|
+
Project-URL: Homepage, https://quicksilverpro.io
|
|
6
|
+
Project-URL: Documentation, https://quicksilverpro.io/dashboard#quickstart
|
|
7
|
+
Project-URL: Repository, https://github.com/machinefi/qspro-cli
|
|
8
|
+
Project-URL: Issues, https://github.com/machinefi/qspro-cli/issues
|
|
9
|
+
Project-URL: Status, https://quicksilverpro.io/status
|
|
10
|
+
Author-email: "MachineFi Inc." <hello@quicksilverpro.io>
|
|
11
|
+
License: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: ai,api,chatgpt,cli,deepseek,inference,llm,openai,openrouter-alternative,quicksilverpro,qwen
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Environment :: Console
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
26
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
27
|
+
Classifier: Topic :: Utilities
|
|
28
|
+
Requires-Python: >=3.9
|
|
29
|
+
Requires-Dist: click>=8.1
|
|
30
|
+
Requires-Dist: httpx>=0.25
|
|
31
|
+
Requires-Dist: rich>=13
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# QuickSilver Pro CLI
|
|
35
|
+
|
|
36
|
+
`qsp` — a command-line client for [QuickSilver Pro](https://quicksilverpro.io), an OpenAI-compatible inference API for top open-source LLMs (DeepSeek V3, DeepSeek R1, Qwen 3.5) priced 20% below OpenRouter / Together / Fireworks.
|
|
37
|
+
|
|
38
|
+
Designed to be **AI-agent friendly**: every command accepts `--json` for structured output, exit codes are reliable, and the API surface is intentionally small.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install quicksilverpro
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Python 3.9+. Also exports itself as `quicksilverpro` if you prefer the long name.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
qsp init # opens dashboard to get a key, stores it locally
|
|
56
|
+
qsp chat "Write me a haiku" # one-shot streaming chat (deepseek-v3 by default)
|
|
57
|
+
qsp balance # current credits
|
|
58
|
+
qsp models # supported models with prices & context length
|
|
59
|
+
qsp status # live per-model latency
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Commands
|
|
65
|
+
|
|
66
|
+
| Command | Purpose |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `qsp init [--email X] [--key sk-...]` | Sign in (browser walkthrough) or paste an existing key |
|
|
69
|
+
| `qsp logout` | Forget locally-stored key |
|
|
70
|
+
| `qsp whoami [--json]` | Show signed-in email + balance |
|
|
71
|
+
| `qsp balance [--json]` | Credit balance + lifetime spend |
|
|
72
|
+
| `qsp models [--json]` | Available models + pricing + context length |
|
|
73
|
+
| `qsp chat "PROMPT" [-m MODEL] [-s SYS] [--max-tokens N] [--temperature F] [--no-stream] [--json]` | One-shot completion, streams to stdout by default |
|
|
74
|
+
| `qsp usage [-n 10] [--json]` | Recent calls + aggregate per-model |
|
|
75
|
+
| `qsp status [--json]` | Live health of API + per-model probes |
|
|
76
|
+
| `qsp keys list [--json]` | Your API keys |
|
|
77
|
+
| `qsp keys create ALIAS [--monthly-limit USD] [--json]` | Create a new key with optional spend cap |
|
|
78
|
+
| `qsp keys delete ALIAS [-y]` | Delete a key (confirmation prompt unless `-y`) |
|
|
79
|
+
| `qsp pay {5,20,50}` | Opens Stripe checkout for a credit top-up |
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## AI-agent usage
|
|
84
|
+
|
|
85
|
+
Every command supports `--json` and prints OpenAI-shaped JSON to stdout with errors on stderr.
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
qsp models --json | jq '.[].id'
|
|
89
|
+
qsp usage --json | jq '.totals.cost'
|
|
90
|
+
qsp chat "Summarize: $DOCUMENT" --json --no-stream | jq -r '.choices[0].message.content'
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Exit codes: `0` success · `1` remote/operational error · `2` usage / auth error.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Config
|
|
98
|
+
|
|
99
|
+
Key stored at `~/.config/quicksilverpro/config.json` (chmod 600). Override with:
|
|
100
|
+
|
|
101
|
+
- `QSP_API_KEY` — use this key directly, ignore stored config
|
|
102
|
+
- `QSP_API_URL` — default `https://api.quicksilverpro.io/v1`
|
|
103
|
+
- `QSP_AUTH_URL` — default `https://pay.quicksilverpro.io`
|
|
104
|
+
- `QSP_MODEL` — default model for `qsp chat`
|
|
105
|
+
- `QSP_CONFIG_DIR` — where to store config
|
|
106
|
+
- `QSP_HTTP_TIMEOUT` — seconds, default 60
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Use the `openai` SDK directly
|
|
111
|
+
|
|
112
|
+
You don't need this CLI to use QuickSilver Pro. The OpenAI Python / Node / Swift SDKs work with only a `base_url` change:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from openai import OpenAI
|
|
116
|
+
client = OpenAI(
|
|
117
|
+
base_url="https://api.quicksilverpro.io/v1",
|
|
118
|
+
api_key="sk-...", # your QuickSilver Pro key
|
|
119
|
+
)
|
|
120
|
+
r = client.chat.completions.create(
|
|
121
|
+
model="deepseek-v3",
|
|
122
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
See [quicksilverpro.io/dashboard#quickstart](https://quicksilverpro.io/dashboard#quickstart) for JS / Swift / curl.
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
MIT.
|
|
133
|
+
|
|
134
|
+
QuickSilver Pro is a product of MachineFi Inc. (68 Willow Rd, Menlo Park, CA).
|
|
135
|
+
|
|
136
|
+
Links: [home](https://quicksilverpro.io) · [status](https://quicksilverpro.io/status) · [terms](https://quicksilverpro.io/terms) · [privacy](https://quicksilverpro.io/privacy)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# QuickSilver Pro CLI
|
|
2
|
+
|
|
3
|
+
`qsp` — a command-line client for [QuickSilver Pro](https://quicksilverpro.io), an OpenAI-compatible inference API for top open-source LLMs (DeepSeek V3, DeepSeek R1, Qwen 3.5) priced 20% below OpenRouter / Together / Fireworks.
|
|
4
|
+
|
|
5
|
+
Designed to be **AI-agent friendly**: every command accepts `--json` for structured output, exit codes are reliable, and the API surface is intentionally small.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install quicksilverpro
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Python 3.9+. Also exports itself as `quicksilverpro` if you prefer the long name.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
qsp init # opens dashboard to get a key, stores it locally
|
|
23
|
+
qsp chat "Write me a haiku" # one-shot streaming chat (deepseek-v3 by default)
|
|
24
|
+
qsp balance # current credits
|
|
25
|
+
qsp models # supported models with prices & context length
|
|
26
|
+
qsp status # live per-model latency
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Commands
|
|
32
|
+
|
|
33
|
+
| Command | Purpose |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `qsp init [--email X] [--key sk-...]` | Sign in (browser walkthrough) or paste an existing key |
|
|
36
|
+
| `qsp logout` | Forget locally-stored key |
|
|
37
|
+
| `qsp whoami [--json]` | Show signed-in email + balance |
|
|
38
|
+
| `qsp balance [--json]` | Credit balance + lifetime spend |
|
|
39
|
+
| `qsp models [--json]` | Available models + pricing + context length |
|
|
40
|
+
| `qsp chat "PROMPT" [-m MODEL] [-s SYS] [--max-tokens N] [--temperature F] [--no-stream] [--json]` | One-shot completion, streams to stdout by default |
|
|
41
|
+
| `qsp usage [-n 10] [--json]` | Recent calls + aggregate per-model |
|
|
42
|
+
| `qsp status [--json]` | Live health of API + per-model probes |
|
|
43
|
+
| `qsp keys list [--json]` | Your API keys |
|
|
44
|
+
| `qsp keys create ALIAS [--monthly-limit USD] [--json]` | Create a new key with optional spend cap |
|
|
45
|
+
| `qsp keys delete ALIAS [-y]` | Delete a key (confirmation prompt unless `-y`) |
|
|
46
|
+
| `qsp pay {5,20,50}` | Opens Stripe checkout for a credit top-up |
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## AI-agent usage
|
|
51
|
+
|
|
52
|
+
Every command supports `--json` and prints OpenAI-shaped JSON to stdout with errors on stderr.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
qsp models --json | jq '.[].id'
|
|
56
|
+
qsp usage --json | jq '.totals.cost'
|
|
57
|
+
qsp chat "Summarize: $DOCUMENT" --json --no-stream | jq -r '.choices[0].message.content'
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Exit codes: `0` success · `1` remote/operational error · `2` usage / auth error.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Config
|
|
65
|
+
|
|
66
|
+
Key stored at `~/.config/quicksilverpro/config.json` (chmod 600). Override with:
|
|
67
|
+
|
|
68
|
+
- `QSP_API_KEY` — use this key directly, ignore stored config
|
|
69
|
+
- `QSP_API_URL` — default `https://api.quicksilverpro.io/v1`
|
|
70
|
+
- `QSP_AUTH_URL` — default `https://pay.quicksilverpro.io`
|
|
71
|
+
- `QSP_MODEL` — default model for `qsp chat`
|
|
72
|
+
- `QSP_CONFIG_DIR` — where to store config
|
|
73
|
+
- `QSP_HTTP_TIMEOUT` — seconds, default 60
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Use the `openai` SDK directly
|
|
78
|
+
|
|
79
|
+
You don't need this CLI to use QuickSilver Pro. The OpenAI Python / Node / Swift SDKs work with only a `base_url` change:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from openai import OpenAI
|
|
83
|
+
client = OpenAI(
|
|
84
|
+
base_url="https://api.quicksilverpro.io/v1",
|
|
85
|
+
api_key="sk-...", # your QuickSilver Pro key
|
|
86
|
+
)
|
|
87
|
+
r = client.chat.completions.create(
|
|
88
|
+
model="deepseek-v3",
|
|
89
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
90
|
+
)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
See [quicksilverpro.io/dashboard#quickstart](https://quicksilverpro.io/dashboard#quickstart) for JS / Swift / curl.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT.
|
|
100
|
+
|
|
101
|
+
QuickSilver Pro is a product of MachineFi Inc. (68 Willow Rd, Menlo Park, CA).
|
|
102
|
+
|
|
103
|
+
Links: [home](https://quicksilverpro.io) · [status](https://quicksilverpro.io/status) · [terms](https://quicksilverpro.io/terms) · [privacy](https://quicksilverpro.io/privacy)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "quicksilverpro"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "CLI for QuickSilver Pro — OpenAI-compatible API for DeepSeek V3, DeepSeek R1, and Qwen 3.5 at 20% below resellers."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "MachineFi Inc.", email = "hello@quicksilverpro.io" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"openai", "deepseek", "qwen", "llm", "inference", "api",
|
|
15
|
+
"chatgpt", "ai", "cli", "openrouter-alternative", "quicksilverpro",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Environment :: Console",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
24
|
+
"Programming Language :: Python :: 3.9",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Programming Language :: Python :: 3.13",
|
|
29
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
30
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
31
|
+
"Topic :: Utilities",
|
|
32
|
+
]
|
|
33
|
+
dependencies = [
|
|
34
|
+
"click>=8.1",
|
|
35
|
+
"httpx>=0.25",
|
|
36
|
+
"rich>=13",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://quicksilverpro.io"
|
|
41
|
+
Documentation = "https://quicksilverpro.io/dashboard#quickstart"
|
|
42
|
+
Repository = "https://github.com/machinefi/qspro-cli"
|
|
43
|
+
Issues = "https://github.com/machinefi/qspro-cli/issues"
|
|
44
|
+
Status = "https://quicksilverpro.io/status"
|
|
45
|
+
|
|
46
|
+
[project.scripts]
|
|
47
|
+
qsp = "quicksilverpro.cli:main"
|
|
48
|
+
quicksilverpro = "quicksilverpro.cli:main"
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.wheel]
|
|
51
|
+
packages = ["src/quicksilverpro"]
|
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
"""QuickSilver Pro CLI — single-file implementation.
|
|
2
|
+
|
|
3
|
+
Design goals:
|
|
4
|
+
1. Drop-in OpenAI-compatible thin wrapper: `qsp chat "..."` works immediately.
|
|
5
|
+
2. AI-agent friendly: every command accepts `--json` and emits structured
|
|
6
|
+
output on stdout with errors on stderr and non-zero exit codes on failure.
|
|
7
|
+
3. No config surprises: API key lives in `~/.config/quicksilverpro/config.json`
|
|
8
|
+
(or `$QSP_CONFIG_DIR`); the `QSP_API_KEY` env var overrides.
|
|
9
|
+
|
|
10
|
+
The CLI talks to two endpoints:
|
|
11
|
+
- https://api.quicksilverpro.io/v1 — OpenAI-shaped inference
|
|
12
|
+
- https://pay.quicksilverpro.io/v1 — account + usage + keys management
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
import webbrowser
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import click
|
|
25
|
+
import httpx
|
|
26
|
+
from rich.console import Console
|
|
27
|
+
from rich.table import Table
|
|
28
|
+
|
|
29
|
+
from . import __version__
|
|
30
|
+
|
|
31
|
+
# ────────────────────────── constants ──────────────────────────
|
|
32
|
+
|
|
33
|
+
DEFAULT_API_URL = os.environ.get("QSP_API_URL", "https://api.quicksilverpro.io/v1")
|
|
34
|
+
DEFAULT_AUTH_URL = os.environ.get("QSP_AUTH_URL", "https://pay.quicksilverpro.io")
|
|
35
|
+
DEFAULT_APP_URL = os.environ.get("QSP_APP_URL", "https://quicksilverpro.io")
|
|
36
|
+
DEFAULT_MODEL = os.environ.get("QSP_MODEL", "deepseek-v3")
|
|
37
|
+
HTTP_TIMEOUT = float(os.environ.get("QSP_HTTP_TIMEOUT", "60"))
|
|
38
|
+
|
|
39
|
+
CONFIG_DIR = Path(os.environ.get("QSP_CONFIG_DIR",
|
|
40
|
+
Path.home() / ".config" / "quicksilverpro"))
|
|
41
|
+
CONFIG_PATH = CONFIG_DIR / "config.json"
|
|
42
|
+
|
|
43
|
+
# stderr for humans, stdout reserved for parseable data when `--json` is used.
|
|
44
|
+
_err = Console(stderr=True)
|
|
45
|
+
_out = Console()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ────────────────────────── config + auth ──────────────────────────
|
|
49
|
+
|
|
50
|
+
def _load_config() -> dict:
|
|
51
|
+
if not CONFIG_PATH.exists():
|
|
52
|
+
return {}
|
|
53
|
+
try:
|
|
54
|
+
return json.loads(CONFIG_PATH.read_text())
|
|
55
|
+
except Exception:
|
|
56
|
+
return {}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _save_config(cfg: dict) -> None:
|
|
60
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
CONFIG_PATH.write_text(json.dumps(cfg, indent=2))
|
|
62
|
+
try:
|
|
63
|
+
os.chmod(CONFIG_PATH, 0o600) # key is sensitive — best-effort on POSIX.
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _resolve_api_key() -> str | None:
|
|
69
|
+
"""QSP_API_KEY env var wins; else fall back to ~/.config store."""
|
|
70
|
+
env = os.environ.get("QSP_API_KEY") or os.environ.get("OPENAI_API_KEY_QSP")
|
|
71
|
+
if env:
|
|
72
|
+
return env.strip()
|
|
73
|
+
return (_load_config().get("api_key") or "").strip() or None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _require_key() -> str:
|
|
77
|
+
k = _resolve_api_key()
|
|
78
|
+
if not k:
|
|
79
|
+
_err.print(
|
|
80
|
+
"[red]Not signed in.[/red] Run [bold]qsp init[/bold] to get started, "
|
|
81
|
+
"or set [bold]QSP_API_KEY[/bold] in your environment."
|
|
82
|
+
)
|
|
83
|
+
sys.exit(2)
|
|
84
|
+
return k
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ────────────────────────── http helpers ──────────────────────────
|
|
88
|
+
|
|
89
|
+
def _auth_client(key: str | None = None) -> httpx.Client:
|
|
90
|
+
headers = {"User-Agent": f"quicksilverpro-cli/{__version__}"}
|
|
91
|
+
if key:
|
|
92
|
+
headers["Authorization"] = f"Bearer {key}"
|
|
93
|
+
return httpx.Client(
|
|
94
|
+
base_url=DEFAULT_AUTH_URL,
|
|
95
|
+
timeout=HTTP_TIMEOUT,
|
|
96
|
+
headers=headers,
|
|
97
|
+
follow_redirects=False,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _api_client(key: str) -> httpx.Client:
|
|
102
|
+
return httpx.Client(
|
|
103
|
+
base_url=DEFAULT_API_URL,
|
|
104
|
+
timeout=HTTP_TIMEOUT,
|
|
105
|
+
headers={
|
|
106
|
+
"Authorization": f"Bearer {key}",
|
|
107
|
+
"User-Agent": f"quicksilverpro-cli/{__version__}",
|
|
108
|
+
"Content-Type": "application/json",
|
|
109
|
+
},
|
|
110
|
+
follow_redirects=False,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _extract_error(resp: httpx.Response) -> str:
|
|
115
|
+
try:
|
|
116
|
+
d = resp.json()
|
|
117
|
+
if isinstance(d, dict):
|
|
118
|
+
err = d.get("error")
|
|
119
|
+
if isinstance(err, dict):
|
|
120
|
+
return err.get("message") or str(err)
|
|
121
|
+
if isinstance(d.get("detail"), str):
|
|
122
|
+
return d["detail"]
|
|
123
|
+
return json.dumps(d)
|
|
124
|
+
return resp.text
|
|
125
|
+
except Exception:
|
|
126
|
+
return resp.text or f"HTTP {resp.status_code}"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _emit(obj, *, as_json: bool, table_fn=None) -> None:
|
|
130
|
+
"""JSON goes to stdout; pretty tables go through Rich to stdout."""
|
|
131
|
+
if as_json:
|
|
132
|
+
click.echo(json.dumps(obj, indent=2, default=str))
|
|
133
|
+
return
|
|
134
|
+
if table_fn:
|
|
135
|
+
table_fn(obj)
|
|
136
|
+
else:
|
|
137
|
+
click.echo(json.dumps(obj, indent=2, default=str))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ────────────────────────── root command group ──────────────────────────
|
|
141
|
+
|
|
142
|
+
@click.group(
|
|
143
|
+
help=(
|
|
144
|
+
"QuickSilver Pro — OpenAI-compatible API for top open-source LLMs.\n\n"
|
|
145
|
+
"Docs: https://quicksilverpro.io · Status: https://quicksilverpro.io/status"
|
|
146
|
+
),
|
|
147
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
148
|
+
)
|
|
149
|
+
@click.version_option(__version__, prog_name="qsp")
|
|
150
|
+
def main() -> None:
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ────────────────────────── qsp init ──────────────────────────
|
|
155
|
+
|
|
156
|
+
@main.command(help="Sign in: opens the dashboard in a browser, you paste the API key back.")
|
|
157
|
+
@click.option("--key", "apikey", default=None, help="Paste an existing API key instead of opening the browser.")
|
|
158
|
+
@click.option("--email", default=None, help="Email for a new account (sends verification link).")
|
|
159
|
+
def init(apikey: str | None, email: str | None) -> None:
|
|
160
|
+
# Three paths: explicit --key, new-account via --email, or default browser walk-through.
|
|
161
|
+
if apikey:
|
|
162
|
+
_save_key(apikey)
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
if email:
|
|
166
|
+
with _auth_client() as c:
|
|
167
|
+
r = c.post("/v1/register", json={"email": email})
|
|
168
|
+
if r.status_code >= 400:
|
|
169
|
+
_err.print(f"[red]Register failed:[/red] {_extract_error(r)}")
|
|
170
|
+
sys.exit(1)
|
|
171
|
+
data = r.json()
|
|
172
|
+
if data.get("verification_required"):
|
|
173
|
+
_out.print(f"✓ Verification email sent to [bold]{email}[/bold]. Click the magic link, "
|
|
174
|
+
f"copy the key from the dashboard, then run [bold]qsp init --key sk-...[/bold]")
|
|
175
|
+
return
|
|
176
|
+
# Legacy path (email not configured) — returns key immediately.
|
|
177
|
+
if k := data.get("key"):
|
|
178
|
+
_save_key(k)
|
|
179
|
+
return
|
|
180
|
+
_err.print("[red]Unexpected register response shape.[/red]")
|
|
181
|
+
sys.exit(1)
|
|
182
|
+
|
|
183
|
+
# Default walkthrough: open the dashboard and guide copy-paste.
|
|
184
|
+
_out.print("Opening [bold]quicksilverpro.io/dashboard[/bold] — sign up or sign in, copy your API key, then paste below.")
|
|
185
|
+
try:
|
|
186
|
+
webbrowser.open(f"{DEFAULT_APP_URL}/dashboard")
|
|
187
|
+
except Exception:
|
|
188
|
+
pass
|
|
189
|
+
pasted = click.prompt("Paste API key", hide_input=True, default="", show_default=False).strip()
|
|
190
|
+
if not pasted:
|
|
191
|
+
_err.print("[red]No key entered — aborted.[/red]")
|
|
192
|
+
sys.exit(1)
|
|
193
|
+
_save_key(pasted)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _save_key(apikey: str) -> None:
|
|
197
|
+
if not apikey.startswith("sk-"):
|
|
198
|
+
_err.print("[red]That doesn't look like an API key (must start with sk-).[/red]")
|
|
199
|
+
sys.exit(1)
|
|
200
|
+
# Verify by calling /v1/me — catches typos + dead keys up-front.
|
|
201
|
+
with _auth_client(apikey) as c:
|
|
202
|
+
r = c.get("/v1/me")
|
|
203
|
+
if r.status_code >= 400:
|
|
204
|
+
_err.print(f"[red]Key rejected by server:[/red] {_extract_error(r)}")
|
|
205
|
+
sys.exit(1)
|
|
206
|
+
info = r.json()
|
|
207
|
+
cfg = _load_config()
|
|
208
|
+
cfg["api_key"] = apikey
|
|
209
|
+
cfg["email"] = info.get("email") or cfg.get("email", "")
|
|
210
|
+
_save_config(cfg)
|
|
211
|
+
_out.print(f"✓ Signed in as [bold]{info.get('email') or 'unknown'}[/bold]. Key stored at {CONFIG_PATH}.")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ────────────────────────── qsp logout / whoami ──────────────────────────
|
|
215
|
+
|
|
216
|
+
@main.command(help="Forget the locally-stored API key.")
|
|
217
|
+
def logout() -> None:
|
|
218
|
+
if CONFIG_PATH.exists():
|
|
219
|
+
CONFIG_PATH.unlink()
|
|
220
|
+
_out.print("✓ Signed out.")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@main.command(help="Show the email the CLI is signed in as.")
|
|
224
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
225
|
+
def whoami(as_json: bool) -> None:
|
|
226
|
+
key = _require_key()
|
|
227
|
+
with _auth_client(key) as c:
|
|
228
|
+
r = c.get("/v1/me")
|
|
229
|
+
if r.status_code >= 400:
|
|
230
|
+
_err.print(f"[red]{_extract_error(r)}[/red]")
|
|
231
|
+
sys.exit(1)
|
|
232
|
+
info = r.json()
|
|
233
|
+
info["api_base"] = DEFAULT_API_URL
|
|
234
|
+
_emit(info, as_json=as_json, table_fn=_print_whoami)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _print_whoami(info: dict) -> None:
|
|
238
|
+
_out.print(f"Email: [bold]{info.get('email','—')}[/bold]")
|
|
239
|
+
_out.print(f"Balance: [bold]${max(0, (info.get('max_budget') or 0) - (info.get('spend') or 0)):.4f}[/bold]")
|
|
240
|
+
_out.print(f"Total spent: ${info.get('spend') or 0:.4f}")
|
|
241
|
+
_out.print(f"API base: {info.get('api_base')}")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ────────────────────────── qsp balance ──────────────────────────
|
|
245
|
+
|
|
246
|
+
@main.command(help="Show current account balance and spend.")
|
|
247
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
248
|
+
def balance(as_json: bool) -> None:
|
|
249
|
+
key = _require_key()
|
|
250
|
+
with _auth_client(key) as c:
|
|
251
|
+
r = c.get("/v1/me")
|
|
252
|
+
if r.status_code >= 400:
|
|
253
|
+
_err.print(f"[red]{_extract_error(r)}[/red]")
|
|
254
|
+
sys.exit(1)
|
|
255
|
+
info = r.json()
|
|
256
|
+
bal = max(0, (info.get("max_budget") or 0) - (info.get("spend") or 0))
|
|
257
|
+
payload = {
|
|
258
|
+
"balance": round(bal, 6),
|
|
259
|
+
"loaded": info.get("max_budget") or 0,
|
|
260
|
+
"spent": info.get("spend") or 0,
|
|
261
|
+
"currency": "USD",
|
|
262
|
+
}
|
|
263
|
+
_emit(payload, as_json=as_json, table_fn=lambda p:
|
|
264
|
+
_out.print(f"Balance: [bold]${p['balance']:.4f}[/bold] of ${p['loaded']:.2f} loaded · spent ${p['spent']:.4f}"))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ────────────────────────── qsp models ──────────────────────────
|
|
268
|
+
|
|
269
|
+
# Hardcoded so `qsp models` works before the user has signed in. Keep in sync
|
|
270
|
+
# with the backend; tests in CI should catch drift.
|
|
271
|
+
_MODELS_FALLBACK: list[dict] = [
|
|
272
|
+
{"id": "deepseek-v3", "object": "model", "owned_by": "quicksilverpro",
|
|
273
|
+
"context_length": 131072,
|
|
274
|
+
"pricing": {"prompt": "0.0000002400", "completion": "0.0000007000"},
|
|
275
|
+
"best_for": "chat, coding, structured output"},
|
|
276
|
+
{"id": "deepseek-r1", "object": "model", "owned_by": "quicksilverpro",
|
|
277
|
+
"context_length": 131072,
|
|
278
|
+
"pricing": {"prompt": "0.0000004000", "completion": "0.0000017000"},
|
|
279
|
+
"best_for": "math, multi-step reasoning, logic"},
|
|
280
|
+
{"id": "qwen3.5-35b", "object": "model", "owned_by": "quicksilverpro",
|
|
281
|
+
"context_length": 262144,
|
|
282
|
+
"pricing": {"prompt": "0.0000001300", "completion": "0.0000010000"},
|
|
283
|
+
"best_for": "long-context RAG, summarization (thinking model)"},
|
|
284
|
+
]
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@main.command(help="List available models.")
|
|
288
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
289
|
+
def models(as_json: bool) -> None:
|
|
290
|
+
# Try the live endpoint for freshness; fall back to our shipped list if
|
|
291
|
+
# the user isn't signed in, so `qsp models` is useful before `qsp init`.
|
|
292
|
+
key = _resolve_api_key()
|
|
293
|
+
data: list[dict] = []
|
|
294
|
+
if key:
|
|
295
|
+
try:
|
|
296
|
+
with httpx.Client(
|
|
297
|
+
base_url=DEFAULT_API_URL,
|
|
298
|
+
timeout=HTTP_TIMEOUT,
|
|
299
|
+
headers={"Authorization": f"Bearer {key}"},
|
|
300
|
+
) as c:
|
|
301
|
+
r = c.get("/models")
|
|
302
|
+
if r.status_code < 400:
|
|
303
|
+
data = r.json().get("data", [])
|
|
304
|
+
except httpx.HTTPError:
|
|
305
|
+
data = []
|
|
306
|
+
if not data:
|
|
307
|
+
data = _MODELS_FALLBACK
|
|
308
|
+
_emit(data, as_json=as_json, table_fn=_print_models)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _print_models(rows: list[dict]) -> None:
|
|
312
|
+
t = Table(show_lines=False)
|
|
313
|
+
t.add_column("id", style="bold")
|
|
314
|
+
t.add_column("context", justify="right")
|
|
315
|
+
t.add_column("prompt $/M", justify="right")
|
|
316
|
+
t.add_column("completion $/M", justify="right")
|
|
317
|
+
for m in rows:
|
|
318
|
+
p = m.get("pricing") or {}
|
|
319
|
+
ctx = m.get("context_length")
|
|
320
|
+
t.add_row(
|
|
321
|
+
m.get("id", ""),
|
|
322
|
+
f"{ctx:,}" if ctx else "—",
|
|
323
|
+
f"{float(p.get('prompt', 0)) * 1_000_000:.2f}" if p else "—",
|
|
324
|
+
f"{float(p.get('completion', 0)) * 1_000_000:.2f}" if p else "—",
|
|
325
|
+
)
|
|
326
|
+
_out.print(t)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ────────────────────────── qsp chat ──────────────────────────
|
|
330
|
+
|
|
331
|
+
@main.command(help="One-shot chat completion. Echoes to stdout.")
|
|
332
|
+
@click.argument("prompt")
|
|
333
|
+
@click.option("-m", "--model", default=DEFAULT_MODEL, show_default=True)
|
|
334
|
+
@click.option("-s", "--system", default=None, help="Optional system prompt.")
|
|
335
|
+
@click.option("--max-tokens", type=int, default=None)
|
|
336
|
+
@click.option("--temperature", type=float, default=None)
|
|
337
|
+
@click.option("--stream/--no-stream", default=True, show_default=True)
|
|
338
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit raw JSON response (implies --no-stream).")
|
|
339
|
+
def chat(prompt: str, model: str, system: str | None, max_tokens: int | None,
|
|
340
|
+
temperature: float | None, stream: bool, as_json: bool) -> None:
|
|
341
|
+
key = _require_key()
|
|
342
|
+
messages: list[dict] = []
|
|
343
|
+
if system:
|
|
344
|
+
messages.append({"role": "system", "content": system})
|
|
345
|
+
messages.append({"role": "user", "content": prompt})
|
|
346
|
+
|
|
347
|
+
body = {"model": model, "messages": messages}
|
|
348
|
+
if max_tokens is not None: body["max_tokens"] = max_tokens
|
|
349
|
+
if temperature is not None: body["temperature"] = temperature
|
|
350
|
+
|
|
351
|
+
# --json means we want the full structured response, so force non-streaming.
|
|
352
|
+
if as_json:
|
|
353
|
+
stream = False
|
|
354
|
+
|
|
355
|
+
if stream:
|
|
356
|
+
body["stream"] = True
|
|
357
|
+
body["stream_options"] = {"include_usage": True}
|
|
358
|
+
_chat_stream(key, body)
|
|
359
|
+
else:
|
|
360
|
+
_chat_sync(key, body, as_json=as_json)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _chat_sync(key: str, body: dict, as_json: bool) -> None:
|
|
364
|
+
with _api_client(key) as c:
|
|
365
|
+
r = c.post("/chat/completions", json=body)
|
|
366
|
+
if r.status_code >= 400:
|
|
367
|
+
_err.print(f"[red]{_extract_error(r)}[/red]")
|
|
368
|
+
sys.exit(1)
|
|
369
|
+
data = r.json()
|
|
370
|
+
if as_json:
|
|
371
|
+
click.echo(json.dumps(data, indent=2))
|
|
372
|
+
return
|
|
373
|
+
msg = ((data.get("choices") or [{}])[0].get("message") or {}).get("content") or ""
|
|
374
|
+
click.echo(msg)
|
|
375
|
+
usage = data.get("usage") or {}
|
|
376
|
+
cost = usage.get("cost")
|
|
377
|
+
if cost is not None:
|
|
378
|
+
_err.print(
|
|
379
|
+
f"[dim]{usage.get('prompt_tokens',0)} in + "
|
|
380
|
+
f"{usage.get('completion_tokens',0)} out · "
|
|
381
|
+
f"${cost:.6f}[/dim]"
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _chat_stream(key: str, body: dict) -> None:
|
|
386
|
+
last_usage = {}
|
|
387
|
+
try:
|
|
388
|
+
with _api_client(key).stream("POST", "/chat/completions", json=body) as r:
|
|
389
|
+
if r.status_code >= 400:
|
|
390
|
+
_err.print(f"[red]{_extract_error(r)}[/red]")
|
|
391
|
+
sys.exit(1)
|
|
392
|
+
for line in r.iter_lines():
|
|
393
|
+
if not line or not line.startswith("data:"):
|
|
394
|
+
continue
|
|
395
|
+
payload = line[5:].strip()
|
|
396
|
+
if not payload or payload == "[DONE]":
|
|
397
|
+
continue
|
|
398
|
+
try:
|
|
399
|
+
ev = json.loads(payload)
|
|
400
|
+
except Exception:
|
|
401
|
+
continue
|
|
402
|
+
for ch in ev.get("choices") or []:
|
|
403
|
+
delta = (ch.get("delta") or {})
|
|
404
|
+
if delta.get("content"):
|
|
405
|
+
sys.stdout.write(delta["content"])
|
|
406
|
+
sys.stdout.flush()
|
|
407
|
+
if ev.get("usage"):
|
|
408
|
+
last_usage = ev["usage"]
|
|
409
|
+
except httpx.HTTPError as e:
|
|
410
|
+
_err.print(f"\n[red]Network error:[/red] {e}")
|
|
411
|
+
sys.exit(1)
|
|
412
|
+
sys.stdout.write("\n")
|
|
413
|
+
if last_usage.get("cost") is not None:
|
|
414
|
+
_err.print(
|
|
415
|
+
f"[dim]{last_usage.get('prompt_tokens',0)} in + "
|
|
416
|
+
f"{last_usage.get('completion_tokens',0)} out · "
|
|
417
|
+
f"${last_usage['cost']:.6f}[/dim]"
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# ────────────────────────── qsp keys ──────────────────────────
|
|
422
|
+
|
|
423
|
+
@main.group(help="Manage API keys.")
|
|
424
|
+
def keys() -> None: pass
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
@keys.command("list", help="List all of your API keys.")
|
|
428
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
429
|
+
def keys_list(as_json: bool) -> None:
|
|
430
|
+
key = _require_key()
|
|
431
|
+
with _auth_client(key) as c:
|
|
432
|
+
r = c.get("/v1/keys")
|
|
433
|
+
if r.status_code >= 400:
|
|
434
|
+
_err.print(f"[red]{_extract_error(r)}[/red]")
|
|
435
|
+
sys.exit(1)
|
|
436
|
+
rows = r.json().get("keys", [])
|
|
437
|
+
_emit(rows, as_json=as_json, table_fn=_print_keys)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _print_keys(rows: list[dict]) -> None:
|
|
441
|
+
t = Table(show_lines=False)
|
|
442
|
+
t.add_column("alias", style="bold")
|
|
443
|
+
t.add_column("key")
|
|
444
|
+
t.add_column("monthly limit", justify="right")
|
|
445
|
+
t.add_column("spend", justify="right")
|
|
446
|
+
t.add_column("current?")
|
|
447
|
+
for k in rows:
|
|
448
|
+
lim = k.get("monthly_limit")
|
|
449
|
+
t.add_row(
|
|
450
|
+
k.get("alias", "—"),
|
|
451
|
+
k.get("key_name") or "",
|
|
452
|
+
f"${lim:.4f}/30d" if lim is not None else "—",
|
|
453
|
+
f"${k.get('spend') or 0:.4f}",
|
|
454
|
+
"✓" if k.get("is_current") else "",
|
|
455
|
+
)
|
|
456
|
+
_out.print(t)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
@keys.command("create", help="Create a new API key.")
|
|
460
|
+
@click.argument("alias")
|
|
461
|
+
@click.option("--monthly-limit", type=float, default=None, help="Max USD this key can spend per 30 days.")
|
|
462
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
463
|
+
def keys_create(alias: str, monthly_limit: float | None, as_json: bool) -> None:
|
|
464
|
+
key = _require_key()
|
|
465
|
+
body = {"alias": alias}
|
|
466
|
+
if monthly_limit is not None:
|
|
467
|
+
body["monthly_limit"] = monthly_limit
|
|
468
|
+
with _auth_client(key) as c:
|
|
469
|
+
r = c.post("/v1/keys", json=body)
|
|
470
|
+
if r.status_code >= 400:
|
|
471
|
+
_err.print(f"[red]{_extract_error(r)}[/red]")
|
|
472
|
+
sys.exit(1)
|
|
473
|
+
data = r.json()
|
|
474
|
+
if as_json:
|
|
475
|
+
click.echo(json.dumps(data, indent=2))
|
|
476
|
+
return
|
|
477
|
+
_out.print(f"✓ Created key [bold]{data.get('alias')}[/bold]")
|
|
478
|
+
_out.print(f" {data.get('key')}")
|
|
479
|
+
_out.print("[yellow]Copy it now — it won't be shown again.[/yellow]")
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@keys.command("delete", help="Delete an API key by alias.")
|
|
483
|
+
@click.argument("alias")
|
|
484
|
+
@click.option("-y", "--yes", is_flag=True, help="Skip the confirmation prompt.")
|
|
485
|
+
def keys_delete(alias: str, yes: bool) -> None:
|
|
486
|
+
key = _require_key()
|
|
487
|
+
with _auth_client(key) as c:
|
|
488
|
+
r = c.get("/v1/keys")
|
|
489
|
+
if r.status_code >= 400:
|
|
490
|
+
_err.print(f"[red]{_extract_error(r)}[/red]")
|
|
491
|
+
sys.exit(1)
|
|
492
|
+
matches = [k for k in r.json().get("keys", []) if k.get("alias") == alias]
|
|
493
|
+
if not matches:
|
|
494
|
+
_err.print(f"[red]No key with alias '{alias}'.[/red]")
|
|
495
|
+
sys.exit(1)
|
|
496
|
+
target = matches[0]
|
|
497
|
+
if not yes:
|
|
498
|
+
click.confirm(f"Delete key '{alias}' ({target.get('key_name')})?", abort=True)
|
|
499
|
+
with _auth_client(key) as c:
|
|
500
|
+
r = c.post("/v1/keys/delete", json={"token": target["token"]})
|
|
501
|
+
if r.status_code >= 400:
|
|
502
|
+
_err.print(f"[red]{_extract_error(r)}[/red]")
|
|
503
|
+
sys.exit(1)
|
|
504
|
+
_out.print(f"✓ Deleted [bold]{alias}[/bold].")
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
# ────────────────────────── qsp usage ──────────────────────────
|
|
508
|
+
|
|
509
|
+
@main.command(help="Recent API calls with cost.")
|
|
510
|
+
@click.option("-n", "--limit", type=int, default=10, show_default=True)
|
|
511
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
512
|
+
def usage(limit: int, as_json: bool) -> None:
|
|
513
|
+
key = _require_key()
|
|
514
|
+
with _auth_client(key) as c:
|
|
515
|
+
r = c.get("/v1/usage")
|
|
516
|
+
if r.status_code >= 400:
|
|
517
|
+
_err.print(f"[red]{_extract_error(r)}[/red]")
|
|
518
|
+
sys.exit(1)
|
|
519
|
+
data = r.json()
|
|
520
|
+
data["recent"] = (data.get("recent") or [])[:limit]
|
|
521
|
+
_emit(data, as_json=as_json, table_fn=_print_usage)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _print_usage(data: dict) -> None:
|
|
525
|
+
totals = data.get("totals") or {}
|
|
526
|
+
_out.print(
|
|
527
|
+
f"Total: [bold]{totals.get('requests',0)}[/bold] requests · "
|
|
528
|
+
f"{totals.get('tokens',0):,} tokens · "
|
|
529
|
+
f"${totals.get('cost',0):.4f}"
|
|
530
|
+
)
|
|
531
|
+
by_model = data.get("by_model") or []
|
|
532
|
+
if by_model:
|
|
533
|
+
t = Table(title="By model")
|
|
534
|
+
t.add_column("model", style="bold"); t.add_column("requests", justify="right")
|
|
535
|
+
t.add_column("tokens", justify="right"); t.add_column("cost", justify="right")
|
|
536
|
+
for m in by_model:
|
|
537
|
+
t.add_row(m["model"], str(m["requests"]), f"{m['tokens']:,}", f"${m['cost']:.4f}")
|
|
538
|
+
_out.print(t)
|
|
539
|
+
recent = data.get("recent") or []
|
|
540
|
+
if recent:
|
|
541
|
+
t = Table(title="Recent requests")
|
|
542
|
+
t.add_column("model", style="bold"); t.add_column("tokens", justify="right")
|
|
543
|
+
t.add_column("latency", justify="right"); t.add_column("cost", justify="right"); t.add_column("at")
|
|
544
|
+
for r in recent:
|
|
545
|
+
t.add_row(r["model"], str(r.get("tokens", 0)),
|
|
546
|
+
f"{(r.get('duration_ms') or 0)/1000:.2f}s",
|
|
547
|
+
f"${r.get('cost',0):.6f}", r.get("at", "")[:19])
|
|
548
|
+
_out.print(t)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
# ────────────────────────── qsp status ──────────────────────────
|
|
552
|
+
|
|
553
|
+
@main.command(help="Check service status (routes + per-model latency).")
|
|
554
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
555
|
+
def status(as_json: bool) -> None:
|
|
556
|
+
with httpx.Client(base_url=DEFAULT_AUTH_URL, timeout=HTTP_TIMEOUT) as c:
|
|
557
|
+
r = c.get("/v1/status")
|
|
558
|
+
if r.status_code >= 400:
|
|
559
|
+
_err.print(f"[red]{_extract_error(r)}[/red]")
|
|
560
|
+
sys.exit(1)
|
|
561
|
+
data = r.json()
|
|
562
|
+
_emit(data, as_json=as_json, table_fn=_print_status)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _print_status(data: dict) -> None:
|
|
566
|
+
overall = (data.get("overall") or "unknown").upper()
|
|
567
|
+
color = {"OPERATIONAL": "green", "DEGRADED": "yellow",
|
|
568
|
+
"PARTIAL_OUTAGE": "yellow", "MAJOR_OUTAGE": "red"}.get(overall, "white")
|
|
569
|
+
_out.print(f"Overall: [bold {color}]{overall}[/bold {color}]")
|
|
570
|
+
t = Table(show_lines=False)
|
|
571
|
+
t.add_column("model", style="bold"); t.add_column("status")
|
|
572
|
+
t.add_column("latency (ms)", justify="right")
|
|
573
|
+
for m in data.get("models") or []:
|
|
574
|
+
t.add_row(m["model"], m["status"], str(m.get("latency_ms") or "—"))
|
|
575
|
+
_out.print(t)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
# ────────────────────────── qsp pay ──────────────────────────
|
|
579
|
+
|
|
580
|
+
@main.command(help="Open the Stripe checkout for a credit pack ($5 / $20 / $50).")
|
|
581
|
+
@click.argument("amount", type=click.Choice(["5", "20", "50"]))
|
|
582
|
+
def pay(amount: str) -> None:
|
|
583
|
+
urls = {
|
|
584
|
+
"5": "https://buy.stripe.com/6oU9AT5eC2aGexN4t4gjC03",
|
|
585
|
+
"20": "https://buy.stripe.com/6oU5kD0YmbLg9dt9NogjC04",
|
|
586
|
+
"50": "https://buy.stripe.com/7sYbJ1ePc7v0ahxcZAgjC05",
|
|
587
|
+
}
|
|
588
|
+
url = urls[amount]
|
|
589
|
+
_out.print(f"Opening checkout for [bold]${amount}[/bold]: {url}")
|
|
590
|
+
try:
|
|
591
|
+
webbrowser.open(url)
|
|
592
|
+
except Exception:
|
|
593
|
+
pass
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
# ────────────────────────── entrypoint ──────────────────────────
|
|
597
|
+
|
|
598
|
+
if __name__ == "__main__":
|
|
599
|
+
main()
|