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.
@@ -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,6 @@
1
+ """bridgenode_cli — BridgeNode CLI (§8.4, fix.md 4.4)."""
2
+
3
+ from .main import main
4
+
5
+ __all__ = ["main"]
6
+ __version__ = "0.1.0"
@@ -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,2 @@
1
+ [console_scripts]
2
+ bridgenode = bridgenode_cli.main:main
@@ -0,0 +1,4 @@
1
+ bridgenode-llm>=0.1.0
2
+
3
+ [dev]
4
+ pytest>=9.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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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