Files
aitbc/cli/aitbc_cli/commands/client.py
oib 15427c96c0 chore: update file permissions to executable across repository
- Change file mode from 644 to 755 for all project files
- Add chain_id parameter to get_balance RPC endpoint with default "ait-devnet"
- Rename Miner.extra_meta_data to extra_metadata for consistency
2026-03-06 22:17:54 +01:00

565 lines
20 KiB
Python
Executable File

"""Client commands for AITBC CLI"""
import click
import httpx
import json
import time
from typing import Optional
from ..utils import output, error, success
@click.group()
@click.pass_context
def client(ctx):
"""Submit and manage jobs"""
# Set role for client commands
ctx.ensure_object(dict)
ctx.parent.detected_role = 'client'
@client.command()
@click.option("--type", "job_type", default="inference", help="Job type")
@click.option("--prompt", help="Prompt for inference jobs")
@click.option("--model", help="Model name")
@click.option("--ttl", default=900, help="Time to live in seconds")
@click.option("--file", type=click.File('r'), help="Submit job from JSON file")
@click.option("--retries", default=0, help="Number of retry attempts (0 = no retry)")
@click.option("--retry-delay", default=1.0, help="Initial retry delay in seconds")
@click.pass_context
def submit(ctx, job_type: str, prompt: Optional[str], model: Optional[str],
ttl: int, file, retries: int, retry_delay: float):
"""Submit a job to the coordinator"""
config = ctx.obj['config']
# Build job data
if file:
try:
task_data = json.load(file)
except Exception as e:
error(f"Failed to read job file: {e}")
return
else:
task_data = {"type": job_type}
if prompt:
task_data["prompt"] = prompt
if model:
task_data["model"] = model
# Submit job with retry and exponential backoff
max_attempts = retries + 1
for attempt in range(1, max_attempts + 1):
try:
with httpx.Client() as client:
# Use correct API endpoint format
response = client.post(
f"{config.coordinator_url}/v1/jobs",
headers={
"Content-Type": "application/json",
"X-Api-Key": config.api_key or ""
},
json={
"payload": task_data,
"ttl_seconds": ttl
},
timeout=10.0
)
if response.status_code in [200, 201]:
job = response.json()
result = {
"job_id": job.get('job_id'),
"status": "submitted",
"message": "Job submitted successfully"
}
if attempt > 1:
result["attempts"] = attempt
output(result, ctx.obj['output_format'])
return
else:
if attempt < max_attempts:
delay = retry_delay * (2 ** (attempt - 1))
click.echo(f"Attempt {attempt}/{max_attempts} failed ({response.status_code}), retrying in {delay:.1f}s...")
time.sleep(delay)
else:
error(f"Failed to submit job: {response.status_code} - {response.text}")
ctx.exit(response.status_code)
except Exception as e:
if attempt < max_attempts:
delay = retry_delay * (2 ** (attempt - 1))
click.echo(f"Attempt {attempt}/{max_attempts} failed ({e}), retrying in {delay:.1f}s...")
time.sleep(delay)
else:
error(f"Network error after {max_attempts} attempts: {e}")
ctx.exit(1)
@client.command()
@click.argument("job_id")
@click.pass_context
def status(ctx, job_id: str):
"""Check job status"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/jobs/{job_id}",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
data = response.json()
output(data, ctx.obj['output_format'])
else:
error(f"Failed to get job status: {response.status_code}")
ctx.exit(1)
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@client.command()
@click.option("--limit", default=10, help="Number of blocks to show")
@click.option('--chain-id', help='Specific chain ID to query (default: ait-devnet)')
@click.pass_context
def blocks(ctx, limit: int, chain_id: str):
"""List recent blocks from specific chain"""
config = ctx.obj['config']
# Query specific chain (default to ait-devnet if not specified)
target_chain = chain_id or 'ait-devnet'
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/api/v1/blocks",
params={"limit": limit, "chain_id": target_chain},
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
blocks = response.json()
output({
"blocks": blocks,
"chain_id": target_chain,
"limit": limit,
"query_type": "single_chain"
}, ctx.obj['output_format'])
else:
error(f"Failed to get blocks from chain {target_chain}: {response.status_code}")
ctx.exit(1)
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@client.command()
@click.argument("job_id")
@click.pass_context
def cancel(ctx, job_id: str):
"""Cancel a job"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.post(
f"{config.coordinator_url}/v1/jobs/{job_id}/cancel",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
success(f"Job {job_id} cancelled")
else:
error(f"Failed to cancel job: {response.status_code}")
ctx.exit(1)
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@client.command()
@click.argument("job_id")
@click.option("--wait", is_flag=True, help="Wait for job to complete before showing result")
@click.option("--timeout", type=int, default=120, help="Max wait time in seconds")
@click.pass_context
def result(ctx, job_id: str, wait: bool, timeout: int):
"""Retrieve the result of a completed job"""
config = ctx.obj['config']
start = time.time()
while True:
try:
with httpx.Client() as http:
# Try the dedicated result endpoint first
response = http.get(
f"{config.coordinator_url}/v1/jobs/{job_id}/result",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
result_data = response.json()
success(f"Job {job_id} completed")
output(result_data, ctx.obj['output_format'])
return
elif response.status_code == 425:
# Job not ready yet
if wait and (time.time() - start) < timeout:
time.sleep(3)
continue
# Check status for more info
status_resp = http.get(
f"{config.coordinator_url}/v1/jobs/{job_id}",
headers={"X-Api-Key": config.api_key or ""}
)
if status_resp.status_code == 200:
job_data = status_resp.json()
output({"job_id": job_id, "state": job_data.get("state", "UNKNOWN"), "message": "Job not yet completed"}, ctx.obj['output_format'])
else:
error(f"Job not ready (425)")
return
elif response.status_code == 404:
error(f"Job {job_id} not found")
return
else:
error(f"Failed to get result: {response.status_code}")
return
except Exception as e:
error(f"Network error: {e}")
return
@client.command()
@click.option("--limit", default=10, help="Number of receipts to show")
@click.option("--job-id", help="Filter by job ID")
@click.option("--status", help="Filter by status")
@click.pass_context
def receipts(ctx, limit: int, job_id: Optional[str], status: Optional[str]):
"""List job receipts"""
config = ctx.obj['config']
try:
params = {"limit": limit}
if job_id:
params["job_id"] = job_id
if status:
params["status"] = status
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/explorer/receipts",
params=params,
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
receipts = response.json()
output(receipts, ctx.obj['output_format'])
else:
error(f"Failed to get receipts: {response.status_code}")
ctx.exit(1)
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@client.command()
@click.option("--limit", default=10, help="Number of jobs to show")
@click.option("--status", help="Filter by status (pending, running, completed, failed)")
@click.option("--type", help="Filter by job type")
@click.option("--from-time", help="Filter jobs from this timestamp (ISO format)")
@click.option("--to-time", help="Filter jobs until this timestamp (ISO format)")
@click.pass_context
def history(ctx, limit: int, status: Optional[str], type: Optional[str],
from_time: Optional[str], to_time: Optional[str]):
"""Show job history with filtering options"""
config = ctx.obj['config']
try:
params = {"limit": limit}
if status:
params["status"] = status
if type:
params["type"] = type
if from_time:
params["from_time"] = from_time
if to_time:
params["to_time"] = to_time
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/jobs",
params=params,
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
jobs = response.json()
output(jobs, ctx.obj['output_format'])
else:
error(f"Failed to get job history: {response.status_code}")
ctx.exit(1)
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@client.command(name="batch-submit")
@click.argument("file_path", type=click.Path(exists=True))
@click.option("--format", "file_format", type=click.Choice(["json", "csv"]), default=None, help="File format (auto-detected if not specified)")
@click.option("--retries", default=0, help="Retry attempts per job")
@click.option("--delay", default=0.5, help="Delay between submissions (seconds)")
@click.pass_context
def batch_submit(ctx, file_path: str, file_format: Optional[str], retries: int, delay: float):
"""Submit multiple jobs from a CSV or JSON file"""
import csv
from pathlib import Path
from ..utils import progress_bar
config = ctx.obj['config']
path = Path(file_path)
if not file_format:
file_format = "csv" if path.suffix.lower() == ".csv" else "json"
jobs_data = []
if file_format == "json":
with open(path) as f:
data = json.load(f)
jobs_data = data if isinstance(data, list) else [data]
else:
with open(path) as f:
reader = csv.DictReader(f)
jobs_data = list(reader)
if not jobs_data:
error("No jobs found in file")
return
results = {"submitted": 0, "failed": 0, "job_ids": []}
with progress_bar("Submitting jobs...", total=len(jobs_data)) as (progress, task):
for i, job in enumerate(jobs_data):
try:
task_data = {"type": job.get("type", "inference")}
if "prompt" in job:
task_data["prompt"] = job["prompt"]
if "model" in job:
task_data["model"] = job["model"]
with httpx.Client() as http_client:
response = http_client.post(
f"{config.coordinator_url}/v1/jobs",
headers={
"Content-Type": "application/json",
"X-Api-Key": config.api_key or ""
},
json={"payload": task_data, "ttl_seconds": int(job.get("ttl", 900))}
)
if response.status_code == 201:
result = response.json()
results["submitted"] += 1
results["job_ids"].append(result.get("job_id"))
else:
results["failed"] += 1
except Exception:
results["failed"] += 1
progress.update(task, advance=1)
if delay and i < len(jobs_data) - 1:
time.sleep(delay)
output(results, ctx.obj['output_format'])
@client.command(name="template")
@click.argument("action", type=click.Choice(["save", "list", "run", "delete"]))
@click.option("--name", help="Template name")
@click.option("--type", "job_type", help="Job type")
@click.option("--prompt", help="Prompt text")
@click.option("--model", help="Model name")
@click.option("--ttl", type=int, default=900, help="TTL in seconds")
@click.pass_context
def template(ctx, action: str, name: Optional[str], job_type: Optional[str],
prompt: Optional[str], model: Optional[str], ttl: int):
"""Manage job templates for repeated tasks"""
from pathlib import Path
template_dir = Path.home() / ".aitbc" / "templates"
template_dir.mkdir(parents=True, exist_ok=True)
if action == "save":
if not name:
error("Template name required (--name)")
return
template_data = {"type": job_type or "inference", "ttl": ttl}
if prompt:
template_data["prompt"] = prompt
if model:
template_data["model"] = model
with open(template_dir / f"{name}.json", "w") as f:
json.dump(template_data, f, indent=2)
output({"status": "saved", "name": name, "template": template_data}, ctx.obj['output_format'])
elif action == "list":
templates = []
for tf in template_dir.glob("*.json"):
with open(tf) as f:
data = json.load(f)
templates.append({"name": tf.stem, **data})
output(templates if templates else {"message": "No templates found"}, ctx.obj['output_format'])
elif action == "run":
if not name:
error("Template name required (--name)")
return
tf = template_dir / f"{name}.json"
if not tf.exists():
error(f"Template '{name}' not found")
return
with open(tf) as f:
tmpl = json.load(f)
if prompt:
tmpl["prompt"] = prompt
if model:
tmpl["model"] = model
ctx.invoke(submit, job_type=tmpl.get("type", "inference"),
prompt=tmpl.get("prompt"), model=tmpl.get("model"),
ttl=tmpl.get("ttl", 900), file=None, retries=0, retry_delay=1.0)
elif action == "delete":
if not name:
error("Template name required (--name)")
return
tf = template_dir / f"{name}.json"
if not tf.exists():
error(f"Template '{name}' not found")
return
tf.unlink()
output({"status": "deleted", "name": name}, ctx.obj['output_format'])
@client.command(name="pay")
@click.argument("job_id")
@click.argument("amount", type=float)
@click.option("--currency", default="AITBC", help="Payment currency")
@click.option("--method", "payment_method", default="aitbc_token", type=click.Choice(["aitbc_token", "bitcoin"]), help="Payment method")
@click.option("--escrow-timeout", type=int, default=3600, help="Escrow timeout in seconds")
@click.pass_context
def pay(ctx, job_id: str, amount: float, currency: str, payment_method: str, escrow_timeout: int):
"""Create a payment for a job"""
config = ctx.obj['config']
try:
with httpx.Client() as http_client:
response = http_client.post(
f"{config.coordinator_url}/v1/payments",
headers={
"Content-Type": "application/json",
"X-Api-Key": config.api_key or ""
},
json={
"job_id": job_id,
"amount": amount,
"currency": currency,
"payment_method": payment_method,
"escrow_timeout_seconds": escrow_timeout
}
)
if response.status_code == 201:
result = response.json()
success(f"Payment created for job {job_id}")
output(result, ctx.obj['output_format'])
else:
error(f"Payment failed: {response.status_code} - {response.text}")
ctx.exit(1)
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@client.command(name="payment-status")
@click.argument("job_id")
@click.pass_context
def payment_status(ctx, job_id: str):
"""Get payment status for a job"""
config = ctx.obj['config']
try:
with httpx.Client() as http_client:
response = http_client.get(
f"{config.coordinator_url}/v1/jobs/{job_id}/payment",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
output(response.json(), ctx.obj['output_format'])
elif response.status_code == 404:
error(f"No payment found for job {job_id}")
ctx.exit(1)
else:
error(f"Failed: {response.status_code}")
ctx.exit(1)
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@client.command(name="payment-receipt")
@click.argument("payment_id")
@click.pass_context
def payment_receipt(ctx, payment_id: str):
"""Get payment receipt with verification"""
config = ctx.obj['config']
try:
with httpx.Client() as http_client:
response = http_client.get(
f"{config.coordinator_url}/v1/payments/{payment_id}/receipt",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
output(response.json(), ctx.obj['output_format'])
elif response.status_code == 404:
error(f"Payment '{payment_id}' not found")
ctx.exit(1)
else:
error(f"Failed: {response.status_code}")
ctx.exit(1)
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@client.command(name="refund")
@click.argument("job_id")
@click.argument("payment_id")
@click.option("--reason", required=True, help="Reason for refund")
@click.pass_context
def refund(ctx, job_id: str, payment_id: str, reason: str):
"""Request a refund for a payment"""
config = ctx.obj['config']
try:
with httpx.Client() as http_client:
response = http_client.post(
f"{config.coordinator_url}/v1/payments/{payment_id}/refund",
headers={
"Content-Type": "application/json",
"X-Api-Key": config.api_key or ""
},
json={
"job_id": job_id,
"payment_id": payment_id,
"reason": reason
}
)
if response.status_code == 200:
result = response.json()
success(f"Refund processed for payment {payment_id}")
output(result, ctx.obj['output_format'])
else:
error(f"Refund failed: {response.status_code} - {response.text}")
ctx.exit(1)
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)