teardrop-cli 0.1.0__py3-none-any.whl
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.
- teardrop_cli/__init__.py +1 -0
- teardrop_cli/_fixtures.py +132 -0
- teardrop_cli/_lazy.py +67 -0
- teardrop_cli/cli.py +41 -0
- teardrop_cli/commands/__init__.py +0 -0
- teardrop_cli/commands/agent.py +190 -0
- teardrop_cli/commands/auth.py +270 -0
- teardrop_cli/commands/billing.py +58 -0
- teardrop_cli/commands/llm_config.py +376 -0
- teardrop_cli/commands/marketplace.py +192 -0
- teardrop_cli/commands/mcp.py +202 -0
- teardrop_cli/commands/models.py +193 -0
- teardrop_cli/commands/tools.py +207 -0
- teardrop_cli/config.py +240 -0
- teardrop_cli/formatting.py +243 -0
- teardrop_cli-0.1.0.dist-info/METADATA +384 -0
- teardrop_cli-0.1.0.dist-info/RECORD +20 -0
- teardrop_cli-0.1.0.dist-info/WHEEL +4 -0
- teardrop_cli-0.1.0.dist-info/entry_points.txt +2 -0
- teardrop_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
teardrop_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Test fixture helpers shared across test modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import MagicMock
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def make_jwt_payload(
|
|
9
|
+
sub: str = "user@example.com",
|
|
10
|
+
org: str = "acme",
|
|
11
|
+
role: str = "admin",
|
|
12
|
+
) -> MagicMock:
|
|
13
|
+
"""Return a minimal JwtPayloadBase-like mock."""
|
|
14
|
+
payload = MagicMock()
|
|
15
|
+
payload.sub = sub
|
|
16
|
+
payload.org = org
|
|
17
|
+
payload.role = role
|
|
18
|
+
payload.model_dump = lambda: {"sub": sub, "org": org, "role": role}
|
|
19
|
+
return payload
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def make_sse_events(text: str) -> list[MagicMock]:
|
|
23
|
+
"""Build a minimal sequence of SSE events for a successful agent run."""
|
|
24
|
+
events = []
|
|
25
|
+
|
|
26
|
+
# text chunk
|
|
27
|
+
chunk = MagicMock()
|
|
28
|
+
chunk.type = "text_msg_content"
|
|
29
|
+
chunk.data = {"content": text}
|
|
30
|
+
events.append(chunk)
|
|
31
|
+
|
|
32
|
+
# usage summary
|
|
33
|
+
usage = MagicMock()
|
|
34
|
+
usage.type = "usage_summary"
|
|
35
|
+
usage.data = {"input_tokens": 5, "output_tokens": 10, "total_cost_usd": 0.0001}
|
|
36
|
+
events.append(usage)
|
|
37
|
+
|
|
38
|
+
# done
|
|
39
|
+
done = MagicMock()
|
|
40
|
+
done.type = "done"
|
|
41
|
+
done.data = None
|
|
42
|
+
events.append(done)
|
|
43
|
+
|
|
44
|
+
return events
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def make_llm_config(
|
|
48
|
+
org_id: str = "org-1",
|
|
49
|
+
provider: str = "anthropic",
|
|
50
|
+
model: str = "claude-haiku-4-5-20251001",
|
|
51
|
+
has_api_key: bool = False,
|
|
52
|
+
api_base: str | None = None,
|
|
53
|
+
max_tokens: int = 4096,
|
|
54
|
+
temperature: float = 0.0,
|
|
55
|
+
timeout_seconds: int = 120,
|
|
56
|
+
routing_preference: str = "default",
|
|
57
|
+
is_byok: bool = False,
|
|
58
|
+
) -> MagicMock:
|
|
59
|
+
"""Return a minimal OrgLlmConfig-like mock."""
|
|
60
|
+
data = {
|
|
61
|
+
"org_id": org_id,
|
|
62
|
+
"provider": provider,
|
|
63
|
+
"model": model,
|
|
64
|
+
"has_api_key": has_api_key,
|
|
65
|
+
"api_base": api_base,
|
|
66
|
+
"max_tokens": max_tokens,
|
|
67
|
+
"temperature": temperature,
|
|
68
|
+
"timeout_seconds": timeout_seconds,
|
|
69
|
+
"routing_preference": routing_preference,
|
|
70
|
+
"is_byok": is_byok,
|
|
71
|
+
"created_at": "2026-04-16T10:00:00Z",
|
|
72
|
+
"updated_at": "2026-04-16T10:00:00Z",
|
|
73
|
+
}
|
|
74
|
+
obj = MagicMock()
|
|
75
|
+
obj.model_dump = lambda: dict(data)
|
|
76
|
+
for k, v in data.items():
|
|
77
|
+
setattr(obj, k, v)
|
|
78
|
+
return obj
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def make_siwe_session(
|
|
82
|
+
session_id: str = "siwe_sess_1",
|
|
83
|
+
status: str = "pending",
|
|
84
|
+
nonce: str = "abc123nonce",
|
|
85
|
+
jwt: str | None = None,
|
|
86
|
+
) -> MagicMock:
|
|
87
|
+
"""Return a minimal SiweSession-like mock."""
|
|
88
|
+
data = {
|
|
89
|
+
"id": session_id,
|
|
90
|
+
"status": status,
|
|
91
|
+
"nonce": nonce,
|
|
92
|
+
"jwt": jwt,
|
|
93
|
+
}
|
|
94
|
+
obj = MagicMock()
|
|
95
|
+
obj.model_dump = lambda: dict(data)
|
|
96
|
+
for k, v in data.items():
|
|
97
|
+
setattr(obj, k, v)
|
|
98
|
+
return obj
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def make_benchmarks_response(models: list[dict] | None = None) -> MagicMock:
|
|
102
|
+
"""Return a minimal ModelBenchmarksResponse-like mock."""
|
|
103
|
+
if models is None:
|
|
104
|
+
models = [
|
|
105
|
+
{
|
|
106
|
+
"provider": "anthropic",
|
|
107
|
+
"model": "claude-haiku-4-5-20251001",
|
|
108
|
+
"display_name": "Claude Haiku 4.5",
|
|
109
|
+
"context_window": 200000,
|
|
110
|
+
"supports_tools": True,
|
|
111
|
+
"supports_streaming": True,
|
|
112
|
+
"quality_tier": 2,
|
|
113
|
+
"pricing": {
|
|
114
|
+
"tokens_in_cost_per_1k": 0.08,
|
|
115
|
+
"tokens_out_cost_per_1k": 0.24,
|
|
116
|
+
"tool_call_cost": 0.0,
|
|
117
|
+
},
|
|
118
|
+
"benchmarks": {
|
|
119
|
+
"total_runs_7d": 1250,
|
|
120
|
+
"avg_latency_ms": 485.5,
|
|
121
|
+
"p95_latency_ms": 1200.0,
|
|
122
|
+
"avg_cost_usdc_per_run": 12.5,
|
|
123
|
+
"avg_tokens_per_sec": 45.2,
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
]
|
|
127
|
+
data = {"models": models, "updated_at": "2026-04-16T12:00:00Z"}
|
|
128
|
+
obj = MagicMock()
|
|
129
|
+
obj.model_dump = lambda: dict(data)
|
|
130
|
+
obj.models = models
|
|
131
|
+
obj.updated_at = data["updated_at"]
|
|
132
|
+
return obj
|
teardrop_cli/_lazy.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Lazy subcommand loading for Typer/Click apps.
|
|
2
|
+
|
|
3
|
+
Defers module imports until a subcommand is actually invoked, keeping
|
|
4
|
+
``teardrop --help`` fast by avoiding top-level imports of heavy
|
|
5
|
+
dependencies (httpx, teardrop_sdk, etc.).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LazyGroup(click.Group):
|
|
20
|
+
"""A Click Group that imports subcommand modules on demand."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
*args,
|
|
25
|
+
lazy_subcommands: dict[str, str] | None = None,
|
|
26
|
+
**kwargs,
|
|
27
|
+
) -> None:
|
|
28
|
+
super().__init__(*args, **kwargs)
|
|
29
|
+
# Maps command name -> "module.path:attr_name"
|
|
30
|
+
self._lazy: dict[str, str] = lazy_subcommands or {}
|
|
31
|
+
|
|
32
|
+
# ------------------------------------------------------------------
|
|
33
|
+
# Click Group overrides
|
|
34
|
+
# ------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
def list_commands(self, ctx: click.Context) -> list[str]:
|
|
37
|
+
base = super().list_commands(ctx)
|
|
38
|
+
return sorted(set(base) | set(self._lazy.keys()))
|
|
39
|
+
|
|
40
|
+
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
|
|
41
|
+
if cmd_name in self._lazy:
|
|
42
|
+
return self._load(cmd_name)
|
|
43
|
+
return super().get_command(ctx, cmd_name)
|
|
44
|
+
|
|
45
|
+
# ------------------------------------------------------------------
|
|
46
|
+
# Internal helpers
|
|
47
|
+
# ------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def _load(self, cmd_name: str) -> click.Command:
|
|
50
|
+
import_path = self._lazy[cmd_name]
|
|
51
|
+
if ":" in import_path:
|
|
52
|
+
modname, attr = import_path.rsplit(":", 1)
|
|
53
|
+
else:
|
|
54
|
+
# Legacy dot-separated (last segment is attribute)
|
|
55
|
+
modname, attr = import_path.rsplit(".", 1)
|
|
56
|
+
|
|
57
|
+
mod = importlib.import_module(modname)
|
|
58
|
+
cmd = getattr(mod, attr)
|
|
59
|
+
|
|
60
|
+
# Typer apps must be converted to Click commands before use
|
|
61
|
+
# Import here to avoid top-level dependency on typer
|
|
62
|
+
import typer
|
|
63
|
+
|
|
64
|
+
if isinstance(cmd, typer.Typer):
|
|
65
|
+
cmd = typer.main.get_command(cmd)
|
|
66
|
+
|
|
67
|
+
return cmd
|
teardrop_cli/cli.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Main CLI entry point for teardrop-cli.
|
|
2
|
+
|
|
3
|
+
The root command is a plain Click Group (via LazyGroup) so that unknown
|
|
4
|
+
kwargs like ``lazy_subcommands`` are forwarded correctly. Subcommand
|
|
5
|
+
modules are imported only when the subcommand is actually invoked, keeping
|
|
6
|
+
``teardrop --help`` well under 100 ms.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from teardrop_cli._lazy import LazyGroup
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.group(
|
|
17
|
+
name="teardrop",
|
|
18
|
+
cls=LazyGroup,
|
|
19
|
+
lazy_subcommands={
|
|
20
|
+
"auth": "teardrop_cli.commands.auth:app",
|
|
21
|
+
"agent": "teardrop_cli.commands.agent:app",
|
|
22
|
+
"billing": "teardrop_cli.commands.billing:app",
|
|
23
|
+
"llm-config": "teardrop_cli.commands.llm_config:app",
|
|
24
|
+
"marketplace": "teardrop_cli.commands.marketplace:app",
|
|
25
|
+
"mcp": "teardrop_cli.commands.mcp:app",
|
|
26
|
+
"models": "teardrop_cli.commands.models:app",
|
|
27
|
+
"tools": "teardrop_cli.commands.tools:app",
|
|
28
|
+
},
|
|
29
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
30
|
+
invoke_without_command=True,
|
|
31
|
+
)
|
|
32
|
+
@click.version_option(package_name="teardrop-cli", prog_name="teardrop")
|
|
33
|
+
@click.pass_context
|
|
34
|
+
def app(ctx: click.Context) -> None:
|
|
35
|
+
"""Teardrop AI — crypto-native agent orchestration & marketplace."""
|
|
36
|
+
if ctx.invoked_subcommand is None:
|
|
37
|
+
click.echo(ctx.get_help())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if __name__ == "__main__":
|
|
41
|
+
app()
|
|
File without changes
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""agent commands: run."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(
|
|
13
|
+
name="agent",
|
|
14
|
+
help="Run prompts against the Teardrop AI agent.",
|
|
15
|
+
no_args_is_help=True,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.callback()
|
|
20
|
+
def _callback() -> None:
|
|
21
|
+
"""Run prompts against the Teardrop AI agent."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# SSE event type constants (must match teardrop_sdk's streaming module)
|
|
25
|
+
_EV_TEXT = "text_msg_content"
|
|
26
|
+
_EV_TOOL_START = "tool_call_start"
|
|
27
|
+
_EV_TOOL_END = "tool_call_end"
|
|
28
|
+
_EV_USAGE = "usage_summary"
|
|
29
|
+
_EV_BILLING = "billing_settlement"
|
|
30
|
+
_EV_ERROR = "error"
|
|
31
|
+
_EV_DONE = "done"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command()
|
|
35
|
+
def run(
|
|
36
|
+
prompt: Annotated[str, typer.Argument(help="The prompt to send to the agent.")],
|
|
37
|
+
thread_id: Annotated[
|
|
38
|
+
str | None, typer.Option("--thread-id", "-t", help="Continue an existing thread.")
|
|
39
|
+
] = None,
|
|
40
|
+
model: Annotated[str | None, typer.Option("--model", "-m", help="Model override.")] = None,
|
|
41
|
+
as_json: Annotated[
|
|
42
|
+
bool,
|
|
43
|
+
typer.Option("--json", help="Emit raw SSE events as JSON lines (machine-readable)."),
|
|
44
|
+
] = False,
|
|
45
|
+
base_url: Annotated[
|
|
46
|
+
str | None, typer.Option("--base-url", help="Override the API base URL.", hidden=True)
|
|
47
|
+
] = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Send a prompt to the agent and stream the response."""
|
|
50
|
+
from teardrop import PaymentRequiredError, RateLimitError
|
|
51
|
+
|
|
52
|
+
from teardrop_cli import config
|
|
53
|
+
from teardrop_cli.formatting import print_error
|
|
54
|
+
|
|
55
|
+
client = config.get_client(base_url)
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
if as_json:
|
|
59
|
+
asyncio.run(_run_json(client, prompt, thread_id=thread_id, model=model))
|
|
60
|
+
else:
|
|
61
|
+
asyncio.run(_run_rich(client, prompt, thread_id=thread_id, model=model))
|
|
62
|
+
except PaymentRequiredError:
|
|
63
|
+
print_error(
|
|
64
|
+
"Insufficient balance.",
|
|
65
|
+
hint=f"Top up your account at {config.get_base_url()}/billing",
|
|
66
|
+
)
|
|
67
|
+
raise typer.Exit(2) from None
|
|
68
|
+
except RateLimitError as exc:
|
|
69
|
+
retry = getattr(exc, "retry_after", None)
|
|
70
|
+
hint = f"Retry in {retry}s." if retry else "Please wait before retrying."
|
|
71
|
+
print_error("Rate limited.", hint=hint)
|
|
72
|
+
raise typer.Exit(3) from None
|
|
73
|
+
except KeyboardInterrupt:
|
|
74
|
+
raise typer.Exit(130) from None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Rich streaming renderer
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def _run_rich(client, prompt: str, *, thread_id, model) -> None:
|
|
83
|
+
"""Stream response with Rich Live rendering."""
|
|
84
|
+
from rich.live import Live
|
|
85
|
+
from rich.markdown import Markdown
|
|
86
|
+
|
|
87
|
+
from teardrop_cli.formatting import _print_billing_settlement, _print_usage_summary, console
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
accumulated = ""
|
|
91
|
+
tool_depth = 0
|
|
92
|
+
|
|
93
|
+
live = Live(
|
|
94
|
+
Markdown(""),
|
|
95
|
+
console=console,
|
|
96
|
+
refresh_per_second=15,
|
|
97
|
+
auto_refresh=True,
|
|
98
|
+
transient=False,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Show a quick spinner before first token arrives
|
|
102
|
+
first_token = True
|
|
103
|
+
|
|
104
|
+
with live:
|
|
105
|
+
async for event in client.run(prompt, thread_id=thread_id, model=model):
|
|
106
|
+
ev_type: str = getattr(event, "type", "") or ""
|
|
107
|
+
data = getattr(event, "data", None)
|
|
108
|
+
|
|
109
|
+
if ev_type == _EV_TEXT:
|
|
110
|
+
if first_token:
|
|
111
|
+
first_token = False
|
|
112
|
+
chunk = _extract_text(data)
|
|
113
|
+
accumulated += chunk
|
|
114
|
+
live.update(Markdown(accumulated))
|
|
115
|
+
|
|
116
|
+
elif ev_type == _EV_TOOL_START:
|
|
117
|
+
tool_depth += 1
|
|
118
|
+
tool_name = _extract_tool_name(data)
|
|
119
|
+
indicator = f"\n\n*[Tool: {tool_name}…]*"
|
|
120
|
+
live.update(Markdown(accumulated + indicator))
|
|
121
|
+
|
|
122
|
+
elif ev_type == _EV_TOOL_END:
|
|
123
|
+
tool_depth = max(0, tool_depth - 1)
|
|
124
|
+
if tool_depth == 0:
|
|
125
|
+
live.update(Markdown(accumulated))
|
|
126
|
+
|
|
127
|
+
elif ev_type == _EV_USAGE:
|
|
128
|
+
if isinstance(data, dict):
|
|
129
|
+
live.stop()
|
|
130
|
+
_print_usage_summary(data)
|
|
131
|
+
|
|
132
|
+
elif ev_type == _EV_BILLING:
|
|
133
|
+
if isinstance(data, dict):
|
|
134
|
+
_print_billing_settlement(data)
|
|
135
|
+
|
|
136
|
+
elif ev_type == _EV_ERROR:
|
|
137
|
+
msg = _extract_text(data) or "Unknown agent error"
|
|
138
|
+
live.stop()
|
|
139
|
+
from teardrop_cli.formatting import print_error
|
|
140
|
+
|
|
141
|
+
print_error(f"Agent error: {msg}")
|
|
142
|
+
raise typer.Exit(5)
|
|
143
|
+
|
|
144
|
+
elif ev_type == _EV_DONE:
|
|
145
|
+
break
|
|
146
|
+
|
|
147
|
+
console.print() # trailing newline after stream
|
|
148
|
+
finally:
|
|
149
|
+
await client.close()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# JSON streaming renderer (machine-readable)
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def _run_json(client, prompt: str, *, thread_id, model) -> None:
|
|
158
|
+
"""Emit each SSE event as a JSON line to stdout."""
|
|
159
|
+
try:
|
|
160
|
+
async for event in client.run(prompt, thread_id=thread_id, model=model):
|
|
161
|
+
line = json.dumps(
|
|
162
|
+
{
|
|
163
|
+
"type": getattr(event, "type", None),
|
|
164
|
+
"data": getattr(event, "data", None),
|
|
165
|
+
},
|
|
166
|
+
default=str,
|
|
167
|
+
)
|
|
168
|
+
sys.stdout.write(line + "\n")
|
|
169
|
+
sys.stdout.flush()
|
|
170
|
+
finally:
|
|
171
|
+
await client.close()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# Internal helpers
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _extract_text(data) -> str:
|
|
180
|
+
if isinstance(data, dict):
|
|
181
|
+
return data.get("content", data.get("message", data.get("text", "")))
|
|
182
|
+
if isinstance(data, str):
|
|
183
|
+
return data
|
|
184
|
+
return ""
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _extract_tool_name(data) -> str:
|
|
188
|
+
if isinstance(data, dict):
|
|
189
|
+
return data.get("tool_name", data.get("name", "unknown"))
|
|
190
|
+
return "unknown"
|