bridgenode-cli 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.
- bridgenode_cli-0.1.0/PKG-INFO +65 -0
- bridgenode_cli-0.1.0/README.md +55 -0
- bridgenode_cli-0.1.0/bridgenode_cli/__init__.py +6 -0
- bridgenode_cli-0.1.0/bridgenode_cli/main.py +131 -0
- bridgenode_cli-0.1.0/bridgenode_cli.egg-info/PKG-INFO +65 -0
- bridgenode_cli-0.1.0/bridgenode_cli.egg-info/SOURCES.txt +12 -0
- bridgenode_cli-0.1.0/bridgenode_cli.egg-info/dependency_links.txt +1 -0
- bridgenode_cli-0.1.0/bridgenode_cli.egg-info/entry_points.txt +2 -0
- bridgenode_cli-0.1.0/bridgenode_cli.egg-info/requires.txt +4 -0
- bridgenode_cli-0.1.0/bridgenode_cli.egg-info/top_level.txt +1 -0
- bridgenode_cli-0.1.0/pyproject.toml +24 -0
- bridgenode_cli-0.1.0/setup.cfg +4 -0
- bridgenode_cli-0.1.0/tests/test_cli.py +170 -0
- bridgenode_cli-0.1.0/tests/test_cli_integration.py +259 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bridgenode-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: BridgeNode CLI — AI inference su x402 mokėjimu (Solana USDC). Jokių API key.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: bridgenode-llm>=0.1.0
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest>=9.1.1; extra == "dev"
|
|
10
|
+
|
|
11
|
+
# bridgenode-cli
|
|
12
|
+
|
|
13
|
+
BridgeNode CLI — AI inference be API key. Mokėjimas: **Solana USDC per x402** (automatinis handshake, fee sponsorship — SOL nereikia).
|
|
14
|
+
|
|
15
|
+
## Diegimas
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install bridgenode-cli
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Naudojimas
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Vienas chat completion (x402 handshake paslėptas, §4.1)
|
|
25
|
+
bridgenode chat "Labas!" --model deepseek-v4-flash
|
|
26
|
+
|
|
27
|
+
# Smart routing (§5.1/§5.8): auto / eco / premium
|
|
28
|
+
bridgenode chat "Išanalizuok šį kodą" --mode auto
|
|
29
|
+
|
|
30
|
+
# Modelių sąrašas + kainos (§5.2) — be mokėjimo
|
|
31
|
+
bridgenode models
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Parinktys
|
|
35
|
+
|
|
36
|
+
| Parinktis | Aprašas |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `--model` | Explicit model id (žr. `bridgenode models`) |
|
|
39
|
+
| `--mode` | `auto` / `eco` / `premium` (smart routing) |
|
|
40
|
+
| `--max-tokens` | Max output tokenų (billing upfront, §4.2) |
|
|
41
|
+
| `--base-url` | Base URL (default: `BRIDGENODE_BASE_URL` / https://bridgenode.cc/v1) |
|
|
42
|
+
| `--max-per-call` / `--daily-cap` | Spending policy (fail-closed) |
|
|
43
|
+
|
|
44
|
+
## .env
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# Privalomas — jūsų Solana wallet private key (base58)
|
|
48
|
+
BRIDGENODE_WALLET_KEY=...
|
|
49
|
+
# Neprivalomi: BRIDGENODE_BASE_URL, BRIDGENODE_MAX_PER_CALL, BRIDGENODE_DAILY_CAP
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Apsaugos
|
|
53
|
+
|
|
54
|
+
- **Kvito verifikacija (Free-Riding apsauga):** po 200 tikrinamas `PAYMENT-RESPONSE` — netikras kvitas → klaida.
|
|
55
|
+
- **Spending policy (fail-closed):** max USD per call / per day — tikrinama PRIEŠ pasirašant.
|
|
56
|
+
|
|
57
|
+
## Reikalavimai
|
|
58
|
+
|
|
59
|
+
- Python ≥ 3.11
|
|
60
|
+
- Solana wallet su USDC ATA (rent — agento atsakomybė, §3.3)
|
|
61
|
+
|
|
62
|
+
## Nuorodos
|
|
63
|
+
|
|
64
|
+
- Tinklalapis: https://bridgenode.cc
|
|
65
|
+
- Protokolas: x402 V2 (docs.x402.org)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# bridgenode-cli
|
|
2
|
+
|
|
3
|
+
BridgeNode CLI — AI inference be API key. Mokėjimas: **Solana USDC per x402** (automatinis handshake, fee sponsorship — SOL nereikia).
|
|
4
|
+
|
|
5
|
+
## Diegimas
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install bridgenode-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Naudojimas
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Vienas chat completion (x402 handshake paslėptas, §4.1)
|
|
15
|
+
bridgenode chat "Labas!" --model deepseek-v4-flash
|
|
16
|
+
|
|
17
|
+
# Smart routing (§5.1/§5.8): auto / eco / premium
|
|
18
|
+
bridgenode chat "Išanalizuok šį kodą" --mode auto
|
|
19
|
+
|
|
20
|
+
# Modelių sąrašas + kainos (§5.2) — be mokėjimo
|
|
21
|
+
bridgenode models
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Parinktys
|
|
25
|
+
|
|
26
|
+
| Parinktis | Aprašas |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `--model` | Explicit model id (žr. `bridgenode models`) |
|
|
29
|
+
| `--mode` | `auto` / `eco` / `premium` (smart routing) |
|
|
30
|
+
| `--max-tokens` | Max output tokenų (billing upfront, §4.2) |
|
|
31
|
+
| `--base-url` | Base URL (default: `BRIDGENODE_BASE_URL` / https://bridgenode.cc/v1) |
|
|
32
|
+
| `--max-per-call` / `--daily-cap` | Spending policy (fail-closed) |
|
|
33
|
+
|
|
34
|
+
## .env
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Privalomas — jūsų Solana wallet private key (base58)
|
|
38
|
+
BRIDGENODE_WALLET_KEY=...
|
|
39
|
+
# Neprivalomi: BRIDGENODE_BASE_URL, BRIDGENODE_MAX_PER_CALL, BRIDGENODE_DAILY_CAP
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Apsaugos
|
|
43
|
+
|
|
44
|
+
- **Kvito verifikacija (Free-Riding apsauga):** po 200 tikrinamas `PAYMENT-RESPONSE` — netikras kvitas → klaida.
|
|
45
|
+
- **Spending policy (fail-closed):** max USD per call / per day — tikrinama PRIEŠ pasirašant.
|
|
46
|
+
|
|
47
|
+
## Reikalavimai
|
|
48
|
+
|
|
49
|
+
- Python ≥ 3.11
|
|
50
|
+
- Solana wallet su USDC ATA (rent — agento atsakomybė, §3.3)
|
|
51
|
+
|
|
52
|
+
## Nuorodos
|
|
53
|
+
|
|
54
|
+
- Tinklalapis: https://bridgenode.cc
|
|
55
|
+
- Protokolas: x402 V2 (docs.x402.org)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""main.py — bridgenode CLI (§8.4, fix.md 4.4 ž1).
|
|
2
|
+
|
|
3
|
+
Komandos:
|
|
4
|
+
- `bridgenode chat "<prompt>"` — vienas chat completion per x402 handshake
|
|
5
|
+
(SDK: 402 → dalinis TX → PAYMENT-SIGNATURE → 200; §4.1)
|
|
6
|
+
- `bridgenode models` — modelių sąrašas + kainos iš GET /v1/models (§5.2)
|
|
7
|
+
|
|
8
|
+
Parinktys: --model (explicit, §5.1), --mode auto/eco/premium (smart routing,
|
|
9
|
+
§5.1/§5.8), --max-tokens. Visas mokėjimas paslėptas — naudojamas oficialus
|
|
10
|
+
LLMClient (P4). Klaidos → stderr + exit 1; sėkmė → stdout + exit 0.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from bridgenode_llm import BridgenodeError, LLMClient
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _client(args: argparse.Namespace) -> LLMClient:
|
|
24
|
+
"""LLMClient iš CLI argumentų (raktas TIK iš .env, §8.4)."""
|
|
25
|
+
# None reikšmės neperduodamos — SDK naudoja default'us (§8.5);
|
|
26
|
+
# kitaip timeout=None patektų į flow timeout skaičiavimą (Ž42)
|
|
27
|
+
kwargs: dict[str, Any] = {}
|
|
28
|
+
if args.base_url:
|
|
29
|
+
kwargs["base_url"] = args.base_url
|
|
30
|
+
if args.initial_timeout is not None:
|
|
31
|
+
kwargs["initial_timeout"] = args.initial_timeout
|
|
32
|
+
if args.retry_timeout is not None:
|
|
33
|
+
kwargs["retry_timeout"] = args.retry_timeout
|
|
34
|
+
if args.max_per_call is not None:
|
|
35
|
+
kwargs["max_per_call_usd"] = args.max_per_call
|
|
36
|
+
if args.daily_cap is not None:
|
|
37
|
+
kwargs["daily_cap_usd"] = args.daily_cap
|
|
38
|
+
return LLMClient(**kwargs)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cmd_chat(args: argparse.Namespace) -> int:
|
|
42
|
+
"""`bridgenode chat "<prompt>" [--model M | --mode auto|eco|premium]`."""
|
|
43
|
+
if args.model is None and args.mode is None:
|
|
44
|
+
print("Error: --model or --mode required", file=sys.stderr)
|
|
45
|
+
return 2
|
|
46
|
+
try:
|
|
47
|
+
client = _client(args)
|
|
48
|
+
messages = [{"role": "user", "content": args.prompt}]
|
|
49
|
+
resp: dict[str, Any] = client.chat(
|
|
50
|
+
args.model, messages,
|
|
51
|
+
max_tokens=args.max_tokens, mode=args.mode)
|
|
52
|
+
except BridgenodeError as exc:
|
|
53
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
54
|
+
return 1
|
|
55
|
+
content = resp["choices"][0]["message"]["content"]
|
|
56
|
+
print(content)
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def cmd_models(args: argparse.Namespace) -> int:
|
|
61
|
+
"""`bridgenode models` — modelių sąrašas + kainos (§5.2).
|
|
62
|
+
|
|
63
|
+
Viešas endpointas (be mokėjimo) — tiesioginis HTTP, be SDK (SDK turi
|
|
64
|
+
tik chat(), protokolas §8.4)."""
|
|
65
|
+
import httpx
|
|
66
|
+
|
|
67
|
+
base_url = (args.base_url or os.environ.get("BRIDGENODE_BASE_URL")
|
|
68
|
+
or "https://bridgenode.cc/v1").rstrip("/")
|
|
69
|
+
try:
|
|
70
|
+
resp = httpx.get(f"{base_url}/models", timeout=30.0)
|
|
71
|
+
if resp.status_code != 200:
|
|
72
|
+
raise RuntimeError(f"HTTP {resp.status_code}")
|
|
73
|
+
data = resp.json()
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
76
|
+
return 1
|
|
77
|
+
models = data.get("data", [])
|
|
78
|
+
for m in models:
|
|
79
|
+
pricing = m.get("pricing", {})
|
|
80
|
+
print(
|
|
81
|
+
f"{m.get('id', '?'):24} "
|
|
82
|
+
f"prompt=${pricing.get('prompt', 0):.8f} "
|
|
83
|
+
f"completion=${pricing.get('completion', 0):.8f} "
|
|
84
|
+
f"context={m.get('context_window', '?')}")
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
89
|
+
parser = argparse.ArgumentParser(
|
|
90
|
+
prog="bridgenode",
|
|
91
|
+
description=(
|
|
92
|
+
"BridgeNode CLI — AI inference, mokėjimas Solana USDC per x402. "
|
|
93
|
+
"Jokių API key."
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
parser.add_argument("--base-url", default=None,
|
|
97
|
+
help="Base URL (default: BRIDGENODE_BASE_URL / https://bridgenode.cc/v1)")
|
|
98
|
+
parser.add_argument("--initial-timeout", type=float, default=None,
|
|
99
|
+
help="Pradinio request'o timeout s (eilė iki 402, §5.7)")
|
|
100
|
+
parser.add_argument("--retry-timeout", type=float, default=None,
|
|
101
|
+
help="Retry timeout s (≤115s biudžetas, §4.3)")
|
|
102
|
+
parser.add_argument("--max-per-call", type=float, default=None,
|
|
103
|
+
help="Spending policy: max USD per call (ž2)")
|
|
104
|
+
parser.add_argument("--daily-cap", type=float, default=None,
|
|
105
|
+
help="Spending policy: max USD per day (ž2)")
|
|
106
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
107
|
+
|
|
108
|
+
p_chat = sub.add_parser("chat", help="Vienas chat completion per x402 (§4.1)")
|
|
109
|
+
p_chat.add_argument("prompt", help="Prompt tekstas")
|
|
110
|
+
p_chat.add_argument("--model", default=None,
|
|
111
|
+
help="Explicit model id (žr. `bridgenode models`)")
|
|
112
|
+
p_chat.add_argument("--mode", choices=["auto", "eco", "premium"], default=None,
|
|
113
|
+
help="Smart routing profilis (§5.1/§5.8)")
|
|
114
|
+
p_chat.add_argument("--max-tokens", type=int, default=None,
|
|
115
|
+
help="Max output tokenų (billing upfront, §4.2)")
|
|
116
|
+
p_chat.set_defaults(func=cmd_chat)
|
|
117
|
+
|
|
118
|
+
p_models = sub.add_parser("models", help="Modelių sąrašas + kainos (§5.2)")
|
|
119
|
+
p_models.set_defaults(func=cmd_models)
|
|
120
|
+
|
|
121
|
+
return parser
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def main(argv: list[str] | None = None) -> int:
|
|
125
|
+
parser = build_parser()
|
|
126
|
+
args = parser.parse_args(argv)
|
|
127
|
+
return args.func(args)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
sys.exit(main())
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bridgenode-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: BridgeNode CLI — AI inference su x402 mokėjimu (Solana USDC). Jokių API key.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: bridgenode-llm>=0.1.0
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest>=9.1.1; extra == "dev"
|
|
10
|
+
|
|
11
|
+
# bridgenode-cli
|
|
12
|
+
|
|
13
|
+
BridgeNode CLI — AI inference be API key. Mokėjimas: **Solana USDC per x402** (automatinis handshake, fee sponsorship — SOL nereikia).
|
|
14
|
+
|
|
15
|
+
## Diegimas
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install bridgenode-cli
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Naudojimas
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Vienas chat completion (x402 handshake paslėptas, §4.1)
|
|
25
|
+
bridgenode chat "Labas!" --model deepseek-v4-flash
|
|
26
|
+
|
|
27
|
+
# Smart routing (§5.1/§5.8): auto / eco / premium
|
|
28
|
+
bridgenode chat "Išanalizuok šį kodą" --mode auto
|
|
29
|
+
|
|
30
|
+
# Modelių sąrašas + kainos (§5.2) — be mokėjimo
|
|
31
|
+
bridgenode models
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Parinktys
|
|
35
|
+
|
|
36
|
+
| Parinktis | Aprašas |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `--model` | Explicit model id (žr. `bridgenode models`) |
|
|
39
|
+
| `--mode` | `auto` / `eco` / `premium` (smart routing) |
|
|
40
|
+
| `--max-tokens` | Max output tokenų (billing upfront, §4.2) |
|
|
41
|
+
| `--base-url` | Base URL (default: `BRIDGENODE_BASE_URL` / https://bridgenode.cc/v1) |
|
|
42
|
+
| `--max-per-call` / `--daily-cap` | Spending policy (fail-closed) |
|
|
43
|
+
|
|
44
|
+
## .env
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# Privalomas — jūsų Solana wallet private key (base58)
|
|
48
|
+
BRIDGENODE_WALLET_KEY=...
|
|
49
|
+
# Neprivalomi: BRIDGENODE_BASE_URL, BRIDGENODE_MAX_PER_CALL, BRIDGENODE_DAILY_CAP
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Apsaugos
|
|
53
|
+
|
|
54
|
+
- **Kvito verifikacija (Free-Riding apsauga):** po 200 tikrinamas `PAYMENT-RESPONSE` — netikras kvitas → klaida.
|
|
55
|
+
- **Spending policy (fail-closed):** max USD per call / per day — tikrinama PRIEŠ pasirašant.
|
|
56
|
+
|
|
57
|
+
## Reikalavimai
|
|
58
|
+
|
|
59
|
+
- Python ≥ 3.11
|
|
60
|
+
- Solana wallet su USDC ATA (rent — agento atsakomybė, §3.3)
|
|
61
|
+
|
|
62
|
+
## Nuorodos
|
|
63
|
+
|
|
64
|
+
- Tinklalapis: https://bridgenode.cc
|
|
65
|
+
- Protokolas: x402 V2 (docs.x402.org)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
bridgenode_cli/__init__.py
|
|
4
|
+
bridgenode_cli/main.py
|
|
5
|
+
bridgenode_cli.egg-info/PKG-INFO
|
|
6
|
+
bridgenode_cli.egg-info/SOURCES.txt
|
|
7
|
+
bridgenode_cli.egg-info/dependency_links.txt
|
|
8
|
+
bridgenode_cli.egg-info/entry_points.txt
|
|
9
|
+
bridgenode_cli.egg-info/requires.txt
|
|
10
|
+
bridgenode_cli.egg-info/top_level.txt
|
|
11
|
+
tests/test_cli.py
|
|
12
|
+
tests/test_cli_integration.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bridgenode_cli
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "bridgenode-cli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "BridgeNode CLI — AI inference su x402 mokėjimu (Solana USDC). Jokių API key."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"bridgenode-llm>=0.1.0",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[project.optional-dependencies]
|
|
12
|
+
dev = [
|
|
13
|
+
"pytest>=9.1.1",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
bridgenode = "bridgenode_cli.main:main"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["setuptools>=75"]
|
|
21
|
+
build-backend = "setuptools.build_meta"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
include = ["bridgenode_cli*"]
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""test_cli.py — bridgenode CLI testai (fix.md 4.4 ž1).
|
|
2
|
+
|
|
3
|
+
Tikrina komandų išvedimą ir argumentų perdavimą su mock'intu LLMClient
|
|
4
|
+
(jokio tinklo, jokio mokėjimo). Pilnas mock-402 kelias — 4.4 ž2.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from bridgenode_llm import BridgenodeError
|
|
14
|
+
|
|
15
|
+
# ⚠️ import bridgenode_cli.main as cli NEVEIKIA: __init__ `from .main import main`
|
|
16
|
+
# shadow'ina submodulį — cli tampa funkcija. Naudojam importlib.
|
|
17
|
+
cli = importlib.import_module("bridgenode_cli.main")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FakeClient:
|
|
21
|
+
"""LLMClient pakaitalas — fiksuoja kvietimus, grąžina fiktyvius atsakymus."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, **kwargs):
|
|
24
|
+
self.kwargs = kwargs
|
|
25
|
+
self.chat_calls: list[tuple] = []
|
|
26
|
+
|
|
27
|
+
def chat(self, model, messages, max_tokens=None, mode=None):
|
|
28
|
+
self.chat_calls.append((model, messages, max_tokens, mode))
|
|
29
|
+
return {
|
|
30
|
+
"id": "cmpl-1",
|
|
31
|
+
"object": "chat.completion",
|
|
32
|
+
"choices": [{"index": 0,
|
|
33
|
+
"message": {"role": "assistant", "content": "Labas!"},
|
|
34
|
+
"finish_reason": "stop"}],
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture()
|
|
39
|
+
def fake_client(monkeypatch):
|
|
40
|
+
client = FakeClient()
|
|
41
|
+
monkeypatch.setattr(cli, "LLMClient", lambda **kw: client)
|
|
42
|
+
return client
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ── chat ─────────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
def test_chat_prints_content(fake_client, capsys):
|
|
48
|
+
rc = cli.main(["chat", "Labas pasauli", "--model", "deepseek-v4-flash"])
|
|
49
|
+
assert rc == 0
|
|
50
|
+
out = capsys.readouterr().out
|
|
51
|
+
assert "Labas!" in out
|
|
52
|
+
assert fake_client.chat_calls[0][0] == "deepseek-v4-flash"
|
|
53
|
+
assert fake_client.chat_calls[0][1] == [
|
|
54
|
+
{"role": "user", "content": "Labas pasauli"}]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_chat_mode_passed(fake_client, capsys):
|
|
58
|
+
rc = cli.main(["chat", "hi", "--mode", "auto"])
|
|
59
|
+
assert rc == 0
|
|
60
|
+
model, messages, max_tokens, mode = fake_client.chat_calls[0]
|
|
61
|
+
assert model is None
|
|
62
|
+
assert mode == "auto"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_chat_model_and_mode(fake_client):
|
|
66
|
+
"""§5.1: abu perduodami — konfliktą sprendžia serveris (model laimi)."""
|
|
67
|
+
rc = cli.main(["chat", "hi", "--model", "deepseek-v4-flash",
|
|
68
|
+
"--mode", "premium"])
|
|
69
|
+
assert rc == 0
|
|
70
|
+
model, _m, _mt, mode = fake_client.chat_calls[0]
|
|
71
|
+
assert model == "deepseek-v4-flash"
|
|
72
|
+
assert mode == "premium"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_chat_max_tokens(fake_client):
|
|
76
|
+
cli.main(["chat", "hi", "--model", "deepseek-v4-flash",
|
|
77
|
+
"--max-tokens", "128"])
|
|
78
|
+
_model, _m, max_tokens, _mode = fake_client.chat_calls[0]
|
|
79
|
+
assert max_tokens == 128
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_chat_requires_model_or_mode(fake_client, capsys):
|
|
83
|
+
rc = cli.main(["chat", "hi"])
|
|
84
|
+
assert rc == 2
|
|
85
|
+
assert "model or --mode" in capsys.readouterr().err
|
|
86
|
+
assert fake_client.chat_calls == [] # jokio kvietimo — klaida prieš SDK
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_chat_error_exit_1(fake_client, monkeypatch, capsys):
|
|
90
|
+
def boom(self, *a, **kw):
|
|
91
|
+
raise BridgenodeError("Unknown model", status_code=400)
|
|
92
|
+
monkeypatch.setattr(fake_client, "chat", boom)
|
|
93
|
+
rc = cli.main(["chat", "hi", "--model", "bogus"])
|
|
94
|
+
assert rc == 1
|
|
95
|
+
assert "Unknown model" in capsys.readouterr().err
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ── models ───────────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
def _fake_models_response():
|
|
101
|
+
import httpx
|
|
102
|
+
return httpx.Response(200, json={"object": "list", "data": [
|
|
103
|
+
{"id": "deepseek-v4-flash",
|
|
104
|
+
"pricing": {"prompt": 2e-7, "completion": 4e-7},
|
|
105
|
+
"context_window": 1048576, "max_output_tokens": 8192},
|
|
106
|
+
{"id": "deepseek-v4-pro",
|
|
107
|
+
"pricing": {"prompt": 8.5e-7, "completion": 1.7e-6},
|
|
108
|
+
"context_window": 1048576, "max_output_tokens": 8192},
|
|
109
|
+
]})
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_models_lists_prices(monkeypatch, capsys):
|
|
113
|
+
import httpx
|
|
114
|
+
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _fake_models_response())
|
|
115
|
+
rc = cli.main(["models"])
|
|
116
|
+
assert rc == 0
|
|
117
|
+
out = capsys.readouterr().out
|
|
118
|
+
assert "deepseek-v4-flash" in out
|
|
119
|
+
assert "deepseek-v4-pro" in out
|
|
120
|
+
assert "prompt=" in out
|
|
121
|
+
assert "completion=" in out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_models_error_exit_1(monkeypatch, capsys):
|
|
125
|
+
import httpx
|
|
126
|
+
def boom(*a, **kw):
|
|
127
|
+
raise httpx.HTTPStatusError("HTTP 503", request=httpx.Request("GET", "x"),
|
|
128
|
+
response=httpx.Response(503))
|
|
129
|
+
monkeypatch.setattr(httpx, "get", boom)
|
|
130
|
+
rc = cli.main(["models"])
|
|
131
|
+
assert rc == 1
|
|
132
|
+
assert "HTTP 503" in capsys.readouterr().err
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ── Parser ───────────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
def test_unknown_command_exits_2(capsys):
|
|
138
|
+
with pytest.raises(SystemExit) as exc:
|
|
139
|
+
cli.main(["bogus"])
|
|
140
|
+
assert exc.value.code == 2
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_missing_prompt_exits_2(capsys):
|
|
144
|
+
with pytest.raises(SystemExit) as exc:
|
|
145
|
+
cli.main(["chat"])
|
|
146
|
+
assert exc.value.code == 2
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_client_kwargs_wired(monkeypatch):
|
|
150
|
+
"""CLI argumentai → LLMClient kwargs (timeout'ai, spending policy).
|
|
151
|
+
|
|
152
|
+
Globalios parinktys (--base-url ir kt.) — PRIEŠ subkomandą (argparse).
|
|
153
|
+
Tikrinama per `chat` (models komanda SDK nenaudoja — tiesioginis HTTP, §5.2).
|
|
154
|
+
"""
|
|
155
|
+
captured: dict = {}
|
|
156
|
+
|
|
157
|
+
def fake_factory(**kw):
|
|
158
|
+
captured.update(kw)
|
|
159
|
+
return FakeClient(**kw)
|
|
160
|
+
|
|
161
|
+
monkeypatch.setattr(cli, "LLMClient", fake_factory)
|
|
162
|
+
cli.main(["--base-url", "https://alt.example/v1",
|
|
163
|
+
"--initial-timeout", "70", "--retry-timeout", "115",
|
|
164
|
+
"--max-per-call", "0.1", "--daily-cap", "2.0",
|
|
165
|
+
"chat", "hi", "--model", "deepseek-v4-flash"])
|
|
166
|
+
assert captured["base_url"] == "https://alt.example/v1"
|
|
167
|
+
assert captured["initial_timeout"] == 70
|
|
168
|
+
assert captured["retry_timeout"] == 115
|
|
169
|
+
assert captured["max_per_call_usd"] == 0.1
|
|
170
|
+
assert captured["daily_cap_usd"] == 2.0
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""test_cli_integration.py — CLI → realus SDK kelias su mock 402 (fix.md 4.4 ž2).
|
|
2
|
+
|
|
3
|
+
Pilnas x402 handshake per tikrą `bridgenode_llm` SDK: CLI `chat` → 402
|
|
4
|
+
(PAYMENT-REQUIRED) → dalinis TX → PAYMENT-SIGNATURE → 200 + validus
|
|
5
|
+
PAYMENT-RESPONSE kvitas (fee payer pasirašo MŪSŲ TX žinutę). RPC (mint
|
|
6
|
+
metadata) mock'inta. Jokio realaus tinklo, jokio realaus mokėjimo.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import importlib
|
|
13
|
+
import json
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
import pytest
|
|
17
|
+
from solders.keypair import Keypair
|
|
18
|
+
from solders.pubkey import Pubkey
|
|
19
|
+
from solders.transaction import VersionedTransaction
|
|
20
|
+
from x402.mechanisms.svm.mint_cache import MintMetadata
|
|
21
|
+
from x402.schemas import PaymentPayload, SettleResponse
|
|
22
|
+
|
|
23
|
+
cli = importlib.import_module("bridgenode_cli.main")
|
|
24
|
+
|
|
25
|
+
NETWORK = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"
|
|
26
|
+
USDC = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
|
|
27
|
+
WALLET = "BHMDv3ri3LBEZjEzJgDZeUiguVX7LmsCstTXbM3dL8rN"
|
|
28
|
+
TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
|
|
29
|
+
BLOCKHASH = "EZ3rST5dvHmbanh75jc4PuLfV96vp9fEYBVeNk4FfM1k"
|
|
30
|
+
API_BASE = "http://test/v1"
|
|
31
|
+
|
|
32
|
+
_B58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _b58encode(data: bytes) -> str:
|
|
36
|
+
n = int.from_bytes(data, "big")
|
|
37
|
+
out = ""
|
|
38
|
+
while n > 0:
|
|
39
|
+
n, r = divmod(n, 58)
|
|
40
|
+
out = _B58_ALPHABET[r] + out
|
|
41
|
+
pad = 0
|
|
42
|
+
for b in data:
|
|
43
|
+
if b == 0:
|
|
44
|
+
pad += 1
|
|
45
|
+
else:
|
|
46
|
+
break
|
|
47
|
+
return "1" * pad + out
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _wallet_key(kp: Keypair) -> str:
|
|
51
|
+
return _b58encode(bytes(kp))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _envelope(pay_to: str, fee_payer: str, amount: str = "2000") -> dict:
|
|
55
|
+
return {
|
|
56
|
+
"x402Version": 2,
|
|
57
|
+
"error": "PAYMENT-SIGNATURE header is required",
|
|
58
|
+
"resource": {
|
|
59
|
+
"url": "https://bridgenode.cc/v1/chat/completions",
|
|
60
|
+
"description": "AI inference",
|
|
61
|
+
"mimeType": "application/json",
|
|
62
|
+
},
|
|
63
|
+
"accepts": [
|
|
64
|
+
{
|
|
65
|
+
"scheme": "exact",
|
|
66
|
+
"network": NETWORK,
|
|
67
|
+
"amount": amount,
|
|
68
|
+
"asset": USDC,
|
|
69
|
+
"payTo": pay_to,
|
|
70
|
+
"maxTimeoutSeconds": 30,
|
|
71
|
+
"extra": {
|
|
72
|
+
"feePayer": fee_payer,
|
|
73
|
+
"memo": "pi_test123",
|
|
74
|
+
"recentBlockhash": BLOCKHASH,
|
|
75
|
+
"lastValidBlockHeight": "291470237",
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
],
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _receipt_header(payment_header: str, fee_payer_kp: Keypair,
|
|
83
|
+
client_wallet: str, amount: str = "2000") -> str:
|
|
84
|
+
"""Validus PAYMENT-RESPONSE kvitas: fee payer pasirašo MŪSŲ TX žinutę."""
|
|
85
|
+
payload = PaymentPayload.model_validate_json(base64.b64decode(payment_header))
|
|
86
|
+
tx = VersionedTransaction.from_bytes(
|
|
87
|
+
base64.b64decode(payload.payload["transaction"]))
|
|
88
|
+
msg_bytes = bytes([0x80]) + bytes(tx.message)
|
|
89
|
+
sig = fee_payer_kp.sign_message(msg_bytes)
|
|
90
|
+
settle = SettleResponse(
|
|
91
|
+
success=True,
|
|
92
|
+
transaction=str(sig),
|
|
93
|
+
network=NETWORK,
|
|
94
|
+
payer=client_wallet,
|
|
95
|
+
amount=amount,
|
|
96
|
+
)
|
|
97
|
+
return base64.b64encode(
|
|
98
|
+
settle.model_dump_json(by_alias=True, exclude_none=True).encode()).decode()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _openai_response() -> dict:
|
|
102
|
+
return {
|
|
103
|
+
"id": "cmpl-1",
|
|
104
|
+
"object": "chat.completion",
|
|
105
|
+
"created": 1,
|
|
106
|
+
"model": "deepseek-v4-flash",
|
|
107
|
+
"choices": [{"index": 0,
|
|
108
|
+
"message": {"role": "assistant", "content": "Labas!"},
|
|
109
|
+
"finish_reason": "stop"}],
|
|
110
|
+
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@pytest.fixture(autouse=True)
|
|
115
|
+
def _mock_mint_metadata(monkeypatch):
|
|
116
|
+
"""Mint metadata be RPC (SDK scheme reikalauja, testai be tinklo)."""
|
|
117
|
+
def fake(client, network, mint, cache):
|
|
118
|
+
return MintMetadata(decimals=6, token_program=Pubkey.from_string(TOKEN_PROGRAM))
|
|
119
|
+
monkeypatch.setattr(
|
|
120
|
+
"x402.mechanisms.svm.exact.client.get_cached_mint_metadata", fake)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@pytest.fixture()
|
|
124
|
+
def _mock_http(monkeypatch):
|
|
125
|
+
"""httpx.Client → MockTransport (visi SDK request'ai per mock serverį)."""
|
|
126
|
+
real_client = httpx.Client
|
|
127
|
+
state: dict = {"transport": None}
|
|
128
|
+
|
|
129
|
+
def fake_client(*args, **kwargs):
|
|
130
|
+
kwargs["transport"] = state["transport"]
|
|
131
|
+
return real_client(*args, **kwargs)
|
|
132
|
+
|
|
133
|
+
monkeypatch.setattr(httpx, "Client", fake_client)
|
|
134
|
+
return state
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _install_server(state: dict, fee_kp: Keypair, client_wallet: str):
|
|
138
|
+
"""Mock serveris: 402 → validus kvitas → 200. Grąžina (handler, seen)."""
|
|
139
|
+
seen: list[dict] = []
|
|
140
|
+
|
|
141
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
142
|
+
seen.append({
|
|
143
|
+
"url": str(request.url),
|
|
144
|
+
"has_payment": "PAYMENT-SIGNATURE" in request.headers,
|
|
145
|
+
"body": json.loads(request.content),
|
|
146
|
+
})
|
|
147
|
+
if "PAYMENT-SIGNATURE" not in request.headers:
|
|
148
|
+
env = _envelope(pay_to=WALLET, fee_payer=str(fee_kp.pubkey()))
|
|
149
|
+
return httpx.Response(
|
|
150
|
+
402,
|
|
151
|
+
headers={"PAYMENT-REQUIRED": base64.b64encode(
|
|
152
|
+
json.dumps(env).encode()).decode()},
|
|
153
|
+
json=env,
|
|
154
|
+
)
|
|
155
|
+
return httpx.Response(
|
|
156
|
+
200,
|
|
157
|
+
json=_openai_response(),
|
|
158
|
+
headers={"PAYMENT-RESPONSE": _receipt_header(
|
|
159
|
+
request.headers["PAYMENT-SIGNATURE"], fee_kp, client_wallet)},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
state["transport"] = httpx.MockTransport(handler)
|
|
163
|
+
return seen
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ── chat: pilnas x402 handshake per realų SDK ────────────────────────────────
|
|
167
|
+
|
|
168
|
+
def test_cli_chat_full_handshake(monkeypatch, _mock_http, capsys):
|
|
169
|
+
"""CLI chat → realus SDK: 402 → TX → PAYMENT-SIGNATURE → 200 + kvitas."""
|
|
170
|
+
fee_kp = Keypair()
|
|
171
|
+
client_kp = Keypair()
|
|
172
|
+
seen = _install_server(_mock_http, fee_kp, str(client_kp.pubkey()))
|
|
173
|
+
|
|
174
|
+
monkeypatch.setenv("BRIDGENODE_WALLET_KEY", _wallet_key(client_kp))
|
|
175
|
+
rc = cli.main(["--base-url", API_BASE,
|
|
176
|
+
"chat", "Labas pasauli", "--model", "deepseek-v4-flash"])
|
|
177
|
+
|
|
178
|
+
assert rc == 0
|
|
179
|
+
assert "Labas!" in capsys.readouterr().out
|
|
180
|
+
assert len(seen) == 2
|
|
181
|
+
assert seen[0]["has_payment"] is False
|
|
182
|
+
assert seen[1]["has_payment"] is True
|
|
183
|
+
assert seen[0]["body"] == seen[1]["body"] # price-bind (§4.1.1)
|
|
184
|
+
assert seen[0]["body"]["model"] == "deepseek-v4-flash"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_cli_chat_mode_auto(monkeypatch, _mock_http, capsys):
|
|
188
|
+
"""CLI --mode auto → SDK mode perduodamas (smart routing §5.1)."""
|
|
189
|
+
fee_kp = Keypair()
|
|
190
|
+
client_kp = Keypair()
|
|
191
|
+
seen = _install_server(_mock_http, fee_kp, str(client_kp.pubkey()))
|
|
192
|
+
|
|
193
|
+
monkeypatch.setenv("BRIDGENODE_WALLET_KEY", _wallet_key(client_kp))
|
|
194
|
+
rc = cli.main(["--base-url", API_BASE,
|
|
195
|
+
"chat", "hi", "--mode", "auto"])
|
|
196
|
+
assert rc == 0
|
|
197
|
+
assert seen[0]["body"]["mode"] == "auto"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_cli_chat_kvitas_verify_fail(monkeypatch, _mock_http, capsys):
|
|
201
|
+
"""Suklastotas kvitas → CLI error (exit 1) — Free-Riding apsauga veikia."""
|
|
202
|
+
fee_kp = Keypair()
|
|
203
|
+
client_kp = Keypair()
|
|
204
|
+
fake_kp = Keypair()
|
|
205
|
+
seen = _install_server(_mock_http, fee_kp, str(client_kp.pubkey()))
|
|
206
|
+
|
|
207
|
+
# Perrašom kvitą po serverio atsakymo — netikras parašas (kito rakto)
|
|
208
|
+
original = _mock_http["transport"].handler
|
|
209
|
+
|
|
210
|
+
def tampered(request: httpx.Request) -> httpx.Response:
|
|
211
|
+
resp = original(request)
|
|
212
|
+
if "PAYMENT-SIGNATURE" in request.headers and resp.status_code == 200:
|
|
213
|
+
payload = PaymentPayload.model_validate_json(
|
|
214
|
+
base64.b64decode(request.headers["PAYMENT-SIGNATURE"]))
|
|
215
|
+
tx = VersionedTransaction.from_bytes(
|
|
216
|
+
base64.b64decode(payload.payload["transaction"]))
|
|
217
|
+
msg = bytes([0x80]) + bytes(tx.message)
|
|
218
|
+
fake_sig = fake_kp.sign_message(msg) # ne fee payer'io raktas
|
|
219
|
+
settle = SettleResponse(
|
|
220
|
+
success=True, transaction=str(fake_sig), network=NETWORK,
|
|
221
|
+
payer=str(client_kp.pubkey()), amount="2000")
|
|
222
|
+
resp.headers["PAYMENT-RESPONSE"] = base64.b64encode(
|
|
223
|
+
settle.model_dump_json(by_alias=True, exclude_none=True)
|
|
224
|
+
.encode()).decode()
|
|
225
|
+
return resp
|
|
226
|
+
|
|
227
|
+
_mock_http["transport"] = httpx.MockTransport(tampered)
|
|
228
|
+
|
|
229
|
+
monkeypatch.setenv("BRIDGENODE_WALLET_KEY", _wallet_key(client_kp))
|
|
230
|
+
rc = cli.main(["--base-url", API_BASE,
|
|
231
|
+
"chat", "hi", "--model", "deepseek-v4-flash"])
|
|
232
|
+
assert rc == 1
|
|
233
|
+
assert "does not match our TX" in capsys.readouterr().err
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ── models: be mokėjimo (§5.2) ───────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
def test_cli_models_integration(monkeypatch, capsys):
|
|
239
|
+
import httpx
|
|
240
|
+
|
|
241
|
+
def fake_get(url, timeout=None):
|
|
242
|
+
assert str(url).endswith("/v1/models")
|
|
243
|
+
return httpx.Response(200, json={"object": "list", "data": [
|
|
244
|
+
{"id": "deepseek-v4-flash",
|
|
245
|
+
"pricing": {"prompt": 2e-7, "completion": 4e-7},
|
|
246
|
+
"context_window": 1048576, "max_output_tokens": 8192},
|
|
247
|
+
{"id": "groq-llama-3.3-70b",
|
|
248
|
+
"pricing": {"prompt": 7.9e-7, "completion": 9.9e-7},
|
|
249
|
+
"context_window": 131072, "max_output_tokens": 32768},
|
|
250
|
+
]})
|
|
251
|
+
|
|
252
|
+
# models — tiesioginis HTTP (be SDK, §5.2 viešas endpointas)
|
|
253
|
+
monkeypatch.setattr(httpx, "get", fake_get)
|
|
254
|
+
rc = cli.main(["--base-url", API_BASE, "models"])
|
|
255
|
+
assert rc == 0
|
|
256
|
+
out = capsys.readouterr().out
|
|
257
|
+
assert "deepseek-v4-flash" in out
|
|
258
|
+
assert "groq-llama-3.3-70b" in out
|
|
259
|
+
assert "prompt=" in out
|