split-stack 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,288 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ _CONFIGURED_MODELS_DIR: Path | None = None
8
+
9
+
10
+ def configure_models_dir(path: str | Path | None) -> None:
11
+ """Pin an Ollama models directory for discovery (used by demo server)."""
12
+ global _CONFIGURED_MODELS_DIR
13
+ if not path:
14
+ _CONFIGURED_MODELS_DIR = None
15
+ return
16
+ try:
17
+ resolved = Path(path).expanduser().resolve()
18
+ except OSError:
19
+ _CONFIGURED_MODELS_DIR = None
20
+ return
21
+ _CONFIGURED_MODELS_DIR = resolved if resolved.is_dir() else None
22
+
23
+
24
+ def default_models_dir() -> Path | None:
25
+ """First existing Ollama models folder from env and common dev layouts."""
26
+ candidates: list[Path] = []
27
+ if _CONFIGURED_MODELS_DIR is not None:
28
+ candidates.append(_CONFIGURED_MODELS_DIR)
29
+
30
+ for key in ("SPLIT_STACK_OLLAMA_MODELS", "OLLAMA_MODELS"):
31
+ raw = os.environ.get(key, "").strip()
32
+ if raw:
33
+ candidates.append(Path(raw))
34
+
35
+ profile = os.environ.get("USERPROFILE", "").strip()
36
+ if profile:
37
+ candidates.append(Path(profile) / "dev" / "Tools" / ".ollama" / "models")
38
+
39
+ home = Path.home()
40
+ candidates.extend(
41
+ [
42
+ home / "dev" / "Tools" / ".ollama" / "models",
43
+ home / ".ollama" / "models",
44
+ ]
45
+ )
46
+
47
+ seen: set[Path] = set()
48
+ for candidate in candidates:
49
+ try:
50
+ resolved = candidate.expanduser().resolve()
51
+ except OSError:
52
+ continue
53
+ if resolved in seen:
54
+ continue
55
+ seen.add(resolved)
56
+ library = resolved / "manifests" / "registry.ollama.ai" / "library"
57
+ if library.is_dir():
58
+ return resolved
59
+ return None
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class ModelInventory:
64
+ api_models: tuple[str, ...]
65
+ disk_models: tuple[str, ...]
66
+ manifest_roots: tuple[str, ...]
67
+ suggested_stack: tuple[str, ...]
68
+ note: str | None = None
69
+
70
+
71
+ def manifest_search_paths(extra_root: str | Path | None = None) -> list[Path]:
72
+ """Candidate Ollama model directories (OLLAMA_MODELS, home, common dev layout)."""
73
+ seen: set[Path] = set()
74
+ ordered: list[Path] = []
75
+
76
+ def add(path: Path | None) -> None:
77
+ if path is None:
78
+ return
79
+ try:
80
+ resolved = path.expanduser().resolve()
81
+ except OSError:
82
+ return
83
+ if resolved in seen or not resolved.is_dir():
84
+ return
85
+ seen.add(resolved)
86
+ ordered.append(resolved)
87
+
88
+ env_models = os.environ.get("OLLAMA_MODELS", "").strip()
89
+ if env_models:
90
+ add(Path(env_models))
91
+
92
+ split_stack_models = os.environ.get("SPLIT_STACK_OLLAMA_MODELS", "").strip()
93
+ if split_stack_models:
94
+ add(Path(split_stack_models))
95
+
96
+ if _CONFIGURED_MODELS_DIR is not None:
97
+ add(_CONFIGURED_MODELS_DIR)
98
+
99
+ profile = os.environ.get("USERPROFILE", "").strip()
100
+ if profile:
101
+ add(Path(profile) / "dev" / "Tools" / ".ollama" / "models")
102
+
103
+ if extra_root:
104
+ add(Path(extra_root))
105
+
106
+ home = Path.home()
107
+ add(home / ".ollama" / "models")
108
+ add(home / "dev" / "Tools" / ".ollama" / "models")
109
+
110
+ return ordered
111
+
112
+
113
+ def discover_models_from_disk(
114
+ *,
115
+ manifests_root: Path | str | None = None,
116
+ ) -> list[str]:
117
+ """List model tags from on-disk Ollama manifests (family/tag → family:tag)."""
118
+ roots = manifest_search_paths(extra_root=Path(manifests_root) if manifests_root else None)
119
+ found: set[str] = set()
120
+
121
+ for root in roots:
122
+ library = root / "manifests" / "registry.ollama.ai" / "library"
123
+ if not library.is_dir():
124
+ continue
125
+ for family_dir in library.iterdir():
126
+ if not family_dir.is_dir():
127
+ continue
128
+ for tag_path in family_dir.iterdir():
129
+ if tag_path.is_file():
130
+ found.add(f"{family_dir.name}:{tag_path.name}")
131
+
132
+ return sorted(found)
133
+
134
+
135
+ def discover_models(base_url: str = "http://127.0.0.1:11434") -> list[str]:
136
+ """Models the running Ollama server reports via /api/tags."""
137
+ try:
138
+ import requests
139
+ except ImportError as exc:
140
+ raise RuntimeError(
141
+ "discover_models requires optional dependency: pip install split-stack[ollama]"
142
+ ) from exc
143
+
144
+ url = f"{base_url.rstrip('/')}/api/tags"
145
+ response = requests.get(url, timeout=5)
146
+ response.raise_for_status()
147
+ payload = response.json() or {}
148
+ models = [item.get("name", "") for item in payload.get("models", [])]
149
+ return [model for model in models if model]
150
+
151
+
152
+ def _suggest_stack_from_pool(model_names: list[str], *, count: int = 3) -> list[str]:
153
+ if not model_names:
154
+ return []
155
+ if len(model_names) <= count:
156
+ return list(model_names)
157
+
158
+ from split_stack.model_registry import load_registry, model_weight
159
+
160
+ registry = load_registry()
161
+ ranked = sorted(model_names, key=lambda name: model_weight(name, registry))
162
+ if count == 3 and len(ranked) >= 3:
163
+ return [ranked[0], ranked[len(ranked) // 2], ranked[-1]]
164
+ return ranked[:count]
165
+
166
+
167
+ def list_model_inventory(
168
+ *,
169
+ base_url: str = "http://127.0.0.1:11434",
170
+ manifests_root: Path | str | None = None,
171
+ ) -> ModelInventory:
172
+ """Merge Ollama API tags with on-disk manifest scan."""
173
+ roots = manifest_search_paths(extra_root=Path(manifests_root) if manifests_root else None)
174
+ api_models: list[str] = []
175
+ api_error: str | None = None
176
+ try:
177
+ api_models = discover_models(base_url=base_url)
178
+ except Exception as exc:
179
+ api_error = str(exc)
180
+
181
+ disk_models = discover_models_from_disk(manifests_root=manifests_root)
182
+ pool = sorted(set(api_models) | set(disk_models))
183
+ suggested = _suggest_stack_from_pool(pool, count=3)
184
+
185
+ note_parts: list[str] = []
186
+ if api_error:
187
+ note_parts.append(f"Ollama API unreachable: {api_error}")
188
+ elif len(api_models) < len(disk_models):
189
+ note_parts.append(
190
+ f"Ollama API lists {len(api_models)} model(s) but disk has {len(disk_models)}. "
191
+ "Point Ollama at your model folder (OLLAMA_MODELS) or use disk models in the demo."
192
+ )
193
+ if not roots:
194
+ note_parts.append("No Ollama model directories found on disk.")
195
+ if note_parts:
196
+ note = " ".join(note_parts)
197
+ else:
198
+ note = None
199
+
200
+ return ModelInventory(
201
+ api_models=tuple(api_models),
202
+ disk_models=tuple(disk_models),
203
+ manifest_roots=tuple(str(path) for path in roots),
204
+ suggested_stack=tuple(suggested),
205
+ note=note,
206
+ )
207
+
208
+
209
+ def model_locations_by_tag(
210
+ *,
211
+ manifests_root: Path | str | None = None,
212
+ ) -> dict[str, tuple[str, ...]]:
213
+ """Map model tag to every manifest root that contains it."""
214
+ roots = manifest_search_paths(extra_root=Path(manifests_root) if manifests_root else None)
215
+ locations: dict[str, list[str]] = {}
216
+ for root in roots:
217
+ library = root / "manifests" / "registry.ollama.ai" / "library"
218
+ if not library.is_dir():
219
+ continue
220
+ for family_dir in library.iterdir():
221
+ if not family_dir.is_dir():
222
+ continue
223
+ for tag_path in family_dir.iterdir():
224
+ if tag_path.is_file():
225
+ tag = f"{family_dir.name}:{tag_path.name}"
226
+ locations.setdefault(tag, []).append(str(root))
227
+ return {tag: tuple(paths) for tag, paths in sorted(locations.items())}
228
+
229
+
230
+ def audit_model_folders(
231
+ *,
232
+ manifests_root: Path | str | None = None,
233
+ ) -> dict[str, object]:
234
+ """Report duplicate tags across Ollama model directories."""
235
+ locations = model_locations_by_tag(manifests_root=manifests_root)
236
+ duplicates = {tag: list(paths) for tag, paths in locations.items() if len(paths) > 1}
237
+ primary = default_models_dir()
238
+ if primary is None:
239
+ home = Path.home() / ".ollama" / "models"
240
+ primary = home if home.is_dir() else None
241
+ return {
242
+ "primary_root": str(primary) if primary else None,
243
+ "scan_roots": list(manifest_search_paths()),
244
+ "tag_count": len(locations),
245
+ "locations": {tag: list(paths) for tag, paths in locations.items()},
246
+ "duplicates": duplicates,
247
+ "duplicate_tags": sorted(duplicates),
248
+ }
249
+
250
+
251
+ def remove_duplicate_manifests(
252
+ *,
253
+ keep_root: str | Path,
254
+ drop_roots: list[str | Path] | None = None,
255
+ ) -> list[str]:
256
+ """Delete manifest files from secondary folders when keep_root already has the tag."""
257
+ keep = Path(keep_root).expanduser().resolve()
258
+ drops = [Path(path).expanduser().resolve() for path in (drop_roots or manifest_search_paths())]
259
+ drops = [path for path in drops if path != keep and path.is_dir()]
260
+
261
+ keep_library = keep / "manifests" / "registry.ollama.ai" / "library"
262
+ if not keep_library.is_dir():
263
+ return []
264
+
265
+ keep_tags: set[str] = set()
266
+ for family_dir in keep_library.iterdir():
267
+ if not family_dir.is_dir():
268
+ continue
269
+ for tag_path in family_dir.iterdir():
270
+ if tag_path.is_file():
271
+ keep_tags.add(f"{family_dir.name}:{tag_path.name}")
272
+
273
+ removed: list[str] = []
274
+ for drop in drops:
275
+ library = drop / "manifests" / "registry.ollama.ai" / "library"
276
+ if not library.is_dir():
277
+ continue
278
+ for family_dir in library.iterdir():
279
+ if not family_dir.is_dir():
280
+ continue
281
+ for tag_path in list(family_dir.iterdir()):
282
+ if not tag_path.is_file():
283
+ continue
284
+ tag = f"{family_dir.name}:{tag_path.name}"
285
+ if tag in keep_tags:
286
+ tag_path.unlink()
287
+ removed.append(f"{tag} @ {drop}")
288
+ return removed
split_stack/hints.py ADDED
@@ -0,0 +1,102 @@
1
+ """Agent step hints for agent-loop routing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from split_stack.models import ComplexityTier, StepKind
6
+
7
+ # Five step types used in compare POC and agent-runner demos.
8
+ HINT_CATALOG: tuple[dict[str, str], ...] = (
9
+ {
10
+ "id": "lookup",
11
+ "tier": ComplexityTier.SIMPLE.value,
12
+ "label": "Lookup",
13
+ "summary": "Facts, definitions, one-liners",
14
+ },
15
+ {
16
+ "id": "explain",
17
+ "tier": ComplexityTier.MEDIUM.value,
18
+ "label": "Explain",
19
+ "summary": "Summarise, compare, outline, plan",
20
+ },
21
+ {
22
+ "id": "design",
23
+ "tier": ComplexityTier.COMPLEX.value,
24
+ "label": "Design",
25
+ "summary": "Architecture, strategy, tradeoffs",
26
+ },
27
+ {
28
+ "id": "code",
29
+ "tier": ComplexityTier.COMPLEX.value,
30
+ "label": "Code",
31
+ "summary": "Implement, refactor, debug (uses code slot when set)",
32
+ },
33
+ {
34
+ "id": "reason",
35
+ "tier": ComplexityTier.REASONING.value,
36
+ "label": "Reason",
37
+ "summary": "Proofs, step-by-step, formal logic",
38
+ },
39
+ )
40
+
41
+ # Short-lived aliases from an earlier 4-hint experiment.
42
+ LEGACY_HINT_ALIASES: dict[str, str] = {
43
+ "work": "explain",
44
+ "build": "design",
45
+ }
46
+
47
+ _CANONICAL_IDS = frozenset(item["id"] for item in HINT_CATALOG)
48
+
49
+
50
+ def canonical_hint_id(hint: str | StepKind | None) -> str | None:
51
+ if hint is None:
52
+ return None
53
+ if isinstance(hint, StepKind):
54
+ raw = hint.value
55
+ else:
56
+ raw = hint.strip().lower()
57
+ if raw in _CANONICAL_IDS:
58
+ return raw
59
+ if raw in LEGACY_HINT_ALIASES:
60
+ return LEGACY_HINT_ALIASES[raw]
61
+ return raw
62
+
63
+
64
+ def normalize_step_kind(hint: str | StepKind | None) -> StepKind | None:
65
+ if hint is None:
66
+ return None
67
+ if isinstance(hint, StepKind):
68
+ return hint
69
+ lowered = hint.strip().lower()
70
+ canonical = canonical_hint_id(lowered)
71
+ if canonical is None:
72
+ valid = ", ".join(item["id"] for item in HINT_CATALOG)
73
+ raise ValueError(f"Unknown step hint '{hint}'. Valid hints: {valid}")
74
+ try:
75
+ return StepKind(canonical)
76
+ except ValueError as exc:
77
+ valid = ", ".join(item["id"] for item in HINT_CATALOG)
78
+ raise ValueError(f"Unknown step hint '{hint}'. Valid hints: {valid}") from exc
79
+
80
+
81
+ def prefer_code_model(hint: str | StepKind | None) -> bool:
82
+ if hint is None:
83
+ return False
84
+ raw = hint.value if isinstance(hint, StepKind) else hint.strip().lower()
85
+ return raw == "code"
86
+
87
+
88
+ def tier_from_step_kind(kind: StepKind) -> ComplexityTier:
89
+ lookup = {
90
+ StepKind.LOOKUP: ComplexityTier.SIMPLE,
91
+ StepKind.EXPLAIN: ComplexityTier.MEDIUM,
92
+ StepKind.WORK: ComplexityTier.MEDIUM,
93
+ StepKind.DESIGN: ComplexityTier.COMPLEX,
94
+ StepKind.BUILD: ComplexityTier.COMPLEX,
95
+ StepKind.CODE: ComplexityTier.COMPLEX,
96
+ StepKind.REASON: ComplexityTier.REASONING,
97
+ }
98
+ return lookup[kind]
99
+
100
+
101
+ def list_hints() -> tuple[dict[str, str], ...]:
102
+ return HINT_CATALOG
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from split_stack.discovery import discover_models, discover_models_from_disk, list_model_inventory
4
+ from split_stack.model_registry import ResolvedModel, load_registry, resolve_discovered_models
5
+ from split_stack.tiering import assign_tiers
6
+
7
+
8
+ def list_local_models(
9
+ *,
10
+ base_url: str = "http://127.0.0.1:11434",
11
+ config_path: str | None = None,
12
+ profile: str | None = None,
13
+ only_vram_ok: bool = False,
14
+ include_disk: bool = False,
15
+ quant_mode: str | None = None,
16
+ ) -> tuple[list[ResolvedModel], str | None]:
17
+ registry = load_registry(config_path, profile=profile)
18
+ discovered = discover_models(base_url=base_url)
19
+ note: str | None = None
20
+ if include_disk:
21
+ inventory = list_model_inventory(base_url=base_url)
22
+ discovered = sorted(set(discovered) | set(inventory.disk_models))
23
+ note = inventory.note
24
+ effective_filter = only_vram_ok and registry.apply_vram_filter
25
+ resolved = resolve_discovered_models(
26
+ discovered,
27
+ registry=registry,
28
+ only_vram_ok=effective_filter,
29
+ quant_mode=quant_mode,
30
+ )
31
+ warning = None
32
+ if effective_filter and len(resolved) < 2:
33
+ warning = (
34
+ "Fewer than two models fit assumed_vram_gb="
35
+ f"{registry.assumed_vram_gb}. Add smaller models, pick a larger workstation profile, "
36
+ "or set deployment_profile to datacenter with a custom catalog."
37
+ )
38
+ if note and not warning:
39
+ warning = note
40
+ elif note and warning:
41
+ warning = f"{warning} {note}"
42
+ return resolved, warning
43
+
44
+
45
+ def assign_tiers_from_local(
46
+ *,
47
+ base_url: str = "http://127.0.0.1:11434",
48
+ config_path: str | None = None,
49
+ profile: str | None = None,
50
+ only_vram_ok: bool = True,
51
+ quant_mode: str | None = None,
52
+ ):
53
+ models, warning = list_local_models(
54
+ base_url=base_url,
55
+ config_path=config_path,
56
+ profile=profile,
57
+ only_vram_ok=only_vram_ok,
58
+ quant_mode=quant_mode,
59
+ )
60
+ if not models:
61
+ raise RuntimeError("No models available after discovery and VRAM filter")
62
+ tiers = assign_tiers([item.name for item in models])
63
+ return tiers, models, warning