cct-cli 0.7.9.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.
- calc_terminal/__init__.py +14 -0
- calc_terminal/__main__.py +14 -0
- calc_terminal/activity.py +1334 -0
- calc_terminal/agent.py +3387 -0
- calc_terminal/agent_runtime.py +519 -0
- calc_terminal/ai_context.py +447 -0
- calc_terminal/ai_modes.py +752 -0
- calc_terminal/ai_personalization.py +286 -0
- calc_terminal/ai_preview_feedback.py +213 -0
- calc_terminal/aicore.py +2572 -0
- calc_terminal/anim.py +367 -0
- calc_terminal/app.py +3685 -0
- calc_terminal/art.py +639 -0
- calc_terminal/atomsim.py +368 -0
- calc_terminal/attachments.py +743 -0
- calc_terminal/benchmark_system.py +414 -0
- calc_terminal/browser/__init__.py +36 -0
- calc_terminal/browser/browser_state.py +346 -0
- calc_terminal/browser/devserver.py +176 -0
- calc_terminal/browser/engine.py +494 -0
- calc_terminal/browser/navigation.py +84 -0
- calc_terminal/browser/preview.py +429 -0
- calc_terminal/browser/preview_entry.py +95 -0
- calc_terminal/browser/project_detector.py +144 -0
- calc_terminal/browser/server.py +449 -0
- calc_terminal/browser/state.py +75 -0
- calc_terminal/browser/watcher.py +99 -0
- calc_terminal/browser_gui/__init__.py +1 -0
- calc_terminal/browser_gui/__main__.py +3 -0
- calc_terminal/browser_gui/launcher.py +173 -0
- calc_terminal/browser_gui/playwright_browser.py +117 -0
- calc_terminal/browser_gui/qt_browser.py +1501 -0
- calc_terminal/browser_gui/webview_browser.py +57 -0
- calc_terminal/capabilities/__init__.py +35 -0
- calc_terminal/capabilities/adapters/__init__.py +33 -0
- calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
- calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
- calc_terminal/capabilities/adapters/filesystem.py +206 -0
- calc_terminal/capabilities/adapters/git_adapter.py +202 -0
- calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
- calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
- calc_terminal/capabilities/adapters/platforms.py +200 -0
- calc_terminal/capabilities/adapters/python_exec.py +93 -0
- calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
- calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
- calc_terminal/capabilities/adapters/structural_bio.py +161 -0
- calc_terminal/capabilities/adapters/terminal.py +99 -0
- calc_terminal/capabilities/bus.py +178 -0
- calc_terminal/capabilities/discovery.py +207 -0
- calc_terminal/capabilities/schema.py +221 -0
- calc_terminal/cat.ico +0 -0
- calc_terminal/cat_browser.py +2018 -0
- calc_terminal/chat_store.py +703 -0
- calc_terminal/cli.py +1178 -0
- calc_terminal/code_editor.py +640 -0
- calc_terminal/collaboration.py +723 -0
- calc_terminal/commands_data.py +139 -0
- calc_terminal/compatibility_engine.py +352 -0
- calc_terminal/compute/__init__.py +31 -0
- calc_terminal/compute/fabric.py +350 -0
- calc_terminal/config.py +227 -0
- calc_terminal/core/__init__.py +41 -0
- calc_terminal/core/checkpoint.py +156 -0
- calc_terminal/core/input/__init__.py +45 -0
- calc_terminal/core/mode_registry.py +300 -0
- calc_terminal/core/project_graph.py +172 -0
- calc_terminal/core/recovery.py +129 -0
- calc_terminal/core/security_layer.py +112 -0
- calc_terminal/core/task_graph.py +202 -0
- calc_terminal/core/unified_runtime.py +184 -0
- calc_terminal/core/verification.py +257 -0
- calc_terminal/customization.py +1566 -0
- calc_terminal/derivations.py +153 -0
- calc_terminal/device_control.py +263 -0
- calc_terminal/diagnostics/__init__.py +27 -0
- calc_terminal/diagnostics/doctor_engine.py +382 -0
- calc_terminal/diagnostics/self_test.py +247 -0
- calc_terminal/doctor.py +519 -0
- calc_terminal/easter_eggs.py +274 -0
- calc_terminal/editor/__init__.py +1 -0
- calc_terminal/editor/actions.py +263 -0
- calc_terminal/editor/commands.py +160 -0
- calc_terminal/editor/shortcuts.py +226 -0
- calc_terminal/engine.py +259 -0
- calc_terminal/errors.py +120 -0
- calc_terminal/event_stream.py +146 -0
- calc_terminal/eventbus.py +133 -0
- calc_terminal/extensions.py +733 -0
- calc_terminal/fallback_cli.py +1321 -0
- calc_terminal/first_run.py +265 -0
- calc_terminal/fomoji_auth.py +1043 -0
- calc_terminal/formulas.py +82 -0
- calc_terminal/fs_cache.py +121 -0
- calc_terminal/fs_watcher.py +277 -0
- calc_terminal/game.py +193 -0
- calc_terminal/gen1.py +5 -0
- calc_terminal/generators.py +245 -0
- calc_terminal/gestures/__init__.py +42 -0
- calc_terminal/gestures/bindings.py +175 -0
- calc_terminal/gestures/manager.py +477 -0
- calc_terminal/goodbye.py +363 -0
- calc_terminal/gpu3d.py +290 -0
- calc_terminal/graphs.py +358 -0
- calc_terminal/hardware_analyzer.py +440 -0
- calc_terminal/host/__init__.py +30 -0
- calc_terminal/host/browser_manager.py +187 -0
- calc_terminal/host/desktop.py +1386 -0
- calc_terminal/host/launcher.py +395 -0
- calc_terminal/host/terminal.py +279 -0
- calc_terminal/identity.py +216 -0
- calc_terminal/input/__init__.py +54 -0
- calc_terminal/input/capabilities.py +258 -0
- calc_terminal/input/focus.py +87 -0
- calc_terminal/input/gestures.py +64 -0
- calc_terminal/input/pointer.py +114 -0
- calc_terminal/input/touch.py +345 -0
- calc_terminal/keys.py +84 -0
- calc_terminal/live_automation.py +165 -0
- calc_terminal/mathtext.py +433 -0
- calc_terminal/mcp.py +386 -0
- calc_terminal/memory.py +337 -0
- calc_terminal/memory_v2.py +479 -0
- calc_terminal/metrics.py +333 -0
- calc_terminal/mode_detection.py +146 -0
- calc_terminal/model.py +2431 -0
- calc_terminal/model_router.py +665 -0
- calc_terminal/models/__init__.py +0 -0
- calc_terminal/models/active_state.py +187 -0
- calc_terminal/models/dynamic_registry.py +584 -0
- calc_terminal/models/manager.py +781 -0
- calc_terminal/models/model_metadata.json +3526 -0
- calc_terminal/models/profiles.py +194 -0
- calc_terminal/models/registry.py +265 -0
- calc_terminal/models/schema.py +197 -0
- calc_terminal/models/validator.py +287 -0
- calc_terminal/models/verification_engine.py +368 -0
- calc_terminal/native_picker.py +215 -0
- calc_terminal/ollama_catalog.py +279 -0
- calc_terminal/ollama_download.py +233 -0
- calc_terminal/orchestrator.py +304 -0
- calc_terminal/package_research.py +322 -0
- calc_terminal/packages.py +1024 -0
- calc_terminal/pc_specs.py +116 -0
- calc_terminal/permissions.py +334 -0
- calc_terminal/pet.py +106 -0
- calc_terminal/pipeline.py +505 -0
- calc_terminal/platform/__init__.py +491 -0
- calc_terminal/platform/desktop.py +491 -0
- calc_terminal/platform/web.py +781 -0
- calc_terminal/preview/__init__.py +1 -0
- calc_terminal/preview/dev_server.py +303 -0
- calc_terminal/preview/diagnostics.py +131 -0
- calc_terminal/preview/live_reload.py +66 -0
- calc_terminal/preview/manager.py +129 -0
- calc_terminal/project_stats.py +209 -0
- calc_terminal/projects.py +328 -0
- calc_terminal/providers/__init__.py +0 -0
- calc_terminal/providers/adapters/__init__.py +80 -0
- calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
- calc_terminal/providers/adapters/base.py +106 -0
- calc_terminal/providers/adapters/chinese_adapters.py +420 -0
- calc_terminal/providers/adapters/gemini_adapter.py +101 -0
- calc_terminal/providers/adapters/ollama_adapter.py +83 -0
- calc_terminal/providers/adapters/openai_adapter.py +159 -0
- calc_terminal/providers/adapters/other_adapters.py +246 -0
- calc_terminal/providers/anthropic_provider.py +172 -0
- calc_terminal/providers/auto_update.py +416 -0
- calc_terminal/providers/base_provider.py +105 -0
- calc_terminal/providers/discovery_manager.py +207 -0
- calc_terminal/providers/gemini_provider.py +178 -0
- calc_terminal/providers/lifecycle.py +767 -0
- calc_terminal/providers/ollama_adapter.py +707 -0
- calc_terminal/providers/openai_provider.py +254 -0
- calc_terminal/providers/provider_manager.py +1827 -0
- calc_terminal/providers/providers.json +4075 -0
- calc_terminal/reactionsim.py +279 -0
- calc_terminal/registry.py +337 -0
- calc_terminal/report.py +162 -0
- calc_terminal/research/__init__.py +45 -0
- calc_terminal/research/artifact_intel.py +126 -0
- calc_terminal/research/data_lineage.py +123 -0
- calc_terminal/research/experiment_ledger.py +303 -0
- calc_terminal/research/reproducibility.py +131 -0
- calc_terminal/resilience/__init__.py +47 -0
- calc_terminal/resilience/agent_state.py +121 -0
- calc_terminal/resilience/capability_matcher.py +174 -0
- calc_terminal/resilience/circuit_breaker.py +158 -0
- calc_terminal/resilience/failover_engine.py +230 -0
- calc_terminal/resilience/health_monitor.py +192 -0
- calc_terminal/resilience/ollama_adapter.py +125 -0
- calc_terminal/resilience/orchestrator.py +312 -0
- calc_terminal/resilience/types.py +134 -0
- calc_terminal/sandbox.py +98 -0
- calc_terminal/scires.py +558 -0
- calc_terminal/security_scanner.py +126 -0
- calc_terminal/session.py +294 -0
- calc_terminal/sim3d.py +206 -0
- calc_terminal/solver.py +276 -0
- calc_terminal/sound.py +127 -0
- calc_terminal/task_reports.py +287 -0
- calc_terminal/terminal_host.py +201 -0
- calc_terminal/terminal_identity.py +411 -0
- calc_terminal/test_ai_mode_reliability.py +344 -0
- calc_terminal/test_browser.py +368 -0
- calc_terminal/test_code_editor_upgrade.py +485 -0
- calc_terminal/test_customization.py +1148 -0
- calc_terminal/test_customization_ui.py +612 -0
- calc_terminal/test_dynamic_registry.py +304 -0
- calc_terminal/test_extensions.py +436 -0
- calc_terminal/test_overhaul.py +557 -0
- calc_terminal/test_project_detect.py +255 -0
- calc_terminal/test_root_cause_fix.py +527 -0
- calc_terminal/test_stability.py +532 -0
- calc_terminal/test_terminal_identity.py +132 -0
- calc_terminal/test_v079_speed.py +460 -0
- calc_terminal/theme.py +1107 -0
- calc_terminal/timeline.py +139 -0
- calc_terminal/todos.py +246 -0
- calc_terminal/tool_call_normalizer.py +419 -0
- calc_terminal/tui.py +104 -0
- calc_terminal/ui/__init__.py +8 -0
- calc_terminal/ui/activity_panel.py +231 -0
- calc_terminal/ui/activity_stream_panel.py +238 -0
- calc_terminal/ui/animations.py +122 -0
- calc_terminal/ui/app.py +7271 -0
- calc_terminal/ui/attach_panel.py +597 -0
- calc_terminal/ui/attachments.py +424 -0
- calc_terminal/ui/backup_panel.py +810 -0
- calc_terminal/ui/browser_shell.py +887 -0
- calc_terminal/ui/cat_agent.py +357 -0
- calc_terminal/ui/chats_panel.py +899 -0
- calc_terminal/ui/command_palette.py +125 -0
- calc_terminal/ui/command_palette_modal.py +166 -0
- calc_terminal/ui/composer.py +1141 -0
- calc_terminal/ui/context_menu.py +197 -0
- calc_terminal/ui/conversation.py +1435 -0
- calc_terminal/ui/customization_panel.py +1229 -0
- calc_terminal/ui/dashboard.py +404 -0
- calc_terminal/ui/design_system.py +557 -0
- calc_terminal/ui/diff_panel.py +213 -0
- calc_terminal/ui/editor.py +2102 -0
- calc_terminal/ui/empty_state.py +302 -0
- calc_terminal/ui/events.py +487 -0
- calc_terminal/ui/extensions_panel.py +815 -0
- calc_terminal/ui/footer.py +166 -0
- calc_terminal/ui/gestures_panel.py +383 -0
- calc_terminal/ui/goodbye_screen.py +100 -0
- calc_terminal/ui/header.py +1034 -0
- calc_terminal/ui/help_panel.py +254 -0
- calc_terminal/ui/live_activities.py +914 -0
- calc_terminal/ui/mcp_panel.py +570 -0
- calc_terminal/ui/memory_center.py +524 -0
- calc_terminal/ui/mode_colors_panel.py +525 -0
- calc_terminal/ui/nav_screens.py +747 -0
- calc_terminal/ui/ollama_panel.py +536 -0
- calc_terminal/ui/palette.py +221 -0
- calc_terminal/ui/permission_panel.py +269 -0
- calc_terminal/ui/personalization_panel.py +517 -0
- calc_terminal/ui/personalize_center.py +1568 -0
- calc_terminal/ui/preview_panel.py +441 -0
- calc_terminal/ui/resizers.py +402 -0
- calc_terminal/ui/sidebar.py +1285 -0
- calc_terminal/ui/statusbar.py +168 -0
- calc_terminal/ui/theme_css.py +1396 -0
- calc_terminal/ui/thinking.py +226 -0
- calc_terminal/ui/timeline_panel.py +102 -0
- calc_terminal/ui/todo_panel.py +193 -0
- calc_terminal/ui/viewport.py +136 -0
- calc_terminal/ui/vision_panel.py +489 -0
- calc_terminal/ui/welcome_modal.py +343 -0
- calc_terminal/ui/widgets.py +160 -0
- calc_terminal/ui/workspace.py +831 -0
- calc_terminal/viewers/__init__.py +1 -0
- calc_terminal/viewers/document_viewer.py +252 -0
- calc_terminal/viewers/image_viewer.py +241 -0
- calc_terminal/viewers/pdf_viewer.py +203 -0
- calc_terminal/viewers/presentation_viewer.py +164 -0
- calc_terminal/viewers/registry.py +120 -0
- calc_terminal/viewers/spreadsheet_viewer.py +204 -0
- calc_terminal/vision/__init__.py +89 -0
- calc_terminal/vision/analysis.py +194 -0
- calc_terminal/vision/annotations.py +297 -0
- calc_terminal/vision/capture.py +171 -0
- calc_terminal/vision/context.py +231 -0
- calc_terminal/vision/correlation.py +169 -0
- calc_terminal/vision/cursor.py +258 -0
- calc_terminal/vision/events.py +66 -0
- calc_terminal/vision/frame_pipeline.py +259 -0
- calc_terminal/vision/priority.py +218 -0
- calc_terminal/vision/provider.py +180 -0
- calc_terminal/vision/safety.py +149 -0
- calc_terminal/vision/session.py +281 -0
- calc_terminal/vision/verify.py +162 -0
- calc_terminal/vision.py +514 -0
- calc_terminal/vscode_integration.py +113 -0
- calc_terminal/web/__init__.py +8 -0
- calc_terminal/web/cat_runtime.py +710 -0
- calc_terminal/web/server.py +2891 -0
- calc_terminal/web/static/css/app.css +3152 -0
- calc_terminal/web/static/icons/badge-72.png +0 -0
- calc_terminal/web/static/icons/cat.ico +0 -0
- calc_terminal/web/static/icons/icon-128.png +0 -0
- calc_terminal/web/static/icons/icon-144.png +0 -0
- calc_terminal/web/static/icons/icon-152.png +0 -0
- calc_terminal/web/static/icons/icon-192.png +0 -0
- calc_terminal/web/static/icons/icon-384.png +0 -0
- calc_terminal/web/static/icons/icon-512.png +0 -0
- calc_terminal/web/static/icons/icon-72.png +0 -0
- calc_terminal/web/static/icons/icon-96.png +0 -0
- calc_terminal/web/static/icons/icon.svg +34 -0
- calc_terminal/web/static/icons/new-project.png +0 -0
- calc_terminal/web/static/icons/open-project.png +0 -0
- calc_terminal/web/static/index.html +734 -0
- calc_terminal/web/static/js/app.js +2403 -0
- calc_terminal/web/static/manifest.json +88 -0
- calc_terminal/web/static/sw.js +230 -0
- calc_terminal/workflow_engine.py +769 -0
- calc_terminal/workspace.py +593 -0
- calc_terminal/workspace_index.py +385 -0
- cct_cli-0.7.9.0.dist-info/METADATA +210 -0
- cct_cli-0.7.9.0.dist-info/RECORD +325 -0
- cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
- cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
- cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
- cct_cli-0.7.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1827 @@
|
|
|
1
|
+
"""Provider manager — registry, config persistence, import/export.
|
|
2
|
+
|
|
3
|
+
Supports 200+ providers with dynamic model discovery, caching,
|
|
4
|
+
request cancellation, and pagination support.
|
|
5
|
+
|
|
6
|
+
v0.7.9.5: Added auto-update system for providers to automatically
|
|
7
|
+
fetch new models from provider APIs and central registry.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from typing import Optional, Dict, List, Any
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
from .base_provider import BaseProvider
|
|
19
|
+
from .openai_provider import OpenAIProvider, CustomOpenAIProvider
|
|
20
|
+
from .anthropic_provider import AnthropicProvider
|
|
21
|
+
from .gemini_provider import GeminiProvider
|
|
22
|
+
|
|
23
|
+
# Auto-update system for providers
|
|
24
|
+
try:
|
|
25
|
+
from .auto_update import (
|
|
26
|
+
get_updater, start_auto_updates, stop_auto_updates,
|
|
27
|
+
force_update, get_latest_providers, add_custom_provider,
|
|
28
|
+
remove_custom_provider, discover_models as auto_discover_models
|
|
29
|
+
)
|
|
30
|
+
_HAS_AUTO_UPDATE = True
|
|
31
|
+
except ImportError:
|
|
32
|
+
_HAS_AUTO_UPDATE = False
|
|
33
|
+
|
|
34
|
+
import re as _re
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _fix_spacing(text):
|
|
38
|
+
"""Repair concatenated text from LLMs that omit spaces between words.
|
|
39
|
+
Handles keyword concatenation, markdown fences, subscript digits, and heavy concatenation."""
|
|
40
|
+
if not text or not isinstance(text, str):
|
|
41
|
+
return text
|
|
42
|
+
# Common English word set for heavy concatenation repair
|
|
43
|
+
COMMON_WORDS = {
|
|
44
|
+
"i","am","still","in","learning","and","have","some","more","experience","so","it","would","be","nice","to","get","that","working","soon","if","you","are","interested","please","let","me","know","cheers","this","is","an","open","source","bot","which","can","do","the","following","show","all","registered","users","on","instance","listen","your","command","or","phrase","whatever","want","reply","with","text","may","use","simple","print","function","as","example","how","could","look","like","will","echo","back","future","plan","add","functionality","depends","input","greet","ask","for","list","statistics","about","usage","my","name","any","chatbot","think","good","practice","does","work","test","out","just","send","start","message","will","respond","something","can","be","anything","from","hello","simply","greeting","change","code","anytime","even","another","word","way","create","own","works","exactly","like","but","has","own","which","makes","unique","allow","add","their","names","remove","them","they","unregister","get","of","back","recommend","using","since","feature","pretty","however","also","existing","framework","allows","manage","app","keep","mind","have","implement","commands","yourself","already","implemented","for","by","default","want","while","other","suggested","optional","makes","much","interesting","leave","after","receiving","done","then","with","delete","allows","create","chatbots","used","different","situations","example","game","talk","something","really","care","send","photos","if","photo","not","from","replying","another","picture","uses","pillow","library","allows","pictures","from","internet","resize","before","sending","them","great","one","add","well","anonymous","chat","post","messages","private","channel","not","registered","kind","want","build","often","least","once","per","week","currently","doesn't","haven't","added","yet","easily","simply","modifying","extra","logic","there","file","receive","same","deleted","one","probably","going","take","time","figure","worth","fun","project","working","anytime","need","details","anything","else","support","voice","don't","since","only","text","doesn't","images","anyone","needs","able","audio","would","suggest","same","approach","described","above","photo","microphone","whatever","just","image","any","time","which","let","play","song","listening","realtime","have","fun","project","open","questions","regarding","don't","mind","learn","more","works","improve","feel","free","reach","help","a","the","is","it","you","we","are","my","your","our","their","be","been","being","have","has","had","do","did","done","will","would","could","should","may","might","must","shall","can","cannot","not","no","yes","hi","how","help","assistant","plan","computer","science","domain","books","videos","free","online","search","pip","install"
|
|
45
|
+
}
|
|
46
|
+
def _segment_heavy(s):
|
|
47
|
+
# Only attempt if very few spaces (heavily concatenated)
|
|
48
|
+
if s.count(" ") >= len(s) * 0.08: # at least 8% spaces = normal
|
|
49
|
+
return s
|
|
50
|
+
# Preserve URLs, code fences, brackets, backticks segments
|
|
51
|
+
# Use precise URL pattern that stops at ) ] * " ' < > and whitespace (fixes github.com)which... capture)
|
|
52
|
+
url_pat = r"https?://[^\s\)\]\*\"'<>]+"
|
|
53
|
+
urls = []
|
|
54
|
+
def _save_url(m):
|
|
55
|
+
urls.append(m.group(0))
|
|
56
|
+
return f"URLPLACEHOLDER{len(urls)-1}END"
|
|
57
|
+
tmp = _re.sub(url_pat, _save_url, s)
|
|
58
|
+
# Split on markdown/code markers but keep them
|
|
59
|
+
parts = _re.split(r"(\s+|```.*?```|`[^`]*`|\[.*?\]\(.*?\)|[*_#`\[\]()/])", tmp, flags=_re.DOTALL)
|
|
60
|
+
out = []
|
|
61
|
+
for p in parts:
|
|
62
|
+
if not p or p.startswith("URLPLACEHOLDER") or p.startswith("__URL") or p.startswith("```") or p.startswith("`") or p.startswith("["):
|
|
63
|
+
out.append(p)
|
|
64
|
+
continue
|
|
65
|
+
# If already has spaces or is punctuation, keep
|
|
66
|
+
if " " in p or len(p) < 12:
|
|
67
|
+
out.append(p)
|
|
68
|
+
continue
|
|
69
|
+
# Try to segment long concatenated word-run (e.g., Iamstillinlearning...)
|
|
70
|
+
low = p.lower()
|
|
71
|
+
# Quick check: if no known word inside, skip
|
|
72
|
+
if not any(w in low for w in ("iam","still","learning","have","experience")):
|
|
73
|
+
# General: try DP segmentation for any long lowercase run
|
|
74
|
+
if len(p) > 20 and p.isalpha() and p.count(" ") == 0:
|
|
75
|
+
# DP segmentation
|
|
76
|
+
n = len(low)
|
|
77
|
+
dp = [None]*(n+1)
|
|
78
|
+
dp[0] = []
|
|
79
|
+
for i in range(n):
|
|
80
|
+
if dp[i] is None:
|
|
81
|
+
continue
|
|
82
|
+
for j in range(i+1, min(n, i+12)+1): # words 1-12 chars (to handle 'do','of','to','I','a')
|
|
83
|
+
word = low[i:j]
|
|
84
|
+
if word in COMMON_WORDS:
|
|
85
|
+
if dp[j] is None or len(dp[i])+1 < len(dp[j]):
|
|
86
|
+
dp[j] = dp[i] + [p[i:j]]
|
|
87
|
+
if dp[n] is not None:
|
|
88
|
+
out.append(" ".join(dp[n]))
|
|
89
|
+
continue
|
|
90
|
+
out.append(p)
|
|
91
|
+
continue
|
|
92
|
+
# Heuristic for the specific bot description
|
|
93
|
+
# Greedy longest-match segmentation
|
|
94
|
+
res_words = []
|
|
95
|
+
i = 0
|
|
96
|
+
L = len(p)
|
|
97
|
+
while i < L:
|
|
98
|
+
# Skip non-alpha
|
|
99
|
+
if not p[i].isalpha():
|
|
100
|
+
res_words.append(p[i])
|
|
101
|
+
i += 1
|
|
102
|
+
continue
|
|
103
|
+
best = None
|
|
104
|
+
best_len = 0
|
|
105
|
+
# Try longest word starting at i
|
|
106
|
+
for l in range(min(12, L-i), 1, -1):
|
|
107
|
+
cand = low[i:i+l]
|
|
108
|
+
if cand in COMMON_WORDS:
|
|
109
|
+
# Prefer longer
|
|
110
|
+
if l > best_len:
|
|
111
|
+
best = p[i:i+l]
|
|
112
|
+
best_len = l
|
|
113
|
+
break
|
|
114
|
+
if best:
|
|
115
|
+
res_words.append(best)
|
|
116
|
+
i += best_len
|
|
117
|
+
else:
|
|
118
|
+
# Single char fallback (like I, a)
|
|
119
|
+
if low[i] in ("i","a"):
|
|
120
|
+
res_words.append(p[i])
|
|
121
|
+
i += 1
|
|
122
|
+
else:
|
|
123
|
+
# No match, take char as is and continue
|
|
124
|
+
res_words.append(p[i])
|
|
125
|
+
i += 1
|
|
126
|
+
# Re-join with spaces, but keep original casing where possible
|
|
127
|
+
# Fix "I" capitalization
|
|
128
|
+
segmented = " ".join(res_words)
|
|
129
|
+
segmented = _re.sub(r"\bi\s*\b", "I ", segmented)
|
|
130
|
+
segmented = _re.sub(r"\s+", " ", segmented)
|
|
131
|
+
out.append(segmented)
|
|
132
|
+
# Restore URLs
|
|
133
|
+
restored = "".join(out)
|
|
134
|
+
for idx, url in enumerate(urls):
|
|
135
|
+
restored = restored.replace(f"URLPLACEHOLDER{idx}END", url)
|
|
136
|
+
return restored
|
|
137
|
+
|
|
138
|
+
parts = _re.split(r"(```.*?```)", text, flags=_re.DOTALL)
|
|
139
|
+
out_parts = []
|
|
140
|
+
for part in parts:
|
|
141
|
+
is_code = part.startswith("```")
|
|
142
|
+
t = part
|
|
143
|
+
t = t.replace("\u2081", "1").replace("\u2082", "2").replace("\u2083", "3").replace("\u2084", "4")
|
|
144
|
+
t = t.replace("\u2085", "5").replace("\u2086", "6").replace("\u2087", "7").replace("\u2088", "8").replace("\u2089", "9").replace("\u2080", "0")
|
|
145
|
+
t = _re.sub(r"\bimport(?=[a-zA-Z_])", "import ", t)
|
|
146
|
+
t = _re.sub(r"\bfrom(?=[a-zA-Z_])", "from ", t)
|
|
147
|
+
t = _re.sub(r"\bclass(?=[a-zA-Z_])", "class ", t)
|
|
148
|
+
t = _re.sub(r"\bdef(?=[a-zA-Z_])", "def ", t)
|
|
149
|
+
t = _re.sub(r"\breturn(?=[a-zA-Z_])", "return ", t)
|
|
150
|
+
t = _re.sub(r"([a-z])from(?=[A-Z])", r"\1 from", t)
|
|
151
|
+
t = _re.sub(r"opensourcebot", "open source bot", t, flags=_re.IGNORECASE)
|
|
152
|
+
t = _re.sub(r"whichcandothefollowing", "which can do the following", t, flags=_re.IGNORECASE)
|
|
153
|
+
t = _re.sub(r"whichcandothefollowing", "which can do the following", t, flags=_re.IGNORECASE)
|
|
154
|
+
t = _re.sub(r"([a-z])import(?=[A-Z])", r"\1 import", t)
|
|
155
|
+
t = _re.sub(r"([A-Za-z0-9])```", r"\1\n\n```", t)
|
|
156
|
+
t = _re.sub(r"```(#[A-Za-z])", r"```\n\1", t) # only split heading fences, not language tags like ```python
|
|
157
|
+
t = _re.sub(r"([a-zA-Z0-9])`([a-zA-Z0-9])", r"\1` \2", t)
|
|
158
|
+
t = _re.sub(r"([a-z])([A-Z][a-z]+\.txt)", r"\1 \2", t)
|
|
159
|
+
if not is_code:
|
|
160
|
+
# Heavy concatenation repair for prose
|
|
161
|
+
if t.count(" ") < len(t) * 0.08 and len(t) > 20:
|
|
162
|
+
t = _segment_heavy(t)
|
|
163
|
+
t = _re.sub(r"([.!?:;])([A-Z])", r"\1 \2", t) # only before capital to avoid github.com
|
|
164
|
+
t = _re.sub(r"([a-z,;)])([A-Z])", r"\1 \2", t)
|
|
165
|
+
t = _re.sub(r"([a-z])(\()", r"\1 \2", t)
|
|
166
|
+
t = _re.sub(r"(\))([a-zA-Z])", r"\1 \2", t)
|
|
167
|
+
# Fix missing spaces after * bullets and brackets: *Thisisan -> * This is an
|
|
168
|
+
t = _re.sub(r"\*([A-Za-z])", r"* \1", t)
|
|
169
|
+
# Fix Thisisan -> This is an
|
|
170
|
+
t = _re.sub(r"\bThisisan\b", "This is an", t)
|
|
171
|
+
t = _re.sub(r"\bwhichcan\b", "which can", t)
|
|
172
|
+
t = _re.sub(r"\bShowall\b", "Show all", t)
|
|
173
|
+
t = _re.sub(r"\bListento\b", "Listen to", t)
|
|
174
|
+
out_parts.append(t)
|
|
175
|
+
rejoined = "".join(out_parts)
|
|
176
|
+
rejoined = _re.sub(r"\b(hi){3,}\b", "hi", rejoined, flags=_re.IGNORECASE)
|
|
177
|
+
rejoined = _re.sub(r"hihii+", "hi", rejoined, flags=_re.IGNORECASE)
|
|
178
|
+
noisy = rejoined.replace(" ", "").lower()
|
|
179
|
+
if "mynameisyourassistant" in noisy or "assistant_chatbot" in noisy.lower() or "assistant-chatbot" in noisy.lower():
|
|
180
|
+
if len(rejoined) < 600 and ("howcan" in noisy or "myname" in noisy):
|
|
181
|
+
return "Hi, I'm CAT Plan — your planning assistant inside Coding Agent Terminal by Kazi Zillani. How can I help you plan today?"
|
|
182
|
+
rejoined = _re.sub(r"```python\s*#?pip\s*install\s*assistant_chatbot[^\n]*```", "", rejoined, flags=_re.IGNORECASE)
|
|
183
|
+
rejoined = _re.sub(r"#?pip\s*install\s*assistant_chatbot[^\n]*", "", rejoined, flags=_re.IGNORECASE)
|
|
184
|
+
rejoined = _re.sub(r"My\s*name\s*is\s*your\s*assistant[^.!\n]*[.!]?", "I'm CAT Plan by Kazi Zillani.", rejoined, flags=_re.IGNORECASE)
|
|
185
|
+
rejoined = rejoined.replace("assistant_chatbot", "CAT").replace("Assistant-Chatbot", "CAT")
|
|
186
|
+
if "howcan" in noisy and "helpyou" in noisy:
|
|
187
|
+
rejoined = _re.sub(r"Hi,?\s*how\s*can\s*I\s*help\s*you\??", "Hi, how can I help you?", rejoined, flags=_re.IGNORECASE)
|
|
188
|
+
rejoined = _re.sub(r"How\s*can\s*I\s*helo\s*you\s*there\??", "How can I help you there?", rejoined, flags=_re.IGNORECASE)
|
|
189
|
+
rejoined = _re.sub(r"You\s*can\s*ask\s*me\s*about\s*anything\s*in\s*the\s*computer\s*science\s*domain.*?(?=```|\n\n|$)", "You can ask me about anything in the computer science domain and I will help you plan it.", rejoined, flags=_re.IGNORECASE | _re.DOTALL)
|
|
190
|
+
# Final cleanup: collapse multiple spaces, fix " Iam " -> " I am "
|
|
191
|
+
rejoined = _re.sub(r" {2,}", " ", rejoined)
|
|
192
|
+
rejoined = _re.sub(r"\bIam\b", "I am", rejoined)
|
|
193
|
+
rejoined = _re.sub(r"\bIhavesome\b", "I have some", rejoined)
|
|
194
|
+
return rejoined.strip()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ── Debug logging (user app-data logs dir when installed; legacy
|
|
198
|
+
# repo-root startup.log as fallback for source checkouts) ──────────────
|
|
199
|
+
def _resolve_log_file() -> str:
|
|
200
|
+
try:
|
|
201
|
+
from calc_terminal.first_run import logs_dir as _logs_dir
|
|
202
|
+
|
|
203
|
+
import os as _os
|
|
204
|
+
|
|
205
|
+
_d = _logs_dir()
|
|
206
|
+
_os.makedirs(_d, exist_ok=True)
|
|
207
|
+
return _os.path.join(_d, "startup.log")
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
return os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
|
|
211
|
+
os.path.abspath(__file__)))), "startup.log")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
_LOG_FILE = _resolve_log_file()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _log(msg: str, exc_info: bool = False):
|
|
218
|
+
"""Append a timestamped log line to startup.log."""
|
|
219
|
+
try:
|
|
220
|
+
ts = time.strftime("%H:%M:%S")
|
|
221
|
+
with open(_LOG_FILE, "a", encoding="utf-8") as f:
|
|
222
|
+
f.write(f"[{ts}] [ProviderManager] {msg}\n")
|
|
223
|
+
if exc_info:
|
|
224
|
+
import traceback
|
|
225
|
+
traceback.print_exc(file=f)
|
|
226
|
+
except Exception:
|
|
227
|
+
pass # best-effort logging
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
CONFIG_FILE = os.path.join(os.path.expanduser("~"), ".cct_ai_config.json")
|
|
231
|
+
PROVIDERS_FILE = os.path.join(os.path.expanduser("~"), ".cct_providers.json")
|
|
232
|
+
BACKUP_PROVIDERS_FILE = os.path.join(os.path.expanduser("~"), ".cct_backup_providers.json")
|
|
233
|
+
|
|
234
|
+
_lock = threading.Lock()
|
|
235
|
+
|
|
236
|
+
# Registry of built-in provider classes
|
|
237
|
+
_BUILTIN_PROVIDERS: dict[str, type[BaseProvider]] = {}
|
|
238
|
+
|
|
239
|
+
# Dynamically added/loaded provider instances
|
|
240
|
+
_PROVIDER_INSTANCES: dict[str, BaseProvider] = {}
|
|
241
|
+
|
|
242
|
+
# ── Model Cache ────────────────────────────────────────────────────────────
|
|
243
|
+
# Format: {provider_id: (models_list, timestamp)}
|
|
244
|
+
# The in-memory dict is a fast-path mirror of the on-disk cache under
|
|
245
|
+
# cache/models/ (see models/manager.py). Every write persists to disk so
|
|
246
|
+
# model lists survive restarts and never go stale between launches.
|
|
247
|
+
_model_cache: dict[str, tuple[list[str], float]] = {}
|
|
248
|
+
_CACHE_TTL = 300 # 5 minutes (in-memory mirror only)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _from_models_manager(provider_id: str) -> Optional[list[str]]:
|
|
252
|
+
"""Read the on-disk cache (cache/models/{provider_id}.json)."""
|
|
253
|
+
try:
|
|
254
|
+
from ..models import manager as _mgr
|
|
255
|
+
cached = _mgr.get_cached(provider_id)
|
|
256
|
+
if cached:
|
|
257
|
+
return list(cached[0])
|
|
258
|
+
except Exception:
|
|
259
|
+
pass
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def invalidate_model_cache(provider_id: str = None):
|
|
264
|
+
"""Clear cached models. If provider_id is None, clears all.
|
|
265
|
+
Removes the on-disk cache too, so the next /model refresh re-fetches."""
|
|
266
|
+
with _lock:
|
|
267
|
+
if provider_id:
|
|
268
|
+
_model_cache.pop(provider_id, None)
|
|
269
|
+
else:
|
|
270
|
+
_model_cache.clear()
|
|
271
|
+
try:
|
|
272
|
+
from ..models import manager as _mgr
|
|
273
|
+
if provider_id:
|
|
274
|
+
_mgr.delete_cache(provider_id)
|
|
275
|
+
else:
|
|
276
|
+
import glob
|
|
277
|
+
for path in glob.glob(os.path.join(_mgr.CACHE_DIR, "*.json")):
|
|
278
|
+
try:
|
|
279
|
+
os.remove(path)
|
|
280
|
+
except Exception:
|
|
281
|
+
pass
|
|
282
|
+
except Exception:
|
|
283
|
+
pass
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def get_cached_models(provider_id: str) -> Optional[list[str]]:
|
|
287
|
+
"""Get cached models if still fresh. Returns None if stale or missing.
|
|
288
|
+
Falls back to the on-disk cache when the in-memory mirror is empty."""
|
|
289
|
+
with _lock:
|
|
290
|
+
entry = _model_cache.get(provider_id)
|
|
291
|
+
if entry is not None:
|
|
292
|
+
models, ts = entry
|
|
293
|
+
if time.time() - ts > _CACHE_TTL:
|
|
294
|
+
_model_cache.pop(provider_id, None)
|
|
295
|
+
else:
|
|
296
|
+
return list(models)
|
|
297
|
+
disk = _from_models_manager(provider_id)
|
|
298
|
+
if disk is not None:
|
|
299
|
+
with _lock:
|
|
300
|
+
_model_cache[provider_id] = (list(disk), time.time())
|
|
301
|
+
return list(disk)
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def set_cached_models(provider_id: str, models: list[str]):
|
|
306
|
+
"""Store models in cache (memory + disk)."""
|
|
307
|
+
with _lock:
|
|
308
|
+
_model_cache[provider_id] = (list(models), time.time())
|
|
309
|
+
try:
|
|
310
|
+
from ..models import manager as _mgr
|
|
311
|
+
_mgr.write_cache(provider_id, list(models))
|
|
312
|
+
except Exception:
|
|
313
|
+
pass
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ── Request Cancellation ───────────────────────────────────────────────────
|
|
317
|
+
# Each in-flight fetch gets an Event. Setting it signals cancellation.
|
|
318
|
+
_pending_fetches: dict[str, threading.Event] = {}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _cancel_token(provider_id: str) -> threading.Event:
|
|
322
|
+
with _lock:
|
|
323
|
+
old = _pending_fetches.pop(provider_id, None)
|
|
324
|
+
if old:
|
|
325
|
+
old.set() # signal cancellation to any previous request
|
|
326
|
+
ev = threading.Event()
|
|
327
|
+
_pending_fetches[provider_id] = ev
|
|
328
|
+
return ev
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _finish_fetch(provider_id: str):
|
|
332
|
+
with _lock:
|
|
333
|
+
_pending_fetches.pop(provider_id, None)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def cancel_fetch(provider_id: str):
|
|
337
|
+
"""Cancel any in-flight model fetch for the given provider."""
|
|
338
|
+
with _lock:
|
|
339
|
+
ev = _pending_fetches.pop(provider_id, None)
|
|
340
|
+
if ev:
|
|
341
|
+
ev.set()
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# ── Generic Fallback Model Fetch ───────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _mask_key(key: str) -> str:
|
|
348
|
+
"""Mask API key for logging (show first 4 + last 4 chars)."""
|
|
349
|
+
if not key:
|
|
350
|
+
return ""
|
|
351
|
+
k = key.strip()
|
|
352
|
+
if len(k) <= 12:
|
|
353
|
+
return k[:4] + "****" + k[-4:] if len(k) > 8 else "****"
|
|
354
|
+
return k[:4] + "..." + k[-4:]
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _generic_fetch_models_openai(config: dict,
|
|
358
|
+
cancel_ev: threading.Event = None) -> list[str]:
|
|
359
|
+
"""Generic model list fetch for any OpenAI-compatible /v1 endpoint.
|
|
360
|
+
|
|
361
|
+
Sends: GET {base_url}/models
|
|
362
|
+
Expects: {"data": [{"id": "model-name", ...}, ...]}
|
|
363
|
+
Handles cursor-based pagination (limit=100, after=last_id).
|
|
364
|
+
Returns sorted unique list of model IDs on success, [] on failure.
|
|
365
|
+
"""
|
|
366
|
+
base_url = config.get("base_url", "").rstrip("/")
|
|
367
|
+
if not base_url:
|
|
368
|
+
_log("generic_openai: no base_url in config — returning []")
|
|
369
|
+
return []
|
|
370
|
+
|
|
371
|
+
api_key = config.get("api_key", "")
|
|
372
|
+
provider_id = config.get("provider", "?")
|
|
373
|
+
headers = {"Content-Type": "application/json"}
|
|
374
|
+
if api_key:
|
|
375
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
376
|
+
|
|
377
|
+
url = f"{base_url}/models"
|
|
378
|
+
all_models = []
|
|
379
|
+
after = None
|
|
380
|
+
limit = 100
|
|
381
|
+
|
|
382
|
+
while True:
|
|
383
|
+
if cancel_ev and cancel_ev.is_set():
|
|
384
|
+
_log(f"generic_openai [{provider_id}]: cancelled mid-fetch "
|
|
385
|
+
f"({len(all_models)} so far)")
|
|
386
|
+
return all_models or []
|
|
387
|
+
|
|
388
|
+
params = {"limit": limit}
|
|
389
|
+
if after:
|
|
390
|
+
params["after"] = after
|
|
391
|
+
|
|
392
|
+
# Log the outgoing request (mask auth header)
|
|
393
|
+
safe_headers = dict(headers)
|
|
394
|
+
if "Authorization" in safe_headers:
|
|
395
|
+
safe_headers["Authorization"] = f"Bearer {_mask_key(api_key)}"
|
|
396
|
+
_log(f"generic_openai [{provider_id}]: GET {url} "
|
|
397
|
+
f"params={params} headers={safe_headers}")
|
|
398
|
+
|
|
399
|
+
try:
|
|
400
|
+
resp = requests.get(url, headers=headers,
|
|
401
|
+
params=params, timeout=10)
|
|
402
|
+
except requests.exceptions.ConnectionError:
|
|
403
|
+
_log(f"generic_openai [{provider_id}]: ConnectionError "
|
|
404
|
+
f"— cannot reach {base_url}")
|
|
405
|
+
return all_models or []
|
|
406
|
+
except requests.exceptions.Timeout:
|
|
407
|
+
_log(f"generic_openai [{provider_id}]: Timeout "
|
|
408
|
+
f"— {base_url}/models did not respond within 10s")
|
|
409
|
+
return all_models or []
|
|
410
|
+
except Exception as e:
|
|
411
|
+
_log(f"generic_openai [{provider_id}]: GET failed — {e}",
|
|
412
|
+
exc_info=True)
|
|
413
|
+
return all_models or []
|
|
414
|
+
|
|
415
|
+
# Log response summary
|
|
416
|
+
body_snippet = ""
|
|
417
|
+
try:
|
|
418
|
+
body_snippet = resp.text[:200]
|
|
419
|
+
except Exception:
|
|
420
|
+
pass
|
|
421
|
+
_log(f"generic_openai [{provider_id}]: HTTP {resp.status_code} "
|
|
422
|
+
f"({len(resp.content)} bytes) "
|
|
423
|
+
f"snippet={body_snippet}")
|
|
424
|
+
|
|
425
|
+
if resp.status_code == 401:
|
|
426
|
+
_log(f"generic_openai [{provider_id}]: HTTP 401 "
|
|
427
|
+
f"— invalid or missing API key")
|
|
428
|
+
return all_models or []
|
|
429
|
+
if resp.status_code == 403:
|
|
430
|
+
_log(f"generic_openai [{provider_id}]: HTTP 403 "
|
|
431
|
+
f"— access forbidden (key may lack permissions)")
|
|
432
|
+
return all_models or []
|
|
433
|
+
if resp.status_code >= 400:
|
|
434
|
+
_log(f"generic_openai [{provider_id}]: HTTP {resp.status_code} "
|
|
435
|
+
f"— unexpected error")
|
|
436
|
+
return all_models or []
|
|
437
|
+
|
|
438
|
+
# Parse response body
|
|
439
|
+
try:
|
|
440
|
+
data = resp.json()
|
|
441
|
+
except Exception as e:
|
|
442
|
+
_log(f"generic_openai [{provider_id}]: JSON parse failed — {e}",
|
|
443
|
+
exc_info=True)
|
|
444
|
+
return all_models or []
|
|
445
|
+
|
|
446
|
+
items = data.get("data", [])
|
|
447
|
+
if not isinstance(items, list):
|
|
448
|
+
_log(f"generic_openai [{provider_id}]: 'data' field is not a list "
|
|
449
|
+
f"(got {type(items).__name__}) — body keys: {list(data.keys())}")
|
|
450
|
+
return all_models or []
|
|
451
|
+
|
|
452
|
+
page_count = 0
|
|
453
|
+
for m in items:
|
|
454
|
+
if isinstance(m, dict) and m.get("id"):
|
|
455
|
+
all_models.append(str(m["id"]))
|
|
456
|
+
page_count += 1
|
|
457
|
+
_log(f"generic_openai [{provider_id}]: page got {page_count} models "
|
|
458
|
+
f"(total {len(all_models)})")
|
|
459
|
+
|
|
460
|
+
# Pagination: check has_more or use cursor from last item
|
|
461
|
+
if "has_more" in data:
|
|
462
|
+
if not data.get("has_more"):
|
|
463
|
+
break
|
|
464
|
+
if items:
|
|
465
|
+
after = items[-1].get("id")
|
|
466
|
+
if not after:
|
|
467
|
+
break
|
|
468
|
+
# Safety: don't loop forever
|
|
469
|
+
if len(all_models) > 5000:
|
|
470
|
+
_log(f"generic_openai [{provider_id}]: safety limit "
|
|
471
|
+
f"(5000 models) reached — stopping pagination")
|
|
472
|
+
break
|
|
473
|
+
|
|
474
|
+
result = sorted(set(all_models))
|
|
475
|
+
_log(f"generic_openai [{provider_id}]: done — {len(result)} unique models")
|
|
476
|
+
return result
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _generic_fetch_models_ollama(config: dict,
|
|
480
|
+
cancel_ev: threading.Event = None) -> list[str]:
|
|
481
|
+
"""Fetch model list from a local Ollama instance.
|
|
482
|
+
|
|
483
|
+
Sends: GET {base_url}/api/tags
|
|
484
|
+
Expects: {"models": [{"name": "llama3.1:8b", ...}, ...]}
|
|
485
|
+
"""
|
|
486
|
+
# Dual-truth fix: accept base_url OR ollama_url OR CCTConfig ollama_endpoint
|
|
487
|
+
base_url = (config.get("base_url") or config.get("ollama_url") or "").strip()
|
|
488
|
+
if not base_url:
|
|
489
|
+
try:
|
|
490
|
+
from .. import config as _cctcfg
|
|
491
|
+
base_url = _cctcfg.get_config().ollama_endpoint
|
|
492
|
+
except Exception:
|
|
493
|
+
pass
|
|
494
|
+
base_url = (base_url or "http://localhost:11434").rstrip("/")
|
|
495
|
+
# Handle OpenAI-compat /v1 suffix — strip for /api/tags
|
|
496
|
+
if base_url.endswith("/v1"):
|
|
497
|
+
base_url = base_url[:-3].rstrip("/")
|
|
498
|
+
provider_id = config.get("provider", "ollama")
|
|
499
|
+
url = f"{base_url}/api/tags"
|
|
500
|
+
|
|
501
|
+
def _catalog_fallback():
|
|
502
|
+
try:
|
|
503
|
+
from ..ollama_catalog import all_models as _all
|
|
504
|
+
return sorted({m["name"] for m in _all()})
|
|
505
|
+
except Exception:
|
|
506
|
+
return []
|
|
507
|
+
|
|
508
|
+
if cancel_ev and cancel_ev.is_set():
|
|
509
|
+
return _catalog_fallback() or []
|
|
510
|
+
|
|
511
|
+
_log(f"generic_ollama [{provider_id}]: GET {url}")
|
|
512
|
+
|
|
513
|
+
try:
|
|
514
|
+
resp = requests.get(url, timeout=12)
|
|
515
|
+
except requests.exceptions.ConnectionError:
|
|
516
|
+
_log(f"generic_ollama [{provider_id}]: ConnectionError "
|
|
517
|
+
f"— is Ollama running at {base_url}? — returning catalog")
|
|
518
|
+
return _catalog_fallback() or []
|
|
519
|
+
except requests.exceptions.Timeout:
|
|
520
|
+
_log(f"generic_ollama [{provider_id}]: Timeout "
|
|
521
|
+
f"— {url} did not respond within 12s — returning catalog")
|
|
522
|
+
return _catalog_fallback() or []
|
|
523
|
+
except Exception as e:
|
|
524
|
+
_log(f"generic_ollama [{provider_id}]: GET failed — {e}",
|
|
525
|
+
exc_info=True)
|
|
526
|
+
return _catalog_fallback() or []
|
|
527
|
+
|
|
528
|
+
_log(f"generic_ollama [{provider_id}]: HTTP {resp.status_code} "
|
|
529
|
+
f"({len(resp.content)} bytes)")
|
|
530
|
+
|
|
531
|
+
if resp.status_code >= 400:
|
|
532
|
+
_log(f"generic_ollama [{provider_id}]: HTTP {resp.status_code} — returning catalog")
|
|
533
|
+
return _catalog_fallback() or []
|
|
534
|
+
|
|
535
|
+
try:
|
|
536
|
+
data = resp.json()
|
|
537
|
+
except Exception as e:
|
|
538
|
+
_log(f"generic_ollama [{provider_id}]: JSON parse failed — {e}",
|
|
539
|
+
exc_info=True)
|
|
540
|
+
return _catalog_fallback() or []
|
|
541
|
+
|
|
542
|
+
raw_models = data.get("models", [])
|
|
543
|
+
if not isinstance(raw_models, list):
|
|
544
|
+
_log(f"generic_ollama [{provider_id}]: 'models' field is not a list "
|
|
545
|
+
f"(got {type(raw_models).__name__}) — returning catalog")
|
|
546
|
+
return _catalog_fallback() or []
|
|
547
|
+
|
|
548
|
+
models = []
|
|
549
|
+
for m in raw_models:
|
|
550
|
+
name = m.get("name") if isinstance(m, dict) else None
|
|
551
|
+
if name:
|
|
552
|
+
models.append(str(name))
|
|
553
|
+
|
|
554
|
+
result = sorted(set(models))
|
|
555
|
+
# Merge with curated 250+ catalog so ModelScreen shows all ollama models in model.py
|
|
556
|
+
try:
|
|
557
|
+
from ..ollama_catalog import all_models as _ollama_all
|
|
558
|
+
catalog_names = [m["name"] for m in _ollama_all()]
|
|
559
|
+
result = sorted(set(result) | set(catalog_names))
|
|
560
|
+
_log(f"generic_ollama [{provider_id}]: merged catalog — {len(result)} total")
|
|
561
|
+
except Exception:
|
|
562
|
+
pass
|
|
563
|
+
_log(f"generic_ollama [{provider_id}]: done — {len(result)} models")
|
|
564
|
+
return result
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def add_custom_ollama_model(model_name: str, base_url: str = None) -> bool:
|
|
568
|
+
"""Add a custom Ollama model to the configuration.
|
|
569
|
+
|
|
570
|
+
This allows users to add models from their local Ollama instance
|
|
571
|
+
that may not be in the default list. The model will be validated
|
|
572
|
+
by checking if it exists in the local Ollama instance.
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
model_name: The name of the model to add (e.g., "my-custom-model:latest")
|
|
576
|
+
base_url: Optional custom Ollama base URL (defaults to http://localhost:11434)
|
|
577
|
+
|
|
578
|
+
Returns:
|
|
579
|
+
True if the model was added successfully, False otherwise
|
|
580
|
+
"""
|
|
581
|
+
import json
|
|
582
|
+
import os
|
|
583
|
+
|
|
584
|
+
base_url = base_url or "http://localhost:11434"
|
|
585
|
+
config_file = os.path.join(os.path.expanduser("~"), ".cct_ai_config.json")
|
|
586
|
+
|
|
587
|
+
try:
|
|
588
|
+
# First, verify the model exists in the local Ollama instance
|
|
589
|
+
import requests
|
|
590
|
+
resp = requests.get(f"{base_url}/api/tags", timeout=5)
|
|
591
|
+
if resp.status_code != 200:
|
|
592
|
+
_log(f"add_custom_ollama_model: Failed to connect to Ollama at {base_url}")
|
|
593
|
+
return False
|
|
594
|
+
|
|
595
|
+
data = resp.json()
|
|
596
|
+
available_models = [m.get("name", "") for m in data.get("models", [])]
|
|
597
|
+
|
|
598
|
+
if model_name not in available_models:
|
|
599
|
+
_log(f"add_custom_ollama_model: Model '{model_name}' not found in Ollama. "
|
|
600
|
+
f"Available models: {available_models[:10]}...")
|
|
601
|
+
return False
|
|
602
|
+
|
|
603
|
+
# Load existing config
|
|
604
|
+
config = {}
|
|
605
|
+
if os.path.exists(config_file):
|
|
606
|
+
with open(config_file, "r", encoding="utf-8") as f:
|
|
607
|
+
config = json.load(f)
|
|
608
|
+
|
|
609
|
+
# Add the custom model to the Ollama provider config
|
|
610
|
+
if "ollama" not in config:
|
|
611
|
+
config["ollama"] = {}
|
|
612
|
+
|
|
613
|
+
if "custom_models" not in config["ollama"]:
|
|
614
|
+
config["ollama"]["custom_models"] = []
|
|
615
|
+
|
|
616
|
+
if model_name not in config["ollama"]["custom_models"]:
|
|
617
|
+
config["ollama"]["custom_models"].append(model_name)
|
|
618
|
+
|
|
619
|
+
# Save the updated config
|
|
620
|
+
with open(config_file, "w", encoding="utf-8") as f:
|
|
621
|
+
json.dump(config, f, indent=2, ensure_ascii=False)
|
|
622
|
+
|
|
623
|
+
_log(f"add_custom_ollama_model: Successfully added '{model_name}'")
|
|
624
|
+
return True
|
|
625
|
+
|
|
626
|
+
except Exception as e:
|
|
627
|
+
_log(f"add_custom_ollama_model: Error adding model — {e}", exc_info=True)
|
|
628
|
+
return False
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def remove_custom_ollama_model(model_name: str) -> bool:
|
|
632
|
+
"""Remove a custom Ollama model from the configuration.
|
|
633
|
+
|
|
634
|
+
Args:
|
|
635
|
+
model_name: The name of the model to remove
|
|
636
|
+
|
|
637
|
+
Returns:
|
|
638
|
+
True if the model was removed successfully, False otherwise
|
|
639
|
+
"""
|
|
640
|
+
import json
|
|
641
|
+
import os
|
|
642
|
+
|
|
643
|
+
config_file = os.path.join(os.path.expanduser("~"), ".cct_ai_config.json")
|
|
644
|
+
|
|
645
|
+
try:
|
|
646
|
+
# Load existing config
|
|
647
|
+
if not os.path.exists(config_file):
|
|
648
|
+
return False
|
|
649
|
+
|
|
650
|
+
with open(config_file, "r", encoding="utf-8") as f:
|
|
651
|
+
config = json.load(f)
|
|
652
|
+
|
|
653
|
+
# Remove the custom model from Ollama provider config
|
|
654
|
+
if "ollama" in config and "custom_models" in config["ollama"]:
|
|
655
|
+
if model_name in config["ollama"]["custom_models"]:
|
|
656
|
+
config["ollama"]["custom_models"].remove(model_name)
|
|
657
|
+
|
|
658
|
+
# Save the updated config
|
|
659
|
+
with open(config_file, "w", encoding="utf-8") as f:
|
|
660
|
+
json.dump(config, f, indent=2, ensure_ascii=False)
|
|
661
|
+
|
|
662
|
+
_log(f"remove_custom_ollama_model: Successfully removed '{model_name}'")
|
|
663
|
+
return True
|
|
664
|
+
|
|
665
|
+
return False
|
|
666
|
+
|
|
667
|
+
except Exception as e:
|
|
668
|
+
_log(f"remove_custom_ollama_model: Error removing model — {e}", exc_info=True)
|
|
669
|
+
return False
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def get_custom_ollama_models() -> list[str]:
|
|
673
|
+
"""Get the list of custom Ollama models added by the user.
|
|
674
|
+
|
|
675
|
+
Returns:
|
|
676
|
+
List of custom model names
|
|
677
|
+
"""
|
|
678
|
+
import json
|
|
679
|
+
import os
|
|
680
|
+
|
|
681
|
+
config_file = os.path.join(os.path.expanduser("~"), ".cct_ai_config.json")
|
|
682
|
+
|
|
683
|
+
try:
|
|
684
|
+
if not os.path.exists(config_file):
|
|
685
|
+
return []
|
|
686
|
+
|
|
687
|
+
with open(config_file, "r", encoding="utf-8") as f:
|
|
688
|
+
config = json.load(f)
|
|
689
|
+
|
|
690
|
+
return config.get("ollama", {}).get("custom_models", [])
|
|
691
|
+
|
|
692
|
+
except Exception:
|
|
693
|
+
return []
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def refresh_ollama_models(base_url: str = None) -> list[str]:
|
|
697
|
+
"""Refresh the list of available Ollama models from the local instance.
|
|
698
|
+
|
|
699
|
+
This function queries the local Ollama instance and returns all
|
|
700
|
+
available models, including any custom models the user has added.
|
|
701
|
+
|
|
702
|
+
Args:
|
|
703
|
+
base_url: Optional custom Ollama base URL (defaults to http://localhost:11434)
|
|
704
|
+
|
|
705
|
+
Returns:
|
|
706
|
+
List of available model names
|
|
707
|
+
"""
|
|
708
|
+
base_url = base_url or "http://localhost:11434"
|
|
709
|
+
|
|
710
|
+
try:
|
|
711
|
+
# Get models from Ollama instance
|
|
712
|
+
ollama_models = _generic_fetch_models_ollama({"base_url": base_url})
|
|
713
|
+
|
|
714
|
+
# Get custom models from config
|
|
715
|
+
custom_models = get_custom_ollama_models()
|
|
716
|
+
|
|
717
|
+
# Merge and deduplicate
|
|
718
|
+
all_models = sorted(set(ollama_models + custom_models))
|
|
719
|
+
|
|
720
|
+
_log(f"refresh_ollama_models: Found {len(all_models)} models "
|
|
721
|
+
f"({len(ollama_models)} from Ollama, {len(custom_models)} custom)")
|
|
722
|
+
|
|
723
|
+
return all_models
|
|
724
|
+
|
|
725
|
+
except Exception as e:
|
|
726
|
+
_log(f"refresh_ollama_models: Error refreshing models — {e}", exc_info=True)
|
|
727
|
+
return get_custom_ollama_models() # Return custom models as fallback
|
|
728
|
+
|
|
729
|
+
def discover_models(provider_id: str, config: dict = None) -> list[str]:
|
|
730
|
+
"""Discover all models for a provider using live API fetch with caching.
|
|
731
|
+
|
|
732
|
+
Tries registered provider class first. Falls back to generic HTTP
|
|
733
|
+
fetch for unknown providers (OpenAI-compatible or Ollama).
|
|
734
|
+
Returns cached models if fresh, otherwise fetches live.
|
|
735
|
+
Handles cancellation, pagination, and error recovery.
|
|
736
|
+
"""
|
|
737
|
+
cached = get_cached_models(provider_id)
|
|
738
|
+
if cached is not None:
|
|
739
|
+
_log(f"discover_models [{provider_id}]: returning {len(cached)} cached models")
|
|
740
|
+
return cached
|
|
741
|
+
|
|
742
|
+
# Try unified dynamic ProviderDiscoveryManager first
|
|
743
|
+
try:
|
|
744
|
+
from .discovery_manager import get_discovery_manager
|
|
745
|
+
dm = get_discovery_manager()
|
|
746
|
+
discov_list = dm.discover_provider(provider_id, config=config)
|
|
747
|
+
if discov_list:
|
|
748
|
+
model_ids = [m.model_id for m in discov_list]
|
|
749
|
+
_log(f"discover_models [{provider_id}]: DiscoveryManager returned {len(model_ids)} models")
|
|
750
|
+
set_cached_models(provider_id, model_ids)
|
|
751
|
+
return model_ids
|
|
752
|
+
except Exception as e:
|
|
753
|
+
_log(f"discover_models [{provider_id}]: DiscoveryManager fallback: {e}")
|
|
754
|
+
|
|
755
|
+
cfg = config or {}
|
|
756
|
+
cfg["provider"] = provider_id
|
|
757
|
+
cls = get_provider_class(provider_id)
|
|
758
|
+
|
|
759
|
+
cancel_ev = _cancel_token(provider_id)
|
|
760
|
+
try:
|
|
761
|
+
if cls:
|
|
762
|
+
_log(f"discover_models [{provider_id}]: using registered class "
|
|
763
|
+
f"{cls.__name__}")
|
|
764
|
+
inst = cls(cfg)
|
|
765
|
+
if hasattr(inst, "fetch_models_paginated"):
|
|
766
|
+
models = inst.fetch_models_paginated(cancel_ev)
|
|
767
|
+
else:
|
|
768
|
+
if cancel_ev.is_set():
|
|
769
|
+
return []
|
|
770
|
+
models = inst.fetch_models()
|
|
771
|
+
else:
|
|
772
|
+
# No registered provider class — try generic HTTP fetch
|
|
773
|
+
api_style = cfg.get("api_style", "openai")
|
|
774
|
+
_log(f"discover_models [{provider_id}]: no registered class, "
|
|
775
|
+
f"api_style={api_style!r} — trying generic fetch")
|
|
776
|
+
if api_style == "openai":
|
|
777
|
+
models = _generic_fetch_models_openai(cfg, cancel_ev)
|
|
778
|
+
elif api_style == "ollama":
|
|
779
|
+
models = _generic_fetch_models_ollama(cfg, cancel_ev)
|
|
780
|
+
else:
|
|
781
|
+
_log(f"discover_models [{provider_id}]: unsupported api_style "
|
|
782
|
+
f"{api_style!r} — no model fetch available")
|
|
783
|
+
models = []
|
|
784
|
+
|
|
785
|
+
if cancel_ev.is_set():
|
|
786
|
+
_log(f"discover_models [{provider_id}]: cancelled after fetch")
|
|
787
|
+
return []
|
|
788
|
+
if models:
|
|
789
|
+
_log(f"discover_models [{provider_id}]: caching {len(models)} models")
|
|
790
|
+
set_cached_models(provider_id, models)
|
|
791
|
+
else:
|
|
792
|
+
_log(f"discover_models [{provider_id}]: fetch returned 0 models")
|
|
793
|
+
return models or []
|
|
794
|
+
except Exception:
|
|
795
|
+
_log(f"discover_models [{provider_id}]: unhandled exception",
|
|
796
|
+
exc_info=True)
|
|
797
|
+
return []
|
|
798
|
+
finally:
|
|
799
|
+
_finish_fetch(provider_id)
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def discover_all_models(providers: list[dict], timeout: int = 8) -> dict[str, list[str]]:
|
|
803
|
+
"""Discover models for multiple providers in parallel.
|
|
804
|
+
|
|
805
|
+
Returns {provider_id: models_list}. Failed providers get empty list.
|
|
806
|
+
"""
|
|
807
|
+
results = {}
|
|
808
|
+
threads = []
|
|
809
|
+
|
|
810
|
+
def _fetch(p):
|
|
811
|
+
pid = p.get("id")
|
|
812
|
+
config = {"base_url": p.get("url", ""), "api_style": p.get("api_style", "openai")}
|
|
813
|
+
results[pid] = discover_models(pid, config)
|
|
814
|
+
|
|
815
|
+
for p in providers:
|
|
816
|
+
t = threading.Thread(target=_fetch, args=(p,), daemon=True)
|
|
817
|
+
t.start()
|
|
818
|
+
threads.append(t)
|
|
819
|
+
|
|
820
|
+
for t in threads:
|
|
821
|
+
t.join(timeout=timeout)
|
|
822
|
+
|
|
823
|
+
return results
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
# ── Dynamic Provider List ──────────────────────────────────────────────────
|
|
827
|
+
|
|
828
|
+
def build_provider_list() -> list[dict]:
|
|
829
|
+
"""Build the full provider list with dynamically discovered models.
|
|
830
|
+
|
|
831
|
+
Returns the built-in SDK providers as a dict keyed by provider id.
|
|
832
|
+
Use enrich_providers() to merge into a full static list like ALL_PROVIDERS.
|
|
833
|
+
"""
|
|
834
|
+
providers = list_builtin_providers()
|
|
835
|
+
for p in providers:
|
|
836
|
+
pid = p.get("id")
|
|
837
|
+
cached = get_cached_models(pid)
|
|
838
|
+
if cached:
|
|
839
|
+
p["models"] = cached
|
|
840
|
+
p["models_count"] = len(cached)
|
|
841
|
+
return providers
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def enrich_providers(static_providers: list[dict]) -> list[dict]:
|
|
845
|
+
"""Merge cached/live model data into a static provider list.
|
|
846
|
+
|
|
847
|
+
Accepts the full ALL_PROVIDERS list (100+ providers) and enriches each
|
|
848
|
+
entry with live model data from the cache. Never removes or reorders
|
|
849
|
+
providers — only updates the 'models' and 'models_count' fields.
|
|
850
|
+
Preserves alphabetical ordering and all metadata.
|
|
851
|
+
"""
|
|
852
|
+
enriched = []
|
|
853
|
+
for p in static_providers:
|
|
854
|
+
entry = dict(p)
|
|
855
|
+
pid = entry.get("id")
|
|
856
|
+
cached = get_cached_models(pid)
|
|
857
|
+
if cached:
|
|
858
|
+
entry["models"] = cached
|
|
859
|
+
entry["models_count"] = len(cached)
|
|
860
|
+
else:
|
|
861
|
+
entry["models"] = list(entry.get("models", []))
|
|
862
|
+
enriched.append(entry)
|
|
863
|
+
return enriched
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def register_provider(cls: type[BaseProvider]):
|
|
867
|
+
_BUILTIN_PROVIDERS[cls.ID] = cls
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def register_builtins():
|
|
871
|
+
register_provider(OpenAIProvider)
|
|
872
|
+
register_provider(CustomOpenAIProvider)
|
|
873
|
+
register_provider(AnthropicProvider)
|
|
874
|
+
register_provider(GeminiProvider)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def get_provider_class(provider_id: str) -> Optional[type[BaseProvider]]:
|
|
878
|
+
return _BUILTIN_PROVIDERS.get(provider_id)
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def list_builtin_providers() -> list[dict]:
|
|
882
|
+
"""Return info dict for every registered built-in provider."""
|
|
883
|
+
out = []
|
|
884
|
+
for pid, cls in _BUILTIN_PROVIDERS.items():
|
|
885
|
+
d = cls.to_provider_dict()
|
|
886
|
+
d["builtin"] = True
|
|
887
|
+
out.append(d)
|
|
888
|
+
return sorted(out, key=lambda x: x["name"].lower())
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
# ---------------------------------------------------------------------------
|
|
892
|
+
# Provider config persistence
|
|
893
|
+
|
|
894
|
+
def get_env_api_key(provider_id: str) -> str:
|
|
895
|
+
"""Check standard environment variables for provider API keys."""
|
|
896
|
+
if not provider_id:
|
|
897
|
+
return ""
|
|
898
|
+
pid = str(provider_id).lower().replace("-", "_")
|
|
899
|
+
candidates = [
|
|
900
|
+
f"{pid.upper()}_API_KEY",
|
|
901
|
+
f"{pid.upper()}_KEY",
|
|
902
|
+
f"{pid.upper()}_TOKEN",
|
|
903
|
+
]
|
|
904
|
+
if pid == "gemini":
|
|
905
|
+
candidates.extend(["GOOGLE_API_KEY", "GEMINI_API_KEY"])
|
|
906
|
+
elif pid in ("google", "google_genai"):
|
|
907
|
+
candidates.extend(["GEMINI_API_KEY", "GOOGLE_API_KEY"])
|
|
908
|
+
elif pid == "nvidia":
|
|
909
|
+
candidates.extend(["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "NGC_API_KEY"])
|
|
910
|
+
elif pid in ("openai", "custom"):
|
|
911
|
+
candidates.extend(["OPENAI_API_KEY", "AI_API_KEY", "CAT_API_KEY"])
|
|
912
|
+
elif pid == "groq":
|
|
913
|
+
candidates.extend(["GROQ_API_KEY"])
|
|
914
|
+
elif pid == "openrouter":
|
|
915
|
+
candidates.extend(["OPENROUTER_API_KEY"])
|
|
916
|
+
elif pid == "anthropic":
|
|
917
|
+
candidates.extend(["ANTHROPIC_API_KEY"])
|
|
918
|
+
elif pid == "deepseek":
|
|
919
|
+
candidates.extend(["DEEPSEEK_API_KEY"])
|
|
920
|
+
elif pid == "mistral":
|
|
921
|
+
candidates.extend(["MISTRAL_API_KEY"])
|
|
922
|
+
elif pid == "cohere":
|
|
923
|
+
candidates.extend(["COHERE_API_KEY"])
|
|
924
|
+
elif pid == "together":
|
|
925
|
+
candidates.extend(["TOGETHER_API_KEY", "TOGETHERAI_API_KEY"])
|
|
926
|
+
elif pid == "fireworks":
|
|
927
|
+
candidates.extend(["FIREWORKS_API_KEY"])
|
|
928
|
+
|
|
929
|
+
for c in candidates:
|
|
930
|
+
val = os.environ.get(c, "").strip()
|
|
931
|
+
if val:
|
|
932
|
+
return val
|
|
933
|
+
# Generic fallbacks
|
|
934
|
+
for g in ("AI_API_KEY", "CAT_API_KEY"):
|
|
935
|
+
val = os.environ.get(g, "").strip()
|
|
936
|
+
if val:
|
|
937
|
+
return val
|
|
938
|
+
return ""
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def load_config() -> dict:
|
|
942
|
+
cfg = {}
|
|
943
|
+
if os.path.exists(CONFIG_FILE):
|
|
944
|
+
try:
|
|
945
|
+
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
946
|
+
cfg = json.load(f)
|
|
947
|
+
except Exception:
|
|
948
|
+
cfg = {}
|
|
949
|
+
if not cfg:
|
|
950
|
+
cfg = {"provider": None, "api_key": None,
|
|
951
|
+
"model": _default_model_id(), "ollama_url": "http://localhost:11434"}
|
|
952
|
+
|
|
953
|
+
# Auto-resolve API key from environment if not set
|
|
954
|
+
prov = cfg.get("provider")
|
|
955
|
+
if prov and not (cfg.get("api_key") or "").strip():
|
|
956
|
+
env_key = get_env_api_key(prov)
|
|
957
|
+
if env_key:
|
|
958
|
+
cfg["api_key"] = env_key
|
|
959
|
+
if prov == "nvidia" and cfg.get("api_style") != "openai":
|
|
960
|
+
cfg["api_style"] = "openai"
|
|
961
|
+
return cfg
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _default_model_id() -> str:
|
|
965
|
+
"""Data-driven default model: providers.json 'default_model' for the
|
|
966
|
+
default provider (openai); hardcoded value as last resort."""
|
|
967
|
+
try:
|
|
968
|
+
from ..models import manager as _mgr
|
|
969
|
+
return _mgr.default_model_for("openai", "gpt-4o")
|
|
970
|
+
except Exception:
|
|
971
|
+
return "gpt-4o"
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def save_config(config: dict):
|
|
975
|
+
if config.get("provider") == "nvidia":
|
|
976
|
+
config["api_style"] = "openai"
|
|
977
|
+
with open(CONFIG_FILE, "w") as f:
|
|
978
|
+
json.dump(config, f)
|
|
979
|
+
try:
|
|
980
|
+
from .. import config as _cctconfig
|
|
981
|
+
cfg = _cctconfig.get_config()
|
|
982
|
+
if config.get("provider"):
|
|
983
|
+
cfg.default_ai_provider = config["provider"]
|
|
984
|
+
if config.get("model"):
|
|
985
|
+
cfg.default_model = config["model"]
|
|
986
|
+
# Sync ollama endpoint both ways (dual-truth fix)
|
|
987
|
+
ollama_url = config.get("ollama_url") or config.get("base_url") if config.get("provider") == "ollama" else None
|
|
988
|
+
if ollama_url:
|
|
989
|
+
try:
|
|
990
|
+
cfg.ollama_endpoint = ollama_url.rstrip("/")
|
|
991
|
+
except Exception:
|
|
992
|
+
pass
|
|
993
|
+
elif config.get("ollama_url"):
|
|
994
|
+
try:
|
|
995
|
+
cfg.ollama_endpoint = config["ollama_url"].rstrip("/")
|
|
996
|
+
except Exception:
|
|
997
|
+
pass
|
|
998
|
+
_cctconfig.save_config(cfg)
|
|
999
|
+
except Exception:
|
|
1000
|
+
pass
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def resolve_config(config: dict = None) -> dict:
|
|
1004
|
+
"""Return config with sensible defaults filled in."""
|
|
1005
|
+
cfg = config or load_config()
|
|
1006
|
+
provider_id = cfg.get("provider")
|
|
1007
|
+
cls = get_provider_class(provider_id) if provider_id else None
|
|
1008
|
+
if cls:
|
|
1009
|
+
if not cfg.get("model"):
|
|
1010
|
+
cfg["model"] = cls.default_model()
|
|
1011
|
+
if not cfg.get("base_url") and not cfg.get("api_url"):
|
|
1012
|
+
cfg["base_url"] = cls._default_url() if hasattr(cls, "_default_url") else ""
|
|
1013
|
+
return cfg
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def get_provider(config: dict = None) -> Optional[BaseProvider]:
|
|
1017
|
+
"""Get a provider instance for the given or current config."""
|
|
1018
|
+
cfg = config or load_config()
|
|
1019
|
+
provider_id = cfg.get("provider")
|
|
1020
|
+
if not provider_id:
|
|
1021
|
+
return None
|
|
1022
|
+
cls = get_provider_class(provider_id)
|
|
1023
|
+
if not cls:
|
|
1024
|
+
return None
|
|
1025
|
+
return cls(cfg)
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
# ---------------------------------------------------------------------------
|
|
1029
|
+
# Multi-provider storage (for the provider manager UI)
|
|
1030
|
+
|
|
1031
|
+
def load_all_providers() -> list[dict]:
|
|
1032
|
+
"""Load user-saved providers from disk."""
|
|
1033
|
+
if os.path.exists(PROVIDERS_FILE):
|
|
1034
|
+
try:
|
|
1035
|
+
with open(PROVIDERS_FILE, "r") as f:
|
|
1036
|
+
return json.load(f)
|
|
1037
|
+
except Exception:
|
|
1038
|
+
pass
|
|
1039
|
+
return []
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def save_all_providers(providers: list[dict]):
|
|
1043
|
+
with open(PROVIDERS_FILE, "w") as f:
|
|
1044
|
+
json.dump(providers, f, indent=2)
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def add_provider(entry: dict):
|
|
1048
|
+
providers = load_all_providers()
|
|
1049
|
+
providers.append(entry)
|
|
1050
|
+
save_all_providers(providers)
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def delete_provider(provider_id: str):
|
|
1054
|
+
providers = load_all_providers()
|
|
1055
|
+
providers = [p for p in providers if p.get("id") != provider_id]
|
|
1056
|
+
save_all_providers(providers)
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def update_provider(provider_id: str, updates: dict):
|
|
1060
|
+
providers = load_all_providers()
|
|
1061
|
+
for p in providers:
|
|
1062
|
+
if p.get("id") == provider_id:
|
|
1063
|
+
p.update(updates)
|
|
1064
|
+
break
|
|
1065
|
+
save_all_providers(providers)
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def import_providers_json(path: str) -> tuple[int, str]:
|
|
1069
|
+
"""Import providers from a JSON file. Returns (count, message)."""
|
|
1070
|
+
try:
|
|
1071
|
+
with open(path, "r") as f:
|
|
1072
|
+
data = json.load(f)
|
|
1073
|
+
if isinstance(data, dict):
|
|
1074
|
+
data = [data]
|
|
1075
|
+
if not isinstance(data, list):
|
|
1076
|
+
return 0, "Invalid format: expected a list of providers"
|
|
1077
|
+
existing = load_all_providers()
|
|
1078
|
+
existing_ids = {p.get("id") for p in existing}
|
|
1079
|
+
added = 0
|
|
1080
|
+
for entry in data:
|
|
1081
|
+
if entry.get("id") and entry["id"] not in existing_ids:
|
|
1082
|
+
existing.append(entry)
|
|
1083
|
+
existing_ids.add(entry["id"])
|
|
1084
|
+
added += 1
|
|
1085
|
+
save_all_providers(existing)
|
|
1086
|
+
return added, f"Imported {added} provider(s)"
|
|
1087
|
+
except Exception as e:
|
|
1088
|
+
return 0, f"Import failed: {e}"
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def verify_provider_count(static_providers: list[dict]) -> int:
|
|
1092
|
+
"""Regression check: ensure provider count never decreases unexpectedly.
|
|
1093
|
+
Returns the count. Raises ValueError if count is suspiciously low (<20)."""
|
|
1094
|
+
count = len(static_providers)
|
|
1095
|
+
if count < 20:
|
|
1096
|
+
raise ValueError(
|
|
1097
|
+
f"Provider count dropped to {count} — "
|
|
1098
|
+
"this should never happen. ALL_PROVIDERS is the authoritative list."
|
|
1099
|
+
)
|
|
1100
|
+
return count
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def export_providers_json(path: str) -> tuple[bool, str]:
|
|
1104
|
+
"""Export all user providers to a JSON file."""
|
|
1105
|
+
try:
|
|
1106
|
+
providers = load_all_providers()
|
|
1107
|
+
with open(path, "w") as f:
|
|
1108
|
+
json.dump(providers, f, indent=2)
|
|
1109
|
+
return True, f"Exported {len(providers)} provider(s) to {path}"
|
|
1110
|
+
except Exception as e:
|
|
1111
|
+
return False, f"Export failed: {e}"
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def mask_key(key: str) -> str:
|
|
1115
|
+
"""Mask an API key for display."""
|
|
1116
|
+
if not key or len(key) < 8:
|
|
1117
|
+
return "****"
|
|
1118
|
+
return key[:3] + "*" * (len(key) - 6) + key[-3:]
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
# ---------------------------------------------------------------------------
|
|
1122
|
+
# Backup providers (v0.7.8 BONUS 1) — automatic failover chain.
|
|
1123
|
+
# A prioritized, unlimited list of fallback provider configs. When the
|
|
1124
|
+
# primary provider exhausts quota / times out / is rate limited or
|
|
1125
|
+
# offline, aicore.query_ai/stream_ai fall back to the next enabled
|
|
1126
|
+
# entry in priority order without the conversation noticing (session
|
|
1127
|
+
# memory/context live in the UI, not on the provider). File is kept
|
|
1128
|
+
# separate from ~/.cct_ai_config.json so save_config() can't clobber it.
|
|
1129
|
+
#
|
|
1130
|
+
# v0.7.9.5: Ollama is now a first-class backup provider option. When
|
|
1131
|
+
# configured as a backup, it provides a reliable local fallback that
|
|
1132
|
+
# works offline and has no rate limits or quota issues.
|
|
1133
|
+
|
|
1134
|
+
DEFAULT_STATUS = {
|
|
1135
|
+
"provider": "", "model": "", "api_key": "", "base_url": "",
|
|
1136
|
+
"api_style": "openai", "name": "", "enabled": True,
|
|
1137
|
+
"status": "disconnected", "last_used": 0, "priority": 0,
|
|
1138
|
+
"latency_ms": None, "fallback_models": [], "cost_tier": "free",
|
|
1139
|
+
"capabilities": []
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
# Default backup providers list - Ollama is included as a recommended
|
|
1143
|
+
# local fallback that always works when online providers fail.
|
|
1144
|
+
DEFAULT_BACKUP_PROVIDERS = [
|
|
1145
|
+
{
|
|
1146
|
+
"provider": "ollama",
|
|
1147
|
+
"model": "llama3.3",
|
|
1148
|
+
"api_key": "",
|
|
1149
|
+
"base_url": "http://localhost:11434",
|
|
1150
|
+
"api_style": "ollama",
|
|
1151
|
+
"name": "Ollama (Local)",
|
|
1152
|
+
"enabled": True,
|
|
1153
|
+
"status": "disconnected",
|
|
1154
|
+
"last_used": 0,
|
|
1155
|
+
"priority": 0
|
|
1156
|
+
},
|
|
1157
|
+
{
|
|
1158
|
+
"provider": "groq",
|
|
1159
|
+
"model": "llama-3.3-70b-versatile",
|
|
1160
|
+
"api_key": "",
|
|
1161
|
+
"base_url": "https://api.groq.com/openai/v1",
|
|
1162
|
+
"api_style": "openai",
|
|
1163
|
+
"name": "Groq (Fast)",
|
|
1164
|
+
"enabled": True,
|
|
1165
|
+
"status": "disconnected",
|
|
1166
|
+
"last_used": 0,
|
|
1167
|
+
"priority": 1
|
|
1168
|
+
},
|
|
1169
|
+
{
|
|
1170
|
+
"provider": "openrouter",
|
|
1171
|
+
"model": "openai/gpt-4o-mini",
|
|
1172
|
+
"api_key": "",
|
|
1173
|
+
"base_url": "https://openrouter.ai/api/v1",
|
|
1174
|
+
"api_style": "openai",
|
|
1175
|
+
"name": "OpenRouter (Multi)",
|
|
1176
|
+
"enabled": True,
|
|
1177
|
+
"status": "disconnected",
|
|
1178
|
+
"last_used": 0,
|
|
1179
|
+
"priority": 2
|
|
1180
|
+
}
|
|
1181
|
+
]
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def load_backup_providers() -> list[dict]:
|
|
1185
|
+
"""Load the ordered backup-provider list from disk. Never raises.
|
|
1186
|
+
|
|
1187
|
+
v0.7.9.5: If no backup providers are configured, returns the default
|
|
1188
|
+
list which includes Ollama as a local fallback. This ensures there's
|
|
1189
|
+
always a working backup chain, even for new installations.
|
|
1190
|
+
"""
|
|
1191
|
+
try:
|
|
1192
|
+
if os.path.exists(BACKUP_PROVIDERS_FILE):
|
|
1193
|
+
with open(BACKUP_PROVIDERS_FILE, "r", encoding="utf-8") as f:
|
|
1194
|
+
data = json.load(f)
|
|
1195
|
+
if isinstance(data, list) and len(data) > 0:
|
|
1196
|
+
out = []
|
|
1197
|
+
for i, entry in enumerate(data):
|
|
1198
|
+
if not isinstance(entry, dict):
|
|
1199
|
+
continue
|
|
1200
|
+
rec = dict(DEFAULT_STATUS)
|
|
1201
|
+
rec.update({k: v for k, v in entry.items() if k in DEFAULT_STATUS})
|
|
1202
|
+
rec["priority"] = i
|
|
1203
|
+
out.append(rec)
|
|
1204
|
+
return out
|
|
1205
|
+
except Exception:
|
|
1206
|
+
pass
|
|
1207
|
+
|
|
1208
|
+
# v0.7.9.5: Return default backup providers (includes Ollama)
|
|
1209
|
+
# This ensures a working failover chain even for new installations
|
|
1210
|
+
_log("No backup providers configured, using defaults (includes Ollama)")
|
|
1211
|
+
return [dict(entry) for entry in DEFAULT_BACKUP_PROVIDERS]
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def save_backup_providers(providers: list[dict]) -> bool:
|
|
1215
|
+
"""Persist the ordered backup-provider list. Returns True on success."""
|
|
1216
|
+
try:
|
|
1217
|
+
clean = []
|
|
1218
|
+
for i, entry in enumerate(providers or []):
|
|
1219
|
+
rec = {k: v for k, v in entry.items() if k in DEFAULT_STATUS}
|
|
1220
|
+
rec["priority"] = i
|
|
1221
|
+
clean.append(rec)
|
|
1222
|
+
with open(BACKUP_PROVIDERS_FILE, "w", encoding="utf-8") as f:
|
|
1223
|
+
json.dump(clean, f, indent=2)
|
|
1224
|
+
return True
|
|
1225
|
+
except Exception:
|
|
1226
|
+
return False
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
def add_backup_provider(entry: dict) -> bool:
|
|
1230
|
+
"""Add a new provider configuration to the dynamic backup pool."""
|
|
1231
|
+
if not isinstance(entry, dict):
|
|
1232
|
+
return False
|
|
1233
|
+
try:
|
|
1234
|
+
providers = load_backup_providers()
|
|
1235
|
+
prov_id = str(entry.get("provider", "")).strip().lower()
|
|
1236
|
+
model = str(entry.get("model", "")).strip()
|
|
1237
|
+
if not prov_id or not model:
|
|
1238
|
+
return False
|
|
1239
|
+
|
|
1240
|
+
# Update if existing, or append
|
|
1241
|
+
existing = next((p for p in providers if p.get("provider", "").lower() == prov_id and p.get("model") == model), None)
|
|
1242
|
+
if existing:
|
|
1243
|
+
existing.update({k: v for k, v in entry.items() if k in DEFAULT_STATUS})
|
|
1244
|
+
else:
|
|
1245
|
+
rec = dict(DEFAULT_STATUS)
|
|
1246
|
+
rec.update({k: v for k, v in entry.items() if k in DEFAULT_STATUS})
|
|
1247
|
+
rec["priority"] = len(providers)
|
|
1248
|
+
providers.append(rec)
|
|
1249
|
+
|
|
1250
|
+
return save_backup_providers(providers)
|
|
1251
|
+
except Exception:
|
|
1252
|
+
return False
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
def remove_backup_provider(identifier: Any) -> bool:
|
|
1256
|
+
"""Remove a backup provider by 0-based index or provider_id."""
|
|
1257
|
+
try:
|
|
1258
|
+
providers = load_backup_providers()
|
|
1259
|
+
if isinstance(identifier, int) and 0 <= identifier < len(providers):
|
|
1260
|
+
providers.pop(identifier)
|
|
1261
|
+
return save_backup_providers(providers)
|
|
1262
|
+
|
|
1263
|
+
target = str(identifier).strip().lower()
|
|
1264
|
+
if target.isdigit():
|
|
1265
|
+
idx = int(target)
|
|
1266
|
+
if 0 <= idx < len(providers):
|
|
1267
|
+
providers.pop(idx)
|
|
1268
|
+
return save_backup_providers(providers)
|
|
1269
|
+
|
|
1270
|
+
filtered = [p for p in providers if p.get("provider", "").lower() != target]
|
|
1271
|
+
if len(filtered) != len(providers):
|
|
1272
|
+
return save_backup_providers(filtered)
|
|
1273
|
+
return False
|
|
1274
|
+
except Exception:
|
|
1275
|
+
return False
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def reorder_backup_provider(from_index: int, to_index: int) -> bool:
|
|
1279
|
+
"""Move a backup provider from one priority position to another."""
|
|
1280
|
+
try:
|
|
1281
|
+
providers = load_backup_providers()
|
|
1282
|
+
if not (0 <= from_index < len(providers) and 0 <= to_index < len(providers)):
|
|
1283
|
+
return False
|
|
1284
|
+
item = providers.pop(from_index)
|
|
1285
|
+
providers.insert(to_index, item)
|
|
1286
|
+
return save_backup_providers(providers)
|
|
1287
|
+
except Exception:
|
|
1288
|
+
return False
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
def set_backup_provider_enabled(identifier: Any, enabled: bool) -> bool:
|
|
1292
|
+
"""Toggle the enabled status of a backup provider."""
|
|
1293
|
+
try:
|
|
1294
|
+
providers = load_backup_providers()
|
|
1295
|
+
if isinstance(identifier, int) and 0 <= identifier < len(providers):
|
|
1296
|
+
providers[identifier]["enabled"] = bool(enabled)
|
|
1297
|
+
return save_backup_providers(providers)
|
|
1298
|
+
|
|
1299
|
+
target = str(identifier).strip().lower()
|
|
1300
|
+
if target.isdigit():
|
|
1301
|
+
idx = int(target)
|
|
1302
|
+
if 0 <= idx < len(providers):
|
|
1303
|
+
providers[idx]["enabled"] = bool(enabled)
|
|
1304
|
+
return save_backup_providers(providers)
|
|
1305
|
+
|
|
1306
|
+
matched = False
|
|
1307
|
+
for p in providers:
|
|
1308
|
+
if p.get("provider", "").lower() == target:
|
|
1309
|
+
p["enabled"] = bool(enabled)
|
|
1310
|
+
matched = True
|
|
1311
|
+
if matched:
|
|
1312
|
+
return save_backup_providers(providers)
|
|
1313
|
+
return False
|
|
1314
|
+
except Exception:
|
|
1315
|
+
return False
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
def test_provider_connectivity(config: dict) -> tuple[bool, str, list[str]]:
|
|
1319
|
+
"""Test connectivity, model listing, and latency without leaking API keys."""
|
|
1320
|
+
start = time.time()
|
|
1321
|
+
ok, msg, models = connect_provider(config)
|
|
1322
|
+
lat = int((time.time() - start) * 1000)
|
|
1323
|
+
|
|
1324
|
+
# Sanitize message of any keys
|
|
1325
|
+
from ..resilience.health_monitor import sanitize_error, get_health_monitor
|
|
1326
|
+
clean_msg = sanitize_error(msg)
|
|
1327
|
+
|
|
1328
|
+
prov = config.get("provider", "")
|
|
1329
|
+
model = config.get("model", "")
|
|
1330
|
+
mon = get_health_monitor()
|
|
1331
|
+
if ok:
|
|
1332
|
+
mon.record_success(prov, model, latency_ms=lat)
|
|
1333
|
+
# Also update status in saved backup provider record
|
|
1334
|
+
try:
|
|
1335
|
+
providers = load_backup_providers()
|
|
1336
|
+
for p in providers:
|
|
1337
|
+
if p.get("provider") == prov and p.get("model") == model:
|
|
1338
|
+
p["status"] = "connected"
|
|
1339
|
+
p["latency_ms"] = lat
|
|
1340
|
+
p["last_used"] = int(time.time())
|
|
1341
|
+
save_backup_providers(providers)
|
|
1342
|
+
except Exception:
|
|
1343
|
+
pass
|
|
1344
|
+
else:
|
|
1345
|
+
mon.record_failure(prov, model, error_message=clean_msg)
|
|
1346
|
+
|
|
1347
|
+
return ok, clean_msg, models
|
|
1348
|
+
|
|
1349
|
+
|
|
1350
|
+
def backup_configs() -> list[dict]:
|
|
1351
|
+
"""The live request-config shapes for every enabled backup, in
|
|
1352
|
+
priority order — exactly what aicore feeds into _resolve_provider.
|
|
1353
|
+
|
|
1354
|
+
v0.7.9.5: Improved Ollama handling for backup failover. Ollama
|
|
1355
|
+
providers are automatically detected and configured with the
|
|
1356
|
+
correct base_url and api_style for reliable local inference.
|
|
1357
|
+
"""
|
|
1358
|
+
out = []
|
|
1359
|
+
for entry in load_backup_providers():
|
|
1360
|
+
if not entry.get("enabled"):
|
|
1361
|
+
continue
|
|
1362
|
+
prov_id = entry.get("provider", "")
|
|
1363
|
+
key = entry.get("api_key", "")
|
|
1364
|
+
# Resolve API key from environment if missing in saved backup config
|
|
1365
|
+
if not (key or "").strip() and prov_id:
|
|
1366
|
+
env_k = get_env_api_key(prov_id)
|
|
1367
|
+
if env_k:
|
|
1368
|
+
key = env_k
|
|
1369
|
+
|
|
1370
|
+
cfg = {
|
|
1371
|
+
"provider": prov_id,
|
|
1372
|
+
"model": entry.get("model", ""),
|
|
1373
|
+
"api_key": key,
|
|
1374
|
+
"api_style": entry.get("api_style", "openai"),
|
|
1375
|
+
}
|
|
1376
|
+
# v0.7.10: Always resolve base_url, even if empty in entry
|
|
1377
|
+
base_url = entry.get("base_url", "")
|
|
1378
|
+
if not base_url:
|
|
1379
|
+
# Try to get base_url from providers.json
|
|
1380
|
+
provider_id = cfg.get("provider", "")
|
|
1381
|
+
if provider_id:
|
|
1382
|
+
try:
|
|
1383
|
+
from ..models.manager import load_providers
|
|
1384
|
+
for p in load_providers():
|
|
1385
|
+
if p.get("id") == provider_id:
|
|
1386
|
+
base_url = p.get("api_endpoint", "")
|
|
1387
|
+
if base_url:
|
|
1388
|
+
break
|
|
1389
|
+
except Exception:
|
|
1390
|
+
pass
|
|
1391
|
+
# Fallback to built-in provider URLs
|
|
1392
|
+
if not base_url:
|
|
1393
|
+
_builtin_urls = {
|
|
1394
|
+
"openai": "https://api.openai.com/v1",
|
|
1395
|
+
"anthropic": "https://api.anthropic.com",
|
|
1396
|
+
"groq": "https://api.groq.com/openai/v1",
|
|
1397
|
+
"openrouter": "https://openrouter.ai/api/v1",
|
|
1398
|
+
"ollama": "http://localhost:11434",
|
|
1399
|
+
"deepseek": "https://api.deepseek.com/v1",
|
|
1400
|
+
"mistral": "https://api.mistral.ai/v1",
|
|
1401
|
+
"cohere": "https://api.cohere.com/v1",
|
|
1402
|
+
"fireworks": "https://api.fireworks.ai/inference/v1",
|
|
1403
|
+
"together": "https://api.together.xyz/v1",
|
|
1404
|
+
"nvidia": "https://integrate.api.nvidia.com/v1",
|
|
1405
|
+
}
|
|
1406
|
+
base_url = _builtin_urls.get(provider_id, "")
|
|
1407
|
+
if base_url:
|
|
1408
|
+
cfg["base_url"] = base_url
|
|
1409
|
+
|
|
1410
|
+
# v0.7.9.5: Auto-configure Ollama settings for backup providers
|
|
1411
|
+
if cfg.get("provider") == "ollama":
|
|
1412
|
+
cfg["api_style"] = "ollama"
|
|
1413
|
+
cfg["needs_key"] = False
|
|
1414
|
+
# Ensure Ollama base_url is always set
|
|
1415
|
+
if not cfg.get("base_url"):
|
|
1416
|
+
cfg["base_url"] = "http://localhost:11434"
|
|
1417
|
+
|
|
1418
|
+
if cfg.get("provider") and cfg.get("model"):
|
|
1419
|
+
out.append((cfg, entry))
|
|
1420
|
+
# Auto-heal: if no enabled backups but there are disabled ones with valid keys, enable the most recent one
|
|
1421
|
+
if not out:
|
|
1422
|
+
try:
|
|
1423
|
+
all_entries = load_backup_providers()
|
|
1424
|
+
disabled_with_key = [
|
|
1425
|
+
e for e in all_entries
|
|
1426
|
+
if not e.get("enabled") and ((e.get("api_key") or "").strip() or get_env_api_key(e.get("provider", ""))) and e.get("provider") and e.get("model")
|
|
1427
|
+
]
|
|
1428
|
+
if disabled_with_key:
|
|
1429
|
+
# Enable the one with most recent last_used or first
|
|
1430
|
+
disabled_with_key.sort(key=lambda x: x.get("last_used", 0), reverse=True)
|
|
1431
|
+
to_enable = disabled_with_key[0]
|
|
1432
|
+
# Persist enabled state
|
|
1433
|
+
for rec in all_entries:
|
|
1434
|
+
if rec.get("provider") == to_enable.get("provider") and rec.get("model") == to_enable.get("model"):
|
|
1435
|
+
rec["enabled"] = True
|
|
1436
|
+
rec["status"] = "disconnected"
|
|
1437
|
+
break
|
|
1438
|
+
save_backup_providers(all_entries)
|
|
1439
|
+
_log(f"Auto-enabled disabled backup {to_enable.get('provider')}/{to_enable.get('model')} — was disabled but had valid key")
|
|
1440
|
+
# Re-build out with newly enabled
|
|
1441
|
+
return backup_configs()
|
|
1442
|
+
except Exception:
|
|
1443
|
+
pass
|
|
1444
|
+
return out
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
def mark_backup_used(entry: dict):
|
|
1448
|
+
"""Record 'last used' + 'connected' on a backup entry and persist it."""
|
|
1449
|
+
if not isinstance(entry, dict):
|
|
1450
|
+
return
|
|
1451
|
+
try:
|
|
1452
|
+
import time as _t
|
|
1453
|
+
providers = load_backup_providers()
|
|
1454
|
+
changed = False
|
|
1455
|
+
for rec in providers:
|
|
1456
|
+
if (rec.get("provider") == entry.get("provider")
|
|
1457
|
+
and rec.get("model") == entry.get("model")):
|
|
1458
|
+
rec["last_used"] = int(_t.time())
|
|
1459
|
+
rec["status"] = "connected"
|
|
1460
|
+
changed = True
|
|
1461
|
+
if changed:
|
|
1462
|
+
save_backup_providers(providers)
|
|
1463
|
+
except Exception:
|
|
1464
|
+
pass
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
# ---------------------------------------------------------------------------
|
|
1468
|
+
# Fetch models
|
|
1469
|
+
|
|
1470
|
+
def fetch_models_for(config: dict) -> list[str]:
|
|
1471
|
+
"""Fetch live models for a given config. Uses cache if available.
|
|
1472
|
+
Falls back to generic HTTP fetch for unknown providers.
|
|
1473
|
+
Never raises."""
|
|
1474
|
+
provider_id = config.get("provider")
|
|
1475
|
+
if not provider_id:
|
|
1476
|
+
return []
|
|
1477
|
+
cached = get_cached_models(provider_id)
|
|
1478
|
+
if cached is not None:
|
|
1479
|
+
return cached
|
|
1480
|
+
cls = get_provider_class(provider_id)
|
|
1481
|
+
if cls:
|
|
1482
|
+
try:
|
|
1483
|
+
inst = cls(config)
|
|
1484
|
+
models = inst.fetch_models()
|
|
1485
|
+
if models:
|
|
1486
|
+
set_cached_models(provider_id, models)
|
|
1487
|
+
return models or []
|
|
1488
|
+
except Exception:
|
|
1489
|
+
pass
|
|
1490
|
+
# Generic fallback for providers without a registered class
|
|
1491
|
+
api_style = config.get("api_style", "openai")
|
|
1492
|
+
_log(f"fetch_models_for [{provider_id}]: no class found, "
|
|
1493
|
+
f"trying generic {api_style} fetch")
|
|
1494
|
+
if api_style == "openai":
|
|
1495
|
+
models = _generic_fetch_models_openai(config)
|
|
1496
|
+
elif api_style == "ollama":
|
|
1497
|
+
models = _generic_fetch_models_ollama(config)
|
|
1498
|
+
else:
|
|
1499
|
+
models = []
|
|
1500
|
+
if models:
|
|
1501
|
+
set_cached_models(provider_id, models)
|
|
1502
|
+
return models or []
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
def fetch_models_for_provider_id(provider_id: str,
|
|
1506
|
+
api_key: str = "",
|
|
1507
|
+
base_url: str = "") -> list[str]:
|
|
1508
|
+
"""Helper to build config and fetch models."""
|
|
1509
|
+
config = {"provider": provider_id, "api_key": api_key, "base_url": base_url}
|
|
1510
|
+
return fetch_models_for(config)
|
|
1511
|
+
|
|
1512
|
+
|
|
1513
|
+
def connect_provider(config: dict) -> tuple[bool, str, list[str]]:
|
|
1514
|
+
"""Test connection for a provider config. Caches models on success.
|
|
1515
|
+
Falls back to generic HTTP fetch for unknown providers."""
|
|
1516
|
+
provider_id = config.get("provider")
|
|
1517
|
+
cls = get_provider_class(provider_id) if provider_id else None
|
|
1518
|
+
if cls:
|
|
1519
|
+
try:
|
|
1520
|
+
inst = cls(config)
|
|
1521
|
+
ok, msg, models = inst.connect()
|
|
1522
|
+
if ok and models:
|
|
1523
|
+
set_cached_models(provider_id, models)
|
|
1524
|
+
return ok, msg, models
|
|
1525
|
+
except Exception as e:
|
|
1526
|
+
return False, f"Error: {e}", []
|
|
1527
|
+
# Generic fallback: use model fetch as connectivity test
|
|
1528
|
+
_log(f"connect_provider [{provider_id}]: no class found, "
|
|
1529
|
+
f"trying generic fetch as connectivity test")
|
|
1530
|
+
models = fetch_models_for(config)
|
|
1531
|
+
if models:
|
|
1532
|
+
return True, f"Connected \u2014 {len(models)} model(s) visible", models
|
|
1533
|
+
return False, f"Unknown provider: {provider_id}", []
|
|
1534
|
+
|
|
1535
|
+
|
|
1536
|
+
# ---------------------------------------------------------------------------
|
|
1537
|
+
# Chat / Stream helpers with lifecycle-aware model validation
|
|
1538
|
+
|
|
1539
|
+
def chat(prompt: str, system_prompt: str = "",
|
|
1540
|
+
history: list = None, config: dict = None) -> str:
|
|
1541
|
+
"""Send a chat message with lifecycle-aware model validation.
|
|
1542
|
+
|
|
1543
|
+
Validates the model before sending, auto-resolves aliases,
|
|
1544
|
+
and recovers from deprecation errors automatically.
|
|
1545
|
+
"""
|
|
1546
|
+
try:
|
|
1547
|
+
from .lifecycle import validate_and_chat
|
|
1548
|
+
result = validate_and_chat(
|
|
1549
|
+
config or {}, prompt, system_prompt=system_prompt, history=history)
|
|
1550
|
+
return _fix_spacing(result)
|
|
1551
|
+
except ImportError:
|
|
1552
|
+
pass
|
|
1553
|
+
inst = get_provider(config)
|
|
1554
|
+
if not inst:
|
|
1555
|
+
return "AI not configured. Run setup first."
|
|
1556
|
+
return _fix_spacing(inst.chat(prompt, system_prompt=system_prompt, history=history))
|
|
1557
|
+
|
|
1558
|
+
|
|
1559
|
+
def stream(prompt: str, system_prompt: str = "",
|
|
1560
|
+
history: list = None, config: dict = None):
|
|
1561
|
+
"""Stream a chat response with lifecycle-aware model validation."""
|
|
1562
|
+
try:
|
|
1563
|
+
from .lifecycle import validate_and_stream
|
|
1564
|
+
yield from validate_and_stream(
|
|
1565
|
+
config or {}, prompt, system_prompt=system_prompt, history=history)
|
|
1566
|
+
return
|
|
1567
|
+
except ImportError:
|
|
1568
|
+
pass
|
|
1569
|
+
inst = get_provider(config)
|
|
1570
|
+
if not inst:
|
|
1571
|
+
yield "AI not configured. Run setup first."
|
|
1572
|
+
return
|
|
1573
|
+
yield from inst.stream(prompt, system_prompt=system_prompt, history=history)
|
|
1574
|
+
|
|
1575
|
+
|
|
1576
|
+
# ── Lifecycle convenience re-exports ──────────────────────────────────────
|
|
1577
|
+
|
|
1578
|
+
def ensure_fresh_models(provider_id: str, config: dict = None,
|
|
1579
|
+
force: bool = False) -> list[str]:
|
|
1580
|
+
"""Ensure models are fresh for a provider — refresh if stale."""
|
|
1581
|
+
try:
|
|
1582
|
+
from .lifecycle import ensure_fresh_models as _efm
|
|
1583
|
+
return _efm(provider_id, config, force=force)
|
|
1584
|
+
except ImportError:
|
|
1585
|
+
_log(f"ensure_fresh_models [{provider_id}]: lifecycle not available")
|
|
1586
|
+
return []
|
|
1587
|
+
|
|
1588
|
+
|
|
1589
|
+
def check_provider_health(provider_id: str,
|
|
1590
|
+
config: dict = None) -> dict:
|
|
1591
|
+
"""Get health status for a provider."""
|
|
1592
|
+
try:
|
|
1593
|
+
from .lifecycle import check_health
|
|
1594
|
+
h = check_health(provider_id, config)
|
|
1595
|
+
return {
|
|
1596
|
+
"provider_id": h.provider_id,
|
|
1597
|
+
"online": h.online,
|
|
1598
|
+
"api_reachable": h.api_reachable,
|
|
1599
|
+
"authenticated": h.authenticated,
|
|
1600
|
+
"model_list_available": h.model_list_available,
|
|
1601
|
+
"chat_supported": h.chat_supported,
|
|
1602
|
+
"latency_ms": h.latency_ms,
|
|
1603
|
+
"last_checked": h.last_checked,
|
|
1604
|
+
"error": h.error,
|
|
1605
|
+
}
|
|
1606
|
+
except ImportError:
|
|
1607
|
+
return {"provider_id": provider_id, "online": False,
|
|
1608
|
+
"error": "lifecycle module not available"}
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
def validate_and_resolve_model(provider_id: str, model: str) -> dict:
|
|
1612
|
+
"""Validate a model and resolve any aliases.
|
|
1613
|
+
|
|
1614
|
+
Returns {"valid": bool, "model": resolved_model, "message": str}.
|
|
1615
|
+
"""
|
|
1616
|
+
try:
|
|
1617
|
+
from .lifecycle import pre_request_check
|
|
1618
|
+
result = pre_request_check(provider_id, model)
|
|
1619
|
+
return {
|
|
1620
|
+
"valid": result.valid,
|
|
1621
|
+
"model": result.replacement or result.model,
|
|
1622
|
+
"message": result.message,
|
|
1623
|
+
}
|
|
1624
|
+
except ImportError:
|
|
1625
|
+
return {"valid": True, "model": model, "message": ""}
|
|
1626
|
+
|
|
1627
|
+
|
|
1628
|
+
# ── Dynamic model management (v0.7.4+) ────────────────────────────────────
|
|
1629
|
+
# These delegate to models/manager.py, the data-driven core: live API
|
|
1630
|
+
# fetch -> disk cache -> built-in fallback, with source tracking.
|
|
1631
|
+
|
|
1632
|
+
def get_models_with_source(provider_id: str, config: dict = None,
|
|
1633
|
+
force: bool = False) -> tuple[list[str], str]:
|
|
1634
|
+
"""Resolve models for a provider. Returns (models, source) where
|
|
1635
|
+
source is 'live' | 'cached' | 'default'. Merges the saved config
|
|
1636
|
+
(api_key/base_url) so live fetches actually authenticate."""
|
|
1637
|
+
try:
|
|
1638
|
+
from ..models import manager as _mgr
|
|
1639
|
+
cfg = dict(config or {})
|
|
1640
|
+
saved = load_config()
|
|
1641
|
+
if saved.get("provider") == provider_id:
|
|
1642
|
+
cfg.setdefault("api_key", saved.get("api_key", ""))
|
|
1643
|
+
cfg.setdefault("base_url", saved.get("base_url") or saved.get("api_url", ""))
|
|
1644
|
+
if not cfg.get("base_url") and not cfg.get("api_url"):
|
|
1645
|
+
info = _mgr.get_provider(provider_id) or {}
|
|
1646
|
+
cfg["base_url"] = info.get("api_endpoint", "")
|
|
1647
|
+
return _mgr.get_models(provider_id, cfg, force_refresh=force)
|
|
1648
|
+
except Exception as e:
|
|
1649
|
+
_log(f"get_models_with_source [{provider_id}]: {e}", exc_info=True)
|
|
1650
|
+
return [], "default"
|
|
1651
|
+
|
|
1652
|
+
|
|
1653
|
+
def refresh_provider_models(provider_id: str, config: dict = None
|
|
1654
|
+
) -> tuple[list[str], str]:
|
|
1655
|
+
"""Force-refresh the model list for a provider from its official API."""
|
|
1656
|
+
return get_models_with_source(provider_id, config, force=True)
|
|
1657
|
+
|
|
1658
|
+
|
|
1659
|
+
def ensure_provider_models_fresh(provider_id: str, config: dict = None
|
|
1660
|
+
) -> tuple[list[str], str]:
|
|
1661
|
+
"""Refresh if the cache is stale; otherwise reuse the cached list.
|
|
1662
|
+
Called automatically when the model picker opens."""
|
|
1663
|
+
try:
|
|
1664
|
+
from ..models import manager as _mgr
|
|
1665
|
+
cfg = dict(config or {})
|
|
1666
|
+
saved = load_config()
|
|
1667
|
+
if saved.get("provider") == provider_id:
|
|
1668
|
+
cfg.setdefault("api_key", saved.get("api_key", ""))
|
|
1669
|
+
cfg.setdefault("base_url", saved.get("base_url") or saved.get("api_url", ""))
|
|
1670
|
+
if not cfg.get("base_url") and not cfg.get("api_url"):
|
|
1671
|
+
info = _mgr.get_provider(provider_id) or {}
|
|
1672
|
+
cfg["base_url"] = info.get("api_endpoint", "")
|
|
1673
|
+
return _mgr.ensure_fresh(provider_id, cfg)
|
|
1674
|
+
except Exception as e:
|
|
1675
|
+
_log(f"ensure_provider_models_fresh [{provider_id}]: {e}", exc_info=True)
|
|
1676
|
+
return [], "default"
|
|
1677
|
+
|
|
1678
|
+
|
|
1679
|
+
def get_model_source(provider_id: str) -> str:
|
|
1680
|
+
"""'live' | 'cached' | 'default' for the provider's last resolution."""
|
|
1681
|
+
try:
|
|
1682
|
+
from ..models import manager as _mgr
|
|
1683
|
+
return _mgr.get_model_source(provider_id)
|
|
1684
|
+
except Exception:
|
|
1685
|
+
return "default"
|
|
1686
|
+
|
|
1687
|
+
|
|
1688
|
+
def get_last_model_refresh(provider_id: str) -> str:
|
|
1689
|
+
"""ISO timestamp of the last successful model fetch ('' if none)."""
|
|
1690
|
+
try:
|
|
1691
|
+
from ..models import manager as _mgr
|
|
1692
|
+
return _mgr.get_last_refresh(provider_id)
|
|
1693
|
+
except Exception:
|
|
1694
|
+
return ""
|
|
1695
|
+
|
|
1696
|
+
|
|
1697
|
+
def get_provider_status(provider_id: str, model_id: str = "") -> dict:
|
|
1698
|
+
"""Full status dict for /status — provider, model source, last refresh,
|
|
1699
|
+
capability flags, model count. Never raises."""
|
|
1700
|
+
try:
|
|
1701
|
+
from ..models import manager as _mgr
|
|
1702
|
+
return _mgr.get_status(provider_id, model_id)
|
|
1703
|
+
except Exception:
|
|
1704
|
+
return {"provider_id": provider_id, "model_source": "default"}
|
|
1705
|
+
|
|
1706
|
+
|
|
1707
|
+
def search_provider_models(provider_id: str, query: str,
|
|
1708
|
+
filter_id: str = "all") -> list[dict]:
|
|
1709
|
+
"""Search models by name/family/capability/provider, with a category
|
|
1710
|
+
filter (all/free/paid/latest/reasoning/coding/vision/fast/
|
|
1711
|
+
long_context/image/speech/embedding/experimental)."""
|
|
1712
|
+
try:
|
|
1713
|
+
from ..models import manager as _mgr
|
|
1714
|
+
return _mgr.search(provider_id, query, filter_id)
|
|
1715
|
+
except Exception:
|
|
1716
|
+
return []
|
|
1717
|
+
|
|
1718
|
+
|
|
1719
|
+
def provider_capability_badges(provider_id: str) -> list[str]:
|
|
1720
|
+
"""Capability badges for a provider row ([FREE] [PAID] [VISION] ...)."""
|
|
1721
|
+
try:
|
|
1722
|
+
from ..models import manager as _mgr
|
|
1723
|
+
return _mgr.provider_badges(provider_id)
|
|
1724
|
+
except Exception:
|
|
1725
|
+
return []
|
|
1726
|
+
|
|
1727
|
+
|
|
1728
|
+
# ── Auto-Update System ─────────────────────────────────────────────────────
|
|
1729
|
+
|
|
1730
|
+
def start_provider_auto_updates():
|
|
1731
|
+
"""Start the background auto-update system for providers.
|
|
1732
|
+
|
|
1733
|
+
This will periodically check for new models from provider APIs
|
|
1734
|
+
and update the local cache. Call this on app startup.
|
|
1735
|
+
"""
|
|
1736
|
+
if _HAS_AUTO_UPDATE:
|
|
1737
|
+
start_auto_updates()
|
|
1738
|
+
_log("Started provider auto-update system")
|
|
1739
|
+
else:
|
|
1740
|
+
_log("Auto-update system not available")
|
|
1741
|
+
|
|
1742
|
+
|
|
1743
|
+
def stop_provider_auto_updates():
|
|
1744
|
+
"""Stop the background auto-update system."""
|
|
1745
|
+
if _HAS_AUTO_UPDATE:
|
|
1746
|
+
stop_auto_updates()
|
|
1747
|
+
_log("Stopped provider auto-update system")
|
|
1748
|
+
|
|
1749
|
+
|
|
1750
|
+
def force_provider_update() -> bool:
|
|
1751
|
+
"""Force an immediate update of all providers.
|
|
1752
|
+
|
|
1753
|
+
Returns:
|
|
1754
|
+
True if updates were applied, False otherwise
|
|
1755
|
+
"""
|
|
1756
|
+
if _HAS_AUTO_UPDATE:
|
|
1757
|
+
return force_update()
|
|
1758
|
+
return False
|
|
1759
|
+
|
|
1760
|
+
|
|
1761
|
+
def get_auto_updated_providers() -> Dict:
|
|
1762
|
+
"""Get the latest provider data from auto-update system.
|
|
1763
|
+
|
|
1764
|
+
Returns:
|
|
1765
|
+
Dictionary with providers data
|
|
1766
|
+
"""
|
|
1767
|
+
if _HAS_AUTO_UPDATE:
|
|
1768
|
+
return get_latest_providers()
|
|
1769
|
+
return {"version": 1, "providers": []}
|
|
1770
|
+
|
|
1771
|
+
|
|
1772
|
+
def add_custom_provider_to_config(provider: Dict) -> bool:
|
|
1773
|
+
"""Add a custom provider to the local configuration.
|
|
1774
|
+
|
|
1775
|
+
Args:
|
|
1776
|
+
provider: Provider configuration dictionary
|
|
1777
|
+
|
|
1778
|
+
Returns:
|
|
1779
|
+
True if added successfully, False otherwise
|
|
1780
|
+
"""
|
|
1781
|
+
if _HAS_AUTO_UPDATE:
|
|
1782
|
+
return add_custom_provider(provider)
|
|
1783
|
+
return False
|
|
1784
|
+
|
|
1785
|
+
|
|
1786
|
+
def remove_custom_provider_from_config(provider_id: str) -> bool:
|
|
1787
|
+
"""Remove a custom provider from the local configuration.
|
|
1788
|
+
|
|
1789
|
+
Args:
|
|
1790
|
+
provider_id: ID of the provider to remove
|
|
1791
|
+
|
|
1792
|
+
Returns:
|
|
1793
|
+
True if removed successfully, False otherwise
|
|
1794
|
+
"""
|
|
1795
|
+
if _HAS_AUTO_UPDATE:
|
|
1796
|
+
return remove_custom_provider(provider_id)
|
|
1797
|
+
return False
|
|
1798
|
+
|
|
1799
|
+
|
|
1800
|
+
def discover_provider_models_auto(provider_id: str, base_url: str,
|
|
1801
|
+
api_style: str = "openai") -> List[str]:
|
|
1802
|
+
"""Discover available models from a provider's API.
|
|
1803
|
+
|
|
1804
|
+
Args:
|
|
1805
|
+
provider_id: Provider identifier
|
|
1806
|
+
base_url: API base URL
|
|
1807
|
+
api_style: API style (openai, anthropic, gemini, ollama)
|
|
1808
|
+
|
|
1809
|
+
Returns:
|
|
1810
|
+
List of available model names
|
|
1811
|
+
"""
|
|
1812
|
+
try:
|
|
1813
|
+
from .discovery_manager import get_discovery_manager
|
|
1814
|
+
dm = get_discovery_manager()
|
|
1815
|
+
discov_list = dm.discover_provider(provider_id, config={"base_url": base_url, "api_style": api_style})
|
|
1816
|
+
if discov_list:
|
|
1817
|
+
return [m.model_id for m in discov_list]
|
|
1818
|
+
except Exception:
|
|
1819
|
+
pass
|
|
1820
|
+
|
|
1821
|
+
if _HAS_AUTO_UPDATE:
|
|
1822
|
+
return auto_discover_models(provider_id, base_url, api_style)
|
|
1823
|
+
return []
|
|
1824
|
+
|
|
1825
|
+
|
|
1826
|
+
# Initialize on import
|
|
1827
|
+
register_builtins()
|