Instructions to use jpanasuk/basecamp with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- HERMES
How to use jpanasuk/basecamp with HERMES:
# No code snippets available yet for this library. # To use this model, check the repository files and the library's documentation. # Want to help? PRs adding snippets are welcome at: # https://github.com/huggingface/huggingface.js
- Notebooks
- Google Colab
- Kaggle
v1.0.2: auth-gated service discovery, hermes v0.20 named-provider wiring, ollama-first auto-select, searxng fast probe
Browse files- README.md +18 -3
- discover.py +66 -15
- 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.
|
| 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.
|
| 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:
|
| 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 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 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 |
|