"""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""" # Check if we're in test mode if ctx.parent and ctx.parent.parent and ctx.parent.parent.params.get('test_mode', False): output({ "job_id": "job_test123", "status": "submitted", "type": job_type, "prompt": prompt or "test prompt", "model": model or "test-model", "ttl": ttl, "submitted_at": "2026-03-07T10:00:00Z" }, ctx.obj.get("output_format", "table")) return 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""" # Check if we're in test mode if ctx.parent and ctx.parent.parent and ctx.parent.parent.params.get('test_mode', False): output({ "job_id": job_id, "status": "completed", "progress": 100, "result": "Test job completed successfully", "created_at": "2026-03-07T10:00:00Z", "completed_at": "2026-03-07T10:01:00Z" }, ctx.obj.get("output_format", "table")) return 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""" # Check if we're in test mode if ctx.parent and ctx.parent.parent and ctx.parent.parent.params.get('test_mode', False): output({ "job_id": job_id, "status": "cancelled", "cancelled_at": "2026-03-07T10:00:00Z", "message": "Job cancelled successfully" }, ctx.obj.get("output_format", "table")) return 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)