Files
rays eb86f889b3 fix(logging): unify Doctor startup output
Standardize backend log identities and severity highlighting while keeping persisted logs ANSI-free. Restore the encoding-aware startup banner and emit its art as one tightly packed block.
2026-08-09 05:38:01 +08:00

1865 lines
84 KiB
Python

"""
ComfyUI PromptServer route registration for ComfyUI-Doctor.
Route handlers live outside the package entry point so __init__.py remains a
startup/export module for the ComfyUI custom-node host.
"""
from __future__ import annotations
import json
import logging
import re
from pathlib import Path
from .services.prompt_helpers import (
categorize_error,
collect_error_context,
get_error_analysis_prompt,
parse_language_code,
validate_fix_schema,
)
from .terminal_output import emit_doctor_log
logger = logging.getLogger("ComfyUI-Doctor-API")
def _local_llm_auth_placeholder() -> str:
return "local-llm"
REGISTERED_ROUTE_DESCRIPTIONS = [
" - GET /debugger/last_analysis",
" - GET /debugger/history",
" - POST /debugger/set_language",
" - POST /debugger/clear_history",
" - GET /doctor/ui_text",
" - POST /doctor/analyze",
" - POST /doctor/chat (SSE streaming)",
" - GET /doctor/provider_defaults",
" - POST /doctor/verify_key",
" - POST /doctor/list_models",
" - GET /doctor/secrets/status (S8)",
" - PUT /doctor/secrets (S8)",
" - DELETE /doctor/secrets/{provider} (S8)",
" - GET /doctor/statistics (F4)",
" - POST /doctor/statistics/reset (F4)",
" - POST /doctor/mark_resolved (F4)",
" - POST /doctor/feedback/preview (F16)",
" - POST /doctor/feedback/submit (F16)",
" - GET /doctor/health",
" - GET /doctor/plugins",
" - GET /doctor/telemetry/status (S3)",
" - GET /doctor/telemetry/buffer (S3)",
" - POST /doctor/telemetry/track (S3)",
" - POST /doctor/telemetry/clear (S3)",
" - GET /doctor/telemetry/export (S3)",
" - POST /doctor/telemetry/toggle (S3)",
" - POST /doctor/health_check (F14)",
" - GET /doctor/health_report (F14)",
" - GET /doctor/health_history (F14)",
" - POST /doctor/health_ack (F14)",
]
def _print_registered_routes(startup_print) -> None:
startup_print("API hooks registered:")
for route_description in REGISTERED_ROUTE_DESCRIPTIONS:
startup_print(route_description)
startup_print("Questions, updates, suggestions, and contributions are welcome.")
startup_print("GitHub: https://github.com/rookiestar28/ComfyUI-Doctor")
startup_print("")
startup_print("\n")
def register_api_routes(context: dict) -> None:
"""Register all PromptServer routes using dependencies from __init__.py."""
globals().update(context)
@server.PromptServer.instance.routes.get("/debugger/last_analysis")
async def api_get_last_analysis(request):
"""
API endpoint to get the last error analysis.
Returns:
JSON with status, log_path, last error details, and suggestion.
"""
analysis = get_last_analysis()
return web.json_response({
"status": "running",
"log_path": log_path,
"language": get_language(),
"supported_languages": SUPPORTED_LANGUAGES,
"last_error": analysis.get("error"),
"suggestion": analysis.get("suggestion"),
"timestamp": analysis.get("timestamp"),
"node_context": analysis.get("node_context"),
"analysis_metadata": analysis.get("analysis_metadata"),
"matched_pattern_id": analysis.get("matched_pattern_id"),
"pattern_category": analysis.get("pattern_category"),
"pattern_priority": analysis.get("pattern_priority"),
"resolution_status": analysis.get("resolution_status"),
})
@server.PromptServer.instance.routes.post("/debugger/set_language")
async def api_set_language(request):
"""
API endpoint to change the suggestion language.
Body: {"language": "zh_TW"}
"""
try:
data = await request.json()
if "language" in data:
set_language(data["language"])
return web.json_response({"success": True, "language": data["language"]})
return _error_response("Missing language parameter", status=400, code="missing_language")
except Exception as e:
return _error_response(str(e), status=500)
@server.PromptServer.instance.routes.get("/doctor/ui_text")
async def api_get_ui_text(request):
"""
API endpoint to get all UI text translations for current language.
Query params (optional): ?lang=zh_TW
"""
try:
lang = request.query.get("lang", get_language())
ui_text = UI_TEXT.get(lang, UI_TEXT["en"])
def _get_doctor_meta() -> dict:
# Best-effort metadata for UI display.
# IMPORTANT: prefer pyproject.toml values for UI consistency.
meta = {
"name": "ComfyUI-Doctor",
"version": "unknown",
"repository": "https://github.com/rookiestar28/ComfyUI-Doctor",
}
# Primary source: local pyproject.toml
try:
from pathlib import Path
import re
pyproject_path = Path(__file__).resolve().parent / "pyproject.toml"
if pyproject_path.exists():
content = pyproject_path.read_text(encoding="utf-8", errors="ignore")
m = re.search(r'(?m)^version\s*=\s*"([^"]+)"\s*$', content)
if m:
meta["version"] = m.group(1).strip()
m_repo = re.search(
r'(?ms)^\[project\.urls\].*?^Repository\s*=\s*"([^"]+)"\s*$',
content,
)
if m_repo:
meta["repository"] = m_repo.group(1).strip()
except Exception:
pass
# Fallback source: installed distribution metadata.
if meta["version"] in ("unknown", "", None):
try:
import importlib.metadata as _metadata # py3.8+
# Distribution name may differ depending on install method.
for dist_name in ("ComfyUI-Doctor", "comfyui-doctor"):
try:
meta["version"] = _metadata.version(dist_name)
break
except Exception:
pass
except Exception:
pass
return meta
return web.json_response({
"language": lang,
"text": ui_text,
"meta": _get_doctor_meta(),
})
except Exception as e:
return _error_response(str(e), status=500)
@server.PromptServer.instance.routes.post("/doctor/analyze")
async def api_analyze_error(request):
"""
API endpoint to analyze error with LLM.
Payload: { "error": str, "node_context": dict, "api_key": str, "base_url": str, "model": str, "language": str }
Security: API key is transmitted but never logged or persisted.
"""
try:
data = await request.json()
error_text = data.get("error")
node_context = data.get("node_context", {})
workflow = data.get("workflow") # F3: Workflow context from frontend
api_key = data.get("api_key")
base_url = data.get("base_url", "https://api.openai.com/v1")
provider = data.get("provider", "")
model = data.get("model", "gpt-4o")
language = data.get("language", "en")
privacy_mode = data.get("privacy_mode", "basic") # S6: PII sanitization level
logger.info(f"Analyze API called - error_length={len(error_text) if error_text else 0}, has_workflow={bool(workflow)}, model={model}, privacy={privacy_mode}")
# S2: SSRF protection - validate base URL
is_valid, ssrf_error = validate_ssrf_url(base_url)
if not is_valid:
logger.warning(f"SSRF blocked: {ssrf_error}")
return _error_response(f"Invalid Base URL: {ssrf_error}", status=400, code="invalid_base_url")
# Resolve API key via request -> ENV -> server store
api_key, key_source, resolved_provider, is_local = resolve_api_key(
base_url=base_url,
provider_hint=provider,
request_api_key=api_key,
)
if not api_key and not is_local:
return _error_response("Missing API Key", status=401, code="missing_api_key")
logger.info(f"Analyze key source={key_source}, provider={resolved_provider or 'unknown'}, local={is_local}")
if not error_text:
return _error_response("No error text provided", status=400, code="missing_error_text")
# S6: PII Sanitization - Remove sensitive info before sending to LLM
sanitizer, downgraded = get_outbound_sanitizer(base_url, privacy_mode)
if downgraded:
logger.warning("privacy_mode=none is only allowed for verified local providers; using basic")
# Sanitize error text
sanitization_result = sanitizer.sanitize(error_text)
error_text = sanitization_result.sanitized_text
# Log sanitization metadata (for audit)
if sanitization_result.pii_found:
logger.info(f"PII sanitized: {sanitization_result.replacements}")
# Sanitize node context (paths, custom_node_path)
if node_context:
node_context = sanitizer.sanitize_dict(node_context, keys_to_sanitize=[])
# Truncate error text to prevent token overflow (roughly 8000 chars ≈ 2000 tokens)
MAX_ERROR_LENGTH = 8000
if len(error_text) > MAX_ERROR_LENGTH:
error_text = error_text[:MAX_ERROR_LENGTH] + "\n\n[... truncated ...]"
# R8: Smart workflow truncation (preserves error-related nodes)
if workflow:
from .truncate_workflow import truncate_workflow_smart
error_node_id = (
node_context.get("display_node")
or node_context.get("node_id")
or node_context.get("real_node_id")
or node_context.get("id")
) if node_context else None
workflow, truncation_meta = truncate_workflow_smart(workflow, error_node_id, max_chars=4000)
if truncation_meta.get("truncation_method") != "none":
logger.info(f"Workflow truncated: {truncation_meta}")
# Construct Prompt - Enhanced for ComfyUI debugging
system_prompt = (
"You are an expert ComfyUI debugger and Python specialist. "
"ComfyUI is a node-based Stable Diffusion workflow editor where users connect nodes "
"(e.g., 'KSampler', 'VAEDecode', 'CheckpointLoaderSimple', 'CLIPTextEncode') to build image generation pipelines.\n\n"
"Common ComfyUI error categories:\n"
"- **OOM (Out of Memory)**: Reduce batch_size, lower resolution, use --lowvram or --cpu flags\n"
"- **Missing Models**: Check if the model exists in a configured or registered model folder, verify filename spelling\n"
"- **Type Mismatch**: Ensure connected nodes have compatible data types (MODEL, CLIP, VAE, LATENT, IMAGE)\n"
"- **CUDA/cuDNN Errors**: Often driver version issues, try updating GPU drivers or PyTorch\n"
"- **Shape Mismatch**: Usually caused by incompatible image sizes or LoRA/model combinations\n"
"- **Module Not Found**: Missing Python dependencies, run 'pip install <module>' in ComfyUI environment\n\n"
"Analyze the error and provide:\n"
"1. **Root Cause** (1-2 sentences, be specific)\n"
"2. **Solution Steps** (numbered list, actionable commands if applicable)\n"
"3. **Prevention Tips** (optional, if the error is common)\n\n"
f"Respond in {language}. Be concise but thorough."
)
# R14: Use PromptComposer for unified context formatting
if CONFIG.r14_use_prompt_composer:
try:
pipeline_context = ErrorAnalyzer.build_llm_context(
error_text,
workflow_json=workflow,
node_context=node_context,
settings={"privacy_mode": privacy_mode},
)
llm_context = pipeline_context.llm_context or {
"traceback": error_text,
"node_info": node_context if node_context else {},
}
composer_config = PromptComposerConfig(use_legacy_format=CONFIG.r14_use_legacy_format)
prompt_composer = get_prompt_composer()
user_prompt = prompt_composer.compose(llm_context, composer_config)
logger.info("[R14] PromptComposer used for /doctor/analyze")
except Exception as r14_err:
logger.warning(f"[R14] PromptComposer failed in /doctor/analyze, falling back to legacy: {r14_err}")
# Fall through to legacy format
user_prompt = None
else:
user_prompt = None
# Legacy format (fallback or when R14 disabled)
if user_prompt is None:
user_prompt = f"Error:\n{error_text}\n\n"
if node_context:
user_prompt += f"Node Context: {json.dumps(node_context, indent=2)}\n\n"
# F3: Include workflow context if available
if workflow:
user_prompt += f"Workflow Structure (simplified):\n{workflow}\n\n"
# F10: Include system environment context for better debugging
try:
env_info = get_system_environment()
env_text = format_env_for_llm(env_info, max_packages=30)
user_prompt += f"{env_text}\n\n"
except Exception as env_err:
# Don't fail the entire analysis if env collection fails
logger.warning(f"Failed to collect environment info: {env_err}")
user_prompt += "[System environment info unavailable]\n\n"
# Normalize Base URL
base_url = base_url.rstrip("/")
is_local = is_local_llm_url(base_url)
llm_adapter = get_llm_provider_adapter(base_url, is_local=is_local)
provider_request = llm_adapter.build_chat_request(
base_url,
api_key,
model,
system_prompt,
[{"role": "user", "content": user_prompt}],
stream=False,
temperature=0.5,
)
url = provider_request.url
headers = provider_request.headers
payload = provider_request.payload
# R7: Rate limit check (core limiter for heavy endpoint)
if not SessionManager.get_core_limiter().allow():
logger.warning("Rate limit exceeded for /doctor/analyze")
return _error_response(
"Rate limit exceeded. Please wait before retrying.",
status=429,
code="rate_limited",
)
# R7: Concurrency limit (prevent connection pool exhaustion)
async with SessionManager.get_concurrency_limiter():
session = await SessionManager.get_session()
payload = sanitize_outbound_payload(payload, sanitizer)
# R6: Request with retry logic
retry_config = RetryConfig(
max_retries=CONFIG.llm_max_retries,
request_timeout_seconds=CONFIG.llm_request_timeout,
total_timeout_seconds=CONFIG.llm_total_timeout,
retry_on_5xx=False, # Conservative for non-streaming
)
result = await llm_request_with_retry(
session, "POST", url,
json=payload, headers=headers,
config=retry_config,
)
if not result.success:
_close_retry_response(result)
error_msg = result.error or "Unknown error"
logger.error(f"LLM request failed after {result.attempts} attempts: {error_msg}")
return _error_response(f"LLM Error: {error_msg}", status=503, code="llm_error")
async with result.response as response:
if response.status != 200:
error_msg = await response.text()
# Truncate error message for readability
if len(error_msg) > 500:
error_msg = error_msg[:500] + "..."
return _error_response(
f"LLM Provider Error ({response.status}): {error_msg}",
status=response.status,
code="llm_provider_error",
)
# Safely parse JSON response
try:
resp_data = await response.json()
content = llm_adapter.parse_chat_response(resp_data)
if not content:
return _error_response("Empty response from LLM", status=502, code="empty_llm_response")
# R19: Clean output (strip hidden reasoning)
from .services.providers.base import BaseProviderAdapter
content = BaseProviderAdapter.clean_llm_output(content)
logger.info(f"Analysis successful, response length={len(content)}, attempts={result.attempts}")
return web.json_response({"analysis": content})
except (json.JSONDecodeError, KeyError, IndexError) as parse_err:
return _error_response(
f"Failed to parse LLM response: {str(parse_err)}",
status=502,
code="llm_parse_error",
)
except aiohttp.ClientError as e:
# Network-level errors (timeout, connection refused, etc.)
logger.error(f"LLM Network Error: {str(e)}")
return _error_response(f"Network Error: {str(e)}", status=503, code="network_error")
except Exception as e:
logger.error(f"LLM Analysis Failed: {str(e)}")
return _error_response(str(e), status=500)
@server.PromptServer.instance.routes.post("/doctor/chat")
async def api_chat(request):
"""
API endpoint for multi-turn chat with LLM (SSE streaming).
Payload: {
"messages": [{"role": "user|assistant", "content": "..."}],
"error_context": {"error": "...", "node_context": {...}, "workflow": "..."},
"api_key": str,
"base_url": str,
"model": str,
"language": str,
"stream": bool (default: true)
}
Response (SSE):
data: {"delta": "token", "done": false}
data: {"delta": "", "done": true}
"""
try:
data = await request.json()
messages = data.get("messages", [])
error_context = data.get("error_context", {})
api_key = data.get("api_key", "")
base_url = data.get("base_url", "https://api.openai.com/v1")
provider = data.get("provider", "")
model = data.get("model", "gpt-4o")
language = data.get("language", "en")
stream = data.get("stream", True)
intent = data.get("intent", "chat") # New: intent parameter
selected_nodes = data.get("selected_nodes", []) # New: node selection context
privacy_mode = data.get("privacy_mode", "basic") # S6: PII sanitization level
logger.info(f"Chat API called - model={model}, intent={intent}, messages={len(messages)}, stream={stream}, privacy={privacy_mode}")
# S2: SSRF protection - validate base URL
is_valid, ssrf_error = validate_ssrf_url(base_url)
if not is_valid:
logger.warning(f"SSRF blocked: {ssrf_error}")
return _error_response(f"Invalid Base URL: {ssrf_error}", status=400, code="invalid_base_url")
# Resolve API key via request -> ENV -> server store
api_key, key_source, resolved_provider, is_local = resolve_api_key(
base_url=base_url,
provider_hint=provider,
request_api_key=api_key,
)
if not api_key and not is_local:
return _error_response("Missing API Key", status=401, code="missing_api_key")
logger.info(f"Chat key source={key_source}, provider={resolved_provider or 'unknown'}, local={is_local}")
if not messages:
return _error_response("No messages provided", status=400, code="missing_messages")
# R12: Smart Token Budget
# Apply budget metrics and trimming BEFORE sanitization and prompt construction
# This ensures we operate on the raw context keys
# Supports both remote (strict) and local (opt-in soft) modes
r12_meta = {}
r12_should_apply = (CONFIG.r12_enabled_remote and not is_local) or (CONFIG.r12_enabled_local and is_local)
if r12_should_apply:
try:
from .services.token_estimator import EstimatorConfig
# Select appropriate limits and policy based on provider type
if is_local:
# Local mode: use local limits with local_soft policy
soft_max = CONFIG.r12_soft_max_tokens_local
hard_max = CONFIG.r12_hard_max_tokens_local
policy = "local_soft"
else:
# Remote mode: use remote limits with configured policy
soft_max = CONFIG.r12_soft_max_tokens_remote
hard_max = CONFIG.r12_hard_max_tokens_remote
policy = CONFIG.r12_policy_profile
budget_config = BudgetConfig(
enabled_remote=CONFIG.r12_enabled_remote,
enabled_local=CONFIG.r12_enabled_local,
soft_max_tokens=soft_max,
hard_max_tokens=hard_max,
trimming_policy=policy,
estimator_config=EstimatorConfig(
chars_per_token=CONFIG.r12_estimator_fallback_cpt,
safety_multiplier=CONFIG.r12_estimator_safety_mult
),
prune_default_depth=CONFIG.r12_prune_default_depth,
prune_default_nodes=CONFIG.r12_prune_default_nodes,
overhead_fixed=CONFIG.r12_overhead_fixed
)
# Create context wrapper
budget_context = {
"messages": messages,
"error_context": error_context
}
# Apply
budgeted_context, r12_meta = TOKEN_BUDGET_SERVICE.apply_token_budget(
budget_context,
is_remote_provider=not is_local,
config=budget_config
)
# Update local refs with trimmed versions
if "messages" in budgeted_context:
messages = budgeted_context["messages"]
if "error_context" in budgeted_context:
error_context = budgeted_context["error_context"]
except Exception as r12_err:
logger.warning(f"R12 Budget application failed, proceeding with original payload: {r12_err}")
# S6: PII Sanitization - Remove sensitive info before sending to LLM
sanitizer, downgraded = get_outbound_sanitizer(base_url, privacy_mode)
if downgraded:
logger.warning("privacy_mode=none is only allowed for verified local providers; using basic")
# Build system prompt with error context
# R14: Support both "error" and "last_error" keys for compatibility
error_text = error_context.get("error") or error_context.get("last_error", "")
node_context = error_context.get("node_context", {})
workflow = error_context.get("workflow", "")
# Sanitize error context
if error_text:
error_text = sanitizer.sanitize(error_text).sanitized_text
if node_context:
node_context = sanitizer.sanitize_dict(node_context, keys_to_sanitize=[])
# Sanitize user messages (only user role, not assistant responses)
for msg in messages:
if msg.get("role") == "user" and isinstance(msg.get("content"), str):
msg["content"] = sanitizer.sanitize(msg["content"]).sanitized_text
# Truncate to prevent token overflow
MAX_ERROR_LENGTH = 4000
if len(error_text) > MAX_ERROR_LENGTH:
error_text = error_text[:MAX_ERROR_LENGTH] + "\n[... truncated ...]"
# R8: Smart workflow truncation
if workflow:
from .truncate_workflow import truncate_workflow_smart
workflow, _ = truncate_workflow_smart(workflow, max_chars=2000)
# Option B Phase 1: Parse workflow data for enhanced error context
workflow_data = None
if workflow:
try:
# If workflow is a JSON string, parse it
if isinstance(workflow, str):
workflow_data = json.loads(workflow)
elif isinstance(workflow, dict):
workflow_data = workflow
except json.JSONDecodeError:
logger.warning("Failed to parse workflow JSON for enhanced context")
# Option B Phase 1: Collect enhanced error context
# R14: Support both "error" and "last_error" keys
enriched_context = None
canonical_error = error_context.get("error") or error_context.get("last_error")
if error_context and canonical_error:
# Build error_data dict from error_context
error_data = {
"exception_message": canonical_error,
"exception_type": error_context.get("error_type", "Unknown"),
"node_id": node_context.get("node_id") if node_context else None,
"display_node": node_context.get("display_node") if node_context else None,
"parent_node": node_context.get("parent_node") if node_context else None,
"real_node_id": node_context.get("real_node_id") if node_context else None,
"subgraph_lineage": node_context.get("subgraph_lineage") if node_context else None,
"traceback": error_context.get("traceback")
}
enriched_context = collect_error_context(error_data, workflow_data)
error_category = None
if error_context and canonical_error:
# Categorize using the full error_context dict for better keyword matching
error_category = categorize_error(error_context)
logger.info(f"Error categorized as: {error_category['category']} (confidence: {error_category['confidence']:.0%})")
# Option B Phase 1: Detect user's preferred language from request headers or data
user_lang_code = language # Default to language parameter
if not user_lang_code or user_lang_code not in ["en", "zh_TW", "zh_CN", "ja", "de", "fr", "it", "es", "ko"]:
# Try to parse from Accept-Language header if available
accept_lang = request.headers.get("Accept-Language", "en")
user_lang_code = parse_language_code(accept_lang)
# Intent-aware system prompt
if intent == "explain_node":
# Node explanation mode - use simple prompt
system_prompt = (
"You are an expert ComfyUI node documentation assistant. ComfyUI is a node-based Stable Diffusion workflow editor.\n\n"
"Your task is to explain how specific nodes work, their inputs/outputs, and best practices for using them.\n"
"Be concise, clear, and provide practical examples when relevant.\n"
f"Respond in {language}.\n\n"
)
if selected_nodes:
system_prompt += f"**Selected Node(s):** {json.dumps(selected_nodes)}\n\n"
else:
# Option B Phase 1: Error analysis mode - use enhanced multi-language template
if enriched_context and enriched_context.get("error_message"):
# Use enhanced error analysis template with language directive
system_prompt = get_error_analysis_prompt(user_lang_code)
# R14: Use PromptComposer for unified context formatting
r14_composer_succeeded = False # Track success for fallback logic
if CONFIG.r14_use_prompt_composer:
try:
r14_composer_succeeded = True # Assume success until exception
traceback_text = str(enriched_context.get('traceback') or '')
failed_node = enriched_context.get('failed_node', {})
pipeline_node_context = {
"node_id": failed_node.get('id') or node_context.get('node_id'),
"node_name": failed_node.get('title') or node_context.get('node_name'),
"node_class": failed_node.get('class_type') or node_context.get('node_class'),
"display_node": failed_node.get('display_node') or node_context.get('display_node'),
"parent_node": failed_node.get('parent_node') or node_context.get('parent_node'),
"real_node_id": failed_node.get('real_node_id') or node_context.get('real_node_id'),
}
pipeline_context = ErrorAnalyzer.build_llm_context(
traceback_text or enriched_context['error_message'],
workflow_json=workflow_data or workflow,
node_context=pipeline_node_context,
execution_logs=enriched_context.get('execution_logs', []),
settings={"privacy_mode": privacy_mode},
)
llm_context = pipeline_context.llm_context or {
"error_summary": enriched_context['error_message'],
"node_info": pipeline_node_context,
"traceback": traceback_text,
"execution_logs": enriched_context.get('execution_logs', []),
"workflow_subset": enriched_context.get('workflow_structure'),
}
composer_config = PromptComposerConfig(use_legacy_format=CONFIG.r14_use_legacy_format)
prompt_composer = get_prompt_composer()
context_block = prompt_composer.compose(llm_context, composer_config)
system_prompt += f"\n\n{context_block}"
# Add error category if available
if error_category:
system_prompt += f"\n\n**ERROR CATEGORY** (auto-detected):\n"
system_prompt += f"Category: {error_category['category']}\n"
system_prompt += f"Confidence: {error_category['confidence']:.0%}\n"
system_prompt += f"Suggested Approach: {error_category['suggested_approach']}\n"
logger.info("[R14/R15] PromptComposer used for context formatting with canonical system_info")
except Exception as r14_err:
logger.warning(f"[R14] PromptComposer failed, falling back to legacy: {r14_err}")
# R14: Use local flag, NOT global CONFIG mutation
r14_composer_succeeded = False
else:
r14_composer_succeeded = False
# Legacy format (fallback or when R14 disabled)
if not r14_composer_succeeded:
# Add enriched error context (legacy format)
system_prompt += f"\n\n**ERROR CONTEXT**:\n"
system_prompt += f"Error Type: {enriched_context['error_type']}\n"
system_prompt += f"Error Message: {enriched_context['error_message']}\n"
# Option B Phase 2: Add error category with suggested approach
if error_category:
system_prompt += f"\n**ERROR CATEGORY** (auto-detected):\n"
system_prompt += f"Category: {error_category['category']}\n"
system_prompt += f"Confidence: {error_category['confidence']:.0%}\n"
system_prompt += f"Suggested Approach: {error_category['suggested_approach']}\n"
if error_category['keywords_matched']:
matched_kw_str = ', '.join(error_category['keywords_matched'][:5]) # Limit to 5
system_prompt += f"Matched Keywords: {matched_kw_str}\n"
if enriched_context.get('traceback'):
# Truncate traceback to prevent token overflow
traceback_text = str(enriched_context['traceback'])
if len(traceback_text) > 2000:
traceback_text = traceback_text[:2000] + "\n[... truncated ...]"
system_prompt += f"\nPython Stack Trace:\n```\n{traceback_text}\n```\n"
if enriched_context.get('failed_node'):
node = enriched_context['failed_node']
system_prompt += f"\nFailed Node: {node['class_type']} (ID: {node['id']})\n"
if node.get('title'):
system_prompt += f"Node Title: {node['title']}\n"
system_prompt += f"Node Inputs: {json.dumps(node['inputs'], indent=2)}\n"
if enriched_context['workflow_structure'].get('upstream_nodes'):
upstream_nodes = enriched_context['workflow_structure']['upstream_nodes']
system_prompt += f"\nUpstream Nodes: {len(upstream_nodes)} connected\n"
for up in upstream_nodes[:5]: # Limit to 5 to prevent token overflow
system_prompt += f" - {up['class_type']} -> {up['connection']}\n"
if enriched_context['workflow_structure'].get('missing_connections'):
system_prompt += "\nMissing Required Connections:\n"
for missing in enriched_context['workflow_structure']['missing_connections']:
system_prompt += f" - {missing['input']} (type: {missing['type']})\n"
else:
# Fallback to simple chat/debug prompt
system_prompt = (
"You are an expert ComfyUI debugger. ComfyUI is a node-based Stable Diffusion workflow editor.\n\n"
"You are helping the user debug an error. Be concise, helpful, and provide actionable solutions.\n"
f"Respond in {language}.\n\n"
)
if error_text:
system_prompt += f"**Current Error:**\n```\n{error_text}\n```\n\n"
if node_context:
system_prompt += f"**Node Context:** {json.dumps(node_context)}\n\n"
if workflow:
system_prompt += f"**Workflow (simplified):** {workflow}\n\n"
# F10/R15: Include system environment context
# R15: Only append legacy format if PromptComposer was NOT used (avoid duplicate env)
if not r14_composer_succeeded:
try:
env_info = get_system_environment()
env_text = format_env_for_llm(env_info, max_packages=20)
system_prompt += f"\n{env_text}\n\n"
except Exception as env_err:
logger.warning(f"Failed to collect environment info for chat: {env_err}")
# Prepare request
base_url = base_url.rstrip("/")
is_local = is_local_llm_url(base_url)
# Limit conversation history to prevent token overflow
MAX_HISTORY = 10
recent_messages = messages[-MAX_HISTORY:] if len(messages) > MAX_HISTORY else messages
llm_adapter = get_llm_provider_adapter(base_url, is_local=is_local)
provider_request = llm_adapter.build_chat_request(
base_url,
api_key,
model,
system_prompt,
recent_messages,
stream=stream,
temperature=0.7,
)
url = provider_request.url
headers = provider_request.headers
payload = provider_request.payload
logger.info(f"Connecting to LLM: {url}")
# R7: Rate limit check (core limiter for heavy endpoint)
if not SessionManager.get_core_limiter().allow():
logger.warning("Rate limit exceeded for /doctor/chat")
return _error_response(
"Rate limit exceeded. Please wait before retrying.",
status=429,
code="rate_limited",
)
# R7: Concurrency limit (prevent connection pool exhaustion)
async with SessionManager.get_concurrency_limiter():
if not stream:
# Non-streaming fallback with retry
session = await SessionManager.get_session()
payload = sanitize_outbound_payload(payload, sanitizer)
# R6: Request with retry logic
retry_config = RetryConfig(
max_retries=CONFIG.llm_max_retries,
request_timeout_seconds=CONFIG.llm_request_timeout,
total_timeout_seconds=CONFIG.llm_total_timeout,
retry_on_5xx=False,
)
result = await llm_request_with_retry(
session, "POST", url,
json=payload, headers=headers,
config=retry_config,
)
if not result.success:
_close_retry_response(result)
error_msg = result.error or "Unknown error"
logger.error(f"LLM non-stream failed after {result.attempts} attempts: {error_msg}")
return _error_response(f"LLM Error: {error_msg}", status=503, code="llm_error")
async with result.response as response:
if response.status != 200:
error_msg = await response.text()
logger.error(f"LLM non-stream error: {error_msg[:200]}")
return _error_response(f"LLM Error: {error_msg[:500]}", status=response.status, code="llm_error")
resp_data = await response.json()
content = llm_adapter.parse_chat_response(resp_data)
# R19: Clean output (strip hidden reasoning)
from .services.providers.base import BaseProviderAdapter
content = BaseProviderAdapter.clean_llm_output(content)
logger.info(f"LLM response received (non-stream), length={len(content)}, attempts={result.attempts}")
return web.json_response({"content": content, "done": True, "metadata": r12_meta})
# SSE Streaming response
logger.info("Starting SSE stream...")
response = web.StreamResponse(
status=200,
reason='OK',
headers={
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
}
)
await response.prepare(request)
# Send R12 metadata as early SSE event if available
if r12_meta:
meta_event = json.dumps({"type": "usage_metadata", "data": r12_meta})
await response.write(f"data: {meta_event}\n\n".encode('utf-8'))
try:
session = await SessionManager.get_session()
payload = sanitize_outbound_payload(payload, sanitizer)
# R6: Pre-stream retry (only retry before streaming starts)
# Once streaming begins, we cannot retry
retry_config = RetryConfig(
max_retries=CONFIG.llm_max_retries,
request_timeout_seconds=CONFIG.llm_request_timeout,
total_timeout_seconds=CONFIG.llm_total_timeout,
retry_on_5xx=False,
)
result = await llm_request_with_retry(
session, "POST", url,
json=payload, headers=headers,
config=retry_config,
is_streaming=True,
)
if not result.success or result.response is None:
_close_retry_response(result)
error_msg = result.error or "Unknown error"
logger.error(f"LLM stream connection failed after {result.attempts} attempts: {error_msg}")
error_data = json.dumps({"error": f"LLM Error: {error_msg}", "done": True})
await response.write(f"data: {error_data}\n\n".encode('utf-8'))
return response
# Note: From here on, NO RETRY - streaming has begun
async with result.response as llm_response:
if llm_response.status != 200:
error_msg = await llm_response.text()
logger.error(f"LLM stream error: {error_msg[:200]}")
error_data = json.dumps({"error": f"LLM Error: {error_msg[:200]}", "done": True})
await response.write(f"data: {error_data}\n\n".encode('utf-8'))
return response
# Stream chunks with newline buffering to handle partial lines
buffer = ""
stream_done = False
# F7: Accumulate full content for fix detection
full_content = ""
async for chunk in llm_response.content.iter_chunked(1024):
buffer += chunk.decode('utf-8', errors='ignore')
while '\n' in buffer:
line, buffer = buffer.split('\n', 1)
line = line.strip()
if not line:
continue
try:
parsed_chunk = llm_adapter.parse_stream_line(line)
except json.JSONDecodeError:
continue
if parsed_chunk.done:
done_data = json.dumps({"delta": "", "done": True})
await response.write(f"data: {done_data}\n\n".encode('utf-8'))
stream_done = True
break
if parsed_chunk.skip or not parsed_chunk.delta:
continue
full_content += parsed_chunk.delta # F7: Accumulate
chunk_data = json.dumps({"delta": parsed_chunk.delta, "done": False})
await response.write(f"data: {chunk_data}\n\n".encode('utf-8'))
if stream_done:
break
# Process any remaining buffered line if stream ended without newline
if not stream_done and buffer.strip():
line = buffer.strip()
try:
parsed_chunk = llm_adapter.parse_stream_line(line)
if parsed_chunk.done:
done_data = json.dumps({"delta": "", "done": True})
await response.write(f"data: {done_data}\n\n".encode('utf-8'))
elif not parsed_chunk.skip and parsed_chunk.delta:
full_content += parsed_chunk.delta # F7: Accumulate
chunk_data = json.dumps({"delta": parsed_chunk.delta, "done": False})
await response.write(f"data: {chunk_data}\n\n".encode('utf-8'))
except json.JSONDecodeError:
pass
# F7: Detect and send fix suggestions after stream completes
if full_content:
import re
FIX_PATTERN = re.compile(r'```json\s*(\{[^`]*?"fixes"[^`]*?\})\s*```', re.DOTALL)
fix_match = FIX_PATTERN.search(full_content)
if fix_match:
try:
fix_json = json.loads(fix_match.group(1))
if validate_fix_schema(fix_json):
# Send as separate SSE event
fix_data = json.dumps({
"type": "fix_suggestion",
"data": fix_json
})
await response.write(f"data: {fix_data}\n\n".encode('utf-8'))
except json.JSONDecodeError:
pass # Invalid JSON, ignore
except Exception as stream_err:
error_data = json.dumps({"error": str(stream_err), "done": True})
await response.write(f"data: {error_data}\n\n".encode('utf-8'))
return response
except aiohttp.ClientError as e:
emit_doctor_log(f"Chat network error: {e}", "ERROR")
return _error_response(f"Network Error: {str(e)}", status=503, code="network_error")
except Exception as e:
emit_doctor_log(f"Chat failed: {e}", "ERROR")
return _error_response(str(e), status=500)
@server.PromptServer.instance.routes.get("/debugger/history")
async def api_get_history(request):
"""
API endpoint to get error analysis history.
Returns:
JSON with history list (most recent first).
"""
return web.json_response({
"history": get_analysis_history(),
"count": len(get_analysis_history()),
})
@server.PromptServer.instance.routes.post("/debugger/clear_history")
async def api_clear_history(request):
"""
API endpoint to clear error analysis history.
Returns:
JSON with success status.
"""
try:
success = clear_analysis_history()
return web.json_response({"success": success})
except Exception as e:
return _error_response(str(e), status=500)
@server.PromptServer.instance.routes.get("/doctor/provider_defaults")
async def api_get_provider_defaults(request):
"""
API endpoint to get default URLs for LLM providers.
Supports environment variable overrides for cross-platform compatibility.
Returns:
JSON with provider default URLs.
"""
return web.json_response({
"ollama": OLLAMA_BASE_URL,
"lmstudio": LMSTUDIO_BASE_URL,
"openai": "https://api.openai.com/v1",
"anthropic": "https://api.anthropic.com",
"deepseek": "https://api.deepseek.com/v1",
"groq": "https://api.groq.com/openai/v1",
"gemini": "https://generativelanguage.googleapis.com/v1beta/openai",
"xai": "https://api.x.ai/v1",
"openrouter": "https://openrouter.ai/api/v1"
})
@server.PromptServer.instance.routes.get("/doctor/secrets/status")
async def api_secrets_status(request):
"""
Get provider key status without exposing secret values.
Admin-gated to prevent leaking configuration sources.
"""
try:
# S8: read-only guard — loopback convenience, remote needs token
allowed, code, message = validate_admin_request(request)
if not allowed:
return _admin_denied_response(code, message)
providers = get_provider_status()
return web.json_response({
"success": True,
"providers": providers,
})
except Exception as e:
logger.error(f"Secrets status API error: {e}")
return _error_response(str(e), status=500)
@server.PromptServer.instance.routes.put("/doctor/secrets")
async def api_secrets_put(request):
"""
Save a provider secret to server-side secret store.
Body: {"provider": "openai|...|generic", "api_key": "...", "admin_token"?: "..."}
"""
try:
data = await request.json()
except Exception:
return _error_response("Invalid JSON", status=400, code="invalid_json")
allowed, code, message = validate_admin_request(request, payload=data)
if not allowed:
return _admin_denied_response(code, message)
provider = (data.get("provider") or "").strip().lower()
api_key = (data.get("api_key") or "").strip()
if not provider:
return _error_response("Missing provider", status=400, code="missing_provider")
if not api_key:
return _error_response("Missing api_key", status=400, code="missing_api_key")
valid_providers = {"openai", "anthropic", "deepseek", "groq", "gemini", "xai", "openrouter", "generic"}
if provider not in valid_providers:
return _error_response("Invalid provider", status=400, code="invalid_provider")
try:
get_secret_store().set_secret(provider, api_key)
logger.info(f"Secret saved for provider={provider}")
return web.json_response({"success": True, "message": "Secret saved"})
except Exception as e:
logger.error(f"Secrets put API error: {e}")
return _error_response(str(e), status=500)
@server.PromptServer.instance.routes.delete("/doctor/secrets/{provider}")
async def api_secrets_delete(request):
"""
Delete a provider secret from server-side secret store.
Auth: admin token in header/body policy handled by admin guard.
"""
provider = (request.match_info.get("provider") or "").strip().lower()
if not provider:
return _error_response("Missing provider", status=400, code="missing_provider")
# For DELETE we read optional JSON body if present, but also support headers-only.
payload = {}
try:
if request.can_read_body:
payload = await request.json()
except Exception:
payload = {}
allowed, code, message = validate_admin_request(request, payload=payload)
if not allowed:
return _admin_denied_response(code, message)
try:
deleted = get_secret_store().clear_secret(provider)
if not deleted:
return _error_response("Not found", status=404, code="not_found")
logger.info(f"Secret deleted for provider={provider}")
return web.json_response({"success": True, "message": "Secret deleted"})
except Exception as e:
logger.error(f"Secrets delete API error: {e}")
return _error_response(str(e), status=500)
@server.PromptServer.instance.routes.post("/doctor/verify_key")
async def api_verify_key(request):
"""
API endpoint to verify LLM API key validity.
Tests by calling the /models endpoint.
Payload: { "base_url": str, "api_key": str }
Returns: { "success": bool, "message": str, "is_local": bool }
"""
try:
data = await request.json()
base_url = data.get("base_url", DOCTOR_LLM_BASE_URL)
api_key = data.get("api_key", "")
provider = data.get("provider", "")
# S2: SSRF protection - validate base URL
is_valid, ssrf_error = validate_ssrf_url(base_url)
if not is_valid:
logger.warning(f"SSRF blocked in verify_key: {ssrf_error}")
return _error_response(
f"Invalid Base URL: {ssrf_error}",
status=200,
code="invalid_base_url",
extra={"is_local": False},
)
api_key, key_source, resolved_provider, is_local = resolve_api_key(
base_url=base_url,
provider_hint=provider,
request_api_key=api_key,
)
if not api_key and not is_local:
return _error_response(
"No API key provided",
status=200,
code="missing_api_key",
extra={"is_local": False},
)
logger.info(f"Verify key source={key_source}, provider={resolved_provider or 'unknown'}, local={is_local}")
# Normalize base URL
base_url = base_url.rstrip("/")
# Use placeholder for local LLMs without key
if is_local and not api_key:
api_key = _local_llm_auth_placeholder()
llm_adapter = get_llm_provider_adapter(base_url, is_local=is_local)
provider_request = llm_adapter.build_models_request(base_url, api_key)
# R7: Rate limit check (light limiter for quick requests)
if not SessionManager.get_light_limiter().allow():
logger.warning("Rate limit exceeded for /doctor/verify_key")
return _error_response(
"Rate limit exceeded. Please wait before retrying.",
status=200,
code="rate_limited",
extra={"is_local": is_local},
)
session = await SessionManager.get_session()
async with session.get(provider_request.url, headers=provider_request.headers, allow_redirects=False) as response:
if response.status == 200:
msg = "API key is valid" if not is_local else "Local LLM connection successful"
logger.info(f"API key verification successful - base_url={base_url}, is_local={is_local}")
return web.json_response({
"success": True,
"message": msg,
"is_local": is_local
})
else:
error_text = await response.text()
if len(error_text) > 200:
error_text = error_text[:200] + "..."
logger.warning(f"API key verification failed - status={response.status}, base_url={base_url}")
return _error_response(
f"Verification failed ({response.status}): {error_text}",
status=200,
code="verification_failed",
extra={"is_local": is_local},
)
except aiohttp.ClientError as e:
return _error_response(
f"Connection error: {str(e)}",
status=200,
code="connection_error",
extra={"is_local": is_local_llm_url(data.get("base_url", "")) if 'data' in locals() else False},
)
except Exception as e:
return _error_response(f"Error: {str(e)}", status=200, extra={"is_local": False})
@server.PromptServer.instance.routes.post("/doctor/list_models")
async def api_list_models(request):
"""
API endpoint to list available LLM models.
Payload: { "base_url": str, "api_key": str }
Returns: { "success": bool, "models": list[{name, id}], "message": str }
"""
try:
data = await request.json()
base_url = data.get("base_url", DOCTOR_LLM_BASE_URL)
api_key = data.get("api_key", "")
provider = data.get("provider", "")
# S2: SSRF protection - validate base URL
is_valid, ssrf_error = validate_ssrf_url(base_url)
if not is_valid:
logger.warning(f"SSRF blocked in list_models: {ssrf_error}")
return _error_response(
f"Invalid Base URL: {ssrf_error}",
status=200,
code="invalid_base_url",
extra={"models": []},
)
api_key, key_source, resolved_provider, is_local = resolve_api_key(
base_url=base_url,
provider_hint=provider,
request_api_key=api_key,
)
if not api_key and not is_local:
return _error_response(
"No API key provided",
status=200,
code="missing_api_key",
extra={"models": []},
)
logger.info(f"List-models key source={key_source}, provider={resolved_provider or 'unknown'}, local={is_local}")
base_url = base_url.rstrip("/")
if is_local and not api_key:
api_key = _local_llm_auth_placeholder()
llm_adapter = get_llm_provider_adapter(base_url, is_local=is_local)
provider_request = llm_adapter.build_models_request(base_url, api_key)
# R7: Rate limit check (light limiter for quick requests)
if not SessionManager.get_light_limiter().allow():
logger.warning("Rate limit exceeded for /doctor/list_models")
return _error_response(
"Rate limit exceeded. Please wait before retrying.",
status=200,
code="rate_limited",
extra={"models": []},
)
session = await SessionManager.get_session()
async with session.get(provider_request.url, headers=provider_request.headers, allow_redirects=False) as response:
if response.status != 200:
return _error_response(
f"Failed to fetch models ({response.status})",
status=200,
code="model_fetch_failed",
extra={"models": []},
)
try:
result = await response.json()
models = llm_adapter.parse_models_response(result)
logger.info(f"Retrieved {len(models)} models from {provider_request.url}")
return web.json_response({
"success": True,
"models": models,
"message": f"Found {len(models)} models"
})
except (json.JSONDecodeError, KeyError) as e:
return _error_response(
f"Failed to parse model list: {str(e)}",
status=200,
code="model_parse_failed",
extra={"models": []},
)
except aiohttp.ClientError as e:
return _error_response(
f"Connection error: {str(e)}",
status=200,
code="connection_error",
extra={"models": []},
)
except Exception as e:
return _error_response(f"Error: {str(e)}", status=200, extra={"models": []})
# ---- F4: Statistics Dashboard API Endpoints ----
@server.PromptServer.instance.routes.get("/doctor/statistics")
async def api_get_statistics(request):
"""
API endpoint to get error statistics for dashboard.
Query params: ?time_range_days=30 (default: 30)
Returns: {
"success": bool,
"statistics": {
"total_errors": int,
"pattern_frequency": {pattern_id: count},
"category_breakdown": {category: count},
"top_patterns": [{pattern_id, count, category}],
"resolution_rate": {resolved, unresolved, ignored},
"trend": {last_24h, last_7d, last_30d}
}
}
"""
try:
from .statistics import StatisticsCalculator
time_range_days = int(request.query.get("time_range_days", 30))
# Get history from SmartLogger
history = get_analysis_history()
# Calculate statistics
statistics = StatisticsCalculator.calculate(history, time_range_days)
logger.info(f"Statistics calculated: total_errors={statistics['total_errors']}, time_range={time_range_days}d")
return web.json_response({
"success": True,
"statistics": statistics
})
except Exception as e:
logger.error(f"Statistics API error: {str(e)}")
return _error_response(
str(e),
status=500,
extra={
"statistics": {
"total_errors": 0,
"pattern_frequency": {},
"category_breakdown": {},
"top_patterns": [],
"resolution_rate": {"resolved": 0, "unresolved": 0, "ignored": 0},
"trend": {"last_24h": 0, "last_7d": 0, "last_30d": 0}
}
},
)
@server.PromptServer.instance.routes.post("/doctor/statistics/reset")
async def api_reset_statistics(request):
"""
API endpoint to reset statistics (clears all error history).
Returns: {"success": bool, "message": str}
"""
payload = {}
try:
if request.can_read_body:
payload = await request.json()
except Exception:
payload = {}
# CRITICAL: write-sensitive endpoint must remain admin-gated.
allowed, code, message = validate_admin_request(request, payload=payload)
if not allowed:
return _admin_denied_response(code, message)
try:
success = clear_analysis_history()
if success:
logger.info("Statistics reset (history cleared)")
return web.json_response({"success": True, "message": "Statistics reset successfully"})
else:
return _error_response("Failed to clear history", status=500, code="clear_history_failed")
except Exception as e:
logger.error(f"Statistics reset API error: {str(e)}")
return _error_response(str(e), status=500)
@server.PromptServer.instance.routes.post("/doctor/mark_resolved")
async def api_mark_error_resolved(request):
"""
API endpoint to mark an error as resolved/unresolved/ignored.
Body: {
"timestamp": "2026-01-04T12:00:00",
"status": "resolved"|"unresolved"|"ignored"
}
Returns: {"success": bool, "message": str}
"""
try:
data = await request.json()
except Exception:
return _error_response("Invalid JSON", status=400, code="invalid_json")
# CRITICAL: write-sensitive endpoint must remain admin-gated.
allowed, code, message = validate_admin_request(request, payload=data)
if not allowed:
return _admin_denied_response(code, message)
try:
timestamp = data.get("timestamp")
status = data.get("status", "resolved")
if not timestamp:
return _error_response("Missing timestamp", status=400, code="missing_timestamp")
if status not in ["resolved", "unresolved", "ignored"]:
return _error_response("Invalid status", status=400, code="invalid_status")
from .logger import update_resolution_status
if update_resolution_status(timestamp, status):
logger.info(f"Error marked as {status}: {timestamp}")
return web.json_response({"success": True, "message": f"Error marked as {status}"})
return _error_response("Timestamp not found", status=404, code="not_found")
except Exception as e:
logger.error(f"Mark resolved API error: {str(e)}")
return _error_response(str(e), status=500)
# ---- F16: Quick Community Feedback (GitHub PR) ----
@server.PromptServer.instance.routes.post("/doctor/feedback/preview")
async def api_feedback_preview(request):
"""
F16 preview endpoint (read-only): validate + sanitize community feedback payload.
Returns sanitized preview and GitHub config readiness (without exposing token).
"""
try:
data = await request.json()
except Exception:
return _error_response("Invalid JSON", status=400, code="invalid_json")
try:
preview = build_feedback_preview(data, github_config=GitHubFeedbackConfig.from_env())
return web.json_response({"success": True, **preview})
except FeedbackValidationError as e:
return _error_response(
str(e),
status=400,
code="validation_error",
extra={"field_errors": getattr(e, "field_errors", {}) or {}},
)
except Exception as e:
logger.error(f"Feedback preview API error: {e}")
return _error_response(str(e), status=500)
@server.PromptServer.instance.routes.post("/doctor/feedback/submit")
async def api_feedback_submit(request):
"""
F16 submit endpoint (write-sensitive): create GitHub PR with append-only feedback JSON files.
Admin-gated (loopback convenience mode allowed when no admin token is configured).
"""
try:
data = await request.json()
except Exception:
return _error_response("Invalid JSON", status=400, code="invalid_json")
# IMPORTANT: write-sensitive endpoint must remain admin-gated.
allowed, code, message = validate_admin_request(request, payload=data)
if not allowed:
return _admin_denied_response(code, message)
try:
result = await submit_feedback(data, github_config=GitHubFeedbackConfig.from_env())
# Best-effort redacted audit log (server-side only; no secrets recorded)
try:
audit = ActionAudit(Path(get_doctor_data_dir()))
audit.log_action(
provider="github",
action="feedback_pr_submit",
decision="allow",
meta={
"submission_id": result.get("submission_id"),
"repo": ((result.get("github") or {}).get("repo")),
"branch": ((result.get("github") or {}).get("branch")),
"pr_url": ((result.get("github") or {}).get("pr_url")),
},
)
except Exception as audit_err:
logger.warning(f"Feedback audit log failed: {audit_err}")
return web.json_response({"success": True, **result})
except FeedbackValidationError as e:
return _error_response(
str(e),
status=400,
code="validation_error",
extra={"field_errors": getattr(e, "field_errors", {}) or {}},
)
except Exception as e:
logger.error(f"Feedback submit API error: {e}")
return _error_response(str(e), status=500)
@server.PromptServer.instance.routes.get("/doctor/health")
async def api_health(request):
"""
Health endpoint for internal diagnostics.
Returns logger queue stats, SSRF counters, and last analysis state.
"""
try:
last_analysis = get_last_analysis()
analysis_meta = last_analysis.get("analysis_metadata") or {}
payload = {
"logger": get_logger_metrics(),
"ssrf": get_ssrf_metrics(),
"storage": {
"data_dir": get_doctor_data_dir(),
"history_size_bytes": getattr(CONFIG, "history_size_bytes", 0),
"path_diagnostics": get_path_diagnostics(),
},
"outbound_proxy": SessionManager.get_proxy_diagnostics(),
"last_analysis": {
"timestamp": last_analysis.get("timestamp"),
"pipeline_status": analysis_meta.get("pipeline_status"),
},
}
return web.json_response({"success": True, "health": payload})
except Exception as e:
logger.error(f"Health API error: {str(e)}")
return _error_response(str(e), status=500)
# ---- S3: Telemetry API Endpoints ----
from .telemetry import get_telemetry_store
# Initialize telemetry with config setting
_telemetry_store = get_telemetry_store()
_telemetry_store.enabled = CONFIG.telemetry_enabled
@server.PromptServer.instance.routes.get("/doctor/telemetry/status")
async def api_telemetry_status(request):
"""
Get telemetry status and buffer stats.
Returns: {"success": bool, "enabled": bool, "stats": {...}}
"""
try:
store = get_telemetry_store()
stats = store.get_stats()
return web.json_response({
"success": True,
"enabled": store.enabled,
"stats": stats,
"upload_destination": None, # Phase 1-3: local only
})
except Exception as e:
logger.error(f"Telemetry status API error: {str(e)}")
return _error_response(str(e), status=500)
@server.PromptServer.instance.routes.get("/doctor/telemetry/buffer")
async def api_telemetry_buffer(request):
"""
Get buffered telemetry events.
Returns: {"success": bool, "events": [...]}
"""
try:
store = get_telemetry_store()
events = store.get_buffer()
return web.json_response({
"success": True,
"events": events,
"count": len(events),
})
except Exception as e:
logger.error(f"Telemetry buffer API error: {str(e)}")
return _error_response(str(e), status=500)
@server.PromptServer.instance.routes.post("/doctor/telemetry/track")
async def api_telemetry_track(request):
"""
Record a telemetry event.
Body: {"category": str, "action": str, "label"?: str, "value"?: int}
Returns: {"success": bool, "message": str}
"""
try:
# Security: Same-origin check (reject cross-origin requests)
origin = request.headers.get("Origin", "")
host = request.headers.get("Host", "")
if origin:
# Extract host from origin (e.g., "http://localhost:8188" -> "localhost:8188")
from urllib.parse import urlparse
origin_host = urlparse(origin).netloc
if origin_host and host and origin_host != host:
return _error_response("Cross-origin request rejected", status=403, code="cross_origin_rejected")
# Security: Check Content-Type
content_type = request.headers.get("Content-Type", "")
if "application/json" not in content_type:
return _error_response("Content-Type must be application/json", status=400, code="invalid_content_type")
# Security: Payload size limit (1KB)
content_length = request.content_length or 0
if content_length > 1024:
return _error_response("Payload too large", status=413, code="payload_too_large")
# Parse JSON
try:
data = await request.json()
except Exception:
return _error_response("Invalid JSON", status=400, code="invalid_json")
# Security: Reject unexpected fields
allowed_fields = {"category", "action", "label", "value"}
if set(data.keys()) - allowed_fields:
return _error_response("Unexpected fields", status=400, code="unexpected_fields")
# Track event
store = get_telemetry_store()
success, message = store.track(data)
return web.json_response({"success": success, "message": message})
except Exception as e:
logger.error(f"Telemetry track API error: {str(e)}")
return _error_response(str(e), status=500)
@server.PromptServer.instance.routes.post("/doctor/telemetry/clear")
async def api_telemetry_clear(request):
"""
Clear all buffered telemetry events.
Returns: {"success": bool, "message": str}
"""
payload = {}
try:
if request.can_read_body:
payload = await request.json()
except Exception:
payload = {}
# CRITICAL: write-sensitive endpoint must remain admin-gated.
allowed, code, message = validate_admin_request(request, payload=payload)
if not allowed:
return _admin_denied_response(code, message)
try:
store = get_telemetry_store()
store.clear()
return web.json_response({"success": True, "message": "Buffer cleared"})
except Exception as e:
logger.error(f"Telemetry clear API error: {str(e)}")
return _error_response(str(e), status=500)
@server.PromptServer.instance.routes.get("/doctor/telemetry/export")
async def api_telemetry_export(request):
"""
Export telemetry buffer as downloadable JSON file.
Returns: JSON file download
"""
try:
store = get_telemetry_store()
json_data = store.export_json()
return web.Response(
body=json_data,
content_type="application/json",
headers={
"Content-Disposition": "attachment; filename=telemetry_export.json"
}
)
except Exception as e:
logger.error(f"Telemetry export API error: {str(e)}")
return _error_response(str(e), status=500)
@server.PromptServer.instance.routes.post("/doctor/telemetry/toggle")
async def api_telemetry_toggle(request):
"""
Toggle telemetry enabled/disabled state.
Body: {"enabled": bool}
Returns: {"success": bool, "enabled": bool}
"""
try:
data = await request.json()
except Exception:
return _error_response("Invalid JSON", status=400, code="invalid_json")
# CRITICAL: write-sensitive endpoint must remain admin-gated.
allowed, code, message = validate_admin_request(request, payload=data)
if not allowed:
return _admin_denied_response(code, message)
try:
enabled = data.get("enabled", False)
store = get_telemetry_store()
store.enabled = bool(enabled)
return web.json_response({
"success": True,
"enabled": store.enabled,
"message": "Telemetry enabled" if store.enabled else "Telemetry disabled"
})
except Exception as e:
logger.error(f"Telemetry toggle API error: {str(e)}")
return _error_response(str(e), status=500)
# ---- API Routes (R20) ----
try:
from .services.routes import (
api_plugins,
api_get_job,
api_resume_job,
api_cancel_job,
api_provider_status,
)
# Register routes
server.PromptServer.instance.routes.get("/doctor/plugins")(api_plugins)
server.PromptServer.instance.routes.get("/doctor/jobs/{job_id}")(api_get_job)
server.PromptServer.instance.routes.post("/doctor/jobs/{job_id}/resume")(api_resume_job)
server.PromptServer.instance.routes.post("/doctor/jobs/{job_id}/cancel")(api_cancel_job)
server.PromptServer.instance.routes.get("/doctor/providers/{provider_id}/status")(api_provider_status)
except ImportError as e:
logger.error(f"Failed to import API routes: {e}")
# ---- F14: Proactive Diagnostics API Endpoints ----
from .services.diagnostics import (
get_diagnostics_runner,
get_diagnostics_store,
HealthCheckRequest,
HealthAckRequest,
IssueStatus,
)
from .services.intent import init_intent_system
from .services.diagnostics.checks import init_checks
# Initialize checks registry (registers checks with runner)
try:
init_checks()
except Exception as e:
logger.warning(f"Failed to initialize diagnostics checks: {e}")
# Initialize intent system (registers scorer with runner)
try:
init_intent_system()
except Exception as e:
logger.warning(f"Failed to initialize intent system: {e}")
@server.PromptServer.instance.routes.post("/doctor/health_check")
async def api_health_check(request):
"""
F14: Run diagnostics on a workflow snapshot.
Body: {
"workflow": {...},
"scope": "manual|schedule|pre_exec|workflow_change",
"options": {"include_intent": true, "max_paths": 50}
}
Returns: HealthReport
"""
try:
data = await request.json()
# Parse request
check_request = HealthCheckRequest.from_dict(data)
# Run diagnostics
runner = get_diagnostics_runner()
report = await runner.run(check_request)
# Save to history
store = get_diagnostics_store()
store.save_report(report)
return web.json_response({
"success": True,
"report": report.to_dict(),
})
except Exception as e:
logger.error(f"Health check API error: {str(e)}")
return _error_response(str(e), status=500)
@server.PromptServer.instance.routes.get("/doctor/health_report")
async def api_health_report(request):
"""
F14: Fetch last computed health report (cached).
Returns: HealthReport or null
Fallback: If runner has no in-memory report, returns latest from store.
"""
try:
runner = get_diagnostics_runner()
report = runner.get_last_report()
if report:
return web.json_response({
"success": True,
"report": report.to_dict(),
})
# Fallback: Try to get latest from store (survives restart)
store = get_diagnostics_store()
history = store.get_history(limit=1, offset=0)
if history:
report_id = history[0].get("report_id")
if report_id:
stored_report = store.get_report(report_id)
if stored_report:
return web.json_response({
"success": True,
"report": stored_report,
"source": "history", # Indicate this came from store
})
return web.json_response({
"success": True,
"report": None,
"message": "No report available",
})
except Exception as e:
logger.error(f"Health report API error: {str(e)}")
return _error_response(str(e), status=500)
@server.PromptServer.instance.routes.get("/doctor/health_history")
async def api_health_history(request):
"""
F14: Fetch recent health report metadata.
Query params: limit (default 50), offset (default 0)
Returns: List of report metadata (no heavy payload)
"""
try:
limit = int(request.query.get("limit", "50"))
offset = int(request.query.get("offset", "0"))
# Clamp values
limit = max(1, min(100, limit))
offset = max(0, offset)
store = get_diagnostics_store()
history = store.get_history(limit=limit, offset=offset)
return web.json_response({
"success": True,
"history": history,
"count": len(history),
})
except Exception as e:
logger.error(f"Health history API error: {str(e)}")
return _error_response(str(e), status=500)
@server.PromptServer.instance.routes.post("/doctor/health_ack")
async def api_health_ack(request):
"""
F14: Acknowledge/ignore/resolve an issue.
Body: {"report_id": "...", "issue_id": "...", "status": "acknowledged|ignored|resolved"}
Returns: {"success": bool}
"""
try:
data = await request.json()
except Exception:
return _error_response("Invalid JSON", status=400, code="invalid_json")
# CRITICAL: write-sensitive endpoint must remain admin-gated.
allowed, code, message = validate_admin_request(request, payload=data)
if not allowed:
return _admin_denied_response(code, message)
try:
ack_request = HealthAckRequest.from_dict(data)
if not ack_request.report_id or not ack_request.issue_id:
return _error_response(
"Missing report_id or issue_id",
status=400,
code="missing_issue_reference",
)
store = get_diagnostics_store()
updated = store.update_issue_status(
ack_request.report_id,
ack_request.issue_id,
ack_request.status,
)
if updated:
return web.json_response({
"success": True,
"message": f"Issue status updated to {ack_request.status.value}",
})
else:
return _error_response("Issue not found", status=404, code="not_found")
except Exception as e:
logger.error(f"Health ack API error: {str(e)}")
return _error_response(str(e), status=500)
_print_registered_routes(_startup_print)