Spaces:
Running
Running
Commit Β·
ed5527c
1
Parent(s): 33980b3
Expand provider model wiring to all aliases and remove env list file
Browse files- .env.example +11 -1
- CHANGELOG.md +38 -0
- README.md +2 -2
- multi-provider-key-rotator.cjs +13 -3
- openclaw-sync.py +65 -16
- start.sh +134 -2
.env.example
CHANGED
|
@@ -132,7 +132,10 @@ LLM_MODEL=anthropic/claude-sonnet-4-5
|
|
| 132 |
# have multiple accounts or want to spread rate-limit quota.
|
| 133 |
#
|
| 134 |
# Pattern: <PROVIDER>_API_KEYS=key1,key2,key3
|
| 135 |
-
# Fallback order: plural pool β singular key β LLM_API_KEY
|
|
|
|
|
|
|
|
|
|
| 136 |
#
|
| 137 |
# Uncomment and fill in only the providers you use:
|
| 138 |
#
|
|
@@ -172,6 +175,13 @@ LLM_MODEL=anthropic/claude-sonnet-4-5
|
|
| 172 |
# is also picked up by OpenClaw β that provider's models become
|
| 173 |
# available in the Control UI for manual selection.
|
| 174 |
# 3. Rotation pools (*_API_KEYS) work for every active provider
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
# independently and in parallel.
|
| 176 |
#
|
| 177 |
# EXAMPLE β default Anthropic, also use OpenAI and Groq:
|
|
|
|
| 132 |
# have multiple accounts or want to spread rate-limit quota.
|
| 133 |
#
|
| 134 |
# Pattern: <PROVIDER>_API_KEYS=key1,key2,key3
|
| 135 |
+
# Fallback order: plural pool β singular key β LLM_API_KEY (optional)
|
| 136 |
+
# Set false only if you want to disable global LLM_API_KEY fallback
|
| 137 |
+
# across providers.
|
| 138 |
+
LLM_API_KEY_FALLBACK_ENABLED=true
|
| 139 |
#
|
| 140 |
# Uncomment and fill in only the providers you use:
|
| 141 |
#
|
|
|
|
| 175 |
# is also picked up by OpenClaw β that provider's models become
|
| 176 |
# available in the Control UI for manual selection.
|
| 177 |
# 3. Rotation pools (*_API_KEYS) work for every active provider
|
| 178 |
+
#
|
| 179 |
+
# Optional: explicitly pin model lists per provider for Control UI visibility
|
| 180 |
+
# when provider keys are configured.
|
| 181 |
+
# Format: comma-separated model IDs
|
| 182 |
+
# NVIDIA_MODELS=meta/llama-3.1-70b-instruct,nvidia/llama-3.1-nemotron-70b-instruct
|
| 183 |
+
# OPENAI_MODELS=gpt-4o-mini,gpt-4.1
|
| 184 |
+
# GROQ_MODELS=llama-3.3-70b-versatile,deepseek-r1-distill-llama-70b
|
| 185 |
# independently and in parallel.
|
| 186 |
#
|
| 187 |
# EXAMPLE β default Anthropic, also use OpenAI and Groq:
|
CHANGELOG.md
CHANGED
|
@@ -2,6 +2,44 @@
|
|
| 2 |
|
| 3 |
All notable changes to this project will be documented in this file.
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
## [1.4.0] - 2026-04-25
|
| 6 |
|
| 7 |
### Added
|
|
|
|
| 2 |
|
| 3 |
All notable changes to this project will be documented in this file.
|
| 4 |
|
| 5 |
+
## [1.5.0] - 2026-05-13
|
| 6 |
+
|
| 7 |
+
### Added
|
| 8 |
+
|
| 9 |
+
- **NVIDIA key-rotation support** β added `nvidia-key-rotator.cjs` wiring and startup integration so deployments can rotate NVIDIA credentials similarly to other provider key-rotation flows.
|
| 10 |
+
- **Cloudflare keep-alive automation** β added/expanded `cloudflare-keepalive-setup.py` flow and startup wiring to provision keep-alive through Cloudflare Worker automation instead of the older UptimeRobot-first approach.
|
| 11 |
+
- **Sync metadata marker model** β introduced a structured workspace marker `(file_count, total_size, newest_mtime, metadata_hash)` to support stronger change introspection in sync code.
|
| 12 |
+
|
| 13 |
+
### Changed
|
| 14 |
+
|
| 15 |
+
- **Workspace sync script rename finalized** β `workspace-sync.py` flow was migrated to `openclaw-sync.py` in Docker/startup/docs so restore/sync behavior is centralized under one script.
|
| 16 |
+
- **Sync trigger behavior hardened for config churn** β OpenClaw config sync now debounces until JSON settles before immediate sync, reducing false/partial syncs during rapid config writes.
|
| 17 |
+
- **Gateway restart flow now saves state first** β restart path was updated to run a pre-restart one-shot state sync so gateway reloads are less likely to drop recent state.
|
| 18 |
+
- **Shutdown backup now uses a two-step pass** β graceful shutdown now attempts `sync-once-settled` then a final `sync-once` pass to better capture last-second writes.
|
| 19 |
+
- **Telegram allowlist simplified** β consolidated Telegram allowlist into `TELEGRAM_ALLOWED_USERS` and aligned docs/examples.
|
| 20 |
+
- **Plugin startup behavior aligned** β startup-installed plugins are synced into `plugins.allow` before gateway launch so runtime-installed plugins are recognized cleanly.
|
| 21 |
+
- **Cloudflare proxy path matured** β multiple iterations improved fetch/proxy behavior (header handling, endpoint scoping, API root routing, URL parsing, and logging noise reduction), then simplified unstable undici patching paths.
|
| 22 |
+
- **Health dashboard polish** β sync timestamps now show local time, footer credits were corrected, and status rendering/docs were updated for the Cloudflare keep-alive model.
|
| 23 |
+
- **CI workflow churn documented** β GitHub workflow files for HF sync were added/renamed/cleaned multiple times as space/repo naming stabilized.
|
| 24 |
+
|
| 25 |
+
### Fixed
|
| 26 |
+
|
| 27 |
+
- **Missed rapid backup updates** β sync logic now relies on content fingerprint checks for no-op decisions so same-second or quick successive changes are less likely to be skipped.
|
| 28 |
+
- **Non-deterministic metadata hashing** β metadata hashing now iterates paths deterministically to avoid hash jitter from traversal order.
|
| 29 |
+
- **Transient file race sync failures** β sync fingerprinting/snapshot copy paths now tolerate transient `OSError` (file rotated/deleted mid-scan) instead of aborting the whole sync pass.
|
| 30 |
+
- **State restore migration edge cases** β restore flow includes migration/cleanup behavior for legacy hidden state paths and stale backup entries.
|
| 31 |
+
- **Startup/env robustness** β fixed shell export formatting/syntax issues (e.g., NVIDIA/XAI lines) and unbound-variable pitfalls in startup scripts.
|
| 32 |
+
- **Proxy runtime errors and noise** β fixed specific proxy runtime issues (including `UND_ERR_INVALID_ARG`, fetch duplex handling, and upstream error visibility) and reduced noisy stdout logs that interfered with clean process output.
|
| 33 |
+
- **HF workflow/repo reference mismatches** β corrected and later cleaned workflow repository references during repo migration/restructure.
|
| 34 |
+
|
| 35 |
+
### Docs
|
| 36 |
+
|
| 37 |
+
- README/.env/security docs were refreshed across multiple commits to reflect:
|
| 38 |
+
- Cloudflare keep-alive replacing UptimeRobot setup path,
|
| 39 |
+
- updated secrets and startup environment behavior,
|
| 40 |
+
- provider/key-rotation options,
|
| 41 |
+
- backup/sync behavior and troubleshooting guidance.
|
| 42 |
+
|
| 43 |
## [1.4.0] - 2026-04-25
|
| 44 |
|
| 45 |
### Added
|
README.md
CHANGED
|
@@ -250,10 +250,10 @@ GEMINI_API_KEYS=AIza-key1,AIza-key2
|
|
| 250 |
**Fallback chain** (per provider):
|
| 251 |
1. `{PROVIDER}_API_KEYS` β comma-separated pool *(preferred)*
|
| 252 |
2. `{PROVIDER}_API_KEY` β single dedicated key
|
| 253 |
-
3. `LLM_API_KEY` β universal fallback *(default
|
| 254 |
|
| 255 |
> [!TIP]
|
| 256 |
-
>
|
| 257 |
|
| 258 |
Supported per-provider variables: `ANTHROPIC_API_KEYS`, `OPENAI_API_KEYS`, `GEMINI_API_KEYS`, `DEEPSEEK_API_KEYS`, `GROQ_API_KEYS`, `MISTRAL_API_KEYS`, `OPENROUTER_API_KEYS`, `XAI_API_KEYS`, `NVIDIA_API_KEYS`, `COHERE_API_KEYS`, `TOGETHER_API_KEYS`, `CEREBRAS_API_KEYS`, and more β see `.env.example` for the full list.
|
| 259 |
|
|
|
|
| 250 |
**Fallback chain** (per provider):
|
| 251 |
1. `{PROVIDER}_API_KEYS` β comma-separated pool *(preferred)*
|
| 252 |
2. `{PROVIDER}_API_KEY` β single dedicated key
|
| 253 |
+
3. `LLM_API_KEY` β universal fallback *(enabled by default; disable with `LLM_API_KEY_FALLBACK_ENABLED=false`)*
|
| 254 |
|
| 255 |
> [!TIP]
|
| 256 |
+
> By default, `LLM_API_KEY` fallback is enabled for compatibility. Set `LLM_API_KEY_FALLBACK_ENABLED=false` if you want strict provider-only activation.
|
| 257 |
|
| 258 |
Supported per-provider variables: `ANTHROPIC_API_KEYS`, `OPENAI_API_KEYS`, `GEMINI_API_KEYS`, `DEEPSEEK_API_KEYS`, `GROQ_API_KEYS`, `MISTRAL_API_KEYS`, `OPENROUTER_API_KEYS`, `XAI_API_KEYS`, `NVIDIA_API_KEYS`, `COHERE_API_KEYS`, `TOGETHER_API_KEYS`, `CEREBRAS_API_KEYS`, and more β see `.env.example` for the full list.
|
| 259 |
|
multi-provider-key-rotator.cjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
| 8 |
*
|
| 9 |
* For each provider you can supply a comma-separated pool:
|
| 10 |
* ANTHROPIC_API_KEYS=key1,key2,key3
|
| 11 |
-
* Falls back to the singular env var,
|
| 12 |
*
|
| 13 |
* Keys are rotated round-robin per provider independently.
|
| 14 |
*
|
|
@@ -30,7 +30,9 @@ const log = (...args) => console.error(...args);
|
|
| 30 |
// envPlural β env var that holds a comma-separated key pool (preferred)
|
| 31 |
// envSingular β env var that holds a single key (fallback)
|
| 32 |
//
|
| 33 |
-
// LLM_API_KEY
|
|
|
|
|
|
|
| 34 |
//
|
| 35 |
const PROVIDERS = [
|
| 36 |
{
|
|
@@ -180,6 +182,8 @@ function normalizeKeys(...inputs) {
|
|
| 180 |
|
| 181 |
// Build per-provider key pools + rotation indices
|
| 182 |
const providerState = PROVIDERS.map(p => {
|
|
|
|
|
|
|
| 183 |
const dedicatedKeys = normalizeKeys(
|
| 184 |
process.env[p.envPlural] || '',
|
| 185 |
process.env[p.envSingular] || '',
|
|
@@ -187,7 +191,11 @@ const providerState = PROVIDERS.map(p => {
|
|
| 187 |
const hasDedicated = dedicatedKeys.length > 0;
|
| 188 |
const keys = hasDedicated
|
| 189 |
? dedicatedKeys
|
| 190 |
-
:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
if (hasDedicated) {
|
| 193 |
log(`[key-rotator] ${p.name}: ${keys.length} key${keys.length === 1 ? '' : 's'}`);
|
|
@@ -208,6 +216,8 @@ const fallbackCount = providerState.filter(p => {
|
|
| 208 |
}).length;
|
| 209 |
if (fallbackCount > 0) {
|
| 210 |
log(`[key-rotator] ${fallbackCount} provider(s) using LLM_API_KEY fallback`);
|
|
|
|
|
|
|
| 211 |
}
|
| 212 |
|
| 213 |
// βββ Runtime helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 8 |
*
|
| 9 |
* For each provider you can supply a comma-separated pool:
|
| 10 |
* ANTHROPIC_API_KEYS=key1,key2,key3
|
| 11 |
+
* Falls back to the singular env var, and optionally to LLM_API_KEY.
|
| 12 |
*
|
| 13 |
* Keys are rotated round-robin per provider independently.
|
| 14 |
*
|
|
|
|
| 30 |
// envPlural β env var that holds a comma-separated key pool (preferred)
|
| 31 |
// envSingular β env var that holds a single key (fallback)
|
| 32 |
//
|
| 33 |
+
// LLM_API_KEY fallback can be controlled via:
|
| 34 |
+
// LLM_API_KEY_FALLBACK_ENABLED=true|false
|
| 35 |
+
// Default is enabled for backwards compatibility.
|
| 36 |
//
|
| 37 |
const PROVIDERS = [
|
| 38 |
{
|
|
|
|
| 182 |
|
| 183 |
// Build per-provider key pools + rotation indices
|
| 184 |
const providerState = PROVIDERS.map(p => {
|
| 185 |
+
const llmFallbackRaw = String(process.env.LLM_API_KEY_FALLBACK_ENABLED || '').trim().toLowerCase();
|
| 186 |
+
const llmFallbackEnabled = !/^(0|false|no|off)$/.test(llmFallbackRaw);
|
| 187 |
const dedicatedKeys = normalizeKeys(
|
| 188 |
process.env[p.envPlural] || '',
|
| 189 |
process.env[p.envSingular] || '',
|
|
|
|
| 191 |
const hasDedicated = dedicatedKeys.length > 0;
|
| 192 |
const keys = hasDedicated
|
| 193 |
? dedicatedKeys
|
| 194 |
+
: (
|
| 195 |
+
llmFallbackEnabled
|
| 196 |
+
? normalizeKeys(process.env.LLM_API_KEY || '')
|
| 197 |
+
: []
|
| 198 |
+
);
|
| 199 |
|
| 200 |
if (hasDedicated) {
|
| 201 |
log(`[key-rotator] ${p.name}: ${keys.length} key${keys.length === 1 ? '' : 's'}`);
|
|
|
|
| 216 |
}).length;
|
| 217 |
if (fallbackCount > 0) {
|
| 218 |
log(`[key-rotator] ${fallbackCount} provider(s) using LLM_API_KEY fallback`);
|
| 219 |
+
} else if (process.env.LLM_API_KEY && /^(0|false|no|off)$/i.test(String(process.env.LLM_API_KEY_FALLBACK_ENABLED || ''))) {
|
| 220 |
+
log('[key-rotator] LLM_API_KEY fallback disabled (set LLM_API_KEY_FALLBACK_ENABLED=true to re-enable)');
|
| 221 |
}
|
| 222 |
|
| 223 |
// βββ Runtime helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
openclaw-sync.py
CHANGED
|
@@ -18,6 +18,7 @@ import sys
|
|
| 18 |
import tempfile
|
| 19 |
import threading
|
| 20 |
import time
|
|
|
|
| 21 |
from pathlib import Path
|
| 22 |
|
| 23 |
os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
|
|
@@ -69,6 +70,7 @@ EXCLUDED_STATE_NAMES = {
|
|
| 69 |
"openclaw-app",
|
| 70 |
"gateway.log",
|
| 71 |
"browser",
|
|
|
|
| 72 |
}
|
| 73 |
WHATSAPP_CREDS_DIR = OPENCLAW_HOME / "credentials" / "whatsapp" / "default"
|
| 74 |
WHATSAPP_BACKUP_DIR = STATE_DIR / "credentials" / "whatsapp" / "default"
|
|
@@ -76,6 +78,7 @@ RESET_MARKER = WORKSPACE / ".reset_credentials"
|
|
| 76 |
HF_API = HfApi(token=HF_TOKEN) if HF_TOKEN else None
|
| 77 |
STOP_EVENT = threading.Event()
|
| 78 |
_REPO_ID_CACHE: str | None = None
|
|
|
|
| 79 |
|
| 80 |
|
| 81 |
def write_status(status: str, message: str) -> None:
|
|
@@ -280,14 +283,15 @@ def file_marker(path: Path) -> tuple[int, int, int]:
|
|
| 280 |
return (1, int(stat.st_size), int(stat.st_mtime_ns))
|
| 281 |
|
| 282 |
|
| 283 |
-
def metadata_marker(root: Path) ->
|
| 284 |
if not root.exists():
|
| 285 |
-
return (0, 0, 0)
|
| 286 |
|
| 287 |
file_count = 0
|
| 288 |
total_size = 0
|
| 289 |
newest_mtime = 0
|
| 290 |
-
|
|
|
|
| 291 |
if not path.is_file():
|
| 292 |
continue
|
| 293 |
rel = path.relative_to(root).as_posix()
|
|
@@ -298,9 +302,17 @@ def metadata_marker(root: Path) -> tuple[int, int, int]:
|
|
| 298 |
except OSError:
|
| 299 |
continue
|
| 300 |
file_count += 1
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
|
| 305 |
|
| 306 |
def fingerprint_dir(root: Path) -> str:
|
|
@@ -313,9 +325,16 @@ def fingerprint_dir(root: Path) -> str:
|
|
| 313 |
if _should_exclude(rel, path):
|
| 314 |
continue
|
| 315 |
hasher.update(rel.encode("utf-8"))
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
return hasher.hexdigest()
|
| 320 |
|
| 321 |
|
|
@@ -331,10 +350,40 @@ def create_snapshot_dir(source_root: Path) -> Path:
|
|
| 331 |
target.mkdir(parents=True, exist_ok=True)
|
| 332 |
continue
|
| 333 |
target.parent.mkdir(parents=True, exist_ok=True)
|
| 334 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
return staging_root
|
| 336 |
|
| 337 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
def restore_workspace() -> bool:
|
| 339 |
if not HF_TOKEN:
|
| 340 |
write_status("disabled", "HF_TOKEN is not configured.")
|
|
@@ -396,16 +445,15 @@ def restore_workspace() -> bool:
|
|
| 396 |
|
| 397 |
def _sync_once_unlocked(
|
| 398 |
last_fingerprint: str | None = None,
|
| 399 |
-
last_marker:
|
| 400 |
-
) -> tuple[str,
|
| 401 |
if not HF_TOKEN:
|
| 402 |
write_status("disabled", "HF_TOKEN is not configured.")
|
| 403 |
-
return (last_fingerprint or "", last_marker or (0, 0, 0))
|
| 404 |
|
| 405 |
snapshot_state_into_workspace()
|
| 406 |
repo_id = ensure_repo_exists()
|
| 407 |
current_marker = metadata_marker(WORKSPACE)
|
| 408 |
-
|
| 409 |
if last_marker is not None and current_marker == last_marker:
|
| 410 |
write_status("synced", "No workspace changes detected.")
|
| 411 |
return (last_fingerprint or "", current_marker)
|
|
@@ -435,6 +483,7 @@ def _sync_once_unlocked(
|
|
| 435 |
commit_message=f"HuggingClaw sync {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}",
|
| 436 |
ignore_patterns=[".git/*", ".git"],
|
| 437 |
)
|
|
|
|
| 438 |
finally:
|
| 439 |
shutil.rmtree(snapshot_dir, ignore_errors=True)
|
| 440 |
|
|
@@ -444,8 +493,8 @@ def _sync_once_unlocked(
|
|
| 444 |
|
| 445 |
def sync_once(
|
| 446 |
last_fingerprint: str | None = None,
|
| 447 |
-
last_marker:
|
| 448 |
-
) -> tuple[str,
|
| 449 |
SYNC_LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
| 450 |
with SYNC_LOCK_FILE.open("w", encoding="utf-8") as lock_handle:
|
| 451 |
fcntl.flock(lock_handle, fcntl.LOCK_EX)
|
|
|
|
| 18 |
import tempfile
|
| 19 |
import threading
|
| 20 |
import time
|
| 21 |
+
from typing import TypeAlias
|
| 22 |
from pathlib import Path
|
| 23 |
|
| 24 |
os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
|
|
|
|
| 70 |
"openclaw-app",
|
| 71 |
"gateway.log",
|
| 72 |
"browser",
|
| 73 |
+
"npm",
|
| 74 |
}
|
| 75 |
WHATSAPP_CREDS_DIR = OPENCLAW_HOME / "credentials" / "whatsapp" / "default"
|
| 76 |
WHATSAPP_BACKUP_DIR = STATE_DIR / "credentials" / "whatsapp" / "default"
|
|
|
|
| 78 |
HF_API = HfApi(token=HF_TOKEN) if HF_TOKEN else None
|
| 79 |
STOP_EVENT = threading.Event()
|
| 80 |
_REPO_ID_CACHE: str | None = None
|
| 81 |
+
WorkspaceMarker: TypeAlias = tuple[int, int, int, str]
|
| 82 |
|
| 83 |
|
| 84 |
def write_status(status: str, message: str) -> None:
|
|
|
|
| 283 |
return (1, int(stat.st_size), int(stat.st_mtime_ns))
|
| 284 |
|
| 285 |
|
| 286 |
+
def metadata_marker(root: Path) -> WorkspaceMarker:
|
| 287 |
if not root.exists():
|
| 288 |
+
return (0, 0, 0, "")
|
| 289 |
|
| 290 |
file_count = 0
|
| 291 |
total_size = 0
|
| 292 |
newest_mtime = 0
|
| 293 |
+
metadata_hasher = hashlib.sha256()
|
| 294 |
+
for path in sorted(root.rglob("*")):
|
| 295 |
if not path.is_file():
|
| 296 |
continue
|
| 297 |
rel = path.relative_to(root).as_posix()
|
|
|
|
| 302 |
except OSError:
|
| 303 |
continue
|
| 304 |
file_count += 1
|
| 305 |
+
size = int(stat.st_size)
|
| 306 |
+
mtime_ns = int(stat.st_mtime_ns)
|
| 307 |
+
total_size += size
|
| 308 |
+
newest_mtime = max(newest_mtime, mtime_ns)
|
| 309 |
+
metadata_hasher.update(rel.encode("utf-8"))
|
| 310 |
+
metadata_hasher.update(b"\0")
|
| 311 |
+
metadata_hasher.update(str(size).encode("ascii"))
|
| 312 |
+
metadata_hasher.update(b"\0")
|
| 313 |
+
metadata_hasher.update(str(mtime_ns).encode("ascii"))
|
| 314 |
+
metadata_hasher.update(b"\0")
|
| 315 |
+
return (file_count, total_size, newest_mtime, metadata_hasher.hexdigest())
|
| 316 |
|
| 317 |
|
| 318 |
def fingerprint_dir(root: Path) -> str:
|
|
|
|
| 325 |
if _should_exclude(rel, path):
|
| 326 |
continue
|
| 327 |
hasher.update(rel.encode("utf-8"))
|
| 328 |
+
try:
|
| 329 |
+
with path.open("rb") as handle:
|
| 330 |
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
| 331 |
+
hasher.update(chunk)
|
| 332 |
+
except (FileNotFoundError, IsADirectoryError, NotADirectoryError):
|
| 333 |
+
# Fingerprint must represent a complete view of the workspace.
|
| 334 |
+
# Retry next sync pass instead of silently hashing a partial tree.
|
| 335 |
+
raise RuntimeError(
|
| 336 |
+
f"Workspace changed while hashing {rel}; retrying next sync pass."
|
| 337 |
+
)
|
| 338 |
return hasher.hexdigest()
|
| 339 |
|
| 340 |
|
|
|
|
| 350 |
target.mkdir(parents=True, exist_ok=True)
|
| 351 |
continue
|
| 352 |
target.parent.mkdir(parents=True, exist_ok=True)
|
| 353 |
+
try:
|
| 354 |
+
shutil.copy2(path, target)
|
| 355 |
+
except (FileNotFoundError, IsADirectoryError, NotADirectoryError):
|
| 356 |
+
# Do not upload a partial snapshot; let caller retry on next loop.
|
| 357 |
+
raise RuntimeError(
|
| 358 |
+
f"Snapshot changed while copying {rel_posix}; retrying next sync pass."
|
| 359 |
+
)
|
| 360 |
return staging_root
|
| 361 |
|
| 362 |
|
| 363 |
+
def prune_remote_deleted_files(repo_id: str, snapshot_dir: Path) -> None:
|
| 364 |
+
if HF_API is None:
|
| 365 |
+
return
|
| 366 |
+
|
| 367 |
+
local_files = {
|
| 368 |
+
path.relative_to(snapshot_dir).as_posix()
|
| 369 |
+
for path in snapshot_dir.rglob("*")
|
| 370 |
+
if path.is_file()
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
remote_files = HF_API.list_repo_files(repo_id=repo_id, repo_type="dataset")
|
| 374 |
+
stale_files = [
|
| 375 |
+
path for path in remote_files
|
| 376 |
+
if path not in local_files and path not in {".gitattributes"}
|
| 377 |
+
]
|
| 378 |
+
if stale_files:
|
| 379 |
+
HF_API.delete_files(
|
| 380 |
+
delete_patterns=stale_files,
|
| 381 |
+
repo_id=repo_id,
|
| 382 |
+
repo_type="dataset",
|
| 383 |
+
commit_message="Prune stale files after workspace sync",
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
|
| 387 |
def restore_workspace() -> bool:
|
| 388 |
if not HF_TOKEN:
|
| 389 |
write_status("disabled", "HF_TOKEN is not configured.")
|
|
|
|
| 445 |
|
| 446 |
def _sync_once_unlocked(
|
| 447 |
last_fingerprint: str | None = None,
|
| 448 |
+
last_marker: WorkspaceMarker | None = None,
|
| 449 |
+
) -> tuple[str, WorkspaceMarker]:
|
| 450 |
if not HF_TOKEN:
|
| 451 |
write_status("disabled", "HF_TOKEN is not configured.")
|
| 452 |
+
return (last_fingerprint or "", last_marker or (0, 0, 0, ""))
|
| 453 |
|
| 454 |
snapshot_state_into_workspace()
|
| 455 |
repo_id = ensure_repo_exists()
|
| 456 |
current_marker = metadata_marker(WORKSPACE)
|
|
|
|
| 457 |
if last_marker is not None and current_marker == last_marker:
|
| 458 |
write_status("synced", "No workspace changes detected.")
|
| 459 |
return (last_fingerprint or "", current_marker)
|
|
|
|
| 483 |
commit_message=f"HuggingClaw sync {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}",
|
| 484 |
ignore_patterns=[".git/*", ".git"],
|
| 485 |
)
|
| 486 |
+
prune_remote_deleted_files(repo_id, snapshot_dir)
|
| 487 |
finally:
|
| 488 |
shutil.rmtree(snapshot_dir, ignore_errors=True)
|
| 489 |
|
|
|
|
| 493 |
|
| 494 |
def sync_once(
|
| 495 |
last_fingerprint: str | None = None,
|
| 496 |
+
last_marker: WorkspaceMarker | None = None,
|
| 497 |
+
) -> tuple[str, WorkspaceMarker]:
|
| 498 |
SYNC_LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
| 499 |
with SYNC_LOCK_FILE.open("w", encoding="utf-8") as lock_handle:
|
| 500 |
fcntl.flock(lock_handle, fcntl.LOCK_EX)
|
start.sh
CHANGED
|
@@ -131,6 +131,57 @@ case "$LLM_PROVIDER" in
|
|
| 131 |
;;
|
| 132 |
esac
|
| 133 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
# ββ Setup directories ββ
|
| 135 |
mkdir -p /home/node/.openclaw/agents/main/sessions
|
| 136 |
mkdir -p /home/node/.openclaw/credentials
|
|
@@ -285,6 +336,82 @@ if [ -n "$CUSTOM_PROVIDER_NAME" ] || [ -n "$CUSTOM_BASE_URL" ] || [ -n "$CUSTOM_
|
|
| 285 |
fi
|
| 286 |
fi
|
| 287 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
# Browser configuration (managed local Chromium in HF/Docker)
|
| 289 |
BROWSER_EXECUTABLE_PATH=""
|
| 290 |
for candidate in /usr/bin/chromium /usr/bin/chromium-browser /snap/bin/chromium; do
|
|
@@ -540,10 +667,15 @@ fi
|
|
| 540 |
# ββ Trap SIGTERM for graceful shutdown ββ
|
| 541 |
graceful_shutdown() {
|
| 542 |
echo "Shutting down..."
|
| 543 |
-
if [ -f "/home/node/app/openclaw-sync.py" ]; then
|
| 544 |
echo "Saving state before exit..."
|
|
|
|
|
|
|
|
|
|
| 545 |
python3 /home/node/app/openclaw-sync.py sync-once || \
|
| 546 |
-
echo "Warning: could not complete shutdown sync"
|
|
|
|
|
|
|
| 547 |
fi
|
| 548 |
kill $(jobs -p) 2>/dev/null
|
| 549 |
exit 0
|
|
|
|
| 131 |
;;
|
| 132 |
esac
|
| 133 |
|
| 134 |
+
# Ensure OpenClaw provider discovery can see per-provider keys even when users
|
| 135 |
+
# configure only *_API_KEYS pools. Mirror first pool key into singular env.
|
| 136 |
+
promote_first_pool_key() {
|
| 137 |
+
local singular_var="$1"
|
| 138 |
+
local pool_var="$2"
|
| 139 |
+
local singular_val="${!singular_var:-}"
|
| 140 |
+
local pool_val="${!pool_var:-}"
|
| 141 |
+
|
| 142 |
+
[ -n "$singular_val" ] && return 0
|
| 143 |
+
[ -n "$pool_val" ] || return 0
|
| 144 |
+
|
| 145 |
+
local first
|
| 146 |
+
first=$(printf '%s' "$pool_val" | tr ',' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' | awk 'NF{print; exit}')
|
| 147 |
+
[ -n "$first" ] || return 0
|
| 148 |
+
export "${singular_var}=$first"
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
promote_first_pool_key "ANTHROPIC_API_KEY" "ANTHROPIC_API_KEYS"
|
| 152 |
+
promote_first_pool_key "OPENAI_API_KEY" "OPENAI_API_KEYS"
|
| 153 |
+
promote_first_pool_key "GEMINI_API_KEY" "GEMINI_API_KEYS"
|
| 154 |
+
promote_first_pool_key "GEMINI_API_KEY" "GOOGLE_API_KEYS"
|
| 155 |
+
promote_first_pool_key "DEEPSEEK_API_KEY" "DEEPSEEK_API_KEYS"
|
| 156 |
+
promote_first_pool_key "OPENROUTER_API_KEY" "OPENROUTER_API_KEYS"
|
| 157 |
+
promote_first_pool_key "KILOCODE_API_KEY" "KILOCODE_API_KEYS"
|
| 158 |
+
promote_first_pool_key "OPENCODE_API_KEY" "OPENCODE_API_KEYS"
|
| 159 |
+
promote_first_pool_key "ZAI_API_KEY" "ZAI_API_KEYS"
|
| 160 |
+
promote_first_pool_key "MOONSHOT_API_KEY" "MOONSHOT_API_KEYS"
|
| 161 |
+
promote_first_pool_key "MINIMAX_API_KEY" "MINIMAX_API_KEYS"
|
| 162 |
+
promote_first_pool_key "XIAOMI_API_KEY" "XIAOMI_API_KEYS"
|
| 163 |
+
promote_first_pool_key "VOLCANO_ENGINE_API_KEY" "VOLCANO_ENGINE_API_KEYS"
|
| 164 |
+
promote_first_pool_key "BYTEPLUS_API_KEY" "BYTEPLUS_API_KEYS"
|
| 165 |
+
promote_first_pool_key "QIANFAN_API_KEY" "QIANFAN_API_KEYS"
|
| 166 |
+
promote_first_pool_key "MODELSTUDIO_API_KEY" "MODELSTUDIO_API_KEYS"
|
| 167 |
+
promote_first_pool_key "KIMI_API_KEY" "KIMI_API_KEYS"
|
| 168 |
+
promote_first_pool_key "MISTRAL_API_KEY" "MISTRAL_API_KEYS"
|
| 169 |
+
promote_first_pool_key "XAI_API_KEY" "XAI_API_KEYS"
|
| 170 |
+
promote_first_pool_key "NVIDIA_API_KEY" "NVIDIA_API_KEYS"
|
| 171 |
+
promote_first_pool_key "GROQ_API_KEY" "GROQ_API_KEYS"
|
| 172 |
+
promote_first_pool_key "COHERE_API_KEY" "COHERE_API_KEYS"
|
| 173 |
+
promote_first_pool_key "TOGETHER_API_KEY" "TOGETHER_API_KEYS"
|
| 174 |
+
promote_first_pool_key "CEREBRAS_API_KEY" "CEREBRAS_API_KEYS"
|
| 175 |
+
promote_first_pool_key "VENICE_API_KEY" "VENICE_API_KEYS"
|
| 176 |
+
promote_first_pool_key "SYNTHETIC_API_KEY" "SYNTHETIC_API_KEYS"
|
| 177 |
+
promote_first_pool_key "COPILOT_GITHUB_TOKEN" "COPILOT_GITHUB_TOKENS"
|
| 178 |
+
promote_first_pool_key "HUGGINGFACE_HUB_TOKEN" "HUGGINGFACE_HUB_TOKENS"
|
| 179 |
+
|
| 180 |
+
# Compatibility aliases for Google provider secrets some users already have.
|
| 181 |
+
if [ -z "${GEMINI_API_KEY:-}" ] && [ -n "${GOOGLE_API_KEY:-}" ]; then
|
| 182 |
+
export GEMINI_API_KEY="$GOOGLE_API_KEY"
|
| 183 |
+
fi
|
| 184 |
+
|
| 185 |
# ββ Setup directories ββ
|
| 186 |
mkdir -p /home/node/.openclaw/agents/main/sessions
|
| 187 |
mkdir -p /home/node/.openclaw/credentials
|
|
|
|
| 336 |
fi
|
| 337 |
fi
|
| 338 |
|
| 339 |
+
# Optional: explicitly expose provider model lists in Control UI when
|
| 340 |
+
# provider keys are configured. Format:
|
| 341 |
+
# NVIDIA_MODELS=model1,model2
|
| 342 |
+
# OPENAI_MODELS=gpt-4o-mini,gpt-4.1
|
| 343 |
+
# This helps when provider auto-discovery does not populate models reliably.
|
| 344 |
+
inject_provider_models_from_env() {
|
| 345 |
+
local provider="$1"
|
| 346 |
+
local models_env="$2"
|
| 347 |
+
local key_env_single="$3"
|
| 348 |
+
local key_env_pool="$4"
|
| 349 |
+
local models_csv="${!models_env:-}"
|
| 350 |
+
local single_key="${!key_env_single:-}"
|
| 351 |
+
local pool_keys="${!key_env_pool:-}"
|
| 352 |
+
|
| 353 |
+
# Only inject when both:
|
| 354 |
+
# 1) provider has at least one configured key
|
| 355 |
+
# 2) explicit model list env is provided
|
| 356 |
+
if [ -z "$models_csv" ] || { [ -z "$single_key" ] && [ -z "$pool_keys" ]; }; then
|
| 357 |
+
return 0
|
| 358 |
+
fi
|
| 359 |
+
|
| 360 |
+
local models_json
|
| 361 |
+
models_json=$(printf '%s' "$models_csv" \
|
| 362 |
+
| tr ',' '\n' \
|
| 363 |
+
| sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' \
|
| 364 |
+
| awk 'NF' \
|
| 365 |
+
| jq -R . \
|
| 366 |
+
| jq -s 'map({id: ., name: .}) | unique_by(.id)')
|
| 367 |
+
|
| 368 |
+
CONFIG_JSON=$(jq \
|
| 369 |
+
--arg provider "$provider" \
|
| 370 |
+
--argjson models "$models_json" \
|
| 371 |
+
'.models.mode = "merge"
|
| 372 |
+
| .models.providers[$provider] = ((.models.providers[$provider] // {}) + {models: $models})' <<<"$CONFIG_JSON")
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
# Built-in provider model envs (optional)
|
| 376 |
+
inject_provider_models_from_env "anthropic" "ANTHROPIC_MODELS" "ANTHROPIC_API_KEY" "ANTHROPIC_API_KEYS"
|
| 377 |
+
inject_provider_models_from_env "openai" "OPENAI_MODELS" "OPENAI_API_KEY" "OPENAI_API_KEYS"
|
| 378 |
+
inject_provider_models_from_env "openai-codex" "OPENAI_MODELS" "OPENAI_API_KEY" "OPENAI_API_KEYS"
|
| 379 |
+
inject_provider_models_from_env "google" "GEMINI_MODELS" "GEMINI_API_KEY" "GEMINI_API_KEYS"
|
| 380 |
+
inject_provider_models_from_env "google-vertex" "GEMINI_MODELS" "GEMINI_API_KEY" "GEMINI_API_KEYS"
|
| 381 |
+
inject_provider_models_from_env "deepseek" "DEEPSEEK_MODELS" "DEEPSEEK_API_KEY" "DEEPSEEK_API_KEYS"
|
| 382 |
+
inject_provider_models_from_env "openrouter" "OPENROUTER_MODELS" "OPENROUTER_API_KEY" "OPENROUTER_API_KEYS"
|
| 383 |
+
inject_provider_models_from_env "kilocode" "KILOCODE_MODELS" "KILOCODE_API_KEY" "KILOCODE_API_KEYS"
|
| 384 |
+
inject_provider_models_from_env "opencode" "OPENCODE_MODELS" "OPENCODE_API_KEY" "OPENCODE_API_KEYS"
|
| 385 |
+
inject_provider_models_from_env "opencode-go" "OPENCODE_MODELS" "OPENCODE_API_KEY" "OPENCODE_API_KEYS"
|
| 386 |
+
inject_provider_models_from_env "zai" "ZAI_MODELS" "ZAI_API_KEY" "ZAI_API_KEYS"
|
| 387 |
+
inject_provider_models_from_env "z-ai" "ZAI_MODELS" "ZAI_API_KEY" "ZAI_API_KEYS"
|
| 388 |
+
inject_provider_models_from_env "z.ai" "ZAI_MODELS" "ZAI_API_KEY" "ZAI_API_KEYS"
|
| 389 |
+
inject_provider_models_from_env "zhipu" "ZAI_MODELS" "ZAI_API_KEY" "ZAI_API_KEYS"
|
| 390 |
+
inject_provider_models_from_env "moonshot" "MOONSHOT_MODELS" "MOONSHOT_API_KEY" "MOONSHOT_API_KEYS"
|
| 391 |
+
inject_provider_models_from_env "kimi-coding" "KIMI_MODELS" "KIMI_API_KEY" "KIMI_API_KEYS"
|
| 392 |
+
inject_provider_models_from_env "minimax" "MINIMAX_MODELS" "MINIMAX_API_KEY" "MINIMAX_API_KEYS"
|
| 393 |
+
inject_provider_models_from_env "modelstudio" "MODELSTUDIO_MODELS" "MODELSTUDIO_API_KEY" "MODELSTUDIO_API_KEYS"
|
| 394 |
+
inject_provider_models_from_env "qwen" "MODELSTUDIO_MODELS" "MODELSTUDIO_API_KEY" "MODELSTUDIO_API_KEYS"
|
| 395 |
+
inject_provider_models_from_env "xiaomi" "XIAOMI_MODELS" "XIAOMI_API_KEY" "XIAOMI_API_KEYS"
|
| 396 |
+
inject_provider_models_from_env "volcengine" "VOLCANO_ENGINE_MODELS" "VOLCANO_ENGINE_API_KEY" "VOLCANO_ENGINE_API_KEYS"
|
| 397 |
+
inject_provider_models_from_env "volcengine-plan" "VOLCANO_ENGINE_MODELS" "VOLCANO_ENGINE_API_KEY" "VOLCANO_ENGINE_API_KEYS"
|
| 398 |
+
inject_provider_models_from_env "byteplus" "BYTEPLUS_MODELS" "BYTEPLUS_API_KEY" "BYTEPLUS_API_KEYS"
|
| 399 |
+
inject_provider_models_from_env "byteplus-plan" "BYTEPLUS_MODELS" "BYTEPLUS_API_KEY" "BYTEPLUS_API_KEYS"
|
| 400 |
+
inject_provider_models_from_env "qianfan" "QIANFAN_MODELS" "QIANFAN_API_KEY" "QIANFAN_API_KEYS"
|
| 401 |
+
inject_provider_models_from_env "groq" "GROQ_MODELS" "GROQ_API_KEY" "GROQ_API_KEYS"
|
| 402 |
+
inject_provider_models_from_env "mistral" "MISTRAL_MODELS" "MISTRAL_API_KEY" "MISTRAL_API_KEYS"
|
| 403 |
+
inject_provider_models_from_env "mistralai" "MISTRAL_MODELS" "MISTRAL_API_KEY" "MISTRAL_API_KEYS"
|
| 404 |
+
inject_provider_models_from_env "xai" "XAI_MODELS" "XAI_API_KEY" "XAI_API_KEYS"
|
| 405 |
+
inject_provider_models_from_env "x-ai" "XAI_MODELS" "XAI_API_KEY" "XAI_API_KEYS"
|
| 406 |
+
inject_provider_models_from_env "nvidia" "NVIDIA_MODELS" "NVIDIA_API_KEY" "NVIDIA_API_KEYS"
|
| 407 |
+
inject_provider_models_from_env "cohere" "COHERE_MODELS" "COHERE_API_KEY" "COHERE_API_KEYS"
|
| 408 |
+
inject_provider_models_from_env "together" "TOGETHER_MODELS" "TOGETHER_API_KEY" "TOGETHER_API_KEYS"
|
| 409 |
+
inject_provider_models_from_env "cerebras" "CEREBRAS_MODELS" "CEREBRAS_API_KEY" "CEREBRAS_API_KEYS"
|
| 410 |
+
inject_provider_models_from_env "huggingface" "HUGGINGFACE_MODELS" "HUGGINGFACE_HUB_TOKEN" "HUGGINGFACE_HUB_TOKENS"
|
| 411 |
+
inject_provider_models_from_env "venice" "VENICE_MODELS" "VENICE_API_KEY" "VENICE_API_KEYS"
|
| 412 |
+
inject_provider_models_from_env "synthetic" "SYNTHETIC_MODELS" "SYNTHETIC_API_KEY" "SYNTHETIC_API_KEYS"
|
| 413 |
+
inject_provider_models_from_env "github-copilot" "GITHUB_COPILOT_MODELS" "COPILOT_GITHUB_TOKEN" "COPILOT_GITHUB_TOKENS"
|
| 414 |
+
|
| 415 |
# Browser configuration (managed local Chromium in HF/Docker)
|
| 416 |
BROWSER_EXECUTABLE_PATH=""
|
| 417 |
for candidate in /usr/bin/chromium /usr/bin/chromium-browser /snap/bin/chromium; do
|
|
|
|
| 667 |
# ββ Trap SIGTERM for graceful shutdown ββ
|
| 668 |
graceful_shutdown() {
|
| 669 |
echo "Shutting down..."
|
| 670 |
+
if [ -f "/home/node/app/openclaw-sync.py" ] && [ -n "${HF_TOKEN:-}" ]; then
|
| 671 |
echo "Saving state before exit..."
|
| 672 |
+
timeout 8s python3 /home/node/app/openclaw-sync.py sync-once-settled || \
|
| 673 |
+
echo "Warning: could not complete settled shutdown sync"
|
| 674 |
+
sleep 1
|
| 675 |
python3 /home/node/app/openclaw-sync.py sync-once || \
|
| 676 |
+
echo "Warning: could not complete final shutdown sync"
|
| 677 |
+
elif [ -f "/home/node/app/openclaw-sync.py" ]; then
|
| 678 |
+
echo "HF_TOKEN not set; skipping shutdown backup sync."
|
| 679 |
fi
|
| 680 |
kill $(jobs -p) 2>/dev/null
|
| 681 |
exit 0
|