journeyman-cli 1.1.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.
journeyman/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """journeyman — route implementation tasks to a local or economical model."""
2
+ __version__ = "1.1.0"
@@ -0,0 +1,120 @@
1
+ """Shared config and HTTP helpers for journeyman scripts.
2
+
3
+ Single source for load/save config, model listing, and local model picking.
4
+ Import from here — do not duplicate these helpers in setup/detect/worker.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import pathlib
11
+ import re
12
+ import stat
13
+ import urllib.error
14
+ import urllib.request
15
+
16
+ from .constants import CONFIG_PATH # re-exported for callers/tests
17
+
18
+ # Patterns that usually are not usable as chat/code-generation workers.
19
+ _NON_CHAT_RE = re.compile(
20
+ r"(embed|embedding|rerank|whisper|tts|clip|text-embedding)",
21
+ re.IGNORECASE,
22
+ )
23
+ _SIZE_RE = re.compile(r"(\d+(?:\.\d+)?)\s*[bB]\b")
24
+ _INSTRUCT_RE = re.compile(r"(instruct|chat|-it\b|:it\b)", re.IGNORECASE)
25
+
26
+
27
+ def get_json(url: str, timeout: int = 3):
28
+ """Fetch JSON from *url*; return parsed object or None on any error."""
29
+ try:
30
+ with urllib.request.urlopen(url, timeout=timeout) as resp:
31
+ return json.loads(resp.read())
32
+ except (OSError, json.JSONDecodeError, ValueError):
33
+ return None
34
+
35
+
36
+ def list_models_openai(base_url: str) -> list[str] | None:
37
+ """Return model IDs from an OpenAI-compatible /v1/models endpoint, or None."""
38
+ data = get_json(base_url.rstrip("/") + "/v1/models")
39
+ if data is None:
40
+ return None
41
+ return [m["id"] for m in data.get("data", [])]
42
+
43
+
44
+ def list_models_ollama(base_url: str) -> list[str] | None:
45
+ """Return model names from Ollama's /api/tags endpoint, or None."""
46
+ data = get_json(base_url.rstrip("/") + "/api/tags")
47
+ if data is None:
48
+ return None
49
+ return [m["name"] for m in data.get("models", [])]
50
+
51
+
52
+ def load_config() -> dict:
53
+ """Load saved journeyman config, or return empty dict."""
54
+ if CONFIG_PATH.exists():
55
+ try:
56
+ return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
57
+ except (OSError, json.JSONDecodeError):
58
+ pass
59
+ return {}
60
+
61
+
62
+ def save_config(cfg: dict) -> None:
63
+ """Write config as UTF-8 JSON with owner-only permissions (0600)."""
64
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
65
+ payload = json.dumps(cfg, indent=2) + "\n"
66
+ # Write via temp + replace so we can set mode before the file is readable.
67
+ tmp = CONFIG_PATH.with_suffix(CONFIG_PATH.suffix + ".tmp")
68
+ fd = os.open(
69
+ str(tmp),
70
+ os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
71
+ stat.S_IRUSR | stat.S_IWUSR, # 0600
72
+ )
73
+ try:
74
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
75
+ f.write(payload)
76
+ os.replace(str(tmp), str(CONFIG_PATH))
77
+ os.chmod(CONFIG_PATH, stat.S_IRUSR | stat.S_IWUSR)
78
+ except Exception:
79
+ try:
80
+ os.unlink(tmp)
81
+ except OSError:
82
+ pass
83
+ raise
84
+ print(f"Config saved → {CONFIG_PATH}")
85
+
86
+
87
+ def is_non_chat_model(name: str) -> bool:
88
+ """Return True if *name* looks like an embedding/ASR/TTS/etc. model."""
89
+ return bool(_NON_CHAT_RE.search(name))
90
+
91
+
92
+ def _model_sort_key(name: str) -> tuple:
93
+ """Prefer instruct/chat tags, then smaller parameter counts (more economical)."""
94
+ instruct_penalty = 0 if _INSTRUCT_RE.search(name) else 1
95
+ match = _SIZE_RE.search(name)
96
+ size = float(match.group(1)) if match else 50.0 # unknown mid-tier
97
+ return (instruct_penalty, size, name.lower())
98
+
99
+
100
+ def pick_local_model(models: list[str], preferred: str | None = None) -> str:
101
+ """Choose a local chat/code model from *models*.
102
+
103
+ Order of preference:
104
+ 1. Explicit *preferred* if it is installed
105
+ 2. Chat-like models (exclude embeddings / ASR / TTS / CLIP)
106
+ 3. Instruct/chat-tagged names, then smaller sizes
107
+ 4. Fall back to the full list if every model was filtered out
108
+ """
109
+ if not models:
110
+ raise ValueError("pick_local_model requires a non-empty models list")
111
+ if preferred and preferred in models:
112
+ return preferred
113
+ chat = [m for m in models if not is_non_chat_model(m)]
114
+ pool = chat or list(models)
115
+ return min(pool, key=_model_sort_key)
116
+
117
+
118
+ def read_text_utf8(path: pathlib.Path) -> str:
119
+ """Read a text file as UTF-8; raise UnicodeDecodeError on binary/invalid data."""
120
+ return path.read_text(encoding="utf-8")
@@ -0,0 +1,95 @@
1
+ """Shared backend configuration for all journeyman scripts.
2
+
3
+ This is the single source of truth for provider/backend data.
4
+ Import from here — do not duplicate these lists.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import pathlib
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # Config path
13
+ # ---------------------------------------------------------------------------
14
+
15
+ CONFIG_PATH = pathlib.Path(os.environ.get(
16
+ "JOURNEYMAN_CONFIG",
17
+ str(pathlib.Path.home() / ".config" / "journeyman" / "config.json"),
18
+ ))
19
+
20
+ # Append-only usage log for `journeyman stats`. Override with JOURNEYMAN_USAGE_LOG.
21
+ USAGE_LOG_PATH = pathlib.Path(os.environ.get(
22
+ "JOURNEYMAN_USAGE_LOG",
23
+ str(pathlib.Path.home() / ".local" / "share" / "journeyman" / "usage.jsonl"),
24
+ ))
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Backend definitions
28
+ # ---------------------------------------------------------------------------
29
+
30
+ # (friendly_name, base_url, list_api_type)
31
+ # list_api_type controls which endpoint to probe for model enumeration.
32
+ # All local backends use OpenAI-compat /v1/chat/completions for inference.
33
+ LOCAL_BACKENDS: list[tuple[str, str, str]] = [
34
+ ("ollama", "http://localhost:11434", "ollama"),
35
+ ("lmstudio", "http://localhost:1234", "openai"),
36
+ ("llama.cpp/mlx", "http://localhost:8080", "openai"),
37
+ ]
38
+
39
+ # (friendly_name, env_var, base_url, api_type, default_model)
40
+ REMOTE_BACKENDS: list[tuple[str, str, str, str, str]] = [
41
+ (
42
+ "groq",
43
+ "GROQ_API_KEY",
44
+ "https://api.groq.com/openai/v1",
45
+ "openai",
46
+ "llama-3.1-8b-instant",
47
+ ),
48
+ (
49
+ "together",
50
+ "TOGETHER_API_KEY",
51
+ "https://api.together.xyz/v1",
52
+ "openai",
53
+ "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo",
54
+ ),
55
+ (
56
+ "gemini",
57
+ "GEMINI_API_KEY",
58
+ "https://generativelanguage.googleapis.com/v1beta/openai",
59
+ "openai",
60
+ "gemini-2.0-flash",
61
+ ),
62
+ (
63
+ "openai",
64
+ "OPENAI_API_KEY",
65
+ "https://api.openai.com/v1",
66
+ "openai",
67
+ "gpt-4o-mini",
68
+ ),
69
+ ]
70
+
71
+ # Convenience lookup: provider name → env var name (used by sb_worker auth).
72
+ REMOTE_ENV_VAR: dict[str, str] = {
73
+ name: env_var for name, env_var, *_ in REMOTE_BACKENDS
74
+ }
75
+
76
+ # All valid remote provider names (used for validation in setup).
77
+ VALID_REMOTE_PROVIDERS: list[str] = [name for name, *_ in REMOTE_BACKENDS]
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Pricing table (approximate mid-2025 list prices, USD per 1 M tokens)
81
+ # ---------------------------------------------------------------------------
82
+ # Key: (provider_name, model_name) → (input_usd_per_1M, output_usd_per_1M)
83
+ # Use (provider, None) as the fallback for any model from that provider.
84
+ # Local providers always return (0.0, 0.0).
85
+ MODEL_PRICING: dict[tuple[str, str | None], tuple[float, float]] = {
86
+ # Remote — default models
87
+ ("groq", "llama-3.1-8b-instant"): (0.05, 0.08),
88
+ ("together", "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo"): (0.18, 0.18),
89
+ ("gemini", "gemini-2.0-flash"): (0.10, 0.40),
90
+ ("openai", "gpt-4o-mini"): (0.15, 0.60),
91
+ # Local — always zero
92
+ ("ollama", None): (0.0, 0.0),
93
+ ("lmstudio", None): (0.0, 0.0),
94
+ ("llama.cpp/mlx", None): (0.0, 0.0),
95
+ }
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env python3
2
+ """Detect the preferred local-first model backend for journeyman worker tasks.
3
+
4
+ Priority:
5
+ 1. Local servers (no data leaves the machine):
6
+ Ollama → http://localhost:11434
7
+ LM Studio → http://localhost:1234
8
+ llama.cpp / mlx_lm → http://localhost:8080
9
+
10
+ 2. Low-cost remote APIs (checked only if no local server is found; requires env var):
11
+ GROQ_API_KEY → api.groq.com llama-3.1-8b-instant
12
+ TOGETHER_API_KEY → api.together.xyz meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo
13
+ GEMINI_API_KEY → generativelanguage... gemini-2.0-flash
14
+ OPENAI_API_KEY → api.openai.com gpt-4o-mini
15
+
16
+ Usage (standalone):
17
+ python3 detect_worker.py # print JSON, exit 0 if found / 1 if not
18
+ python3 detect_worker.py --list # human-readable list of all backends
19
+ python3 detect_worker.py --require-local # fail with exit 1 if only remote found
20
+
21
+ Output JSON (exit 0):
22
+ {
23
+ "name": "ollama",
24
+ "endpoint": "http://localhost:11434",
25
+ "model": "llama3:8b",
26
+ "api_type": "openai",
27
+ "is_local": true
28
+ }
29
+
30
+ When a local server is running but has no models installed, the JSON
31
+ will include "model": null and "error": "<message>". Callers must check
32
+ for this before attempting an API call.
33
+
34
+ Exit codes:
35
+ 0 backend found; JSON written to stdout
36
+ 1 no backend found (or --require-local and only remote available);
37
+ also when a local server is up but has no models installed
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import argparse
43
+ import json
44
+ import os
45
+ import sys
46
+
47
+ from .backend_util import (
48
+ list_models_ollama,
49
+ list_models_openai,
50
+ load_config,
51
+ pick_local_model,
52
+ )
53
+ from .constants import (
54
+ LOCAL_BACKENDS,
55
+ REMOTE_BACKENDS,
56
+ )
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Detection
60
+ # ---------------------------------------------------------------------------
61
+
62
+ def detect_local() -> dict | None:
63
+ """Return the first live local backend that has at least one model, or None.
64
+
65
+ If a server is running but has no models installed, returns a result dict
66
+ with ``"model": None`` and ``"error": "<message>"`` instead of falling
67
+ through to remote. Callers must check for the error key before use.
68
+ """
69
+ for name, url, api_type in LOCAL_BACKENDS:
70
+ if api_type == "ollama":
71
+ models = list_models_ollama(url)
72
+ else:
73
+ models = list_models_openai(url)
74
+
75
+ if models is None:
76
+ continue # server not running
77
+
78
+ if not models:
79
+ # Server is running but no models installed.
80
+ # Return an error result rather than silently falling through to
81
+ # remote — the user almost certainly does not want their data sent
82
+ # to a remote API just because they forgot to pull a model.
83
+ return {
84
+ "name": name,
85
+ "endpoint": url,
86
+ "model": None,
87
+ "api_type": "openai",
88
+ "is_local": True,
89
+ "available_models": [],
90
+ "error": (
91
+ f"No models installed on {name}. "
92
+ f"Install one (e.g. ollama pull llama3.2) and retry."
93
+ ),
94
+ }
95
+
96
+ config = load_config()
97
+ preferred = config.get("preferred_local_model")
98
+ model = pick_local_model(models, preferred=preferred)
99
+
100
+ return {
101
+ "name": name,
102
+ "endpoint": url,
103
+ "model": model,
104
+ "api_type": "openai", # normalised: all local use /v1/chat/completions
105
+ "is_local": True,
106
+ "available_models": models,
107
+ }
108
+
109
+ return None
110
+
111
+
112
+ def detect_remote(preferred_provider: str | None = None) -> dict | None:
113
+ """Return the first available remote backend, or None.
114
+
115
+ If *preferred_provider* is given (e.g. "groq"), it is tried first
116
+ regardless of the default list order.
117
+ """
118
+ config = load_config()
119
+ remote_model_overrides: dict = config.get("remote_models", {})
120
+
121
+ ordered = list(REMOTE_BACKENDS)
122
+ if preferred_provider:
123
+ ordered.sort(key=lambda b: 0 if b[0] == preferred_provider else 1)
124
+
125
+ for name, env_var, endpoint, api_type, default_model in ordered:
126
+ api_key = os.environ.get(env_var) or config.get("api_keys", {}).get(name)
127
+ if not api_key:
128
+ continue
129
+
130
+ model = remote_model_overrides.get(name, default_model)
131
+ return {
132
+ "name": name,
133
+ "endpoint": endpoint,
134
+ "model": model,
135
+ "api_type": api_type,
136
+ "api_key": api_key,
137
+ "is_local": False,
138
+ }
139
+
140
+ return None
141
+
142
+
143
+ def detect(require_local: bool = False, preferred_remote: str | None = None) -> dict | None:
144
+ """Full detection: local first, remote fallback."""
145
+ local = detect_local()
146
+ if local:
147
+ return local
148
+ if require_local:
149
+ return None
150
+ return detect_remote(preferred_provider=preferred_remote)
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # CLI
155
+ # ---------------------------------------------------------------------------
156
+
157
+ def _cmd_list() -> int:
158
+ print("Local backends:")
159
+ for name, url, api_type in LOCAL_BACKENDS:
160
+ if api_type == "ollama":
161
+ models = list_models_ollama(url)
162
+ else:
163
+ models = list_models_openai(url)
164
+
165
+ if models is None:
166
+ print(f" ✗ {name:<16} {url} (not running)")
167
+ else:
168
+ model_str = ", ".join(models) if models else "(no models installed)"
169
+ print(f" ✓ {name:<16} {url} → {model_str}")
170
+
171
+ print("\nRemote backends:")
172
+ config = load_config()
173
+ for name, env_var, endpoint, _, default_model in REMOTE_BACKENDS:
174
+ api_key = os.environ.get(env_var) or config.get("api_keys", {}).get(name)
175
+ status = "✓ configured" if api_key else "✗ not set "
176
+ override = config.get("remote_models", {}).get(name)
177
+ model_info = f"{override} (override)" if override else default_model
178
+ print(f" {status} {name:<10} [{env_var}] → {model_info}")
179
+
180
+ pref = config.get("preferred_remote")
181
+ if pref:
182
+ print(f"\nPreferred remote: {pref}")
183
+ return 0
184
+
185
+
186
+ def _cmd_detect(require_local: bool) -> int:
187
+ config = load_config()
188
+ preferred_remote = config.get("preferred_remote")
189
+
190
+ result = detect(require_local=require_local, preferred_remote=preferred_remote)
191
+ if result is None:
192
+ if require_local:
193
+ print(
194
+ "No local model server found (--require-local set). "
195
+ "Run: journeyman setup --list",
196
+ file=sys.stderr,
197
+ )
198
+ else:
199
+ print(
200
+ "No model backend found. Run: journeyman setup --list",
201
+ file=sys.stderr,
202
+ )
203
+ return 1
204
+
205
+ # Warn about remote use before printing JSON so callers can capture JSON cleanly.
206
+ if not result["is_local"]:
207
+ print(
208
+ f"[journeyman] WARNING: Using remote backend '{result['name']}' — "
209
+ f"spec and context files will be sent to {result['endpoint']}",
210
+ file=sys.stderr,
211
+ )
212
+
213
+ # Omit api_key from stdout output (security).
214
+ safe = {k: v for k, v in result.items() if k != "api_key"}
215
+ print(json.dumps(safe))
216
+
217
+ # Server running but no usable model is not a successful detection.
218
+ if result.get("error") or not result.get("model"):
219
+ err = result.get("error") or f"Backend '{result.get('name')}' returned no model."
220
+ print(err, file=sys.stderr)
221
+ return 1
222
+
223
+ return 0
224
+
225
+
226
+ def main(argv: list[str] | None = None) -> int:
227
+ ap = argparse.ArgumentParser(
228
+ description="Detect preferred local-first model backend for journeyman.",
229
+ )
230
+ ap.add_argument("--list", action="store_true", help="Show all backends (human-readable)")
231
+ ap.add_argument(
232
+ "--require-local",
233
+ action="store_true",
234
+ help="Exit 1 if only a remote backend is available",
235
+ )
236
+ args = ap.parse_args(argv)
237
+
238
+ if args.list:
239
+ return _cmd_list()
240
+ return _cmd_detect(require_local=args.require_local)
241
+
242
+
243
+ if __name__ == "__main__":
244
+ sys.exit(main())
journeyman/main.py ADDED
@@ -0,0 +1,48 @@
1
+ import sys
2
+
3
+
4
+ def print_help():
5
+ print("""journeyman — route implementation tasks to a local or economical model
6
+
7
+ Usage:
8
+ journeyman worker --task SPEC --out FILE [options] Run the worker
9
+ journeyman setup [options] Configure backends
10
+ journeyman detect [--list] Show detected backend
11
+ journeyman stats [options] Show usage statistics
12
+
13
+ Run 'journeyman <command> --help' for details on a specific command.
14
+ """)
15
+
16
+ def main() -> int:
17
+ args = sys.argv[1:]
18
+ if not args:
19
+ print_help()
20
+ return 2
21
+
22
+ command = args[0]
23
+ sub_args = args[1:]
24
+
25
+ if command in ("-h", "--help", "help"):
26
+ print_help()
27
+ return 0
28
+ elif command in ("-v", "--version", "version"):
29
+ print("journeyman 1.1.0")
30
+ return 0
31
+ elif command == "worker":
32
+ from journeyman import sb_worker
33
+ return sb_worker.main(sub_args)
34
+ elif command == "setup":
35
+ from journeyman import setup
36
+ return setup.main(sub_args)
37
+ elif command == "detect":
38
+ from journeyman import detect_worker
39
+ return detect_worker.main(sub_args)
40
+ elif command == "stats":
41
+ from journeyman import stats
42
+ return stats.main(sub_args)
43
+ else:
44
+ print(f"[journeyman] Unknown command: {command}. Run 'journeyman help' for usage.", file=sys.stderr)
45
+ return 2
46
+
47
+ if __name__ == "__main__":
48
+ sys.exit(main())