ondaru 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.
- ondaru/__init__.py +3 -0
- ondaru/cli/__init__.py +1 -0
- ondaru/cli/main.py +593 -0
- ondaru/config.py +259 -0
- ondaru/contracts.py +338 -0
- ondaru/generation/__init__.py +1 -0
- ondaru/generation/adapter.py +51 -0
- ondaru/generation/factory.py +27 -0
- ondaru/generation/tripo.py +274 -0
- ondaru/generation/vendor.py +154 -0
- ondaru/mcp/__init__.py +1 -0
- ondaru/mcp/__main__.py +182 -0
- ondaru/mcp/payments.py +276 -0
- ondaru/mcp/paywall.py +336 -0
- ondaru/mcp/server.py +299 -0
- ondaru/mcp/verification.py +320 -0
- ondaru/payments/__init__.py +1 -0
- ondaru/payments/balances.py +85 -0
- ondaru/payments/domain.py +123 -0
- ondaru/payments/settlement.py +115 -0
- ondaru/payments/settler.py +343 -0
- ondaru/server/__init__.py +1 -0
- ondaru/server/__main__.py +43 -0
- ondaru/server/http_app.py +109 -0
- ondaru/skill_assets/ondaru.md +70 -0
- ondaru/spec/__init__.py +1 -0
- ondaru/spec/builder.py +85 -0
- ondaru/storage/__init__.py +1 -0
- ondaru/storage/assets.py +94 -0
- ondaru/store/__init__.py +1 -0
- ondaru/store/postgres.py +412 -0
- ondaru/store/redis.py +115 -0
- ondaru/store/schema.sql +81 -0
- ondaru/worker/pipeline.py +187 -0
- ondaru-0.1.0.dist-info/METADATA +93 -0
- ondaru-0.1.0.dist-info/RECORD +38 -0
- ondaru-0.1.0.dist-info/WHEEL +4 -0
- ondaru-0.1.0.dist-info/entry_points.txt +2 -0
ondaru/__init__.py
ADDED
ondaru/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The human-facing command line."""
|
ondaru/cli/main.py
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
"""The human-facing command line.
|
|
2
|
+
|
|
3
|
+
Two jobs: install Ondaru into someone else's agent, and exercise a live
|
|
4
|
+
service from a terminal. The install commands are the distribution mechanism —
|
|
5
|
+
an ASP nobody's agent knows to call is an ASP nobody calls.
|
|
6
|
+
|
|
7
|
+
Every install is idempotent. A second run must not duplicate a server entry or
|
|
8
|
+
half-overwrite a skill.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import shutil
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Annotated, Any
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
import questionary
|
|
20
|
+
import typer
|
|
21
|
+
from rich.console import Console
|
|
22
|
+
from rich.table import Table
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(
|
|
25
|
+
name="ondaru",
|
|
26
|
+
help="3D product mockups your agent can order.",
|
|
27
|
+
invoke_without_command=True,
|
|
28
|
+
add_completion=False,
|
|
29
|
+
)
|
|
30
|
+
console = Console()
|
|
31
|
+
|
|
32
|
+
# Shown when someone runs `ondaru` with nothing after it. A person who typed the
|
|
33
|
+
# name and pressed enter is asking what this is, not reading a flag list.
|
|
34
|
+
#
|
|
35
|
+
# ANSI Shadow at 52 columns, plus a wireframe cube for the thing being sold.
|
|
36
|
+
# Together they need 68, which fits the 80-column terminal that still decides
|
|
37
|
+
# what "fits" means. Narrower than that and the wordmark wraps into noise, so
|
|
38
|
+
# there is a plain fallback.
|
|
39
|
+
WORDMARK = (
|
|
40
|
+
" ██████╗ ███╗ ██╗██████╗ █████╗ ██████╗ ██╗ ██╗",
|
|
41
|
+
"██╔═══██╗████╗ ██║██╔══██╗██╔══██╗██╔══██╗██║ ██║",
|
|
42
|
+
"██║ ██║██╔██╗ ██║██║ ██║███████║██████╔╝██║ ██║",
|
|
43
|
+
"██║ ██║██║╚██╗██║██║ ██║██╔══██║██╔══██╗██║ ██║",
|
|
44
|
+
"╚██████╔╝██║ ╚████║██████╔╝██║ ██║██║ ██║╚██████╔╝",
|
|
45
|
+
" ╚═════╝ ╚═╝ ╚═══╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
CUBE = (
|
|
49
|
+
" ┌──────┐",
|
|
50
|
+
" ╱│ ╱│",
|
|
51
|
+
" ┌──────┐ │",
|
|
52
|
+
" │ │ │ │",
|
|
53
|
+
" │ └────│─┘",
|
|
54
|
+
" │╱ │╱ ",
|
|
55
|
+
" └──────┘ ",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Teal through indigo, two rows each. A per-character gradient reads as a
|
|
59
|
+
# gimmick; per-row reads as depth.
|
|
60
|
+
GRADIENT = ("#5eead4", "#5eead4", "#38bdf8", "#38bdf8", "#818cf8", "#818cf8")
|
|
61
|
+
|
|
62
|
+
BANNER_WIDTH = 68
|
|
63
|
+
|
|
64
|
+
DEFAULT_URL = "https://ondaru.dev/mcp"
|
|
65
|
+
|
|
66
|
+
# The surfaces the service accepts. Kept here so the prompt cannot offer one
|
|
67
|
+
# the server would reject.
|
|
68
|
+
SURFACES = (
|
|
69
|
+
"mug",
|
|
70
|
+
"hoodie",
|
|
71
|
+
"tshirt",
|
|
72
|
+
"phone_case",
|
|
73
|
+
"tote",
|
|
74
|
+
"sticker",
|
|
75
|
+
"figurine",
|
|
76
|
+
"packaging",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
SERVER_KEY = "ondaru"
|
|
80
|
+
SKILL_NAME = "ondaru"
|
|
81
|
+
_ASSETS = Path(__file__).resolve().parent.parent / "skill_assets"
|
|
82
|
+
|
|
83
|
+
# Where each host agent keeps its MCP server list and its skills.
|
|
84
|
+
HOSTS: dict[str, tuple[Path, Path]] = {
|
|
85
|
+
"Claude Code": (Path.home() / ".claude.json", Path.home() / ".claude" / "skills"),
|
|
86
|
+
"Cursor": (Path.home() / ".cursor" / "mcp.json", Path.home() / ".cursor" / "skills"),
|
|
87
|
+
"OpenClaw": (Path.home() / ".openclaw" / "mcp.json", Path.home() / ".agents" / "skills"),
|
|
88
|
+
"Shared agents directory": (
|
|
89
|
+
Path.home() / ".agents" / "mcp.json",
|
|
90
|
+
Path.home() / ".agents" / "skills",
|
|
91
|
+
),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _banner() -> None:
|
|
96
|
+
"""Print the wordmark, or a plain line when there is no room for it."""
|
|
97
|
+
console.print()
|
|
98
|
+
if console.width < BANNER_WIDTH:
|
|
99
|
+
console.print(" [bold #38bdf8]ONDARU[/]")
|
|
100
|
+
else:
|
|
101
|
+
for row, line in enumerate(WORDMARK):
|
|
102
|
+
console.print(f" [{GRADIENT[row]}]{line}[/] [dim]{CUBE[row]}[/]")
|
|
103
|
+
console.print(f" [dim]{' ' * len(WORDMARK[0])} {CUBE[-1]}[/]")
|
|
104
|
+
console.print()
|
|
105
|
+
console.print(" [bold]A textured glb and a turntable render[/bold] — ordered by an agent,")
|
|
106
|
+
console.print(" paid for inside its own workflow.")
|
|
107
|
+
# Highlighting off: rich colours bare numbers by default, and a price
|
|
108
|
+
# lighting up inside a dim line looks like a warning rather than a fact.
|
|
109
|
+
console.print(
|
|
110
|
+
"\n [dim]x402 on X Layer · 0.02 to plan · 0.90 an asset · no account, no card[/dim]\n",
|
|
111
|
+
highlight=False,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@app.callback()
|
|
116
|
+
def main(context: typer.Context) -> None:
|
|
117
|
+
"""Ondaru — 3D product mockups your agent can order."""
|
|
118
|
+
if context.invoked_subcommand is not None:
|
|
119
|
+
return
|
|
120
|
+
_banner()
|
|
121
|
+
|
|
122
|
+
choice = questionary.select(
|
|
123
|
+
"What would you like to do?",
|
|
124
|
+
choices=[
|
|
125
|
+
"Install Ondaru into my agent",
|
|
126
|
+
"Order one mockup from a live service",
|
|
127
|
+
"Check what a live service charges",
|
|
128
|
+
"Show my generation credits",
|
|
129
|
+
"Quit",
|
|
130
|
+
],
|
|
131
|
+
).ask()
|
|
132
|
+
|
|
133
|
+
if choice == "Install Ondaru into my agent":
|
|
134
|
+
host = _ask_host("Which agent should be able to order mockups?")
|
|
135
|
+
url = questionary.text("Endpoint:", default=DEFAULT_URL).ask() or DEFAULT_URL
|
|
136
|
+
setup_mcp(url=url, host=host)
|
|
137
|
+
skills(action="install", host=host)
|
|
138
|
+
console.print("\n Ask your agent for a 3D mockup of something. It knows the rest.")
|
|
139
|
+
elif choice == "Order one mockup from a live service":
|
|
140
|
+
url = questionary.text("Endpoint:", default=DEFAULT_URL).ask() or DEFAULT_URL
|
|
141
|
+
try_order(url=url, subject=None, surface=None, into=Path("out"), yes=False)
|
|
142
|
+
elif choice == "Check what a live service charges":
|
|
143
|
+
url = questionary.text("Endpoint:", default=DEFAULT_URL).ask() or DEFAULT_URL
|
|
144
|
+
check(url=url, subject="an enamel mug", surface="mug")
|
|
145
|
+
elif choice == "Show my generation credits":
|
|
146
|
+
credits(keys=None)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@app.command("setup-mcp")
|
|
150
|
+
def setup_mcp(
|
|
151
|
+
url: Annotated[str, typer.Option(help="The Ondaru MCP endpoint.")] = "https://ondaru.dev/mcp",
|
|
152
|
+
host: Annotated[str | None, typer.Option(help="Skip the prompt and name the host.")] = None,
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Register the Ondaru MCP server with a host agent."""
|
|
155
|
+
chosen = host or _ask_host("Which agent should be able to order mockups?")
|
|
156
|
+
config_path, _ = HOSTS[chosen]
|
|
157
|
+
|
|
158
|
+
config = _read_json(config_path)
|
|
159
|
+
servers = config.setdefault("mcpServers", {})
|
|
160
|
+
already = SERVER_KEY in servers
|
|
161
|
+
servers[SERVER_KEY] = {"type": "http", "url": url}
|
|
162
|
+
|
|
163
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
|
165
|
+
|
|
166
|
+
verb = "Updated" if already else "Added"
|
|
167
|
+
console.print(f"[green]{verb}[/green] the [bold]{SERVER_KEY}[/bold] server in {config_path}")
|
|
168
|
+
console.print(f" endpoint {url}")
|
|
169
|
+
console.print("\nRestart the agent, then ask it to plan a product mockup.")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@app.command("skills")
|
|
173
|
+
def skills(
|
|
174
|
+
action: Annotated[str, typer.Argument(help="install")] = "install",
|
|
175
|
+
host: Annotated[str | None, typer.Option(help="Skip the prompt and name the host.")] = None,
|
|
176
|
+
) -> None:
|
|
177
|
+
"""Install the skill that teaches an agent when to call Ondaru."""
|
|
178
|
+
if action != "install":
|
|
179
|
+
raise typer.BadParameter("the only supported action is 'install'")
|
|
180
|
+
|
|
181
|
+
chosen = host or _ask_host("Which agent should learn when to use Ondaru?")
|
|
182
|
+
_, skills_root = HOSTS[chosen]
|
|
183
|
+
target = skills_root / SKILL_NAME
|
|
184
|
+
|
|
185
|
+
if target.exists():
|
|
186
|
+
shutil.rmtree(target)
|
|
187
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
188
|
+
shutil.copy2(_ASSETS / "ondaru.md", target / "SKILL.md")
|
|
189
|
+
references = _ASSETS / "references"
|
|
190
|
+
if references.is_dir():
|
|
191
|
+
shutil.copytree(references, target / "references")
|
|
192
|
+
|
|
193
|
+
installed = sorted(path.name for path in target.rglob("*.md"))
|
|
194
|
+
console.print(f"[green]Installed[/green] the [bold]{SKILL_NAME}[/bold] skill in {target}")
|
|
195
|
+
for name in installed:
|
|
196
|
+
console.print(f" {name}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@app.command("generate")
|
|
200
|
+
def generate(
|
|
201
|
+
subject: Annotated[str, typer.Argument(help="What to make a mockup of.")],
|
|
202
|
+
surface: Annotated[str, typer.Option(help="Which product surface.")] = "mug",
|
|
203
|
+
vendor: Annotated[str, typer.Option(help="tripo or meshy.")] = "tripo",
|
|
204
|
+
api_key: Annotated[str | None, typer.Option(envvar="ONDARU_GENERATION_API_KEY")] = None,
|
|
205
|
+
base_url: Annotated[str | None, typer.Option(help="Override the vendor base URL.")] = None,
|
|
206
|
+
out: Annotated[Path, typer.Option(help="Where to write the model.")] = Path("out"),
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Run one real generation against a live vendor and save what comes back.
|
|
209
|
+
|
|
210
|
+
Bypasses the marketplace and the payment layer on purpose — this answers
|
|
211
|
+
only whether the adapter genuinely drives the vendor end to end.
|
|
212
|
+
"""
|
|
213
|
+
import asyncio
|
|
214
|
+
|
|
215
|
+
from ondaru.config import GenerationConfig
|
|
216
|
+
from ondaru.contracts import GenerationState, MockupRequest, Surface
|
|
217
|
+
from ondaru.generation.factory import open_backend
|
|
218
|
+
|
|
219
|
+
if not api_key:
|
|
220
|
+
console.print("[red]No API key.[/red] Pass --api-key or set ONDARU_GENERATION_API_KEY.")
|
|
221
|
+
console.print("Tripo grants free credits at https://platform.tripo3d.ai (no card).")
|
|
222
|
+
raise typer.Exit(code=1)
|
|
223
|
+
|
|
224
|
+
defaults = {"tripo": "https://api.tripo3d.ai", "meshy": "https://api.meshy.ai"}
|
|
225
|
+
config = GenerationConfig(
|
|
226
|
+
vendor=vendor,
|
|
227
|
+
base_url=base_url or defaults.get(vendor, ""),
|
|
228
|
+
api_keys=(api_key,),
|
|
229
|
+
submit_timeout_seconds=60,
|
|
230
|
+
poll_interval_seconds=5,
|
|
231
|
+
poll_deadline_seconds=900,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
async def run() -> None:
|
|
235
|
+
backend = open_backend(config)
|
|
236
|
+
try:
|
|
237
|
+
request = MockupRequest(subject=subject, surface=Surface(surface))
|
|
238
|
+
console.print(f"Submitting to [bold]{vendor}[/bold]…")
|
|
239
|
+
handle = await backend.submit(request)
|
|
240
|
+
console.print(f" task {handle.reference}")
|
|
241
|
+
|
|
242
|
+
elapsed = 0
|
|
243
|
+
while True:
|
|
244
|
+
status = await backend.poll(handle)
|
|
245
|
+
console.print(f" {status.state} {status.progress}% ({elapsed}s)")
|
|
246
|
+
if status.state is GenerationState.SUCCEEDED:
|
|
247
|
+
break
|
|
248
|
+
if status.state is GenerationState.FAILED:
|
|
249
|
+
console.print(f"[red]Failed:[/red] {status.failure_message}")
|
|
250
|
+
raise typer.Exit(code=1)
|
|
251
|
+
await asyncio.sleep(5)
|
|
252
|
+
elapsed += 5
|
|
253
|
+
|
|
254
|
+
handle = await backend.refine(handle)
|
|
255
|
+
while True:
|
|
256
|
+
status = await backend.poll(handle)
|
|
257
|
+
if status.state is GenerationState.SUCCEEDED:
|
|
258
|
+
break
|
|
259
|
+
if status.state is GenerationState.FAILED:
|
|
260
|
+
console.print(f"[red]Refine failed:[/red] {status.failure_message}")
|
|
261
|
+
raise typer.Exit(code=1)
|
|
262
|
+
await asyncio.sleep(5)
|
|
263
|
+
|
|
264
|
+
result = await backend.fetch_result(handle)
|
|
265
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
266
|
+
target = out / f"{subject.replace(' ', '-')}.glb"
|
|
267
|
+
async with httpx.AsyncClient(timeout=120, follow_redirects=True) as http:
|
|
268
|
+
downloaded = await http.get(result.model_url)
|
|
269
|
+
downloaded.raise_for_status()
|
|
270
|
+
target.write_bytes(downloaded.content)
|
|
271
|
+
|
|
272
|
+
console.print(f"\n[green]Saved[/green] {target} ({target.stat().st_size:,} bytes)")
|
|
273
|
+
if result.thumbnail_url:
|
|
274
|
+
console.print(f" preview {result.thumbnail_url}")
|
|
275
|
+
finally:
|
|
276
|
+
await backend.aclose()
|
|
277
|
+
|
|
278
|
+
asyncio.run(run())
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@app.command("check")
|
|
282
|
+
def check(
|
|
283
|
+
url: Annotated[str, typer.Option(help="The Ondaru MCP endpoint.")] = "https://ondaru.dev/mcp",
|
|
284
|
+
subject: Annotated[str, typer.Option(help="What to plan a mockup for.")] = "an enamel mug",
|
|
285
|
+
surface: Annotated[str, typer.Option(help="Which product surface.")] = "mug",
|
|
286
|
+
) -> None:
|
|
287
|
+
"""Exercise a live service: plan a mockup and print what it costs."""
|
|
288
|
+
body = _rpc(
|
|
289
|
+
url,
|
|
290
|
+
"tools/call",
|
|
291
|
+
{
|
|
292
|
+
"name": "plan_product_mockup",
|
|
293
|
+
"arguments": {"subject": subject, "surface": surface},
|
|
294
|
+
},
|
|
295
|
+
)
|
|
296
|
+
content = (body.get("result") or {}).get("structuredContent")
|
|
297
|
+
if not content:
|
|
298
|
+
console.print("[red]The service did not return a specification.[/red]")
|
|
299
|
+
console.print(json.dumps(body, indent=2)[:600])
|
|
300
|
+
raise typer.Exit(code=1)
|
|
301
|
+
|
|
302
|
+
table = Table(title=f"{subject} on a {surface}", show_header=False)
|
|
303
|
+
dimensions = content.get("dimensions_mm", {})
|
|
304
|
+
table.add_row("Feasible", str(content.get("feasible")))
|
|
305
|
+
table.add_row(
|
|
306
|
+
"Size",
|
|
307
|
+
f"{dimensions.get('width')} × {dimensions.get('height')} × {dimensions.get('depth')} mm",
|
|
308
|
+
)
|
|
309
|
+
table.add_row("Material", str(content.get("material")))
|
|
310
|
+
table.add_row("Polygons", f"{content.get('polygon_budget'):,}")
|
|
311
|
+
tier = content.get("asset_tier", {})
|
|
312
|
+
table.add_row("Asset price", f"{tier.get('price')} {tier.get('currency')}")
|
|
313
|
+
console.print(table)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@app.command("try")
|
|
317
|
+
def try_order(
|
|
318
|
+
url: Annotated[str, typer.Option(help="The Ondaru MCP endpoint.")] = DEFAULT_URL,
|
|
319
|
+
subject: Annotated[str | None, typer.Option(help="What to build.")] = None,
|
|
320
|
+
surface: Annotated[str | None, typer.Option(help="Which product surface.")] = None,
|
|
321
|
+
into: Annotated[Path, typer.Option(help="Where to save the result.")] = Path("out"),
|
|
322
|
+
yes: Annotated[bool, typer.Option("--yes", help="Do not ask before paying.")] = False,
|
|
323
|
+
) -> None:
|
|
324
|
+
"""Order one mockup end to end and save it here.
|
|
325
|
+
|
|
326
|
+
Payment goes through your own Agentic Wallet via the `onchainos` client.
|
|
327
|
+
This command never sees a private key, and it should not: a terminal tool
|
|
328
|
+
holding the money is a terminal tool worth stealing.
|
|
329
|
+
"""
|
|
330
|
+
if shutil.which("onchainos") is None:
|
|
331
|
+
console.print("[red]The onchainos client is not installed.[/red]")
|
|
332
|
+
console.print("It holds the wallet that pays. Install it, then run this again.")
|
|
333
|
+
raise typer.Exit(code=1)
|
|
334
|
+
|
|
335
|
+
subject = (
|
|
336
|
+
subject
|
|
337
|
+
or questionary.text(
|
|
338
|
+
"What should it be?", default="an enamel camping mug with an embossed wave crest"
|
|
339
|
+
).ask()
|
|
340
|
+
)
|
|
341
|
+
surface = surface or questionary.select("On what?", choices=list(SURFACES)).ask()
|
|
342
|
+
if not subject or not surface:
|
|
343
|
+
raise typer.Exit(code=1)
|
|
344
|
+
|
|
345
|
+
console.print(f"\n Planning [bold]{subject}[/bold] on a {surface}…")
|
|
346
|
+
plan = _pay_for(url, "plan_product_mockup", {"subject": subject, "surface": surface})
|
|
347
|
+
tier = plan.get("asset_tier", {})
|
|
348
|
+
price = tier.get("price")
|
|
349
|
+
console.print(
|
|
350
|
+
f" {plan.get('material')}, {plan.get('polygon_budget'):,} polygons, "
|
|
351
|
+
f"[bold]{price} {tier.get('currency')}[/bold] for the model."
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
if not yes and not questionary.confirm(f"Order it for {price}?", default=True).ask():
|
|
355
|
+
console.print(" Nothing ordered, nothing charged.")
|
|
356
|
+
return
|
|
357
|
+
|
|
358
|
+
ordered = _pay_for(url, "order_product_mockup", {"subject": subject, "surface": surface})
|
|
359
|
+
job_id = ordered.get("job_id")
|
|
360
|
+
if not job_id:
|
|
361
|
+
console.print("[red]The service did not return a job id.[/red]")
|
|
362
|
+
raise typer.Exit(code=1)
|
|
363
|
+
console.print(f" Ordered: [dim]{job_id}[/dim]")
|
|
364
|
+
|
|
365
|
+
view = _await_ready(url, str(job_id))
|
|
366
|
+
if view.get("state") != "ready":
|
|
367
|
+
failure = view.get("failure") or {}
|
|
368
|
+
console.print(f"\n [red]{failure.get('message') or 'The job did not finish.'}[/red]")
|
|
369
|
+
raise typer.Exit(code=1)
|
|
370
|
+
|
|
371
|
+
into.mkdir(parents=True, exist_ok=True)
|
|
372
|
+
asset = view.get("asset") or {}
|
|
373
|
+
saved = [
|
|
374
|
+
_download(str(asset[field]), into / f"{job_id}{suffix}")
|
|
375
|
+
for field, suffix in (("model_url", ".glb"), ("turntable_url", "-turntable.webp"))
|
|
376
|
+
if asset.get(field)
|
|
377
|
+
]
|
|
378
|
+
console.print("\n [green]Ready.[/green]")
|
|
379
|
+
for path, size in saved:
|
|
380
|
+
console.print(f" {path} [dim]{size:,} bytes[/dim]")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _pay_for(url: str, tool: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
384
|
+
"""Call a priced tool, letting the wallet handle the 402.
|
|
385
|
+
|
|
386
|
+
Two steps because that is what the client exposes: quote turns the
|
|
387
|
+
challenge into a payment id, pay signs it and replays the call.
|
|
388
|
+
"""
|
|
389
|
+
|
|
390
|
+
quote = ["onchainos", "payment", "quote", url, "--tool", tool]
|
|
391
|
+
for name, value in arguments.items():
|
|
392
|
+
quote += ["--param", f"{name}={value}"]
|
|
393
|
+
payment_id = _last_json(_run(quote)).get("data", {}).get("paymentId")
|
|
394
|
+
if not payment_id:
|
|
395
|
+
console.print(f"[red]The wallet could not quote {tool}.[/red]")
|
|
396
|
+
raise typer.Exit(code=1)
|
|
397
|
+
|
|
398
|
+
paid = _last_json(_run(["onchainos", "payment", "pay", "--payment-id", payment_id, "--yes"]))
|
|
399
|
+
if not paid.get("ok"):
|
|
400
|
+
console.print(f"[red]Payment failed:[/red] {paid.get('error') or paid}")
|
|
401
|
+
raise typer.Exit(code=1)
|
|
402
|
+
result = (paid.get("data") or {}).get("result") or {}
|
|
403
|
+
content = result.get("structuredContent")
|
|
404
|
+
if not isinstance(content, dict):
|
|
405
|
+
console.print(f"[red]{tool} returned nothing usable.[/red]")
|
|
406
|
+
raise typer.Exit(code=1)
|
|
407
|
+
return content
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _run(command: list[str]) -> str:
|
|
411
|
+
import subprocess
|
|
412
|
+
|
|
413
|
+
finished = subprocess.run(command, capture_output=True, text=True, timeout=180)
|
|
414
|
+
if finished.returncode != 0 and not finished.stdout.strip():
|
|
415
|
+
console.print(f"[red]{' '.join(command[:3])} failed:[/red] {finished.stderr.strip()[:200]}")
|
|
416
|
+
raise typer.Exit(code=1)
|
|
417
|
+
return finished.stdout
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _last_json(output: str) -> dict[str, Any]:
|
|
421
|
+
for line in reversed(output.strip().splitlines()):
|
|
422
|
+
try:
|
|
423
|
+
parsed = json.loads(line)
|
|
424
|
+
except ValueError:
|
|
425
|
+
continue
|
|
426
|
+
if isinstance(parsed, dict):
|
|
427
|
+
return parsed
|
|
428
|
+
return {}
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _await_ready(url: str, job_id: str, *, ceiling_seconds: int = 600) -> dict[str, Any]:
|
|
432
|
+
"""Poll until the job settles. Polling is free, so the wait costs nothing."""
|
|
433
|
+
import time
|
|
434
|
+
|
|
435
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
436
|
+
|
|
437
|
+
deadline = time.monotonic() + ceiling_seconds
|
|
438
|
+
view: dict[str, Any] = {}
|
|
439
|
+
# Silent when the output is not a terminal: a spinner redrawing into a log
|
|
440
|
+
# file writes thousands of lines of escape codes over one useful one.
|
|
441
|
+
with Progress(
|
|
442
|
+
SpinnerColumn(),
|
|
443
|
+
TextColumn("{task.description}"),
|
|
444
|
+
console=console,
|
|
445
|
+
transient=True,
|
|
446
|
+
disable=not console.is_terminal,
|
|
447
|
+
) as progress:
|
|
448
|
+
task = progress.add_task(" queued — waiting for payment to settle on-chain")
|
|
449
|
+
while time.monotonic() < deadline:
|
|
450
|
+
body = _rpc(
|
|
451
|
+
url, "tools/call", {"name": "check_product_mockup", "arguments": {"job_id": job_id}}
|
|
452
|
+
)
|
|
453
|
+
view = (body.get("result") or {}).get("structuredContent") or {}
|
|
454
|
+
state = view.get("state", "unknown")
|
|
455
|
+
if state in {"ready", "failed"}:
|
|
456
|
+
progress.update(task, description=f" {state}")
|
|
457
|
+
return view
|
|
458
|
+
progress.update(task, description=f" {state}")
|
|
459
|
+
time.sleep(max(1, int(view.get("retry_after_seconds") or 5)))
|
|
460
|
+
return view
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _download(url: str, destination: Path) -> tuple[Path, int]:
|
|
464
|
+
with httpx.stream("GET", url, timeout=120, follow_redirects=True) as response:
|
|
465
|
+
response.raise_for_status()
|
|
466
|
+
with destination.open("wb") as sink:
|
|
467
|
+
for chunk in response.iter_bytes():
|
|
468
|
+
sink.write(chunk)
|
|
469
|
+
return destination, destination.stat().st_size
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
@app.command("credits")
|
|
473
|
+
def credits(
|
|
474
|
+
keys: Annotated[
|
|
475
|
+
str | None,
|
|
476
|
+
typer.Option(help="Comma-separated keys. Defaults to ONDARU_GENERATION_API_KEYS."),
|
|
477
|
+
] = None,
|
|
478
|
+
) -> None:
|
|
479
|
+
"""Show what each generation account has left, and how many assets that buys.
|
|
480
|
+
|
|
481
|
+
Worth running before a demo. One account holds 600 credits, which is 30
|
|
482
|
+
assets, and discovering that mid-order means a paying caller waits for a
|
|
483
|
+
job that cannot start.
|
|
484
|
+
"""
|
|
485
|
+
import asyncio
|
|
486
|
+
import os
|
|
487
|
+
|
|
488
|
+
from ondaru.config import GenerationConfig
|
|
489
|
+
from ondaru.generation.tripo import (
|
|
490
|
+
CREDITS_PER_ASSET,
|
|
491
|
+
DEFAULT_BASE_URL,
|
|
492
|
+
TripoGenerationBackend,
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
listed = keys or os.environ.get("ONDARU_GENERATION_API_KEYS", "")
|
|
496
|
+
resolved = tuple(k.strip() for k in listed.split(",") if k.strip()) or (
|
|
497
|
+
os.environ.get("ONDARU_GENERATION_API_KEY", ""),
|
|
498
|
+
)
|
|
499
|
+
if not any(resolved):
|
|
500
|
+
console.print("[red]No keys.[/red] Set ONDARU_GENERATION_API_KEYS or pass --keys.")
|
|
501
|
+
raise typer.Exit(code=1)
|
|
502
|
+
|
|
503
|
+
config = GenerationConfig(
|
|
504
|
+
vendor="tripo",
|
|
505
|
+
base_url=os.environ.get("ONDARU_GENERATION_BASE_URL") or DEFAULT_BASE_URL,
|
|
506
|
+
api_keys=resolved,
|
|
507
|
+
submit_timeout_seconds=20,
|
|
508
|
+
poll_interval_seconds=5,
|
|
509
|
+
poll_deadline_seconds=900,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
async def read() -> list[tuple[str, int]]:
|
|
513
|
+
backend = TripoGenerationBackend.open(config)
|
|
514
|
+
try:
|
|
515
|
+
return await backend.balances()
|
|
516
|
+
finally:
|
|
517
|
+
await backend.aclose()
|
|
518
|
+
|
|
519
|
+
readings = asyncio.run(read())
|
|
520
|
+
table = Table(title="Generation accounts", box=None)
|
|
521
|
+
table.add_column("Key")
|
|
522
|
+
table.add_column("Credits", justify="right")
|
|
523
|
+
table.add_column("Assets", justify="right")
|
|
524
|
+
total = 0
|
|
525
|
+
for masked, balance in readings:
|
|
526
|
+
if balance < 0:
|
|
527
|
+
table.add_row(masked, "[yellow]unreadable[/yellow]", "—")
|
|
528
|
+
continue
|
|
529
|
+
total += balance
|
|
530
|
+
style = "" if balance >= CREDITS_PER_ASSET else "[dim]"
|
|
531
|
+
table.add_row(
|
|
532
|
+
f"{style}{masked}", f"{style}{balance:,}", f"{style}{balance // CREDITS_PER_ASSET}"
|
|
533
|
+
)
|
|
534
|
+
console.print(table)
|
|
535
|
+
console.print(
|
|
536
|
+
f"\n {total:,} credits · [bold]{total // CREDITS_PER_ASSET} assets[/bold] "
|
|
537
|
+
f"· roughly ${total * 0.01:,.2f} of generation"
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _ask_host(question: str) -> str:
|
|
542
|
+
answer = questionary.select(question, choices=list(HOSTS)).ask()
|
|
543
|
+
if answer is None:
|
|
544
|
+
raise typer.Abort()
|
|
545
|
+
return str(answer)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
549
|
+
if not path.exists():
|
|
550
|
+
return {}
|
|
551
|
+
try:
|
|
552
|
+
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
553
|
+
except json.JSONDecodeError as exc:
|
|
554
|
+
raise typer.BadParameter(f"{path} is not valid JSON: {exc}") from exc
|
|
555
|
+
return dict(loaded) if isinstance(loaded, dict) else {}
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _rpc(url: str, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
559
|
+
headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
|
|
560
|
+
with httpx.Client(timeout=60, follow_redirects=True) as client:
|
|
561
|
+
client.post(
|
|
562
|
+
url,
|
|
563
|
+
headers=headers,
|
|
564
|
+
json={
|
|
565
|
+
"jsonrpc": "2.0",
|
|
566
|
+
"id": 1,
|
|
567
|
+
"method": "initialize",
|
|
568
|
+
"params": {
|
|
569
|
+
"protocolVersion": "2025-06-18",
|
|
570
|
+
"capabilities": {},
|
|
571
|
+
"clientInfo": {"name": "ondaru-cli", "version": "0.1.0"},
|
|
572
|
+
},
|
|
573
|
+
},
|
|
574
|
+
)
|
|
575
|
+
response = client.post(
|
|
576
|
+
url,
|
|
577
|
+
headers=headers,
|
|
578
|
+
json={"jsonrpc": "2.0", "id": 2, "method": method, "params": params},
|
|
579
|
+
)
|
|
580
|
+
text = response.text
|
|
581
|
+
if "data:" in text:
|
|
582
|
+
for line in text.splitlines():
|
|
583
|
+
if line.startswith("data:"):
|
|
584
|
+
text = line[5:].strip()
|
|
585
|
+
break
|
|
586
|
+
try:
|
|
587
|
+
return dict(json.loads(text))
|
|
588
|
+
except json.JSONDecodeError:
|
|
589
|
+
return {"raw": text[:600], "status": response.status_code}
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
if __name__ == "__main__":
|
|
593
|
+
app()
|