- Add role detection to command groups (admin, client, miner, blockchain)
- Load role-specific config files (~/.aitbc/{role}-config.yaml)
- Add role field to Config class with environment variable support
- Implement automatic role detection from invoked subcommand
- Add development mode API key bypass for testing (APP_ENV=dev)
- Update CLI checklist with role-based configuration documentation
- Add configuration override priority and
638 lines
23 KiB
Python
638 lines
23 KiB
Python
"""Miner commands for AITBC CLI"""
|
|
|
|
import click
|
|
import httpx
|
|
import json
|
|
import time
|
|
import concurrent.futures
|
|
from typing import Optional, Dict, Any, List
|
|
from ..utils import output, error, success
|
|
|
|
|
|
@click.group(invoke_without_command=True)
|
|
@click.pass_context
|
|
def miner(ctx):
|
|
"""Register as miner and process jobs"""
|
|
# Set role for miner commands - this will be used by parent context
|
|
ctx.ensure_object(dict)
|
|
# Set role at the highest level context (CLI root)
|
|
ctx.find_root().detected_role = 'miner'
|
|
|
|
# If no subcommand was invoked, show help
|
|
if ctx.invoked_subcommand is None:
|
|
click.echo(ctx.get_help())
|
|
|
|
|
|
@miner.command()
|
|
@click.option("--gpu", help="GPU model name")
|
|
@click.option("--memory", type=int, help="GPU memory in GB")
|
|
@click.option("--cuda-cores", type=int, help="Number of CUDA cores")
|
|
@click.option("--miner-id", default="cli-miner", help="Miner ID")
|
|
@click.pass_context
|
|
def register(ctx, gpu: Optional[str], memory: Optional[int],
|
|
cuda_cores: Optional[int], miner_id: str):
|
|
"""Register as a miner with the coordinator"""
|
|
config = ctx.obj['config']
|
|
|
|
# Build capabilities
|
|
capabilities = {}
|
|
if gpu:
|
|
capabilities["gpu"] = {"model": gpu}
|
|
if memory:
|
|
if "gpu" not in capabilities:
|
|
capabilities["gpu"] = {}
|
|
capabilities["gpu"]["memory_gb"] = memory
|
|
if cuda_cores:
|
|
if "gpu" not in capabilities:
|
|
capabilities["gpu"] = {}
|
|
capabilities["gpu"]["cuda_cores"] = cuda_cores
|
|
|
|
# Default capabilities if none provided
|
|
if not capabilities:
|
|
capabilities = {
|
|
"cpu": {"cores": 4},
|
|
"memory": {"gb": 16}
|
|
}
|
|
|
|
try:
|
|
with httpx.Client() as client:
|
|
response = client.post(
|
|
f"{config.coordinator_url}/v1/miners/register",
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"X-Api-Key": config.api_key or "",
|
|
"X-Miner-ID": miner_id
|
|
},
|
|
json={"capabilities": capabilities}
|
|
)
|
|
|
|
if response.status_code in (200, 204):
|
|
output({
|
|
"miner_id": miner_id,
|
|
"status": "registered",
|
|
"capabilities": capabilities
|
|
}, ctx.obj['output_format'])
|
|
else:
|
|
error(f"Failed to register: {response.status_code} - {response.text}")
|
|
except Exception as e:
|
|
error(f"Network error: {e}")
|
|
|
|
|
|
@miner.command()
|
|
@click.option("--wait", type=int, default=5, help="Max wait time in seconds")
|
|
@click.option("--miner-id", default="cli-miner", help="Miner ID")
|
|
@click.pass_context
|
|
def poll(ctx, wait: int, miner_id: str):
|
|
"""Poll for a single job"""
|
|
config = ctx.obj['config']
|
|
|
|
try:
|
|
with httpx.Client() as client:
|
|
response = client.post(
|
|
f"{config.coordinator_url}/v1/miners/poll",
|
|
json={"max_wait_seconds": 5},
|
|
headers={
|
|
"X-Api-Key": config.api_key or "",
|
|
"X-Miner-ID": miner_id
|
|
},
|
|
timeout=wait + 5
|
|
)
|
|
|
|
if response.status_code in (200, 204):
|
|
if response.status_code == 204:
|
|
output({"message": "No jobs available"}, ctx.obj['output_format'])
|
|
else:
|
|
job = response.json()
|
|
if job:
|
|
output(job, ctx.obj['output_format'])
|
|
else:
|
|
output({"message": "No jobs available"}, ctx.obj['output_format'])
|
|
else:
|
|
error(f"Failed to poll: {response.status_code}")
|
|
except httpx.TimeoutException:
|
|
output({"message": f"No jobs available within {wait} seconds"}, ctx.obj['output_format'])
|
|
except Exception as e:
|
|
error(f"Network error: {e}")
|
|
|
|
|
|
@miner.command()
|
|
@click.option("--jobs", type=int, default=1, help="Number of jobs to process")
|
|
@click.option("--miner-id", default="cli-miner", help="Miner ID")
|
|
@click.pass_context
|
|
def mine(ctx, jobs: int, miner_id: str):
|
|
"""Mine continuously for specified number of jobs"""
|
|
config = ctx.obj['config']
|
|
|
|
processed = 0
|
|
while processed < jobs:
|
|
try:
|
|
with httpx.Client() as client:
|
|
# Poll for job
|
|
response = client.post(
|
|
f"{config.coordinator_url}/v1/miners/poll",
|
|
json={"max_wait_seconds": 5},
|
|
headers={
|
|
"X-Api-Key": config.api_key or "",
|
|
"X-Miner-ID": miner_id
|
|
},
|
|
timeout=30
|
|
)
|
|
|
|
if response.status_code in (200, 204):
|
|
if response.status_code == 204:
|
|
time.sleep(5)
|
|
continue
|
|
job = response.json()
|
|
if job:
|
|
job_id = job.get('job_id')
|
|
output({
|
|
"job_id": job_id,
|
|
"status": "processing",
|
|
"job_number": processed + 1
|
|
}, ctx.obj['output_format'])
|
|
|
|
# Simulate processing (in real implementation, do actual work)
|
|
time.sleep(2)
|
|
|
|
# Submit result
|
|
result_response = client.post(
|
|
f"{config.coordinator_url}/v1/miners/{job_id}/result",
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"X-Api-Key": config.api_key or "",
|
|
"X-Miner-ID": miner_id
|
|
},
|
|
json={
|
|
"result": {"output": f"Processed job {job_id}"},
|
|
"metrics": {}
|
|
}
|
|
)
|
|
|
|
if result_response.status_code == 200:
|
|
success(f"Job {job_id} completed successfully")
|
|
processed += 1
|
|
else:
|
|
error(f"Failed to submit result: {result_response.status_code}")
|
|
else:
|
|
# No job available, wait a bit
|
|
time.sleep(5)
|
|
else:
|
|
error(f"Failed to poll: {response.status_code}")
|
|
break
|
|
|
|
except Exception as e:
|
|
error(f"Error: {e}")
|
|
break
|
|
|
|
output({
|
|
"total_processed": processed,
|
|
"miner_id": miner_id
|
|
}, ctx.obj['output_format'])
|
|
|
|
|
|
@miner.command()
|
|
@click.option("--miner-id", default="cli-miner", help="Miner ID")
|
|
@click.pass_context
|
|
def heartbeat(ctx, miner_id: str):
|
|
"""Send heartbeat to coordinator"""
|
|
config = ctx.obj['config']
|
|
|
|
try:
|
|
with httpx.Client() as client:
|
|
response = client.post(
|
|
f"{config.coordinator_url}/v1/miners/heartbeat",
|
|
headers={
|
|
"X-Api-Key": config.api_key or "",
|
|
"X-Miner-ID": miner_id
|
|
},
|
|
json={
|
|
"inflight": 0,
|
|
"status": "ONLINE",
|
|
"metadata": {}
|
|
}
|
|
)
|
|
|
|
if response.status_code in (200, 204):
|
|
output({
|
|
"miner_id": miner_id,
|
|
"status": "heartbeat_sent",
|
|
"timestamp": time.time()
|
|
}, ctx.obj['output_format'])
|
|
else:
|
|
error(f"Failed to send heartbeat: {response.status_code}")
|
|
except Exception as e:
|
|
error(f"Network error: {e}")
|
|
|
|
|
|
@miner.command()
|
|
@click.option("--miner-id", default="cli-miner", help="Miner ID")
|
|
@click.pass_context
|
|
def status(ctx, miner_id: str):
|
|
"""Check miner status"""
|
|
config = ctx.obj['config']
|
|
|
|
# This would typically query a miner status endpoint
|
|
# For now, we'll just show the miner info
|
|
output({
|
|
"miner_id": miner_id,
|
|
"coordinator": config.coordinator_url,
|
|
"status": "active"
|
|
}, ctx.obj['output_format'])
|
|
|
|
|
|
@miner.command()
|
|
@click.option("--miner-id", default="cli-miner", help="Miner ID")
|
|
@click.option("--from-time", help="Filter from timestamp (ISO format)")
|
|
@click.option("--to-time", help="Filter to timestamp (ISO format)")
|
|
@click.pass_context
|
|
def earnings(ctx, miner_id: str, from_time: Optional[str], to_time: Optional[str]):
|
|
"""Show miner earnings"""
|
|
config = ctx.obj['config']
|
|
|
|
try:
|
|
params = {"miner_id": miner_id}
|
|
if from_time:
|
|
params["from_time"] = from_time
|
|
if to_time:
|
|
params["to_time"] = to_time
|
|
|
|
with httpx.Client() as client:
|
|
response = client.post(
|
|
f"{config.coordinator_url}/v1/miners/{miner_id}/earnings",
|
|
params=params,
|
|
headers={"X-Api-Key": config.api_key or ""}
|
|
)
|
|
|
|
if response.status_code in (200, 204):
|
|
data = response.json()
|
|
output(data, ctx.obj['output_format'])
|
|
else:
|
|
error(f"Failed to get earnings: {response.status_code}")
|
|
ctx.exit(1)
|
|
except Exception as e:
|
|
error(f"Network error: {e}")
|
|
ctx.exit(1)
|
|
|
|
|
|
@miner.command(name="update-capabilities")
|
|
@click.option("--gpu", help="GPU model name")
|
|
@click.option("--memory", type=int, help="GPU memory in GB")
|
|
@click.option("--cuda-cores", type=int, help="Number of CUDA cores")
|
|
@click.option("--miner-id", default="cli-miner", help="Miner ID")
|
|
@click.pass_context
|
|
def update_capabilities(ctx, gpu: Optional[str], memory: Optional[int],
|
|
cuda_cores: Optional[int], miner_id: str):
|
|
"""Update miner GPU capabilities"""
|
|
config = ctx.obj['config']
|
|
|
|
capabilities = {}
|
|
if gpu:
|
|
capabilities["gpu"] = {"model": gpu}
|
|
if memory:
|
|
if "gpu" not in capabilities:
|
|
capabilities["gpu"] = {}
|
|
capabilities["gpu"]["memory_gb"] = memory
|
|
if cuda_cores:
|
|
if "gpu" not in capabilities:
|
|
capabilities["gpu"] = {}
|
|
capabilities["gpu"]["cuda_cores"] = cuda_cores
|
|
|
|
if not capabilities:
|
|
error("No capabilities specified. Use --gpu, --memory, or --cuda-cores.")
|
|
return
|
|
|
|
try:
|
|
with httpx.Client() as client:
|
|
response = client.put(
|
|
f"{config.coordinator_url}/v1/miners/{miner_id}/capabilities",
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"X-Api-Key": config.api_key or ""
|
|
},
|
|
json={"capabilities": capabilities}
|
|
)
|
|
|
|
if response.status_code in (200, 204):
|
|
output({
|
|
"miner_id": miner_id,
|
|
"status": "capabilities_updated",
|
|
"capabilities": capabilities
|
|
}, ctx.obj['output_format'])
|
|
else:
|
|
error(f"Failed to update capabilities: {response.status_code}")
|
|
ctx.exit(1)
|
|
except Exception as e:
|
|
error(f"Network error: {e}")
|
|
ctx.exit(1)
|
|
|
|
|
|
@miner.command()
|
|
@click.option("--miner-id", default="cli-miner", help="Miner ID")
|
|
@click.option("--force", is_flag=True, help="Force deregistration without confirmation")
|
|
@click.pass_context
|
|
def deregister(ctx, miner_id: str, force: bool):
|
|
"""Deregister miner from the coordinator"""
|
|
if not force:
|
|
if not click.confirm(f"Deregister miner '{miner_id}'?"):
|
|
click.echo("Cancelled.")
|
|
return
|
|
|
|
config = ctx.obj['config']
|
|
|
|
try:
|
|
with httpx.Client() as client:
|
|
response = client.delete(
|
|
f"{config.coordinator_url}/v1/miners/{miner_id}",
|
|
headers={"X-Api-Key": config.api_key or ""}
|
|
)
|
|
|
|
if response.status_code in (200, 204):
|
|
output({
|
|
"miner_id": miner_id,
|
|
"status": "deregistered"
|
|
}, ctx.obj['output_format'])
|
|
else:
|
|
error(f"Failed to deregister: {response.status_code}")
|
|
ctx.exit(1)
|
|
except Exception as e:
|
|
error(f"Network error: {e}")
|
|
ctx.exit(1)
|
|
|
|
|
|
@miner.command()
|
|
@click.option("--limit", default=10, help="Number of jobs to show")
|
|
@click.option("--type", "job_type", help="Filter by job type")
|
|
@click.option("--min-reward", type=float, help="Minimum reward threshold")
|
|
@click.option("--status", "job_status", help="Filter by status (pending, running, completed, failed)")
|
|
@click.option("--miner-id", default="cli-miner", help="Miner ID")
|
|
@click.pass_context
|
|
def jobs(ctx, limit: int, job_type: Optional[str], min_reward: Optional[float],
|
|
job_status: Optional[str], miner_id: str):
|
|
"""List miner jobs with filtering"""
|
|
config = ctx.obj['config']
|
|
|
|
try:
|
|
params = {"limit": limit, "miner_id": miner_id}
|
|
if job_type:
|
|
params["type"] = job_type
|
|
if min_reward is not None:
|
|
params["min_reward"] = min_reward
|
|
if job_status:
|
|
params["status"] = job_status
|
|
|
|
with httpx.Client() as client:
|
|
response = client.post(
|
|
f"{config.coordinator_url}/v1/miners/{miner_id}/jobs",
|
|
params=params,
|
|
headers={"X-Api-Key": config.api_key or ""}
|
|
)
|
|
|
|
if response.status_code in (200, 204):
|
|
data = response.json()
|
|
output(data, ctx.obj['output_format'])
|
|
else:
|
|
error(f"Failed to get jobs: {response.status_code}")
|
|
ctx.exit(1)
|
|
except Exception as e:
|
|
error(f"Network error: {e}")
|
|
ctx.exit(1)
|
|
|
|
|
|
def _process_single_job(config, miner_id: str, worker_id: int) -> Dict[str, Any]:
|
|
"""Process a single job (used by concurrent mine)"""
|
|
try:
|
|
with httpx.Client() as http_client:
|
|
response = http_client.post(
|
|
f"{config.coordinator_url}/v1/miners/poll",
|
|
json={"max_wait_seconds": 5},
|
|
headers={
|
|
"X-Api-Key": config.api_key or "",
|
|
"X-Miner-ID": miner_id
|
|
},
|
|
timeout=30
|
|
)
|
|
|
|
if response.status_code == 204:
|
|
return {"worker": worker_id, "status": "no_job"}
|
|
if response.status_code == 200:
|
|
job = response.json()
|
|
if job:
|
|
job_id = job.get('job_id')
|
|
time.sleep(2) # Simulate processing
|
|
|
|
result_response = http_client.post(
|
|
f"{config.coordinator_url}/v1/miners/{job_id}/result",
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"X-Api-Key": config.api_key or "",
|
|
"X-Miner-ID": miner_id
|
|
},
|
|
json={"result": {"output": f"Processed by worker {worker_id}"}, "metrics": {}}
|
|
)
|
|
|
|
return {
|
|
"worker": worker_id,
|
|
"job_id": job_id,
|
|
"status": "completed" if result_response.status_code == 200 else "failed"
|
|
}
|
|
return {"worker": worker_id, "status": "no_job"}
|
|
except Exception as e:
|
|
return {"worker": worker_id, "status": "error", "error": str(e)}
|
|
|
|
|
|
def _run_ollama_inference(ollama_url: str, model: str, prompt: str) -> Dict[str, Any]:
|
|
"""Run inference through local Ollama instance"""
|
|
try:
|
|
with httpx.Client(timeout=120) as client:
|
|
response = client.post(
|
|
f"{ollama_url}/api/generate",
|
|
json={
|
|
"model": model,
|
|
"prompt": prompt,
|
|
"stream": False
|
|
}
|
|
)
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
return {
|
|
"response": data.get("response", ""),
|
|
"model": data.get("model", model),
|
|
"total_duration": data.get("total_duration", 0),
|
|
"eval_count": data.get("eval_count", 0),
|
|
"eval_duration": data.get("eval_duration", 0),
|
|
}
|
|
else:
|
|
return {"error": f"Ollama returned {response.status_code}"}
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
|
|
@miner.command(name="mine-ollama")
|
|
@click.option("--jobs", type=int, default=1, help="Number of jobs to process")
|
|
@click.option("--miner-id", default="cli-miner", help="Miner ID")
|
|
@click.option("--ollama-url", default="http://localhost:11434", help="Ollama API URL")
|
|
@click.option("--model", default="gemma3:1b", help="Ollama model to use")
|
|
@click.pass_context
|
|
def mine_ollama(ctx, jobs: int, miner_id: str, ollama_url: str, model: str):
|
|
"""Mine jobs using local Ollama for GPU inference"""
|
|
config = ctx.obj['config']
|
|
|
|
# Verify Ollama is reachable
|
|
try:
|
|
with httpx.Client(timeout=5) as client:
|
|
resp = client.get(f"{ollama_url}/api/tags")
|
|
if resp.status_code != 200:
|
|
error(f"Cannot reach Ollama at {ollama_url}")
|
|
return
|
|
models = [m["name"] for m in resp.json().get("models", [])]
|
|
if model not in models:
|
|
error(f"Model '{model}' not found. Available: {', '.join(models)}")
|
|
return
|
|
success(f"Ollama connected: {ollama_url} | model: {model}")
|
|
except Exception as e:
|
|
error(f"Cannot connect to Ollama: {e}")
|
|
return
|
|
|
|
processed = 0
|
|
while processed < jobs:
|
|
try:
|
|
with httpx.Client() as client:
|
|
response = client.post(
|
|
f"{config.coordinator_url}/v1/miners/poll",
|
|
json={"max_wait_seconds": 10},
|
|
headers={
|
|
"X-Api-Key": config.api_key or "",
|
|
"X-Miner-ID": miner_id
|
|
},
|
|
timeout=30
|
|
)
|
|
|
|
if response.status_code == 204:
|
|
time.sleep(5)
|
|
continue
|
|
|
|
if response.status_code != 200:
|
|
error(f"Failed to poll: {response.status_code}")
|
|
break
|
|
|
|
job = response.json()
|
|
if not job:
|
|
time.sleep(5)
|
|
continue
|
|
|
|
job_id = job.get('job_id')
|
|
payload = job.get('payload', {})
|
|
prompt = payload.get('prompt', '')
|
|
job_model = payload.get('model', model)
|
|
|
|
output({
|
|
"job_id": job_id,
|
|
"status": "processing",
|
|
"prompt": prompt[:80] + ("..." if len(prompt) > 80 else ""),
|
|
"model": job_model,
|
|
"job_number": processed + 1
|
|
}, ctx.obj['output_format'])
|
|
|
|
# Run inference through Ollama
|
|
start_time = time.time()
|
|
ollama_result = _run_ollama_inference(ollama_url, job_model, prompt)
|
|
duration_ms = int((time.time() - start_time) * 1000)
|
|
|
|
if "error" in ollama_result:
|
|
error(f"Ollama inference failed: {ollama_result['error']}")
|
|
# Submit failure
|
|
client.post(
|
|
f"{config.coordinator_url}/v1/miners/{job_id}/fail",
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"X-Api-Key": config.api_key or "",
|
|
"X-Miner-ID": miner_id
|
|
},
|
|
json={"error_code": "INFERENCE_FAILED", "error_message": ollama_result['error'], "metrics": {}}
|
|
)
|
|
continue
|
|
|
|
# Submit successful result
|
|
result_response = client.post(
|
|
f"{config.coordinator_url}/v1/miners/{job_id}/result",
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"X-Api-Key": config.api_key or "",
|
|
"X-Miner-ID": miner_id
|
|
},
|
|
json={
|
|
"result": {
|
|
"response": ollama_result.get("response", ""),
|
|
"model": ollama_result.get("model", job_model),
|
|
"provider": "ollama",
|
|
"eval_count": ollama_result.get("eval_count", 0),
|
|
},
|
|
"metrics": {
|
|
"duration_ms": duration_ms,
|
|
"eval_count": ollama_result.get("eval_count", 0),
|
|
"eval_duration": ollama_result.get("eval_duration", 0),
|
|
"total_duration": ollama_result.get("total_duration", 0),
|
|
}
|
|
}
|
|
)
|
|
|
|
if result_response.status_code == 200:
|
|
success(f"Job {job_id} completed via Ollama ({duration_ms}ms)")
|
|
processed += 1
|
|
else:
|
|
error(f"Failed to submit result: {result_response.status_code}")
|
|
|
|
except Exception as e:
|
|
error(f"Error: {e}")
|
|
break
|
|
|
|
output({
|
|
"total_processed": processed,
|
|
"miner_id": miner_id,
|
|
"model": model,
|
|
"provider": "ollama"
|
|
}, ctx.obj['output_format'])
|
|
|
|
|
|
@miner.command(name="concurrent-mine")
|
|
@click.option("--workers", type=int, default=2, help="Number of concurrent workers")
|
|
@click.option("--jobs", "total_jobs", type=int, default=5, help="Total jobs to process")
|
|
@click.option("--miner-id", default="cli-miner", help="Miner ID")
|
|
@click.pass_context
|
|
def concurrent_mine(ctx, workers: int, total_jobs: int, miner_id: str):
|
|
"""Mine with concurrent job processing"""
|
|
config = ctx.obj['config']
|
|
|
|
success(f"Starting concurrent mining: {workers} workers, {total_jobs} jobs")
|
|
|
|
completed = 0
|
|
failed = 0
|
|
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
|
remaining = total_jobs
|
|
while remaining > 0:
|
|
batch_size = min(remaining, workers)
|
|
futures = [
|
|
executor.submit(_process_single_job, config, miner_id, i)
|
|
for i in range(batch_size)
|
|
]
|
|
|
|
for future in concurrent.futures.as_completed(futures):
|
|
result = future.result()
|
|
if result.get("status") == "completed":
|
|
completed += 1
|
|
remaining -= 1
|
|
output(result, ctx.obj['output_format'])
|
|
elif result.get("status") == "no_job":
|
|
time.sleep(2)
|
|
else:
|
|
failed += 1
|
|
remaining -= 1
|
|
|
|
output({
|
|
"status": "finished",
|
|
"completed": completed,
|
|
"failed": failed,
|
|
"workers": workers
|
|
}, ctx.obj['output_format'])
|