Skip to content

Commit 471113b

Browse files
committed
feat: implement GPU auto-scaling, model presets, and hot-swappable CLI overrides
1 parent 3cb8246 commit 471113b

8 files changed

Lines changed: 517 additions & 37 deletions

File tree

ghostcoder/backends/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# GhostCoder Backends

ghostcoder/backends/gpu_tier.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import subprocess
2+
import shutil
3+
from dataclasses import dataclass
4+
from typing import Dict, Any
5+
6+
@dataclass
7+
class GPUTier:
8+
name: str
9+
vram_gb: float
10+
tier: str # "entry" | "mid" | "high" | "workstation" | "datacenter"
11+
max_model_size: float # max parameter size in B (billion)
12+
13+
# Recommended model presets for each tier
14+
MODEL_PRESETS: Dict[str, Dict[str, str]] = {
15+
"entry": {
16+
"classifier": "qwen2.5:0.5b",
17+
"coder": "qwen2.5-coder:1.5b",
18+
"reasoner": "qwen2.5-coder:1.5b",
19+
"skeptic": "qwen2.5:0.5b"
20+
},
21+
"mid": {
22+
"classifier": "qwen2.5:0.5b",
23+
"coder": "qwen2.5-coder:7b",
24+
"reasoner": "qwen2.5:7b",
25+
"skeptic": "qwen2.5:3b"
26+
},
27+
"high": {
28+
"classifier": "qwen2.5:3b",
29+
"coder": "qwen2.5-coder:14b",
30+
"reasoner": "qwen2.5:14b",
31+
"skeptic": "qwen2.5:7b"
32+
},
33+
"workstation": {
34+
"classifier": "qwen2.5:7b",
35+
"coder": "qwen2.5-coder:32b",
36+
"reasoner": "qwen2.5:32b",
37+
"skeptic": "qwen2.5:14b"
38+
},
39+
"datacenter": {
40+
"classifier": "qwen2.5:14b",
41+
"coder": "qwen2.5-coder:32b",
42+
"reasoner": "qwen2.5:72b",
43+
"skeptic": "qwen2.5:32b"
44+
}
45+
}
46+
47+
class GPUTierDetector:
48+
@staticmethod
49+
def get_tier_by_vram(vram_gb: float) -> str:
50+
if vram_gb < 6.0:
51+
return "entry"
52+
elif vram_gb < 12.0:
53+
return "mid"
54+
elif vram_gb < 20.0:
55+
return "high"
56+
elif vram_gb < 48.0:
57+
return "workstation"
58+
else:
59+
return "datacenter"
60+
61+
@staticmethod
62+
def get_max_model_size_by_tier(tier: str) -> float:
63+
mapping = {
64+
"entry": 3.0,
65+
"mid": 7.0,
66+
"high": 14.0,
67+
"workstation": 32.0,
68+
"datacenter": 72.0
69+
}
70+
return mapping.get(tier, 3.0)
71+
72+
@classmethod
73+
def detect(cls) -> GPUTier:
74+
if not shutil.which("nvidia-smi"):
75+
# Fallback for CPU / non-NVIDIA environments
76+
return GPUTier(
77+
name="CPU / Non-NVIDIA GPU",
78+
vram_gb=0.0,
79+
tier="entry",
80+
max_model_size=3.0
81+
)
82+
83+
try:
84+
# Query nvidia-smi for total VRAM (in MiB) and GPU Name
85+
res = subprocess.run(
86+
["nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader,nounits"],
87+
capture_output=True,
88+
text=True,
89+
check=True
90+
)
91+
output = res.stdout.strip()
92+
if not output:
93+
raise ValueError("Empty output from nvidia-smi")
94+
95+
# In case of multi-GPU, take the first one
96+
first_line = output.splitlines()[0]
97+
parts = first_line.split(",")
98+
if len(parts) < 2:
99+
raise ValueError(f"Unexpected nvidia-smi output format: {first_line}")
100+
101+
gpu_name = parts[0].strip()
102+
vram_mib = float(parts[1].strip())
103+
vram_gb = vram_mib / 1024.0
104+
105+
tier = cls.get_tier_by_vram(vram_gb)
106+
max_model_size = cls.get_max_model_size_by_tier(tier)
107+
108+
return GPUTier(
109+
name=gpu_name,
110+
vram_gb=vram_gb,
111+
tier=tier,
112+
max_model_size=max_model_size
113+
)
114+
except Exception:
115+
# Graceful degradation on query failure
116+
return GPUTier(
117+
name="NVIDIA GPU (Detection Failed)",
118+
vram_gb=4.0,
119+
tier="entry",
120+
max_model_size=3.0
121+
)

ghostcoder/config.py

Lines changed: 106 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22
import pathlib
33
import yaml
4+
from typing import Optional
45

56
DEFAULT_CONFIG = {
67
"ollama_url": "http://localhost:11434",
@@ -14,6 +15,11 @@
1415
"skeptic": True,
1516
"gemini_api_key": "",
1617
"use_gemini_fallback": False,
18+
"gpu_tier": "auto",
19+
"model_classifier": "",
20+
"model_coder": "",
21+
"model_reasoner": "",
22+
"model_skeptic": "",
1723
"agent_mappings": {
1824
"errors": [
1925
{"pattern": "FAILED tests/", "agents": ["agency-reality-checker", "agency-senior-developer"]},
@@ -47,24 +53,61 @@
4753

4854
class Config:
4955
def __init__(self, config_path=None):
50-
self.config_path = config_path or os.path.expanduser("~/.ghostcoder/config.yaml")
5156
self.data = DEFAULT_CONFIG.copy()
57+
self.global_config_path = os.path.expanduser("~/.ghostcoder/config.yaml")
58+
self.local_config_path = self._find_local_config()
59+
60+
if config_path:
61+
self.config_path = config_path
62+
else:
63+
self.config_path = self.local_config_path or self.global_config_path
64+
5265
self.load()
5366

67+
def _find_local_config(self) -> Optional[str]:
68+
# Walk up to find .ghostcoder/config.yml or config.yaml
69+
try:
70+
curr = pathlib.Path(os.getcwd()).resolve()
71+
for parent in [curr] + list(curr.parents):
72+
for filename in ["config.yml", "config.yaml"]:
73+
p = parent / ".ghostcoder" / filename
74+
if p.exists() and p.is_file():
75+
return str(p)
76+
except Exception:
77+
pass
78+
return None
79+
5480
def load(self):
55-
path = pathlib.Path(self.config_path)
56-
if path.exists():
81+
# 1. Load global
82+
global_path = pathlib.Path(self.global_config_path)
83+
if global_path.exists():
5784
try:
58-
with open(path, "r", encoding="utf-8") as f:
85+
with open(global_path, "r", encoding="utf-8") as f:
5986
user_data = yaml.safe_load(f)
6087
if user_data:
6188
self._merge(self.data, user_data)
6289
except Exception as e:
63-
print(f"Error loading config file: {e}")
90+
print(f"Error loading global config: {e}")
6491
else:
65-
# Ensure folder exists
66-
path.parent.mkdir(parents=True, exist_ok=True)
67-
self.save()
92+
# Create global directory and file on first run
93+
try:
94+
global_path.parent.mkdir(parents=True, exist_ok=True)
95+
with open(global_path, "w", encoding="utf-8") as f:
96+
yaml.safe_dump(DEFAULT_CONFIG, f)
97+
except Exception:
98+
pass
99+
100+
# 2. Load local
101+
if self.local_config_path:
102+
local_path = pathlib.Path(self.local_config_path)
103+
if local_path.exists():
104+
try:
105+
with open(local_path, "r", encoding="utf-8") as f:
106+
user_data = yaml.safe_load(f)
107+
if user_data:
108+
self._merge(self.data, user_data)
109+
except Exception as e:
110+
print(f"Error loading local config: {e}")
68111

69112
def _merge(self, base, update):
70113
for k, v in update.items():
@@ -107,13 +150,66 @@ def vram_headroom_mb(self):
107150
def coder_idle_timeout(self):
108151
return self.data.get("coder_idle_timeout", 30.0)
109152

153+
def resolve_model(self, role: str) -> str:
154+
# 1. Check for manual override in config
155+
override_key = f"model_{role}"
156+
if self.data.get(override_key):
157+
return self.data[override_key]
158+
159+
# 2. Check for backward compatibility with old config keys
160+
if role == "classifier" and self.data.get("classifier_model"):
161+
if self.data["classifier_model"] != DEFAULT_CONFIG["classifier_model"]:
162+
return self.data["classifier_model"]
163+
if role == "coder" and self.data.get("coder_model"):
164+
if self.data["coder_model"] != DEFAULT_CONFIG["coder_model"]:
165+
return self.data["coder_model"]
166+
167+
# 3. Detect GPU and use preset
168+
from .backends.gpu_tier import GPUTierDetector, MODEL_PRESETS
169+
170+
configured_tier = self.gpu_tier
171+
if configured_tier == "auto":
172+
gpu_info = GPUTierDetector.detect()
173+
configured_tier = gpu_info.tier
174+
175+
presets = MODEL_PRESETS.get(configured_tier, MODEL_PRESETS["entry"])
176+
return presets.get(role, "")
177+
178+
@property
179+
def gpu_tier(self):
180+
return self.data.get("gpu_tier", "auto")
181+
182+
@property
183+
def model_classifier(self):
184+
return self.data.get("model_classifier", "")
185+
186+
@property
187+
def model_coder(self):
188+
return self.data.get("model_coder", "")
189+
190+
@property
191+
def model_reasoner(self):
192+
return self.data.get("model_reasoner", "")
193+
194+
@property
195+
def model_skeptic(self):
196+
return self.data.get("model_skeptic", "")
197+
110198
@property
111199
def classifier_model(self):
112-
return self.data.get("classifier_model", "qwen2.5:0.5b")
200+
return self.resolve_model("classifier")
113201

114202
@property
115203
def coder_model(self):
116-
return self.data.get("coder_model", "qwen2.5-coder:1.5b")
204+
return self.resolve_model("coder")
205+
206+
@property
207+
def reasoner_model(self):
208+
return self.resolve_model("reasoner")
209+
210+
@property
211+
def skeptic_model(self):
212+
return self.resolve_model("skeptic")
117213

118214
@property
119215
def skeptic(self):

0 commit comments

Comments
 (0)