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.
@@ -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"