551 lines
20 KiB
Python
551 lines
20 KiB
Python
"""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()
|
|
def client():
|
|
"""Submit and manage jobs"""
|
|
pass
|
|
|
|
|
|
@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:
|
|
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
|
|
}
|
|
)
|
|
|
|
if response.status_code == 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.pass_context
|
|
def blocks(ctx, limit: int):
|
|
"""List recent blocks"""
|
|
config = ctx.obj['config']
|
|
|
|
try:
|
|
with httpx.Client() as client:
|
|
response = client.get(
|
|
f"{config.coordinator_url}/v1/explorer/blocks",
|
|
params={"limit": limit},
|
|
headers={"X-Api-Key": config.api_key or ""}
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
blocks = response.json()
|
|
output(blocks, ctx.obj['output_format'])
|
|
else:
|
|
error(f"Failed to get blocks: {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)
|