forgecommandline 0.8.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.
- forgecli/__init__.py +2 -0
- forgecli/ads.py +322 -0
- forgecli/agent.py +1093 -0
- forgecli/art.py +574 -0
- forgecli/attach.py +246 -0
- forgecli/brief.py +215 -0
- forgecli/browsercheck.py +99 -0
- forgecli/checkpoints.py +81 -0
- forgecli/city.py +833 -0
- forgecli/cli.py +1668 -0
- forgecli/config.py +194 -0
- forgecli/courier.py +177 -0
- forgecli/courier_data.py +164 -0
- forgecli/courier_hd.py +431 -0
- forgecli/courier_hd_data.bin +0 -0
- forgecli/courier_hd_data.py +19 -0
- forgecli/ghlogin.py +76 -0
- forgecli/github.py +54 -0
- forgecli/guards.py +403 -0
- forgecli/hooks.py +50 -0
- forgecli/httpapi.py +434 -0
- forgecli/images.py +110 -0
- forgecli/keys.py +152 -0
- forgecli/keyvault.py +320 -0
- forgecli/mcp.py +674 -0
- forgecli/memory.py +42 -0
- forgecli/openapi.py +331 -0
- forgecli/parallel.py +308 -0
- forgecli/prices.py +72 -0
- forgecli/procs.py +144 -0
- forgecli/providers.py +758 -0
- forgecli/quality.py +192 -0
- forgecli/repomap.py +78 -0
- forgecli/roster.py +227 -0
- forgecli/safety.py +93 -0
- forgecli/sandbox.py +83 -0
- forgecli/sessions.py +97 -0
- forgecli/skills.py +446 -0
- forgecli/tools.py +1184 -0
- forgecli/ui.py +860 -0
- forgecli/worktrees.py +79 -0
- forgecommandline-0.8.0.data/data/skills/commit/SKILL.md +14 -0
- forgecommandline-0.8.0.data/data/skills/debug/SKILL.md +17 -0
- forgecommandline-0.8.0.data/data/skills/explain/SKILL.md +14 -0
- forgecommandline-0.8.0.data/data/skills/init/SKILL.md +19 -0
- forgecommandline-0.8.0.data/data/skills/library/LICENSES/agent-skills-MIT.txt +21 -0
- forgecommandline-0.8.0.data/data/skills/library/LICENSES/wondelai-MIT.txt +23 -0
- forgecommandline-0.8.0.data/data/skills/library/README.md +9 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/accessibility-checklist.md +160 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/definition-of-done.md +67 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/observability-checklist.md +92 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/orchestration-patterns.md +370 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/performance-checklist.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/security-checklist.md +248 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/testing-patterns.md +235 -0
- forgecommandline-0.8.0.data/data/skills/library/api-and-interface-design/SKILL.md +367 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/SKILL.md +279 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/artifact-templates.md +157 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/methods.md +87 -0
- forgecommandline-0.8.0.data/data/skills/library/awwwards-tier-web-application/SKILL.md +877 -0
- forgecommandline-0.8.0.data/data/skills/library/browser-testing-with-devtools/SKILL.md +317 -0
- forgecommandline-0.8.0.data/data/skills/library/ci-cd-and-automation/SKILL.md +390 -0
- forgecommandline-0.8.0.data/data/skills/library/code-review-and-quality/SKILL.md +398 -0
- forgecommandline-0.8.0.data/data/skills/library/code-simplification/SKILL.md +331 -0
- forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/SKILL.md +311 -0
- forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/references/floor-guard.md +182 -0
- forgecommandline-0.8.0.data/data/skills/library/context-engineering/SKILL.md +353 -0
- forgecommandline-0.8.0.data/data/skills/library/debugging-and-error-recovery/SKILL.md +300 -0
- forgecommandline-0.8.0.data/data/skills/library/deprecation-and-migration/SKILL.md +247 -0
- forgecommandline-0.8.0.data/data/skills/library/documentation-and-adrs/SKILL.md +297 -0
- forgecommandline-0.8.0.data/data/skills/library/doubt-driven-development/SKILL.md +243 -0
- forgecommandline-0.8.0.data/data/skills/library/frontend-ui-engineering/SKILL.md +340 -0
- forgecommandline-0.8.0.data/data/skills/library/git-workflow-and-versioning/SKILL.md +355 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/SKILL.md +193 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/examples.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/frameworks.md +99 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/refinement-criteria.md +113 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/scripts/idea-refine.sh +15 -0
- forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/SKILL.md +275 -0
- forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/references/artifact-templates.md +101 -0
- forgecommandline-0.8.0.data/data/skills/library/incremental-implementation/SKILL.md +249 -0
- forgecommandline-0.8.0.data/data/skills/library/interview-me/SKILL.md +235 -0
- forgecommandline-0.8.0.data/data/skills/library/observability-and-instrumentation/SKILL.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/performance-optimization/SKILL.md +267 -0
- forgecommandline-0.8.0.data/data/skills/library/performance-optimization/references/optimization-patterns.md +250 -0
- forgecommandline-0.8.0.data/data/skills/library/planning-and-task-breakdown/SKILL.md +257 -0
- forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/SKILL.md +203 -0
- forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/references/artifact-templates.md +79 -0
- forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/SKILL.md +216 -0
- forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/references/hardening-patterns.md +324 -0
- forgecommandline-0.8.0.data/data/skills/library/shipping-and-launch/SKILL.md +336 -0
- forgecommandline-0.8.0.data/data/skills/library/source-driven-development/SKILL.md +216 -0
- forgecommandline-0.8.0.data/data/skills/library/spec-driven-development/SKILL.md +259 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/SKILL.md +207 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/building-blocks.md +272 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/common-designs.md +257 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/database-scaling.md +295 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/estimation-numbers.md +250 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/four-step-process.md +286 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/reliability-operations.md +373 -0
- forgecommandline-0.8.0.data/data/skills/library/test-driven-development/SKILL.md +398 -0
- forgecommandline-0.8.0.data/data/skills/library/using-agent-skills/SKILL.md +192 -0
- forgecommandline-0.8.0.data/data/skills/review/SKILL.md +16 -0
- forgecommandline-0.8.0.data/data/skills/run/SKILL.md +17 -0
- forgecommandline-0.8.0.data/data/skills/security-review/SKILL.md +20 -0
- forgecommandline-0.8.0.data/data/skills/simplify/SKILL.md +18 -0
- forgecommandline-0.8.0.data/data/skills/test/SKILL.md +16 -0
- forgecommandline-0.8.0.data/data/skills/web-build/SKILL.md +25 -0
- forgecommandline-0.8.0.dist-info/METADATA +357 -0
- forgecommandline-0.8.0.dist-info/RECORD +113 -0
- forgecommandline-0.8.0.dist-info/WHEEL +5 -0
- forgecommandline-0.8.0.dist-info/entry_points.txt +2 -0
- forgecommandline-0.8.0.dist-info/top_level.txt +1 -0
forgecli/keys.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Round-robin key pools with per-key cooldowns that survive restarts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import datetime as dt
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import pathlib
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from .config import HOME_DIR
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def tail(key: str) -> str:
|
|
15
|
+
"""Only ever show/log the last 4 characters of a key."""
|
|
16
|
+
return "..." + key[-4:]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def key_id(provider: str, key: str) -> str:
|
|
20
|
+
# Keys are never written to disk; only a short hash identifies them.
|
|
21
|
+
return f"{provider}:{hashlib.sha256(key.encode()).hexdigest()[:12]}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def seconds_until_utc_midnight() -> int:
|
|
25
|
+
now = dt.datetime.now(dt.timezone.utc)
|
|
26
|
+
nxt = (now + dt.timedelta(days=1)).replace(hour=0, minute=0, second=5, microsecond=0)
|
|
27
|
+
return int((nxt - now).total_seconds())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CooldownStore:
|
|
31
|
+
"""Cooldowns and daily usage counts, persisted to ~/.forge/state.json."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, path: pathlib.Path | None = None):
|
|
34
|
+
self.path = path or HOME_DIR / "state.json"
|
|
35
|
+
self._lock = threading.Lock()
|
|
36
|
+
self.until: dict[str, float] = {}
|
|
37
|
+
self.usage: dict[str, dict[str, int]] = {}
|
|
38
|
+
try:
|
|
39
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
40
|
+
self.until = {k: v for k, v in data.get("until", {}).items() if v > time.time()}
|
|
41
|
+
today = dt.date.today().isoformat()
|
|
42
|
+
self.usage = {today: data.get("usage", {}).get(today, {})}
|
|
43
|
+
except (OSError, ValueError):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
def _save(self) -> None:
|
|
47
|
+
try:
|
|
48
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
self.path.write_text(json.dumps({"until": self.until, "usage": self.usage}), encoding="utf-8")
|
|
50
|
+
except OSError:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
def available(self, kid: str) -> bool:
|
|
54
|
+
return time.time() >= self.until.get(kid, 0)
|
|
55
|
+
|
|
56
|
+
def bench(self, kid: str, seconds: float) -> None:
|
|
57
|
+
with self._lock:
|
|
58
|
+
self.until[kid] = max(self.until.get(kid, 0), time.time() + seconds)
|
|
59
|
+
self._save()
|
|
60
|
+
|
|
61
|
+
def count(self, kid: str, ok: bool) -> None:
|
|
62
|
+
with self._lock:
|
|
63
|
+
today = dt.date.today().isoformat()
|
|
64
|
+
day = self.usage.setdefault(today, {})
|
|
65
|
+
day[kid + (":ok" if ok else ":fail")] = day.get(kid + (":ok" if ok else ":fail"), 0) + 1
|
|
66
|
+
self._save()
|
|
67
|
+
|
|
68
|
+
def remaining(self, kid: str) -> float:
|
|
69
|
+
return max(0.0, self.until.get(kid, 0) - time.time())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class KeyPool:
|
|
73
|
+
def __init__(self, provider: str, keys: list[str], store: CooldownStore):
|
|
74
|
+
self.provider = provider
|
|
75
|
+
self.keys = keys
|
|
76
|
+
self.store = store
|
|
77
|
+
self._idx = 0
|
|
78
|
+
self._lock = threading.Lock()
|
|
79
|
+
|
|
80
|
+
def kid(self, key: str) -> str:
|
|
81
|
+
return key_id(self.provider, key)
|
|
82
|
+
|
|
83
|
+
def usable(self, key: str, model: str | None = None) -> bool:
|
|
84
|
+
"""Key not resting, and (when a model is given) not resting for that model either.
|
|
85
|
+
Most free tiers (NVIDIA, Groq) count limits per model, so a 429 on one model
|
|
86
|
+
leaves the same key free for the others."""
|
|
87
|
+
kid = self.kid(key)
|
|
88
|
+
return self.store.available(kid) and (model is None or self.store.available(f"{kid}|{model}"))
|
|
89
|
+
|
|
90
|
+
def next_key(self, model: str | None = None) -> str | None:
|
|
91
|
+
"""Next usable key, round-robin. None if every key is benched (for this model)."""
|
|
92
|
+
with self._lock:
|
|
93
|
+
for _ in range(len(self.keys)):
|
|
94
|
+
key = self.keys[self._idx % len(self.keys)]
|
|
95
|
+
self._idx += 1
|
|
96
|
+
if self.usable(key, model):
|
|
97
|
+
return key
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
def any_available(self, model: str | None = None) -> bool:
|
|
101
|
+
return any(self.usable(k, model) for k in self.keys)
|
|
102
|
+
|
|
103
|
+
def wait_for(self, model: str | None = None) -> float:
|
|
104
|
+
"""Seconds until some key is usable again (for this model); 0 if one is now."""
|
|
105
|
+
waits = []
|
|
106
|
+
for k in self.keys:
|
|
107
|
+
kid = self.kid(k)
|
|
108
|
+
w = self.store.remaining(kid)
|
|
109
|
+
if model:
|
|
110
|
+
w = max(w, self.store.remaining(f"{kid}|{model}"))
|
|
111
|
+
waits.append(w)
|
|
112
|
+
return min(waits) if waits else 0.0
|
|
113
|
+
|
|
114
|
+
def bench(self, key: str, seconds: float, model: str | None = None) -> None:
|
|
115
|
+
self.store.bench(self.kid(key) + (f"|{model}" if model else ""), seconds)
|
|
116
|
+
|
|
117
|
+
def model_benches(self) -> dict[str, list[float]]:
|
|
118
|
+
"""model -> seconds left, one entry per key resting on that model."""
|
|
119
|
+
out: dict[str, list[float]] = {}
|
|
120
|
+
prefixes = {self.kid(k) + "|": k for k in self.keys}
|
|
121
|
+
for kid in list(self.store.until):
|
|
122
|
+
for pre in prefixes:
|
|
123
|
+
if kid.startswith(pre):
|
|
124
|
+
rem = self.store.remaining(kid)
|
|
125
|
+
if rem > 0:
|
|
126
|
+
out.setdefault(kid[len(pre):], []).append(rem)
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
def status_rows(self) -> list[tuple[str, bool, str, int, int]]:
|
|
130
|
+
"""(key tail, ready?, state text, ok today, failed today) per key."""
|
|
131
|
+
today = dt.date.today().isoformat()
|
|
132
|
+
day = self.store.usage.get(today, {})
|
|
133
|
+
rows = []
|
|
134
|
+
for k in self.keys:
|
|
135
|
+
kid = self.kid(k)
|
|
136
|
+
rem = self.store.remaining(kid)
|
|
137
|
+
state = "ready" if rem <= 0 else (f"benched {int(rem // 3600)}h{int(rem % 3600 // 60)}m" if rem >= 3600
|
|
138
|
+
else f"benched {int(rem // 60)}m{int(rem % 60)}s")
|
|
139
|
+
rows.append((tail(k), rem <= 0, state, day.get(kid + ":ok", 0), day.get(kid + ":fail", 0)))
|
|
140
|
+
return rows
|
|
141
|
+
|
|
142
|
+
def status_lines(self) -> list[str]:
|
|
143
|
+
today = dt.date.today().isoformat()
|
|
144
|
+
day = self.store.usage.get(today, {})
|
|
145
|
+
out = []
|
|
146
|
+
for k in self.keys:
|
|
147
|
+
kid = self.kid(k)
|
|
148
|
+
rem = self.store.remaining(kid)
|
|
149
|
+
state = "ready" if rem <= 0 else f"benched {int(rem // 60)}m{int(rem % 60)}s"
|
|
150
|
+
out.append(f" {self.provider:<11} {tail(k)} {state:<16} "
|
|
151
|
+
f"ok={day.get(kid + ':ok', 0)} fail={day.get(kid + ':fail', 0)}")
|
|
152
|
+
return out
|
forgecli/keyvault.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""API keys (NEXT 7): spot them, mask them, store them, test them, and keep them away from models.
|
|
2
|
+
|
|
3
|
+
- detect_provider / candidates: which provider a key belongs to, from its prefix.
|
|
4
|
+
- redact: replace keys pasted into text (known prefixes, plus long secret-looking tokens when the
|
|
5
|
+
text talks about a key/token) before that text reaches a model, a session file or input history.
|
|
6
|
+
- KeyStore: unlimited keys per provider plus custom OpenAI-compatible providers. Secrets live in the
|
|
7
|
+
OS keychain (`keyring`: Windows Credential Manager, macOS Keychain, Secret Service) when it is
|
|
8
|
+
installed; otherwise in ~/.forge/keys.json (owner-only permissions where the OS supports it).
|
|
9
|
+
- test_key / identify: a live check against the provider (models list, else a 1-token call).
|
|
10
|
+
Keys are only ever shown as their last 4 characters.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import concurrent.futures as cf
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import pathlib
|
|
19
|
+
import re
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.request
|
|
22
|
+
|
|
23
|
+
# Prefix -> provider names it can belong to (the first is the most likely). Longest prefix first.
|
|
24
|
+
# Checked against each provider's key format as of 2026-11; a bare "sk-" is shared by several
|
|
25
|
+
# providers, so those keys are identified with a live test (identify()).
|
|
26
|
+
PREFIXES: list[tuple[str, list[str]]] = [
|
|
27
|
+
("github_pat_", ["github"]), ("ghp_", ["github"]), ("gho_", ["github"]),
|
|
28
|
+
("sk-ant-", ["anthropic"]), ("sk-or-", ["openrouter"]), ("sk-proj-", ["openai"]),
|
|
29
|
+
("sk-svcacct-", ["openai"]), ("nvapi-", ["nvidia"]), ("gsk_", ["groq"]), ("AIza", ["gemini"]),
|
|
30
|
+
("hf_", ["huggingface"]), ("tgp_v1_", ["together"]),
|
|
31
|
+
("sk-", ["openai", "deepseek", "siliconflow"]),
|
|
32
|
+
]
|
|
33
|
+
PREFIXES.sort(key=lambda p: -len(p[0]))
|
|
34
|
+
_UNVERIFIED_PREFIXES = ("csk-", "xai-")
|
|
35
|
+
_KNOWN = re.compile(r"(?<![A-Za-z0-9_-])(?:" +
|
|
36
|
+
"|".join(re.escape(p) for p in [*(p for p, _ in PREFIXES), *_UNVERIFIED_PREFIXES]) +
|
|
37
|
+
r")[A-Za-z0-9_\-]{16,}")
|
|
38
|
+
# Keys without a known prefix (Mistral, Cohere, SambaNova…): long mixed tokens, only redacted when
|
|
39
|
+
# the text is about a key, so code hashes and ids in ordinary messages are left alone.
|
|
40
|
+
_LOOSE = re.compile(r"(?<![A-Za-z0-9_\-/.])[A-Za-z0-9][A-Za-z0-9_\-]{30,}(?![A-Za-z0-9_\-/.])")
|
|
41
|
+
_ABOUT_KEYS = re.compile(r"\b(api|key|keys|token|secret|credential)s?\b", re.I)
|
|
42
|
+
|
|
43
|
+
# Shown by the setup walkthrough: free tier?, what it's good at, where to get a key.
|
|
44
|
+
SIGNUP: dict[str, tuple[str, str, str]] = {
|
|
45
|
+
"groq": ("free", "very fast replies", "https://console.groq.com/keys"),
|
|
46
|
+
"cerebras": ("free", "fastest large models", "https://cloud.cerebras.ai/platform"),
|
|
47
|
+
"gemini": ("free", "long context, vision", "https://aistudio.google.com/apikey"),
|
|
48
|
+
"openrouter": ("free models", "many models behind one key", "https://openrouter.ai/keys"),
|
|
49
|
+
"nvidia": ("free credits", "big open models", "https://build.nvidia.com/settings/api-keys"),
|
|
50
|
+
"github": ("free", "GPT-4.1 and others via GitHub Models", "https://github.com/settings/tokens"),
|
|
51
|
+
"mistral": ("free tier", "coding (Codestral, Devstral)", "https://console.mistral.ai/api-keys"),
|
|
52
|
+
"sambanova": ("free", "fast Llama/DeepSeek", "https://cloud.sambanova.ai/apis"),
|
|
53
|
+
"huggingface": ("free credits", "open models router", "https://huggingface.co/settings/tokens"),
|
|
54
|
+
"cohere": ("trial", "Command A", "https://dashboard.cohere.com/api-keys"),
|
|
55
|
+
"deepseek": ("paid, cheap", "strong coding/reasoning", "https://platform.deepseek.com/api_keys"),
|
|
56
|
+
"openai": ("paid", "GPT models", "https://platform.openai.com/api-keys"),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
SERVICE = "forge-cli"
|
|
60
|
+
ANON = "anonymous" # same as config.ANON_KEY: keyless providers' placeholder
|
|
61
|
+
UA = "forge-cli/0.5"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def default_path() -> pathlib.Path:
|
|
65
|
+
return pathlib.Path(os.environ.get("FORGE_KEYS_FILE") or pathlib.Path.home() / ".forge" / "keys.json")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def candidates(key: str) -> list[str]:
|
|
69
|
+
key = key.strip()
|
|
70
|
+
for prefix, names in PREFIXES:
|
|
71
|
+
if key.startswith(prefix):
|
|
72
|
+
return list(names)
|
|
73
|
+
return []
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def detect_provider(key: str) -> str | None:
|
|
77
|
+
"""The provider a key surely belongs to; None when unknown or shared (bare "sk-")."""
|
|
78
|
+
c = candidates(key)
|
|
79
|
+
return c[0] if len(c) == 1 else None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def mask(key: str) -> str:
|
|
83
|
+
"""Only ever show the last 4 characters."""
|
|
84
|
+
return "…" + key[-4:] if len(key) > 8 else "…"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _secret_like(tok: str) -> bool:
|
|
88
|
+
if re.fullmatch(r"[0-9a-fA-F\-]+", tok): # git hashes, uuids, checksums
|
|
89
|
+
return False
|
|
90
|
+
kinds = sum(bool(re.search(rx, tok)) for rx in (r"[a-z]", r"[A-Z]", r"[0-9]"))
|
|
91
|
+
return kinds >= 3 or (kinds == 2 and len(tok) >= 40)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def find_keys(text: str) -> list[str]:
|
|
95
|
+
text = text or ""
|
|
96
|
+
found = [m.group(0) for m in _KNOWN.finditer(text)]
|
|
97
|
+
if _ABOUT_KEYS.search(text):
|
|
98
|
+
found += [m.group(0) for m in _LOOSE.finditer(text)
|
|
99
|
+
if _secret_like(m.group(0)) and not any(m.group(0) in f or f in m.group(0) for f in found)]
|
|
100
|
+
return list(dict.fromkeys(found))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def redact(text: str) -> tuple[str, list[str]]:
|
|
104
|
+
"""Text with every key replaced by a masked placeholder, plus the keys found."""
|
|
105
|
+
found = find_keys(text)
|
|
106
|
+
for k in sorted(found, key=len, reverse=True):
|
|
107
|
+
text = text.replace(k, f"[{detect_provider(k) or 'api'} key {mask(k)} saved, hidden from the model]")
|
|
108
|
+
return text, found
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# --- live checks -----------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def _request(url: str, key: str, body: dict | None, timeout: float) -> int:
|
|
114
|
+
headers = {"User-Agent": UA, "Accept": "application/json"}
|
|
115
|
+
if key:
|
|
116
|
+
headers["Authorization"] = f"Bearer {key}"
|
|
117
|
+
data = None
|
|
118
|
+
if body is not None:
|
|
119
|
+
data = json.dumps(body).encode()
|
|
120
|
+
headers["Content-Type"] = "application/json"
|
|
121
|
+
req = urllib.request.Request(url, data=data, headers=headers, method="POST" if body is not None else "GET")
|
|
122
|
+
try:
|
|
123
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
124
|
+
return r.status
|
|
125
|
+
except urllib.error.HTTPError as e:
|
|
126
|
+
return e.code
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_key(base_url: str, key: str, model: str = "", timeout: float = 8.0) -> tuple[bool, str]:
|
|
130
|
+
"""(works, reason). Lists models; where a provider has no models list, sends a 1-token request."""
|
|
131
|
+
base = base_url.rstrip("/")
|
|
132
|
+
try:
|
|
133
|
+
code = _request(base + "/models", key, None, timeout)
|
|
134
|
+
if code in (404, 405) and model:
|
|
135
|
+
code = _request(base + "/chat/completions", key, {
|
|
136
|
+
"model": model, "max_tokens": 1, "messages": [{"role": "user", "content": "hi"}]}, timeout)
|
|
137
|
+
except (urllib.error.URLError, OSError, ValueError) as e:
|
|
138
|
+
return False, f"could not reach {base} ({getattr(e, 'reason', e)})"
|
|
139
|
+
if 200 <= code < 300:
|
|
140
|
+
return True, "works"
|
|
141
|
+
if code == 429:
|
|
142
|
+
return True, "works (rate-limited right now)"
|
|
143
|
+
if code in (401, 403):
|
|
144
|
+
return False, f"rejected by the provider ({code}): wrong or revoked key"
|
|
145
|
+
if code == 402:
|
|
146
|
+
return False, "accepted but the account has no credit (402)"
|
|
147
|
+
return False, f"unexpected answer {code} from {base}"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def identify(key: str, endpoints: dict[str, tuple[str, str]], names: list[str] | None = None,
|
|
151
|
+
timeout: float = 6.0) -> str | None:
|
|
152
|
+
"""Find which provider accepts a key by testing it against each candidate at once.
|
|
153
|
+
endpoints: name -> (base_url, model). Returns the first candidate (in order) that works."""
|
|
154
|
+
names = [n for n in (names or list(endpoints)) if n in endpoints]
|
|
155
|
+
if not names:
|
|
156
|
+
return None
|
|
157
|
+
with cf.ThreadPoolExecutor(max_workers=min(8, len(names))) as pool:
|
|
158
|
+
futures = {n: pool.submit(test_key, *endpoints[n][:1], key, endpoints[n][1], timeout) for n in names}
|
|
159
|
+
results = {n: f.result()[0] for n, f in futures.items()}
|
|
160
|
+
return next((n for n in names if results[n]), None)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# --- storage ---------------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def _keyring():
|
|
166
|
+
if os.environ.get("FORGE_KEYRING", "1").strip().lower() in ("0", "off", "false", "no"):
|
|
167
|
+
return None
|
|
168
|
+
try:
|
|
169
|
+
import keyring # type: ignore
|
|
170
|
+
from keyring.backends import fail # type: ignore
|
|
171
|
+
kr = keyring.get_keyring()
|
|
172
|
+
if isinstance(kr, fail.Keyring) or "null" in type(kr).__module__:
|
|
173
|
+
return None
|
|
174
|
+
return keyring
|
|
175
|
+
except Exception:
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def fingerprint(key: str) -> str:
|
|
180
|
+
"""Stable, non-reversible identifier for per-key usage records."""
|
|
181
|
+
return "sha256-" + hashlib.sha256(key.encode()).hexdigest()[:12]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _kid(key: str) -> str:
|
|
185
|
+
return hashlib.sha256(key.encode()).hexdigest()[:12]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
UNFILED = "_unfiled"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class KeyStore:
|
|
192
|
+
"""{"version": 2, "keys": {provider: [{"id", "tail", "secret"?}]}, "custom": {name: {...}}}
|
|
193
|
+
Secrets go to the OS keychain when available ("secret" is then absent from the file)."""
|
|
194
|
+
|
|
195
|
+
def __init__(self, path: pathlib.Path | str | None = None, backend: str | None = None):
|
|
196
|
+
self.path = pathlib.Path(path) if path else default_path()
|
|
197
|
+
if backend is None:
|
|
198
|
+
backend = "file" if path else "auto"
|
|
199
|
+
self.kr = _keyring() if backend in ("auto", "keyring") else None
|
|
200
|
+
self.backend = "keyring" if self.kr else "file"
|
|
201
|
+
|
|
202
|
+
# raw file ---------------------------------------------------------------
|
|
203
|
+
def _read(self) -> dict:
|
|
204
|
+
try:
|
|
205
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
206
|
+
except (OSError, ValueError):
|
|
207
|
+
data = {}
|
|
208
|
+
if not isinstance(data, dict):
|
|
209
|
+
data = {}
|
|
210
|
+
if "version" not in data: # v1: {"groq": ["gsk_…"]}
|
|
211
|
+
data = {"version": 2, "custom": {},
|
|
212
|
+
"keys": {p: [{"id": _kid(k), "tail": k[-4:], "secret": k} for k in ks if isinstance(k, str)]
|
|
213
|
+
for p, ks in data.items() if isinstance(ks, list)}}
|
|
214
|
+
data.setdefault("keys", {})
|
|
215
|
+
data.setdefault("custom", {})
|
|
216
|
+
return data
|
|
217
|
+
|
|
218
|
+
def _write(self, data: dict) -> None:
|
|
219
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
220
|
+
tmp = self.path.with_suffix(".tmp")
|
|
221
|
+
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
222
|
+
try:
|
|
223
|
+
os.chmod(tmp, 0o600)
|
|
224
|
+
except OSError:
|
|
225
|
+
pass
|
|
226
|
+
tmp.replace(self.path)
|
|
227
|
+
|
|
228
|
+
def _secret(self, provider: str, row: dict) -> str:
|
|
229
|
+
if row.get("secret"):
|
|
230
|
+
return row["secret"]
|
|
231
|
+
if self.kr:
|
|
232
|
+
try:
|
|
233
|
+
return self.kr.get_password(SERVICE, f"{provider}:{row['id']}") or ""
|
|
234
|
+
except Exception:
|
|
235
|
+
return ""
|
|
236
|
+
return ""
|
|
237
|
+
|
|
238
|
+
# api --------------------------------------------------------------------
|
|
239
|
+
def load(self) -> dict[str, list[str]]:
|
|
240
|
+
"""provider -> full keys (unfiled keys excluded)."""
|
|
241
|
+
out = {}
|
|
242
|
+
for p, rows in self._read()["keys"].items():
|
|
243
|
+
if p == UNFILED:
|
|
244
|
+
continue
|
|
245
|
+
keys = [s for s in (self._secret(p, r) for r in rows) if s]
|
|
246
|
+
if keys:
|
|
247
|
+
out[p] = keys
|
|
248
|
+
return out
|
|
249
|
+
|
|
250
|
+
def add(self, key: str, provider: str | None = None, guesses: list[str] | None = None) -> str | None:
|
|
251
|
+
"""Store a key under its provider; returns that provider, or None when it was filed as
|
|
252
|
+
unidentified (see unfiled() / file())."""
|
|
253
|
+
key = key.strip()
|
|
254
|
+
provider = provider or detect_provider(key)
|
|
255
|
+
bucket = provider or UNFILED
|
|
256
|
+
data = self._read()
|
|
257
|
+
rows = data["keys"].setdefault(bucket, [])
|
|
258
|
+
kid = _kid(key)
|
|
259
|
+
if not any(r["id"] == kid for r in rows):
|
|
260
|
+
row = {"id": kid, "tail": key[-4:]}
|
|
261
|
+
if not provider:
|
|
262
|
+
row["guesses"] = guesses if guesses is not None else candidates(key)
|
|
263
|
+
stored = False
|
|
264
|
+
if self.kr:
|
|
265
|
+
try:
|
|
266
|
+
self.kr.set_password(SERVICE, f"{bucket}:{kid}", key)
|
|
267
|
+
stored = True
|
|
268
|
+
except Exception:
|
|
269
|
+
stored = False
|
|
270
|
+
if not stored:
|
|
271
|
+
row["secret"] = key
|
|
272
|
+
rows.append(row)
|
|
273
|
+
self._write(data)
|
|
274
|
+
return provider
|
|
275
|
+
|
|
276
|
+
def remove(self, provider: str, index: int) -> bool:
|
|
277
|
+
data = self._read()
|
|
278
|
+
rows = data["keys"].get(provider, [])
|
|
279
|
+
if not 0 <= index < len(rows):
|
|
280
|
+
return False
|
|
281
|
+
row = rows.pop(index)
|
|
282
|
+
if self.kr and not row.get("secret"):
|
|
283
|
+
try:
|
|
284
|
+
self.kr.delete_password(SERVICE, f"{provider}:{row['id']}")
|
|
285
|
+
except Exception:
|
|
286
|
+
pass
|
|
287
|
+
if not rows:
|
|
288
|
+
data["keys"].pop(provider, None)
|
|
289
|
+
self._write(data)
|
|
290
|
+
return True
|
|
291
|
+
|
|
292
|
+
def unfiled(self) -> list[tuple[str, list[str]]]:
|
|
293
|
+
"""(key, guesses) for keys whose provider is not known yet."""
|
|
294
|
+
return [(self._secret(UNFILED, r), r.get("guesses", [])) for r in self._read()["keys"].get(UNFILED, [])]
|
|
295
|
+
|
|
296
|
+
def file(self, index: int, provider: str) -> str | None:
|
|
297
|
+
"""Move unfiled key #index to provider; returns the key."""
|
|
298
|
+
items = self.unfiled()
|
|
299
|
+
if not 0 <= index < len(items):
|
|
300
|
+
return None
|
|
301
|
+
key = items[index][0]
|
|
302
|
+
self.remove(UNFILED, index)
|
|
303
|
+
self.add(key, provider)
|
|
304
|
+
return key
|
|
305
|
+
|
|
306
|
+
def custom(self) -> dict[str, dict]:
|
|
307
|
+
return dict(self._read()["custom"])
|
|
308
|
+
|
|
309
|
+
def add_custom(self, name: str, base_url: str, model: str) -> None:
|
|
310
|
+
data = self._read()
|
|
311
|
+
data["custom"][name] = {"base_url": base_url.rstrip("/"), "model": model}
|
|
312
|
+
self._write(data)
|
|
313
|
+
|
|
314
|
+
def rows(self) -> list[tuple[str, int, str]]:
|
|
315
|
+
"""(provider, index, masked) for display; never the key itself."""
|
|
316
|
+
return [(p, i, "…" + r.get("tail", "")) for p, rs in sorted(self._read()["keys"].items())
|
|
317
|
+
for i, r in enumerate(rs)]
|
|
318
|
+
|
|
319
|
+
def listing(self) -> list[str]:
|
|
320
|
+
return [f"{p} #{i}: {m}" for p, i, m in self.rows()]
|