sirb 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.
- sirb-0.1.0/PKG-INFO +88 -0
- sirb-0.1.0/README.md +53 -0
- sirb-0.1.0/pyproject.toml +64 -0
- sirb-0.1.0/setup.cfg +4 -0
- sirb-0.1.0/sirb.egg-info/PKG-INFO +88 -0
- sirb-0.1.0/sirb.egg-info/SOURCES.txt +22 -0
- sirb-0.1.0/sirb.egg-info/dependency_links.txt +1 -0
- sirb-0.1.0/sirb.egg-info/entry_points.txt +2 -0
- sirb-0.1.0/sirb.egg-info/requires.txt +10 -0
- sirb-0.1.0/sirb.egg-info/top_level.txt +1 -0
- sirb-0.1.0/sirb_cli/__init__.py +1 -0
- sirb-0.1.0/sirb_cli/client.py +85 -0
- sirb-0.1.0/sirb_cli/commands/__init__.py +0 -0
- sirb-0.1.0/sirb_cli/commands/balance.py +19 -0
- sirb-0.1.0/sirb_cli/commands/chat.py +34 -0
- sirb-0.1.0/sirb_cli/commands/keys.py +45 -0
- sirb-0.1.0/sirb_cli/commands/login.py +22 -0
- sirb-0.1.0/sirb_cli/commands/models.py +17 -0
- sirb-0.1.0/sirb_cli/commands/observations.py +112 -0
- sirb-0.1.0/sirb_cli/commands/opencode.py +47 -0
- sirb-0.1.0/sirb_cli/commands/usage.py +62 -0
- sirb-0.1.0/sirb_cli/config.py +68 -0
- sirb-0.1.0/sirb_cli/main.py +34 -0
- sirb-0.1.0/tests/test_cli.py +123 -0
sirb-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sirb
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Sirb — command-line client for the decentralized AI compute network.
|
|
5
|
+
Author-email: Sirb maintainers <ops@sirb.run>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://sirb.run
|
|
8
|
+
Project-URL: Documentation, https://sirb.run/docs/cli/
|
|
9
|
+
Project-URL: Repository, https://github.com/ammarwa/SIRB
|
|
10
|
+
Project-URL: Issues, https://github.com/ammarwa/SIRB/issues
|
|
11
|
+
Keywords: sirb,ai,inference,decentralized,openai-compatible,llm
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: Utilities
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
Requires-Dist: typer>=0.12
|
|
27
|
+
Requires-Dist: httpx>=0.27
|
|
28
|
+
Requires-Dist: rich>=13.7
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=8.3; extra == "dev"
|
|
31
|
+
Requires-Dist: respx>=0.21; extra == "dev"
|
|
32
|
+
Requires-Dist: ruff>=0.7; extra == "dev"
|
|
33
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
34
|
+
Requires-Dist: twine>=5.0; extra == "dev"
|
|
35
|
+
|
|
36
|
+
# `sirb` — Sirb CLI
|
|
37
|
+
|
|
38
|
+
A thin command-line client for the Decentralized AI Compute network.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
cd cli
|
|
44
|
+
pip install -e .
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
This installs a `sirb` console script.
|
|
48
|
+
|
|
49
|
+
## First-time setup
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
sirb login
|
|
53
|
+
# prompted for: API key (hidden), base URL, default model
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Saves to `~/.sirb/config.json` (0600 on Unix). Environment variables override
|
|
57
|
+
the saved config per-command: `SIRB_API_KEY`, `SIRB_BASE_URL`, `SIRB_MODEL`.
|
|
58
|
+
|
|
59
|
+
## Commands
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
sirb models # list available models (and aliases)
|
|
63
|
+
sirb chat "write a haiku about uvicorn"
|
|
64
|
+
sirb chat --stream "explain CRDTs" # streaming
|
|
65
|
+
sirb balance # your Sirb allowance
|
|
66
|
+
sirb usage --from 2026-05-01 --to 2026-05-14
|
|
67
|
+
sirb keys list
|
|
68
|
+
sirb keys create --name "opencode" # prints plaintext key once
|
|
69
|
+
sirb keys revoke key_abc123
|
|
70
|
+
|
|
71
|
+
sirb opencode config # JSON snippet for OpenCode config
|
|
72
|
+
sirb opencode env # `export` lines for OpenAI-SDK tools
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## What it talks to
|
|
76
|
+
|
|
77
|
+
Plain `httpx`. No `openai` SDK dependency — the CLI hits admin endpoints
|
|
78
|
+
too (`/admin/api-keys`), so the marginal value of pulling in the full SDK
|
|
79
|
+
just for `/v1/chat/completions` wasn't worth the dependency footprint. The
|
|
80
|
+
HTTP code in `sirb_cli/client.py` is short enough to lift into a user's
|
|
81
|
+
own integration if they want a starting point.
|
|
82
|
+
|
|
83
|
+
## Status
|
|
84
|
+
|
|
85
|
+
Phase 5B. The commands above all work today. Phase 5B-2 adds `/v1/completions`
|
|
86
|
+
and `/v1/embeddings` to the CLI once the backend ships them; Phase 7 adds
|
|
87
|
+
real onboarding (wallet-signed key creation) so users can self-serve their
|
|
88
|
+
first key.
|
sirb-0.1.0/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# `sirb` — Sirb CLI
|
|
2
|
+
|
|
3
|
+
A thin command-line client for the Decentralized AI Compute network.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
cd cli
|
|
9
|
+
pip install -e .
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
This installs a `sirb` console script.
|
|
13
|
+
|
|
14
|
+
## First-time setup
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
sirb login
|
|
18
|
+
# prompted for: API key (hidden), base URL, default model
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Saves to `~/.sirb/config.json` (0600 on Unix). Environment variables override
|
|
22
|
+
the saved config per-command: `SIRB_API_KEY`, `SIRB_BASE_URL`, `SIRB_MODEL`.
|
|
23
|
+
|
|
24
|
+
## Commands
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
sirb models # list available models (and aliases)
|
|
28
|
+
sirb chat "write a haiku about uvicorn"
|
|
29
|
+
sirb chat --stream "explain CRDTs" # streaming
|
|
30
|
+
sirb balance # your Sirb allowance
|
|
31
|
+
sirb usage --from 2026-05-01 --to 2026-05-14
|
|
32
|
+
sirb keys list
|
|
33
|
+
sirb keys create --name "opencode" # prints plaintext key once
|
|
34
|
+
sirb keys revoke key_abc123
|
|
35
|
+
|
|
36
|
+
sirb opencode config # JSON snippet for OpenCode config
|
|
37
|
+
sirb opencode env # `export` lines for OpenAI-SDK tools
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## What it talks to
|
|
41
|
+
|
|
42
|
+
Plain `httpx`. No `openai` SDK dependency — the CLI hits admin endpoints
|
|
43
|
+
too (`/admin/api-keys`), so the marginal value of pulling in the full SDK
|
|
44
|
+
just for `/v1/chat/completions` wasn't worth the dependency footprint. The
|
|
45
|
+
HTTP code in `sirb_cli/client.py` is short enough to lift into a user's
|
|
46
|
+
own integration if they want a starting point.
|
|
47
|
+
|
|
48
|
+
## Status
|
|
49
|
+
|
|
50
|
+
Phase 5B. The commands above all work today. Phase 5B-2 adds `/v1/completions`
|
|
51
|
+
and `/v1/embeddings` to the CLI once the backend ships them; Phase 7 adds
|
|
52
|
+
real onboarding (wallet-signed key creation) so users can self-serve their
|
|
53
|
+
first key.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sirb"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Sirb — command-line client for the decentralized AI compute network."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Sirb maintainers", email = "ops@sirb.run" },
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
keywords = ["sirb", "ai", "inference", "decentralized", "openai-compatible", "llm"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Environment :: Console",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
"Topic :: Utilities",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"typer>=0.12",
|
|
28
|
+
"httpx>=0.27",
|
|
29
|
+
"rich>=13.7",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://sirb.run"
|
|
34
|
+
Documentation = "https://sirb.run/docs/cli/"
|
|
35
|
+
Repository = "https://github.com/ammarwa/SIRB"
|
|
36
|
+
Issues = "https://github.com/ammarwa/SIRB/issues"
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
dev = [
|
|
40
|
+
"pytest>=8.3",
|
|
41
|
+
"respx>=0.21",
|
|
42
|
+
"ruff>=0.7",
|
|
43
|
+
"build>=1.2",
|
|
44
|
+
"twine>=5.0",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[project.scripts]
|
|
48
|
+
sirb = "sirb_cli.main:app"
|
|
49
|
+
|
|
50
|
+
[build-system]
|
|
51
|
+
requires = ["setuptools>=68"]
|
|
52
|
+
build-backend = "setuptools.build_meta"
|
|
53
|
+
|
|
54
|
+
[tool.setuptools.packages.find]
|
|
55
|
+
where = ["."]
|
|
56
|
+
include = ["sirb_cli*"]
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
line-length = 100
|
|
60
|
+
target-version = "py311"
|
|
61
|
+
|
|
62
|
+
[tool.pytest.ini_options]
|
|
63
|
+
pythonpath = ["."]
|
|
64
|
+
testpaths = ["tests"]
|
sirb-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sirb
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Sirb — command-line client for the decentralized AI compute network.
|
|
5
|
+
Author-email: Sirb maintainers <ops@sirb.run>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://sirb.run
|
|
8
|
+
Project-URL: Documentation, https://sirb.run/docs/cli/
|
|
9
|
+
Project-URL: Repository, https://github.com/ammarwa/SIRB
|
|
10
|
+
Project-URL: Issues, https://github.com/ammarwa/SIRB/issues
|
|
11
|
+
Keywords: sirb,ai,inference,decentralized,openai-compatible,llm
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: Utilities
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
Requires-Dist: typer>=0.12
|
|
27
|
+
Requires-Dist: httpx>=0.27
|
|
28
|
+
Requires-Dist: rich>=13.7
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=8.3; extra == "dev"
|
|
31
|
+
Requires-Dist: respx>=0.21; extra == "dev"
|
|
32
|
+
Requires-Dist: ruff>=0.7; extra == "dev"
|
|
33
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
34
|
+
Requires-Dist: twine>=5.0; extra == "dev"
|
|
35
|
+
|
|
36
|
+
# `sirb` — Sirb CLI
|
|
37
|
+
|
|
38
|
+
A thin command-line client for the Decentralized AI Compute network.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
cd cli
|
|
44
|
+
pip install -e .
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
This installs a `sirb` console script.
|
|
48
|
+
|
|
49
|
+
## First-time setup
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
sirb login
|
|
53
|
+
# prompted for: API key (hidden), base URL, default model
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Saves to `~/.sirb/config.json` (0600 on Unix). Environment variables override
|
|
57
|
+
the saved config per-command: `SIRB_API_KEY`, `SIRB_BASE_URL`, `SIRB_MODEL`.
|
|
58
|
+
|
|
59
|
+
## Commands
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
sirb models # list available models (and aliases)
|
|
63
|
+
sirb chat "write a haiku about uvicorn"
|
|
64
|
+
sirb chat --stream "explain CRDTs" # streaming
|
|
65
|
+
sirb balance # your Sirb allowance
|
|
66
|
+
sirb usage --from 2026-05-01 --to 2026-05-14
|
|
67
|
+
sirb keys list
|
|
68
|
+
sirb keys create --name "opencode" # prints plaintext key once
|
|
69
|
+
sirb keys revoke key_abc123
|
|
70
|
+
|
|
71
|
+
sirb opencode config # JSON snippet for OpenCode config
|
|
72
|
+
sirb opencode env # `export` lines for OpenAI-SDK tools
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## What it talks to
|
|
76
|
+
|
|
77
|
+
Plain `httpx`. No `openai` SDK dependency — the CLI hits admin endpoints
|
|
78
|
+
too (`/admin/api-keys`), so the marginal value of pulling in the full SDK
|
|
79
|
+
just for `/v1/chat/completions` wasn't worth the dependency footprint. The
|
|
80
|
+
HTTP code in `sirb_cli/client.py` is short enough to lift into a user's
|
|
81
|
+
own integration if they want a starting point.
|
|
82
|
+
|
|
83
|
+
## Status
|
|
84
|
+
|
|
85
|
+
Phase 5B. The commands above all work today. Phase 5B-2 adds `/v1/completions`
|
|
86
|
+
and `/v1/embeddings` to the CLI once the backend ships them; Phase 7 adds
|
|
87
|
+
real onboarding (wallet-signed key creation) so users can self-serve their
|
|
88
|
+
first key.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
sirb.egg-info/PKG-INFO
|
|
4
|
+
sirb.egg-info/SOURCES.txt
|
|
5
|
+
sirb.egg-info/dependency_links.txt
|
|
6
|
+
sirb.egg-info/entry_points.txt
|
|
7
|
+
sirb.egg-info/requires.txt
|
|
8
|
+
sirb.egg-info/top_level.txt
|
|
9
|
+
sirb_cli/__init__.py
|
|
10
|
+
sirb_cli/client.py
|
|
11
|
+
sirb_cli/config.py
|
|
12
|
+
sirb_cli/main.py
|
|
13
|
+
sirb_cli/commands/__init__.py
|
|
14
|
+
sirb_cli/commands/balance.py
|
|
15
|
+
sirb_cli/commands/chat.py
|
|
16
|
+
sirb_cli/commands/keys.py
|
|
17
|
+
sirb_cli/commands/login.py
|
|
18
|
+
sirb_cli/commands/models.py
|
|
19
|
+
sirb_cli/commands/observations.py
|
|
20
|
+
sirb_cli/commands/opencode.py
|
|
21
|
+
sirb_cli/commands/usage.py
|
|
22
|
+
tests/test_cli.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sirb_cli
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Thin HTTP client over the Sirb backend.
|
|
2
|
+
|
|
3
|
+
Plain httpx — we deliberately do NOT depend on the openai SDK here. The
|
|
4
|
+
CLI also hits admin endpoints (keys, usage) that aren't OpenAI-compatible,
|
|
5
|
+
so dragging the SDK in for two chat endpoints would just add deps. Plus
|
|
6
|
+
plain httpx is easier to lift into a user's own integration.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from sirb_cli.config import CliConfig
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SirbClient:
|
|
21
|
+
def __init__(self, config: CliConfig, timeout: float = 60.0) -> None:
|
|
22
|
+
self._cfg = config
|
|
23
|
+
self._timeout = timeout
|
|
24
|
+
|
|
25
|
+
def _headers(self) -> dict[str, str]:
|
|
26
|
+
if not self._cfg.api_key:
|
|
27
|
+
raise RuntimeError("no API key configured — run `sirb login`")
|
|
28
|
+
return {"Authorization": f"Bearer {self._cfg.api_key}"}
|
|
29
|
+
|
|
30
|
+
# ─────────── reads ───────────
|
|
31
|
+
|
|
32
|
+
def get(self, path: str, params: dict | None = None) -> dict:
|
|
33
|
+
r = httpx.get(f"{self._cfg.base_url}{path}", headers=self._headers(),
|
|
34
|
+
params=params, timeout=self._timeout)
|
|
35
|
+
r.raise_for_status()
|
|
36
|
+
return r.json()
|
|
37
|
+
|
|
38
|
+
def post(self, path: str, body: dict) -> dict:
|
|
39
|
+
r = httpx.post(f"{self._cfg.base_url}{path}", headers=self._headers(),
|
|
40
|
+
json=body, timeout=self._timeout)
|
|
41
|
+
r.raise_for_status()
|
|
42
|
+
return r.json()
|
|
43
|
+
|
|
44
|
+
def delete(self, path: str) -> None:
|
|
45
|
+
r = httpx.delete(f"{self._cfg.base_url}{path}", headers=self._headers(),
|
|
46
|
+
timeout=self._timeout)
|
|
47
|
+
r.raise_for_status()
|
|
48
|
+
|
|
49
|
+
# ─────────── chat / streaming ───────────
|
|
50
|
+
|
|
51
|
+
def chat(self, prompt: str, model: str | None = None) -> dict:
|
|
52
|
+
return self.post("/v1/chat/completions", {
|
|
53
|
+
"model": model or self._cfg.default_model,
|
|
54
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
def chat_stream(self, prompt: str, model: str | None = None) -> Iterator[str]:
|
|
58
|
+
"""Yields completion text fragments as they arrive."""
|
|
59
|
+
with httpx.stream(
|
|
60
|
+
"POST", f"{self._cfg.base_url}/v1/chat/completions",
|
|
61
|
+
headers=self._headers(),
|
|
62
|
+
json={
|
|
63
|
+
"model": model or self._cfg.default_model,
|
|
64
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
65
|
+
"stream": True,
|
|
66
|
+
},
|
|
67
|
+
timeout=self._timeout,
|
|
68
|
+
) as r:
|
|
69
|
+
r.raise_for_status()
|
|
70
|
+
for line in r.iter_lines():
|
|
71
|
+
if not line or not line.startswith("data:"):
|
|
72
|
+
continue
|
|
73
|
+
payload = line[len("data:"):].strip()
|
|
74
|
+
if payload == "[DONE]":
|
|
75
|
+
return
|
|
76
|
+
try:
|
|
77
|
+
obj: Any = json.loads(payload)
|
|
78
|
+
except ValueError:
|
|
79
|
+
continue
|
|
80
|
+
choices = obj.get("choices") or []
|
|
81
|
+
if not choices:
|
|
82
|
+
continue
|
|
83
|
+
delta = choices[0].get("delta", {}).get("content")
|
|
84
|
+
if delta:
|
|
85
|
+
yield delta
|
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from rich import print as rprint
|
|
4
|
+
|
|
5
|
+
from sirb_cli.client import SirbClient
|
|
6
|
+
from sirb_cli.config import env_override_or_config
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def run() -> None:
|
|
10
|
+
body = SirbClient(env_override_or_config()).get("/v1/balance")
|
|
11
|
+
src = body.get("source", "?")
|
|
12
|
+
bal = body.get("balance", 0.0)
|
|
13
|
+
if src == "unconfigured":
|
|
14
|
+
rprint("[yellow]Balance unavailable[/yellow] (backend has no chain configured).")
|
|
15
|
+
rprint(f" wallet: [dim]{body.get('wallet')}[/dim]")
|
|
16
|
+
return
|
|
17
|
+
rprint(f"[green]{bal:.6f} Sirb[/green] available")
|
|
18
|
+
rprint(f" wallet: [dim]{body.get('wallet')}[/dim]")
|
|
19
|
+
rprint(f" source: [dim]{src}[/dim]")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich import print as rprint
|
|
7
|
+
|
|
8
|
+
from sirb_cli.client import SirbClient
|
|
9
|
+
from sirb_cli.config import env_override_or_config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run(
|
|
13
|
+
prompt: list[str] = typer.Argument(..., help="The user prompt."),
|
|
14
|
+
model: str | None = typer.Option(None, "--model", "-m",
|
|
15
|
+
help="Model id or alias. Defaults to config default."),
|
|
16
|
+
stream: bool = typer.Option(False, "--stream", help="Stream tokens as they arrive."),
|
|
17
|
+
) -> None:
|
|
18
|
+
text = " ".join(prompt)
|
|
19
|
+
client = SirbClient(env_override_or_config())
|
|
20
|
+
|
|
21
|
+
if stream:
|
|
22
|
+
for chunk in client.chat_stream(text, model=model):
|
|
23
|
+
sys.stdout.write(chunk)
|
|
24
|
+
sys.stdout.flush()
|
|
25
|
+
sys.stdout.write("\n")
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
body = client.chat(text, model=model)
|
|
29
|
+
completion = body["choices"][0]["message"]["content"]
|
|
30
|
+
rprint(completion)
|
|
31
|
+
u = body.get("usage", {})
|
|
32
|
+
rprint(f"[dim]usage: {u.get('prompt_tokens', 0)} prompt + "
|
|
33
|
+
f"{u.get('completion_tokens', 0)} completion = "
|
|
34
|
+
f"{u.get('total_tokens', 0)} tokens[/dim]")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich import print as rprint
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from sirb_cli.client import SirbClient
|
|
9
|
+
from sirb_cli.config import env_override_or_config
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(no_args_is_help=True, help="Manage your Sirb API keys.")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("list")
|
|
15
|
+
def list_keys() -> None:
|
|
16
|
+
body = SirbClient(env_override_or_config()).get("/admin/api-keys")
|
|
17
|
+
t = Table(title="Your API keys")
|
|
18
|
+
t.add_column("id"); t.add_column("name"); t.add_column("status")
|
|
19
|
+
t.add_column("created"); t.add_column("last used")
|
|
20
|
+
for k in body.get("data", []):
|
|
21
|
+
t.add_row(k["id"], k["name"], k["status"],
|
|
22
|
+
(k.get("created_at") or "")[:19],
|
|
23
|
+
(k.get("last_used_at") or "—")[:19])
|
|
24
|
+
Console().print(t)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command("create")
|
|
28
|
+
def create_key(
|
|
29
|
+
name: str = typer.Option(..., "--name", "-n", help="Human-friendly label."),
|
|
30
|
+
rate_limit: int | None = typer.Option(None, "--rate-limit-per-minute",
|
|
31
|
+
help="Optional rate limit (default 60)."),
|
|
32
|
+
) -> None:
|
|
33
|
+
body = SirbClient(env_override_or_config()).post(
|
|
34
|
+
"/admin/api-keys",
|
|
35
|
+
{"name": name, "rate_limit_per_minute": rate_limit},
|
|
36
|
+
)
|
|
37
|
+
rprint(f"[green]Created[/green] key [bold]{body['id']}[/bold]")
|
|
38
|
+
rprint(f"\n [bold yellow]{body['key']}[/bold yellow]\n")
|
|
39
|
+
rprint("[red]This is the only time this plaintext key is shown. Save it now.[/red]")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.command("revoke")
|
|
43
|
+
def revoke_key(key_id: str = typer.Argument(..., help="Key id to revoke.")) -> None:
|
|
44
|
+
SirbClient(env_override_or_config()).delete(f"/admin/api-keys/{key_id}")
|
|
45
|
+
rprint(f"[green]Revoked[/green] {key_id}")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich import print as rprint
|
|
5
|
+
|
|
6
|
+
from sirb_cli.config import CliConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def run(
|
|
10
|
+
api_key: str = typer.Option(..., "--api-key", "-k", prompt=True, hide_input=True,
|
|
11
|
+
help="Your Sirb API key (input is hidden)."),
|
|
12
|
+
base_url: str = typer.Option("http://localhost:8000", "--base-url", "-u",
|
|
13
|
+
help="Backend base URL."),
|
|
14
|
+
default_model: str = typer.Option("mock-7b", "--model", "-m",
|
|
15
|
+
help="Default model id for `sirb chat`."),
|
|
16
|
+
) -> None:
|
|
17
|
+
cfg = CliConfig(base_url=base_url.rstrip("/"), api_key=api_key, default_model=default_model)
|
|
18
|
+
p = cfg.save()
|
|
19
|
+
rprint(f"[green]Saved[/green] config to [bold]{p}[/bold]")
|
|
20
|
+
rprint(f" base_url: {cfg.base_url}")
|
|
21
|
+
rprint(f" default_model: {cfg.default_model}")
|
|
22
|
+
rprint(f" api_key: [dim]sirb_***{api_key[-6:] if len(api_key) > 6 else '***'}[/dim]")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
|
|
6
|
+
from sirb_cli.client import SirbClient
|
|
7
|
+
from sirb_cli.config import env_override_or_config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run() -> None:
|
|
11
|
+
body = SirbClient(env_override_or_config()).get("/v1/models")
|
|
12
|
+
table = Table(title="Available models", show_lines=False)
|
|
13
|
+
table.add_column("id", style="bold")
|
|
14
|
+
table.add_column("owned_by", style="dim")
|
|
15
|
+
for m in body.get("data", []):
|
|
16
|
+
table.add_row(m.get("id", ""), m.get("owned_by", ""))
|
|
17
|
+
Console().print(table)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Phase 9A — agent observations subcommand.
|
|
2
|
+
|
|
3
|
+
Two operations, matching the admin API surface:
|
|
4
|
+
|
|
5
|
+
sirb observations list [--kind monitoring] [--limit 20]
|
|
6
|
+
sirb observations run
|
|
7
|
+
|
|
8
|
+
``list`` paginates the most-recent observations across all kinds (or a
|
|
9
|
+
single kind). ``run`` triggers a manual monitoring agent cycle — useful
|
|
10
|
+
for verifying snapshot + LLM config without waiting for the runner
|
|
11
|
+
interval.
|
|
12
|
+
|
|
13
|
+
Observations include the network snapshot that drove them; pass
|
|
14
|
+
``--full`` to ``list`` to print that too. Otherwise output is a compact
|
|
15
|
+
one-line-per-observation table — what an operator wants when glancing
|
|
16
|
+
at recent agent activity.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
from rich import print as rprint
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
from rich.table import Table
|
|
25
|
+
|
|
26
|
+
from sirb_cli.client import SirbClient
|
|
27
|
+
from sirb_cli.config import env_override_or_config
|
|
28
|
+
|
|
29
|
+
app = typer.Typer(no_args_is_help=True, help="Agent observations (Phase 9A monitoring agent).")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _client() -> SirbClient:
|
|
33
|
+
return SirbClient(env_override_or_config())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _fmt_findings(findings: list[dict]) -> str:
|
|
37
|
+
"""Compact one-line summary of findings — count + severity histogram."""
|
|
38
|
+
if not findings:
|
|
39
|
+
return "(none)"
|
|
40
|
+
counts: dict[str, int] = {}
|
|
41
|
+
for f in findings:
|
|
42
|
+
sev = f.get("severity", "info")
|
|
43
|
+
counts[sev] = counts.get(sev, 0) + 1
|
|
44
|
+
parts = []
|
|
45
|
+
for sev in ("critical", "warning", "info"):
|
|
46
|
+
if sev in counts:
|
|
47
|
+
parts.append(f"{counts[sev]} {sev}")
|
|
48
|
+
return ", ".join(parts) or "(none)"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command("list")
|
|
52
|
+
def list_observations(
|
|
53
|
+
kind: str | None = typer.Option(None, "--kind", help="Filter by agent kind, e.g. monitoring."),
|
|
54
|
+
limit: int = typer.Option(20, "--limit", min=1, max=500),
|
|
55
|
+
full: bool = typer.Option(False, "--full", help="Print the per-observation findings detail."),
|
|
56
|
+
) -> None:
|
|
57
|
+
"""List recent agent observations."""
|
|
58
|
+
params = {"limit": str(limit)}
|
|
59
|
+
if kind:
|
|
60
|
+
params["kind"] = kind
|
|
61
|
+
body = _client().get("/admin/observations", params=params)
|
|
62
|
+
data = body.get("data", [])
|
|
63
|
+
if not data:
|
|
64
|
+
rprint("[dim](no observations recorded)[/dim]")
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
t = Table(title=f"agent observations ({len(data)} shown)")
|
|
68
|
+
for col in ("when", "kind", "outcome", "provider", "summary", "findings"):
|
|
69
|
+
t.add_column(col)
|
|
70
|
+
for obs in data:
|
|
71
|
+
t.add_row(
|
|
72
|
+
obs.get("created_at", "")[:19].replace("T", " "),
|
|
73
|
+
obs.get("kind", ""),
|
|
74
|
+
obs.get("outcome", ""),
|
|
75
|
+
obs.get("provider", ""),
|
|
76
|
+
(obs.get("summary") or "")[:80],
|
|
77
|
+
_fmt_findings(obs.get("findings", [])),
|
|
78
|
+
)
|
|
79
|
+
Console().print(t)
|
|
80
|
+
|
|
81
|
+
if full:
|
|
82
|
+
for obs in data:
|
|
83
|
+
rprint(f"\n[bold]{obs.get('id')}[/bold] ({obs.get('outcome')})")
|
|
84
|
+
rprint(f" summary: {obs.get('summary')}")
|
|
85
|
+
rprint(f" confidence: {obs.get('confidence')}")
|
|
86
|
+
for f in obs.get("findings", []):
|
|
87
|
+
color = {"critical": "red", "warning": "yellow", "info": "cyan"}.get(
|
|
88
|
+
f.get("severity", "info"), "white",
|
|
89
|
+
)
|
|
90
|
+
rprint(f" [{color}]{f.get('severity'):>8}[/{color}] "
|
|
91
|
+
f"{f.get('category', '')} "
|
|
92
|
+
f"[bold]{f.get('target', '')}[/bold] {f.get('detail', '')}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command("run")
|
|
96
|
+
def run_now() -> None:
|
|
97
|
+
"""Trigger one monitoring agent run immediately."""
|
|
98
|
+
body = _client().post("/admin/observations/run", {})
|
|
99
|
+
rprint(f"[bold]{body.get('id')}[/bold] {body.get('outcome')} "
|
|
100
|
+
f"({body.get('provider')}, {body.get('latency_ms')}ms)")
|
|
101
|
+
rprint(f" summary: {body.get('summary')}")
|
|
102
|
+
findings = body.get("findings", [])
|
|
103
|
+
if not findings:
|
|
104
|
+
rprint(" findings: [dim](none)[/dim]")
|
|
105
|
+
return
|
|
106
|
+
for f in findings:
|
|
107
|
+
color = {"critical": "red", "warning": "yellow", "info": "cyan"}.get(
|
|
108
|
+
f.get("severity", "info"), "white",
|
|
109
|
+
)
|
|
110
|
+
rprint(f" [{color}]{f.get('severity'):>8}[/{color}] "
|
|
111
|
+
f"{f.get('category', '')} "
|
|
112
|
+
f"[bold]{f.get('target', '')}[/bold] {f.get('detail', '')}")
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""`sirb opencode config` and `sirb opencode env`.
|
|
2
|
+
|
|
3
|
+
Prints config snippets for OpenCode-style tools. Reads the saved CLI
|
|
4
|
+
config so users don't have to retype anything.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import typer
|
|
11
|
+
from rich import print as rprint
|
|
12
|
+
|
|
13
|
+
from sirb_cli.config import env_override_or_config
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(no_args_is_help=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.command("config")
|
|
19
|
+
def show_config() -> None:
|
|
20
|
+
"""Print an OpenAI-compatible JSON config for OpenCode."""
|
|
21
|
+
cfg = env_override_or_config()
|
|
22
|
+
if not cfg.api_key:
|
|
23
|
+
rprint("[red]No API key configured.[/red] Run `sirb login` first.")
|
|
24
|
+
raise typer.Exit(1)
|
|
25
|
+
out = {
|
|
26
|
+
"provider": "openai-compatible",
|
|
27
|
+
"baseURL": cfg.base_url.rstrip("/") + "/v1",
|
|
28
|
+
"apiKey": cfg.api_key,
|
|
29
|
+
"model": cfg.default_model,
|
|
30
|
+
}
|
|
31
|
+
print(json.dumps(out, indent=2))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command("env")
|
|
35
|
+
def show_env() -> None:
|
|
36
|
+
"""Print export commands that point an OpenAI-SDK-based tool at Sirb."""
|
|
37
|
+
cfg = env_override_or_config()
|
|
38
|
+
if not cfg.api_key:
|
|
39
|
+
rprint("[red]No API key configured.[/red] Run `sirb login` first.")
|
|
40
|
+
raise typer.Exit(1)
|
|
41
|
+
base = cfg.base_url.rstrip("/") + "/v1"
|
|
42
|
+
print(f'export OPENAI_API_KEY="{cfg.api_key}"')
|
|
43
|
+
print(f'export OPENAI_BASE_URL="{base}"')
|
|
44
|
+
print(f'export OPENAI_MODEL="{cfg.default_model}"')
|
|
45
|
+
print(f'export SIRB_API_KEY="{cfg.api_key}"')
|
|
46
|
+
print(f'export SIRB_BASE_URL="{base}"')
|
|
47
|
+
print(f'export SIRB_MODEL="{cfg.default_model}"')
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich import print as rprint
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from sirb_cli.client import SirbClient
|
|
9
|
+
from sirb_cli.config import env_override_or_config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run(
|
|
13
|
+
from_date: str | None = typer.Option(None, "--from", help="Start date (YYYY-MM-DD)."),
|
|
14
|
+
to_date: str | None = typer.Option(None, "--to", help="End date (YYYY-MM-DD)."),
|
|
15
|
+
) -> None:
|
|
16
|
+
params = {}
|
|
17
|
+
if from_date:
|
|
18
|
+
params["from"] = from_date
|
|
19
|
+
if to_date:
|
|
20
|
+
params["to"] = to_date
|
|
21
|
+
body = SirbClient(env_override_or_config()).get("/v1/usage", params=params or None)
|
|
22
|
+
# Phase 8D: backends running 8D+ return total_cost/per-row cost in
|
|
23
|
+
# SIRB; older backends omit them. Default to 0.0 / "—" so the CLI
|
|
24
|
+
# works against either era.
|
|
25
|
+
total_cost = body.get("total_cost", 0.0)
|
|
26
|
+
currency = body.get("currency", "SIRB")
|
|
27
|
+
rprint(f"[bold]{body['wallet']}[/bold] {body['from_date']} → {body['to_date']}")
|
|
28
|
+
rprint(f" total: [green]{body['total_requests']}[/green] requests "
|
|
29
|
+
f"({body['successful_requests']} ok, {body['failed_requests']} failed)")
|
|
30
|
+
rprint(f" tokens: {body['total_prompt_tokens']} in + "
|
|
31
|
+
f"{body['total_completion_tokens']} out = "
|
|
32
|
+
f"[green]{body['total_tokens']}[/green]")
|
|
33
|
+
rprint(f" cost: [green]{_fmt_cost(total_cost)}[/green] {currency}")
|
|
34
|
+
|
|
35
|
+
if body.get("by_day"):
|
|
36
|
+
t = Table(title="By day")
|
|
37
|
+
for col in ("date", "requests", "in", "out", f"cost ({currency})"):
|
|
38
|
+
t.add_column(col)
|
|
39
|
+
for d in body["by_day"]:
|
|
40
|
+
t.add_row(d["date"], str(d["requests"]), str(d["prompt_tokens"]),
|
|
41
|
+
str(d["completion_tokens"]),
|
|
42
|
+
_fmt_cost(d.get("cost", 0.0)))
|
|
43
|
+
Console().print(t)
|
|
44
|
+
|
|
45
|
+
if body.get("by_model"):
|
|
46
|
+
t = Table(title="By model")
|
|
47
|
+
for col in ("model", "requests", "in", "out", f"cost ({currency})"):
|
|
48
|
+
t.add_column(col)
|
|
49
|
+
for d in body["by_model"]:
|
|
50
|
+
t.add_row(d["model"], str(d["requests"]), str(d["prompt_tokens"]),
|
|
51
|
+
str(d["completion_tokens"]),
|
|
52
|
+
_fmt_cost(d.get("cost", 0.0)))
|
|
53
|
+
Console().print(t)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _fmt_cost(c: float) -> str:
|
|
57
|
+
"""SIRB prices live in the 1e-4 range at MVP rates. Six decimals is
|
|
58
|
+
enough resolution to distinguish a 100-token call from a 10-token
|
|
59
|
+
one (~1e-7 difference) without looking like a scientific paper."""
|
|
60
|
+
if c == 0.0:
|
|
61
|
+
return "0"
|
|
62
|
+
return f"{c:.6f}"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""CLI config persistence.
|
|
2
|
+
|
|
3
|
+
State lives at ``~/.sirb/config.json`` by default (overridable with
|
|
4
|
+
``SIRB_CLI_CONFIG``). One JSON object with base_url + api_key. We do NOT
|
|
5
|
+
keep a separate credential store — the config file's permissions are the
|
|
6
|
+
boundary (0600 on Unix). On Windows the file inherits user-level perms.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import stat
|
|
14
|
+
from dataclasses import asdict, dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _config_path() -> Path:
|
|
19
|
+
env = os.environ.get("SIRB_CLI_CONFIG")
|
|
20
|
+
if env:
|
|
21
|
+
return Path(env)
|
|
22
|
+
return Path.home() / ".sirb" / "config.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class CliConfig:
|
|
27
|
+
base_url: str = "http://localhost:8000"
|
|
28
|
+
api_key: str | None = None
|
|
29
|
+
default_model: str = "mock-7b"
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def load(cls) -> "CliConfig":
|
|
33
|
+
p = _config_path()
|
|
34
|
+
if not p.exists():
|
|
35
|
+
return cls()
|
|
36
|
+
try:
|
|
37
|
+
data = json.loads(p.read_text())
|
|
38
|
+
except (OSError, json.JSONDecodeError):
|
|
39
|
+
return cls()
|
|
40
|
+
return cls(
|
|
41
|
+
base_url=data.get("base_url", cls.base_url),
|
|
42
|
+
api_key=data.get("api_key"),
|
|
43
|
+
default_model=data.get("default_model", cls.default_model),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def save(self) -> Path:
|
|
47
|
+
p = _config_path()
|
|
48
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
p.write_text(json.dumps(asdict(self), indent=2))
|
|
50
|
+
# Tighten perms on Unix so other users can't read the key.
|
|
51
|
+
try:
|
|
52
|
+
os.chmod(p, stat.S_IRUSR | stat.S_IWUSR)
|
|
53
|
+
except (OSError, NotImplementedError):
|
|
54
|
+
pass
|
|
55
|
+
return p
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def env_override_or_config() -> CliConfig:
|
|
59
|
+
"""Env vars trump the config file. Lets users do one-off calls without
|
|
60
|
+
rewriting their saved config."""
|
|
61
|
+
cfg = CliConfig.load()
|
|
62
|
+
if (env_url := os.environ.get("SIRB_BASE_URL")):
|
|
63
|
+
cfg.base_url = env_url
|
|
64
|
+
if (env_key := os.environ.get("SIRB_API_KEY")):
|
|
65
|
+
cfg.api_key = env_key
|
|
66
|
+
if (env_model := os.environ.get("SIRB_MODEL")):
|
|
67
|
+
cfg.default_model = env_model
|
|
68
|
+
return cfg
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""``sirb`` CLI entry point.
|
|
2
|
+
|
|
3
|
+
Each command lives in ``sirb_cli.commands.<name>`` and registers itself
|
|
4
|
+
against the Typer app here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from sirb_cli.commands import balance, chat, keys, login, models, observations, opencode, usage
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(
|
|
14
|
+
name="sirb",
|
|
15
|
+
no_args_is_help=True,
|
|
16
|
+
help="Sirb — Decentralized AI Compute CLI",
|
|
17
|
+
rich_markup_mode="rich",
|
|
18
|
+
add_completion=False,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
app.command(name="login", help="Save your API key and base URL to ~/.sirb/config.json.")(login.run)
|
|
22
|
+
app.command(name="models", help="List available models (and aliases).")(models.run)
|
|
23
|
+
app.command(name="balance", help="Show your current Sirb allowance.")(balance.run)
|
|
24
|
+
app.command(name="usage", help="Show your usage rollup.")(usage.run)
|
|
25
|
+
app.command(name="chat", help="Send a single chat completion request.")(chat.run)
|
|
26
|
+
|
|
27
|
+
app.add_typer(keys.app, name="keys", help="Manage API keys.")
|
|
28
|
+
app.add_typer(opencode.app, name="opencode", help="Print OpenCode-compatible config.")
|
|
29
|
+
app.add_typer(observations.app, name="observations",
|
|
30
|
+
help="Agent observations (Phase 9A monitoring agent).")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
app()
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""CLI smoke tests.
|
|
2
|
+
|
|
3
|
+
We don't spin up a real backend — respx intercepts httpx calls and returns
|
|
4
|
+
canned responses. The goal is to assert the CLI parses output correctly
|
|
5
|
+
and shapes the right HTTP requests, not to re-test the backend.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
import respx
|
|
16
|
+
from httpx import Response
|
|
17
|
+
from typer.testing import CliRunner
|
|
18
|
+
|
|
19
|
+
from sirb_cli.config import CliConfig
|
|
20
|
+
from sirb_cli.main import app
|
|
21
|
+
|
|
22
|
+
runner = CliRunner()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture(autouse=True)
|
|
26
|
+
def tmp_config(tmp_path: Path, monkeypatch):
|
|
27
|
+
"""Point the CLI's config at a tmp file so tests don't touch the real one."""
|
|
28
|
+
monkeypatch.setenv("SIRB_CLI_CONFIG", str(tmp_path / "config.json"))
|
|
29
|
+
# Pre-seed with a base_url + api_key so commands work without `login`.
|
|
30
|
+
CliConfig(base_url="http://sirb.test", api_key="sirb_test", default_model="mock-7b").save()
|
|
31
|
+
yield
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_login_writes_config(tmp_path):
|
|
35
|
+
cfg_path = Path(os.environ["SIRB_CLI_CONFIG"])
|
|
36
|
+
r = runner.invoke(app, ["login", "--api-key", "sirb_new", "--base-url", "https://example.com"])
|
|
37
|
+
assert r.exit_code == 0, r.output
|
|
38
|
+
saved = json.loads(cfg_path.read_text())
|
|
39
|
+
assert saved["api_key"] == "sirb_new"
|
|
40
|
+
assert saved["base_url"] == "https://example.com"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@respx.mock
|
|
44
|
+
def test_models_calls_endpoint_and_renders():
|
|
45
|
+
respx.get("http://sirb.test/v1/models").mock(
|
|
46
|
+
return_value=Response(200, json={"object": "list", "data": [
|
|
47
|
+
{"id": "mock-7b", "owned_by": "decentralized-community"},
|
|
48
|
+
]})
|
|
49
|
+
)
|
|
50
|
+
r = runner.invoke(app, ["models"])
|
|
51
|
+
assert r.exit_code == 0, r.output
|
|
52
|
+
assert "mock-7b" in r.output
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@respx.mock
|
|
56
|
+
def test_chat_non_stream_prints_completion():
|
|
57
|
+
respx.post("http://sirb.test/v1/chat/completions").mock(
|
|
58
|
+
return_value=Response(200, json={
|
|
59
|
+
"id": "x", "object": "chat.completion", "created": 0, "model": "mock-7b",
|
|
60
|
+
"choices": [{"index": 0, "message": {"role": "assistant", "content": "pong"},
|
|
61
|
+
"finish_reason": "stop"}],
|
|
62
|
+
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
|
63
|
+
})
|
|
64
|
+
)
|
|
65
|
+
r = runner.invoke(app, ["chat", "ping"])
|
|
66
|
+
assert r.exit_code == 0, r.output
|
|
67
|
+
assert "pong" in r.output
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@respx.mock
|
|
71
|
+
def test_balance_unconfigured_renders_warning():
|
|
72
|
+
respx.get("http://sirb.test/v1/balance").mock(
|
|
73
|
+
return_value=Response(200, json={
|
|
74
|
+
"object": "sirb.balance", "wallet": "0xtest",
|
|
75
|
+
"balance": 0.0, "balance_wei": "0", "currency": "SIRB",
|
|
76
|
+
"source": "unconfigured",
|
|
77
|
+
})
|
|
78
|
+
)
|
|
79
|
+
r = runner.invoke(app, ["balance"])
|
|
80
|
+
assert r.exit_code == 0, r.output
|
|
81
|
+
assert "unavailable" in r.output.lower() or "no chain" in r.output.lower()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@respx.mock
|
|
85
|
+
def test_keys_create_renders_plaintext_warning():
|
|
86
|
+
respx.post("http://sirb.test/admin/api-keys").mock(
|
|
87
|
+
return_value=Response(201, json={
|
|
88
|
+
"object": "sirb.api_key.created", "id": "key_abc",
|
|
89
|
+
"key": "sirb_supersecret", "name": "demo",
|
|
90
|
+
"wallet_address": "0xtest", "created_at": "2026-05-14T00:00:00Z",
|
|
91
|
+
"rate_limit_per_minute": 60,
|
|
92
|
+
})
|
|
93
|
+
)
|
|
94
|
+
r = runner.invoke(app, ["keys", "create", "--name", "demo"])
|
|
95
|
+
assert r.exit_code == 0, r.output
|
|
96
|
+
assert "sirb_supersecret" in r.output
|
|
97
|
+
assert "only time" in r.output.lower()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@respx.mock
|
|
101
|
+
def test_keys_revoke_calls_delete():
|
|
102
|
+
route = respx.delete("http://sirb.test/admin/api-keys/key_abc").mock(
|
|
103
|
+
return_value=Response(204)
|
|
104
|
+
)
|
|
105
|
+
r = runner.invoke(app, ["keys", "revoke", "key_abc"])
|
|
106
|
+
assert r.exit_code == 0, r.output
|
|
107
|
+
assert route.called
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_opencode_env_prints_export_lines():
|
|
111
|
+
r = runner.invoke(app, ["opencode", "env"])
|
|
112
|
+
assert r.exit_code == 0, r.output
|
|
113
|
+
assert 'export OPENAI_API_KEY="sirb_test"' in r.output
|
|
114
|
+
assert 'export OPENAI_BASE_URL="http://sirb.test/v1"' in r.output
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_opencode_config_prints_json():
|
|
118
|
+
r = runner.invoke(app, ["opencode", "config"])
|
|
119
|
+
assert r.exit_code == 0, r.output
|
|
120
|
+
parsed = json.loads(r.output)
|
|
121
|
+
assert parsed["provider"] == "openai-compatible"
|
|
122
|
+
assert parsed["baseURL"] == "http://sirb.test/v1"
|
|
123
|
+
assert parsed["apiKey"] == "sirb_test"
|