jpanasuk commited on
Commit
fe9a48a
·
1 Parent(s): a098992

v1.0.2: auth-gated service discovery, hermes v0.20 named-provider wiring, ollama-first auto-select, searxng fast probe

Browse files
Files changed (3) hide show
  1. README.md +18 -3
  2. discover.py +66 -15
  3. entrypoint.sh +28 -2
README.md CHANGED
@@ -17,9 +17,24 @@ library_name: docker
17
 
18
  # 🏕️ Basecamp — The First Portable AI Agent Container with Network Auto-Discovery
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  **v1.0.1** — rebuilt on the official Hermes installer (the old `pip3 install
21
  hermes-cli` stub is gone), leaked-key fallback removed from `tavern.sh`, and
22
- the git history scrubbed of both.
23
 
24
  One image. One command. It finds your AI stack and just works.
25
 
@@ -65,7 +80,7 @@ install on the host that runs it:
65
 
66
  ```bash
67
  # Option A: pull the prebuilt image (Docker Hub)
68
- docker pull jpanasuk/basecamp:1.0.1
69
 
70
  # Option B: build from source
71
  docker build -t local/basecamp:latest .
@@ -78,7 +93,7 @@ docker run -it --rm \
78
  -p 11436:11434 \
79
  -v basecamp-ollama:/root/.ollama \
80
  -v basecamp-hermes:/root/.hermes \
81
- jpanasuk/basecamp:1.0.1
82
  ```
83
 
84
  Or use the convenience launcher (auto-detects network + GPU, TTY-safe):
 
17
 
18
  # 🏕️ Basecamp — The First Portable AI Agent Container with Network Auto-Discovery
19
 
20
+ **v1.0.2** — the stack-gauntlet release (tested against a live 6-service AI
21
+ stack):
22
+ - **TabbyAPI + SillyTavern now discovered** — auth-gated services (401/403)
23
+ are fingerprinted and listed as `[needs auth]` so the connect screen can
24
+ prompt for keys (previously invisible)
25
+ - **Fixed hermes v0.20 wiring** — named custom provider (`providers.basecamp`)
26
+ with inline API key, sidestepping the #28660 security gate that blocks env
27
+ keys for LAN endpoints; reasoning-effort turned off (local engines reject
28
+ thinking params with HTTP 400); stale context probes cleared
29
+ - **Ollama preferred for auto-select** — hermes hard-requires ≥64K context, so
30
+ headless mode picks an Ollama endpoint; OpenAI-compatible engines (TabbyAPI,
31
+ vLLM) are still listed for interactive choice
32
+ - **SearXNG probed via its fast static page** — no more slow search-query probe
33
+ losing the boot race
34
+
35
  **v1.0.1** — rebuilt on the official Hermes installer (the old `pip3 install
36
  hermes-cli` stub is gone), leaked-key fallback removed from `tavern.sh`, and
37
+ the git history scrubbed of both. Build from source below.
38
 
39
  One image. One command. It finds your AI stack and just works.
40
 
 
80
 
81
  ```bash
82
  # Option A: pull the prebuilt image (Docker Hub)
83
+ docker pull jpanasuk/basecamp:1.0.2
84
 
85
  # Option B: build from source
86
  docker build -t local/basecamp:latest .
 
93
  -p 11436:11434 \
94
  -v basecamp-ollama:/root/.ollama \
95
  -v basecamp-hermes:/root/.hermes \
96
+ jpanasuk/basecamp:1.0.2
97
  ```
98
 
99
  Or use the convenience launcher (auto-detects network + GPU, TTY-safe):
discover.py CHANGED
@@ -41,6 +41,7 @@ SERVICE_PROBES = {
41
  "paths": ["/v1/models"],
42
  "method": "GET",
43
  "match": lambda r: '"data"' in r and ('"model"' in r.lower() or '"owned_by"' in r or '"id"' in r),
 
44
  "parse": lambda r: {
45
  "models": [m["id"] for m in json.loads(r).get("data", [])]
46
  },
@@ -116,9 +117,9 @@ SERVICE_PROBES = {
116
  "auth_type": "basic",
117
  },
118
  "searxng": {
119
- "paths": ["/search?q=test&format=json"],
120
  "method": "GET",
121
- "match": lambda r: '"results"' in r,
122
  "parse": lambda r: {},
123
  "label": "SearXNG (private search)",
124
  "icon": "SRC",
@@ -153,7 +154,13 @@ COMMON_PORTS = [11434, 5000, 8000, 8080, 8001, 3000, 11435, 11436]
153
  # ── HTTP helpers ──
154
 
155
  def http_get(url, timeout=1, headers=None):
156
- """Simple HTTP GET returning response text or None."""
 
 
 
 
 
 
157
  try:
158
  req = urllib.request.Request(url, method="GET")
159
  if headers:
@@ -161,6 +168,13 @@ def http_get(url, timeout=1, headers=None):
161
  req.add_header(k, v)
162
  with urllib.request.urlopen(req, timeout=timeout) as resp:
163
  return resp.read().decode("utf-8", errors="replace")
 
 
 
 
 
 
 
164
  except Exception:
165
  return None
166
 
@@ -293,6 +307,26 @@ def scan_network():
293
  "needs_auth": probe.get("needs_auth", False),
294
  "auth_type": probe.get("auth_type", "bearer"),
295
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  # If service needs auth and we got nothing, try with common defaults
297
  if probe.get("needs_auth") and not resp:
298
  auth_headers = get_default_auth_headers(probe.get("auth_type", "bearer"))
@@ -391,17 +425,31 @@ def generate_config(services, selected=None, auth_keys=None):
391
  else:
392
  config[role] = entry
393
  else:
394
- # Auto-select
395
- for svc in services:
396
- if svc["type"] == "ollama":
397
- config["secondary"] = {"type": "ollama", "url": svc["url"], "label": svc["label"]}
398
- config["ollama_models"] = svc.get("details", {}).get("models", [])
399
- elif svc["type"] in ("tabbyapi", "vllm", "litellm", "localai", "llamacpp", "text-generation-webui") and not config["inference"]:
400
- config["inference"] = {"type": svc["type"], "url": svc["url"], "label": svc["label"]}
401
- config["openai_models"] = svc.get("details", {}).get("models", [])
402
- if not config["inference"] and config["secondary"]:
403
- config["inference"] = config["secondary"]
404
- config["secondary"] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
405
  for svc in services:
406
  if svc["type"] == "searxng":
407
  config["search"] = {"type": "searxng", "url": svc["url"], "label": svc["label"]}
@@ -806,7 +854,10 @@ def write_basecamp_env(config):
806
  env_path.write_text(
807
  f'export OPENAI_BASE_URL="{base_url}"\n'
808
  f'export OPENAI_API_KEY="{api_key}"\n'
809
- f'export HERMES_MODEL="openai/{default_model}"\n'
 
 
 
810
  )
811
  try:
812
  os.chmod(env_path, 0o600)
 
41
  "paths": ["/v1/models"],
42
  "method": "GET",
43
  "match": lambda r: '"data"' in r and ('"model"' in r.lower() or '"owned_by"' in r or '"id"' in r),
44
+ "match_auth": lambda r: '"detail"' in r and "api key" in r.lower(),
45
  "parse": lambda r: {
46
  "models": [m["id"] for m in json.loads(r).get("data", [])]
47
  },
 
117
  "auth_type": "basic",
118
  },
119
  "searxng": {
120
+ "paths": ["/", "/search?q=test&format=json"],
121
  "method": "GET",
122
+ "match": lambda r: "searxng" in r.lower(),
123
  "parse": lambda r: {},
124
  "label": "SearXNG (private search)",
125
  "icon": "SRC",
 
154
  # ── HTTP helpers ──
155
 
156
  def http_get(url, timeout=1, headers=None):
157
+ """Simple HTTP GET returning response text or None.
158
+
159
+ For 401/403 responses, returns the error body (HTTPError bodies are
160
+ read and returned) so auth-gated services can still be fingerprinted
161
+ and listed as ``needs_auth`` — otherwise a service behind a 401 wall
162
+ (TabbyAPI, SillyTavern, ...) is invisible to discovery.
163
+ """
164
  try:
165
  req = urllib.request.Request(url, method="GET")
166
  if headers:
 
168
  req.add_header(k, v)
169
  with urllib.request.urlopen(req, timeout=timeout) as resp:
170
  return resp.read().decode("utf-8", errors="replace")
171
+ except urllib.error.HTTPError as e:
172
+ if e.code in (401, 403):
173
+ try:
174
+ return e.read().decode("utf-8", errors="replace")
175
+ except Exception:
176
+ return None
177
+ return None
178
  except Exception:
179
  return None
180
 
 
307
  "needs_auth": probe.get("needs_auth", False),
308
  "auth_type": probe.get("auth_type", "bearer"),
309
  }
310
+ # Auth-gated service: the 401/403 body still identifies it
311
+ # (e.g. TabbyAPI's {"detail":"Please provide an API key"}).
312
+ # List it as needs_auth so the connect screen can prompt for a key.
313
+ if (
314
+ probe.get("match_auth")
315
+ and resp
316
+ and probe["match_auth"](resp)
317
+ ):
318
+ return {
319
+ "type": svc_type,
320
+ "label": probe["label"],
321
+ "icon": probe["icon"],
322
+ "host": name,
323
+ "url": base_url,
324
+ "port": port,
325
+ "network": net,
326
+ "details": {},
327
+ "needs_auth": True,
328
+ "auth_type": probe.get("auth_type", "bearer"),
329
+ }
330
  # If service needs auth and we got nothing, try with common defaults
331
  if probe.get("needs_auth") and not resp:
332
  auth_headers = get_default_auth_headers(probe.get("auth_type", "bearer"))
 
425
  else:
426
  config[role] = entry
427
  else:
428
+ # Auto-select. Hermes hard-requires >=64K context (its system prompt
429
+ # alone is ~16K), so PREFER an Ollama endpoint as inference: Ollama
430
+ # models are typically served with a 64K+ window, whereas TabbyAPI /
431
+ # vLLM endpoints on small GPUs are often capped at 8K (exl2/exl3 KV
432
+ # cache limits) and would be rejected by hermes. OpenAI-compatible
433
+ # engines are still listed at the connect screen for interactive pick.
434
+ ollamas = [s for s in services if s["type"] == "ollama"]
435
+ openai_engines = [s for s in services if s["type"] in (
436
+ "tabbyapi", "vllm", "litellm", "localai", "llamacpp",
437
+ "text-generation-webui")]
438
+ if ollamas:
439
+ primary = ollamas[0]
440
+ config["inference"] = {
441
+ "type": primary["type"], "url": primary["url"],
442
+ "label": primary["label"]}
443
+ config["openai_models"] = []
444
+ config["ollama_models"] = primary.get("details", {}).get("models", [])
445
+ if len(ollamas) > 1:
446
+ config["secondary"] = {"type": "ollama", "url": ollamas[1]["url"], "label": ollamas[1]["label"]}
447
+ elif openai_engines:
448
+ primary = openai_engines[0]
449
+ config["inference"] = {
450
+ "type": primary["type"], "url": primary["url"],
451
+ "label": primary["label"]}
452
+ config["openai_models"] = primary.get("details", {}).get("models", [])
453
  for svc in services:
454
  if svc["type"] == "searxng":
455
  config["search"] = {"type": "searxng", "url": svc["url"], "label": svc["label"]}
 
854
  env_path.write_text(
855
  f'export OPENAI_BASE_URL="{base_url}"\n'
856
  f'export OPENAI_API_KEY="{api_key}"\n'
857
+ # bare model id — the base URL already routes to the discovered
858
+ # engine; an "openai/" prefix is sent literally by hermes and 404s
859
+ # against ollama's /v1 (model 'openai/llama3.1:8b' not found).
860
+ f'export HERMES_MODEL="{default_model}"\n'
861
  )
862
  try:
863
  os.chmod(env_path, 0o600)
entrypoint.sh CHANGED
@@ -61,11 +61,37 @@ if [ -f "$ENV_FILE" ]; then
61
  fi
62
 
63
  # Best-effort: if Hermes has no model configured yet, set it via Hermes' own
64
- # config CLI (schema-safe) so the first-run wizard doesn't block. This only
65
- # ever writes inside basecamp's own HERMES_HOME.
66
  if [ -n "${HERMES_MODEL:-}" ] && ! hermes config get model > /dev/null 2>&1; then
67
  hermes config set model "$HERMES_MODEL" > /dev/null 2>&1 || true
68
  fi
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
  echo ""
71
 
 
61
  fi
62
 
63
  # Best-effort: if Hermes has no model configured yet, set it via Hermes' own
64
+ # Best-effort: if Hermes has no model configured yet, set it via Hermes' own
65
+ # config CLI (schema-safe). This only ever writes inside basecamp's own HERMES_HOME.
66
  if [ -n "${HERMES_MODEL:-}" ] && ! hermes config get model > /dev/null 2>&1; then
67
  hermes config set model "$HERMES_MODEL" > /dev/null 2>&1 || true
68
  fi
69
+ # CRITICAL (v0.20+): the stock config.yaml ships a default model+base_url
70
+ # (openrouter), so the guard above never fires and env-only wiring
71
+ # (OPENAI_BASE_URL / OPENAI_API_KEY) is IGNORED for non-authoritative hosts —
72
+ # hermes's #28660 security gate blocks env keys for LAN/custom endpoints and
73
+ # sends the "no-key-required" sentinel instead (auth 401). The sanctioned path
74
+ # is a NAMED custom provider with an inline api_key. Configure it in
75
+ # basecamp's OWN HERMES_HOME (never a host install).
76
+ if [ -n "${OPENAI_BASE_URL:-}" ]; then
77
+ hermes config set providers.basecamp.api "$OPENAI_BASE_URL" > /dev/null 2>&1 || true
78
+ hermes config set providers.basecamp.default_model "$HERMES_MODEL" > /dev/null 2>&1 || true
79
+ if [ -n "${OPENAI_API_KEY:-}" ]; then
80
+ hermes config set providers.basecamp.api_key "$OPENAI_API_KEY" > /dev/null 2>&1 || true
81
+ fi
82
+ hermes config set providers.basecamp.context_length 131072 > /dev/null 2>&1 || true
83
+ hermes config set model.provider basecamp > /dev/null 2>&1 || true
84
+ hermes config set model.default "$HERMES_MODEL" > /dev/null 2>&1 || true
85
+ # Drop the stock openrouter base_url so model.base_url doesn't lie about
86
+ # where traffic actually goes (the named provider's api is authoritative).
87
+ hermes config set model.base_url "" > /dev/null 2>&1 || true
88
+ # Local engines (ollama/tabbyapi/vllm) generally don't support hermes's
89
+ # reasoning-effort params — sending them yields "does not support thinking"
90
+ # HTTP 400. Turn reasoning off for the basecamp provider.
91
+ hermes config set agent.reasoning_effort off > /dev/null 2>&1 || true
92
+ # Clear stale context probes so the live endpoint is re-queried.
93
+ rm -f "${HERMES_HOME:-/root/.hermes}/context_length_cache.yaml" 2>/dev/null || true
94
+ fi
95
 
96
  echo ""
97