Add inline credential validation endpoints and UI for CivitAI, HuggingFace, and Brave Search

This commit is contained in:
Dariusz L
2026-06-05 03:56:15 +02:00
parent c3fad878af
commit d38e1cbb67
5 changed files with 501 additions and 2 deletions
+58
View File
@@ -101,6 +101,8 @@ class ModelLinkerExtension:
search_huggingface_for_file,
get_author_fallback_index_status,
refresh_author_fallback_index,
check_huggingface_token,
check_brave_search_api_key,
clear_search_cache as clear_huggingface_search_cache,
)
from .core.sources.civitai import (
@@ -108,6 +110,8 @@ class ModelLinkerExtension:
search_civitai,
get_civitai_download_url,
resolve_urn,
check_civitai_api_key,
check_civitai_session_token,
clear_search_cache as clear_civitai_search_cache,
)
from .core.sources.civarchive import (
@@ -1270,6 +1274,60 @@ class ModelLinkerExtension:
log_exception(f"Clear search cache error: {e}")
return web.json_response({"error": str(e)}, status=500)
@routes.post("/model_linker/civitai/session-token/check")
async def civitai_session_token_check_route(request):
"""Check whether a CivitAI browser session token is valid."""
try:
data = await request.json()
token = data.get("civitai_session_token", "")
result = await asyncio.to_thread(
check_civitai_session_token, token
)
return web.json_response(result)
except Exception as e:
log_exception(f"CivitAI session token check error: {e}")
return web.json_response({"error": str(e)}, status=500)
@routes.post("/model_linker/civitai/api-key/check")
async def civitai_api_key_check_route(request):
"""Check whether a CivitAI API key is valid."""
try:
data = await request.json()
api_key = data.get("civitai_key", "")
result = await asyncio.to_thread(
check_civitai_api_key, api_key
)
return web.json_response(result)
except Exception as e:
log_exception(f"CivitAI API key check error: {e}")
return web.json_response({"error": str(e)}, status=500)
@routes.post("/model_linker/huggingface/token/check")
async def huggingface_token_check_route(request):
"""Check whether a HuggingFace token is valid."""
try:
data = await request.json()
token = data.get("hf_token", "")
result = await asyncio.to_thread(check_huggingface_token, token)
return web.json_response(result)
except Exception as e:
log_exception(f"HuggingFace token check error: {e}")
return web.json_response({"error": str(e)}, status=500)
@routes.post("/model_linker/brave/api-key/check")
async def brave_api_key_check_route(request):
"""Check whether a Brave Search API key is valid."""
try:
data = await request.json()
api_key = data.get("brave_search_api_key", "")
result = await asyncio.to_thread(
check_brave_search_api_key, api_key
)
return web.json_response(result)
except Exception as e:
log_exception(f"Brave Search API key check error: {e}")
return web.json_response({"error": str(e)}, status=500)
@routes.get("/model_linker/huggingface/author-index/status")
async def huggingface_author_index_status_route(request):
"""Return local HuggingFace author fallback index status."""
+164
View File
@@ -50,6 +50,170 @@ def clear_search_cache():
_search_cache.clear()
def check_civitai_session_token(session_token: Optional[str]) -> Dict[str, Any]:
"""Check whether a CivitAI browser session token is accepted by civitai.com."""
token = (session_token or "").strip()
if not token:
return {
"success": False,
"valid": False,
"status": "missing",
"message": "Paste a CivitAI session token first.",
}
headers = {
"accept": "application/json",
"Cookie": f"__Secure-civitai-token={token}",
"user-agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/147.0.0.0 Safari/537.36"
),
}
try:
response = requests.get(
"https://civitai.com/api/v1/me", headers=headers, timeout=10
)
if response.status_code == 200:
username = ""
try:
data = response.json()
username = (
data.get("username")
or data.get("name")
or data.get("email")
or ""
)
except Exception:
username = ""
message = "Session token is valid."
if username:
message = f"Session token is valid for {username}."
return {
"success": True,
"valid": True,
"status": "valid",
"message": message,
"username": username,
}
if response.status_code in {401, 403}:
return {
"success": True,
"valid": False,
"status": "invalid",
"message": "Session token is not accepted by CivitAI.",
"status_code": response.status_code,
}
return {
"success": False,
"valid": False,
"status": "error",
"message": f"CivitAI returned HTTP {response.status_code}.",
"status_code": response.status_code,
}
except requests.exceptions.Timeout:
return {
"success": False,
"valid": False,
"status": "timeout",
"message": "CivitAI did not respond before the timeout.",
}
except Exception as e:
return {
"success": False,
"valid": False,
"status": "error",
"message": str(e),
}
def check_civitai_api_key(api_key: Optional[str]) -> Dict[str, Any]:
"""Check whether a CivitAI API key is accepted by civitai.com."""
key = (api_key or "").strip()
if not key:
return {
"success": False,
"valid": False,
"status": "missing",
"message": "Paste a CivitAI API key first.",
}
headers = {
"accept": "application/json",
"Authorization": f"Bearer {key}",
"user-agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/147.0.0.0 Safari/537.36"
),
}
try:
response = requests.get(
"https://civitai.com/api/v1/me", headers=headers, timeout=10
)
if response.status_code == 200:
username = ""
try:
data = response.json()
username = (
data.get("username")
or data.get("name")
or data.get("email")
or ""
)
except Exception:
username = ""
message = "CivitAI API key is valid."
if username:
message = f"CivitAI API key is valid for {username}."
return {
"success": True,
"valid": True,
"status": "valid",
"message": message,
"username": username,
}
if response.status_code in {401, 403}:
return {
"success": True,
"valid": False,
"status": "invalid",
"message": "CivitAI API key is not accepted.",
"status_code": response.status_code,
}
return {
"success": False,
"valid": False,
"status": "error",
"message": f"CivitAI returned HTTP {response.status_code}.",
"status_code": response.status_code,
}
except requests.exceptions.Timeout:
return {
"success": False,
"valid": False,
"status": "timeout",
"message": "CivitAI did not respond before the timeout.",
}
except Exception as e:
return {
"success": False,
"valid": False,
"status": "error",
"message": str(e),
}
def _build_civitai_result_from_version(
model_id: int,
model_name: str,
+135
View File
@@ -45,6 +45,141 @@ def clear_search_cache():
_search_cache.clear()
def check_huggingface_token(token: Optional[str]) -> Dict[str, Any]:
"""Check whether a HuggingFace access token is accepted."""
value = (token or "").strip()
if not value:
return {
"success": False,
"valid": False,
"status": "missing",
"message": "Paste a HuggingFace token first.",
}
headers = {"Authorization": f"Bearer {value}"}
try:
response = requests.get(f"{HF_API_URL}/whoami-v2", headers=headers, timeout=10)
if response.status_code == 404:
response = requests.get(f"{HF_API_URL}/whoami", headers=headers, timeout=10)
if response.status_code == 200:
username = ""
try:
data = response.json()
username = data.get("name") or data.get("user", {}).get("name") or ""
except Exception:
username = ""
message = "HuggingFace token is valid."
if username:
message = f"HuggingFace token is valid for {username}."
return {
"success": True,
"valid": True,
"status": "valid",
"message": message,
"username": username,
}
if response.status_code in {401, 403}:
return {
"success": True,
"valid": False,
"status": "invalid",
"message": "HuggingFace token is not accepted.",
"status_code": response.status_code,
}
return {
"success": False,
"valid": False,
"status": "error",
"message": f"HuggingFace returned HTTP {response.status_code}.",
"status_code": response.status_code,
}
except requests.exceptions.Timeout:
return {
"success": False,
"valid": False,
"status": "timeout",
"message": "HuggingFace did not respond before the timeout.",
}
except Exception as e:
return {
"success": False,
"valid": False,
"status": "error",
"message": str(e),
}
def check_brave_search_api_key(api_key: Optional[str]) -> Dict[str, Any]:
"""Check whether a Brave Search API key is accepted."""
key = (api_key or "").strip()
if not key:
return {
"success": False,
"valid": False,
"status": "missing",
"message": "Paste a Brave Search API key first.",
}
headers = {"X-Subscription-Token": key}
params = {"q": "test", "count": 1}
try:
response = requests.get(
BRAVE_SEARCH_API_URL, headers=headers, params=params, timeout=10
)
if response.status_code == 200:
return {
"success": True,
"valid": True,
"status": "valid",
"message": "Brave Search API key is valid.",
}
if response.status_code in {401, 403}:
return {
"success": True,
"valid": False,
"status": "invalid",
"message": "Brave Search API key is not accepted.",
"status_code": response.status_code,
}
if response.status_code == 429:
return {
"success": False,
"valid": False,
"status": "limited",
"message": "Brave Search rate limit was reached.",
"status_code": response.status_code,
}
return {
"success": False,
"valid": False,
"status": "error",
"message": f"Brave Search returned HTTP {response.status_code}.",
"status_code": response.status_code,
}
except requests.exceptions.Timeout:
return {
"success": False,
"valid": False,
"status": "timeout",
"message": "Brave Search did not respond before the timeout.",
}
except Exception as e:
return {
"success": False,
"valid": False,
"status": "error",
"message": str(e),
}
def _author_index_cache_key(author: str, headers: Dict[str, str]) -> str:
auth_key = "token" if headers.get("Authorization") else "public"
return f"{author}::{auth_key}"
+29 -2
View File
@@ -3542,6 +3542,8 @@
flex-direction: column;
gap: 0;
align-items: stretch;
padding-bottom: 14px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.ml-options-help,
#model-linker-modal .ml-options-help {
@@ -3579,6 +3581,32 @@
color: var(--ml-text);
font-size: var(--ml-font-xs);
}
.ml-options-token-check,
#model-linker-modal .ml-options-token-check {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
}
.ml-options-token-check-status,
#model-linker-modal .ml-options-token-check-status {
color: var(--ml-text-muted);
font-size: var(--ml-font-sm);
font-weight: 600;
}
.ml-options-token-check-status.is-pending,
#model-linker-modal .ml-options-token-check-status.is-pending {
color: var(--ml-text-secondary);
}
.ml-options-token-check-status.is-valid,
#model-linker-modal .ml-options-token-check-status.is-valid {
color: var(--ml-color-success);
}
.ml-options-token-check-status.is-invalid,
#model-linker-modal .ml-options-token-check-status.is-invalid {
color: var(--ml-color-error-light);
}
.ml-options-label {
display: flex;
align-items: center;
@@ -3650,8 +3678,7 @@
grid-template-columns: minmax(150px, 1fr) minmax(0, 380px) 42px;
align-items: center;
gap: 10px;
padding: 14px 0;
border-bottom: 1px solid rgba(255,255,255,0.1);
padding: 14px 0 0;
min-width: 0;
}
.ml-options-input-row > .ml-options-label {
+115
View File
@@ -4051,6 +4051,10 @@ class LinkerManagerDialog extends ComfyDialog {
${getSvgIcon('eye')}
</button>
</div>
<div class="ml-options-token-check">
<button id="ml-options-civitai-key-check" type="button" class="ml-btn ml-btn-secondary ml-btn-sm">${getSvgIcon('refreshCw')} Check API key</button>
<span id="ml-options-civitai-key-check-status" class="ml-options-token-check-status">Not checked</span>
</div>
</div>
<div class="ml-options-field">
<div class="ml-options-input-row">
@@ -4069,6 +4073,10 @@ class LinkerManagerDialog extends ComfyDialog {
</ol>
<p>Treat it like a password. It is only needed when session-aware CivitAI search is required.</p>
</details>
<div class="ml-options-token-check">
<button id="ml-options-civitai-session-check" type="button" class="ml-btn ml-btn-secondary ml-btn-sm">${getSvgIcon('refreshCw')} Check session</button>
<span id="ml-options-civitai-session-check-status" class="ml-options-token-check-status">Not checked</span>
</div>
</div>
</div>
<label class="ml-options-toggle-row">
@@ -4117,6 +4125,10 @@ class LinkerManagerDialog extends ComfyDialog {
${getSvgIcon('eye')}
</button>
</div>
<div class="ml-options-token-check">
<button id="ml-options-hf-token-check" type="button" class="ml-btn ml-btn-secondary ml-btn-sm">${getSvgIcon('refreshCw')} Check token</button>
<span id="ml-options-hf-token-check-status" class="ml-options-token-check-status">Not checked</span>
</div>
</div>
</div>
<label class="ml-options-toggle-row">
@@ -4166,6 +4178,10 @@ class LinkerManagerDialog extends ComfyDialog {
${getSvgIcon('eye')}
</button>
</div>
<div class="ml-options-token-check">
<button id="ml-options-brave-key-check" type="button" class="ml-btn ml-btn-secondary ml-btn-sm">${getSvgIcon('refreshCw')} Check API key</button>
<span id="ml-options-brave-key-check-status" class="ml-options-token-check-status">Not checked</span>
</div>
</div>
</div>
</div>
@@ -4188,9 +4204,17 @@ class LinkerManagerDialog extends ComfyDialog {
const civitaiLimitInput = this.contentElement.querySelector('#ml-options-civitai-limit');
const civitaiUseTrpcSearchInput = this.contentElement.querySelector('#ml-options-civitai-use-trpc-search');
const civitaiUseHtmlFallbackInput = this.contentElement.querySelector('#ml-options-civitai-use-html-fallback');
const civitaiKeyCheckBtn = this.contentElement.querySelector('#ml-options-civitai-key-check');
const civitaiKeyCheckStatus = this.contentElement.querySelector('#ml-options-civitai-key-check-status');
const civitaiSessionCheckBtn = this.contentElement.querySelector('#ml-options-civitai-session-check');
const civitaiSessionCheckStatus = this.contentElement.querySelector('#ml-options-civitai-session-check-status');
const hfUseApiSearchInput = this.contentElement.querySelector('#ml-options-hf-use-api-search');
const hfUseComfyOrgFallbackInput = this.contentElement.querySelector('#ml-options-hf-use-comfy-org-fallback');
const hfUseBraveFallbackInput = this.contentElement.querySelector('#ml-options-hf-use-brave-fallback');
const hfTokenCheckBtn = this.contentElement.querySelector('#ml-options-hf-token-check');
const hfTokenCheckStatus = this.contentElement.querySelector('#ml-options-hf-token-check-status');
const braveKeyCheckBtn = this.contentElement.querySelector('#ml-options-brave-key-check');
const braveKeyCheckStatus = this.contentElement.querySelector('#ml-options-brave-key-check-status');
const sourceEnabledInputs = Array.from(this.contentElement.querySelectorAll('.ml-options-source-enabled'));
const status = this.contentElement.querySelector('#ml-options-status');
const saveBtn = this.contentElement.querySelector('#ml-options-save');
@@ -4246,6 +4270,45 @@ class LinkerManagerDialog extends ComfyDialog {
if (hfIndexRefreshBtn) hfIndexRefreshBtn.disabled = busy;
};
const setTokenCheckStatus = (statusEl, text, mode = '') => {
if (!statusEl) return;
statusEl.textContent = text;
statusEl.classList.remove('is-valid', 'is-invalid', 'is-pending');
if (mode) statusEl.classList.add(mode);
};
const checkCredential = async ({ input, button, statusEl, endpoint, payloadKey, missingText }) => {
const value = (input?.value || '').trim();
if (!value) {
setTokenCheckStatus(statusEl, missingText, 'is-invalid');
return;
}
try {
if (button) button.disabled = true;
setTokenCheckStatus(statusEl, 'Checking...', 'is-pending');
const response = await api.fetchApi(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [payloadKey]: value })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `Check failed: ${response.status}`);
}
setTokenCheckStatus(
statusEl,
data.message || (data.valid ? 'Valid' : 'Invalid'),
data.valid ? 'is-valid' : 'is-invalid'
);
} catch (error) {
console.error('Model Linker: Credential check error:', error);
setTokenCheckStatus(statusEl, error.message || 'Check failed', 'is-invalid');
} finally {
if (button) button.disabled = false;
}
};
const renderModelListStatus = (data = {}, checkedRemote = false) => {
if (modelListCountEl) {
modelListCountEl.textContent = Number.isFinite(Number(data.local_count))
@@ -4494,6 +4557,58 @@ class LinkerManagerDialog extends ComfyDialog {
});
}
if (civitaiSessionCheckBtn) {
civitaiSessionCheckBtn.addEventListener('click', () => {
checkCredential({
input: civitaiSessionInput,
button: civitaiSessionCheckBtn,
statusEl: civitaiSessionCheckStatus,
endpoint: '/model_linker/civitai/session-token/check',
payloadKey: 'civitai_session_token',
missingText: 'Paste token first'
});
});
}
if (civitaiKeyCheckBtn) {
civitaiKeyCheckBtn.addEventListener('click', () => {
checkCredential({
input: civitaiInput,
button: civitaiKeyCheckBtn,
statusEl: civitaiKeyCheckStatus,
endpoint: '/model_linker/civitai/api-key/check',
payloadKey: 'civitai_key',
missingText: 'Paste API key first'
});
});
}
if (hfTokenCheckBtn) {
hfTokenCheckBtn.addEventListener('click', () => {
checkCredential({
input: hfInput,
button: hfTokenCheckBtn,
statusEl: hfTokenCheckStatus,
endpoint: '/model_linker/huggingface/token/check',
payloadKey: 'hf_token',
missingText: 'Paste token first'
});
});
}
if (braveKeyCheckBtn) {
braveKeyCheckBtn.addEventListener('click', () => {
checkCredential({
input: braveInput,
button: braveKeyCheckBtn,
statusEl: braveKeyCheckStatus,
endpoint: '/model_linker/brave/api-key/check',
payloadKey: 'brave_search_api_key',
missingText: 'Paste API key first'
});
});
}
loadModelListStatus(false);
loadHfIndexStatus();