File size: 8,665 Bytes
a250316
6369972
 
1bfe7f5
8628c58
6369972
 
 
1bfe7f5
6369972
 
d7b6667
6369972
 
 
 
8628c58
6369972
a250316
 
 
 
 
8628c58
6369972
5d19961
 
 
6369972
5d19961
 
6369972
 
 
 
 
 
 
 
 
a250316
6369972
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1bfe7f5
 
 
 
 
 
 
8628c58
 
 
 
 
 
 
 
1bfe7f5
8628c58
 
 
 
 
 
 
1bfe7f5
 
8628c58
1bfe7f5
 
 
 
 
 
 
 
 
8628c58
1bfe7f5
 
 
8628c58
1bfe7f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8628c58
 
 
 
 
 
 
1bfe7f5
 
 
8628c58
 
 
 
 
 
 
 
 
 
 
 
 
 
1bfe7f5
 
 
 
 
 
 
 
 
8628c58
 
1bfe7f5
8628c58
 
6369972
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a250316
6369972
 
 
 
 
 
 
 
 
 
 
 
a250316
 
 
 
 
 
 
 
 
 
 
 
 
6369972
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
import logging
import os
import json
from enum import Enum
from dataclasses import dataclass
from dotenv import dotenv_values
from typing import Optional, Any, Dict
from llama_index.core.llms.llm import LLM
from llama_index.llms.mistralai import MistralAI
from llama_index.llms.ollama import Ollama
from llama_index.llms.openai_like import OpenAILike
from llama_index.llms.openai import OpenAI
from llama_index.llms.together import TogetherLLM
from llama_index.llms.groq import Groq
from llama_index.llms.lmstudio import LMStudio
from llama_index.llms.openrouter import OpenRouter
from src.llm_util.ollama_info import OllamaInfo

# You can disable this if you don't want to send app info to OpenRouter.
SEND_APP_INFO_TO_OPENROUTER = True

logger = logging.getLogger(__name__)

__all__ = ["get_llm", "LLMInfo"]

# Load .env values and merge with system environment variables.
# This one-liner makes sure any secret injected by Hugging Face, like OPENROUTER_API_KEY
# overrides what’s in your .env file.
_dotenv_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".env"))
_dotenv_dict = {**dotenv_values(dotenv_path=_dotenv_path), **os.environ}

_config_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "llm_config.json"))


def load_config(config_path: str) -> Dict[str, Any]:
    """Loads the configuration from a JSON file."""
    try:
        with open(config_path, "r") as f:
            return json.load(f)
    except FileNotFoundError:
        logger.error(f"Warning: llm_config.json not found at {config_path}. Using default settings.")
        return {}
    except json.JSONDecodeError as e:
        raise ValueError(f"Error decoding JSON from {config_path}: {e}")


_llm_configs = load_config(_config_path)


def substitute_env_vars(config: Dict[str, Any], env_vars: Dict[str, str]) -> Dict[str, Any]:
    """Recursively substitutes environment variables in the configuration."""

    def replace_value(value: Any) -> Any:
        if isinstance(value, str) and value.startswith("${") and value.endswith("}"):
            var_name = value[2:-1]  # Extract variable name
            if var_name in env_vars:
                return env_vars[var_name]
            else:
                print(f"Warning: Environment variable '{var_name}' not found.")
                return value  # Or raise an error if you prefer strict enforcement
        return value

    def process_item(item):
        if isinstance(item, dict):
            return {k: process_item(v) for k, v in item.items()}
        elif isinstance(item, list):
            return [process_item(i) for i in item]
        else:
            return replace_value(item)

    return process_item(config)

class OllamaStatus(str, Enum):
    no_ollama_models = 'no ollama models in the llm_config.json file'
    ollama_not_running = 'ollama is NOT running'
    mixed = 'Mixed. Some ollama models are running, but some are NOT running.'
    ollama_running = 'Ollama is running'


@dataclass
class LLMConfigItem:
    id: str
    label: str

@dataclass
class LLMInfo:
    llm_config_items: list[LLMConfigItem]
    ollama_status: OllamaStatus
    error_message_list: list[str]

    @classmethod
    def obtain_info(cls) -> 'LLMInfo':
        """
        Returns a list of available LLM names.
        """

        # Probe each Ollama service endpoint just once.
        error_message_list = []
        ollama_info_per_host = {}
        count_running = 0
        count_not_running = 0
        for config_id, config in _llm_configs.items():
            if config.get("class") != "Ollama":
                continue
            arguments = config.get("arguments", {})
            model = arguments.get("model", None)
            base_url = arguments.get("base_url", None)

            if base_url in ollama_info_per_host:
                # Already got info for this host. No need to get it again.
                continue

            ollama_info = OllamaInfo.obtain_info(base_url=base_url)
            ollama_info_per_host[base_url] = ollama_info

            running_on = "localhost" if base_url is None else base_url

            if ollama_info.is_running:
                count_running += 1
            else:
                count_not_running += 1

            if ollama_info.is_running == False:
                print(f"Ollama is not running on {running_on}. Please start the Ollama service, in order to use the models via Ollama.")
            elif ollama_info.error_message:
                print(f"Error message: {ollama_info.error_message}")
                error_message_list.append(ollama_info.error_message)

        # Get info about the each LLM config item that is using Ollama.
        llm_config_items = []
        for config_id, config in _llm_configs.items():
            if config.get("class") != "Ollama":
                item = LLMConfigItem(id=config_id, label=config_id)
                llm_config_items.append(item)
                continue
            arguments = config.get("arguments", {})
            model = arguments.get("model", None)
            base_url = arguments.get("base_url", None)

            ollama_info = ollama_info_per_host[base_url]

            is_model_available = ollama_info.is_model_available(model)
            if is_model_available:
                label = config_id
            else:
                label = f"{config_id} ❌ unavailable"
            
            if ollama_info.is_running and not is_model_available:
                error_message = f"Problem with config `\"{config_id}\"`: The model `\"{model}\"` is not available in Ollama. Compare model names in `llm_config.json` with the names available in Ollama."
                error_message_list.append(error_message)
            
            item = LLMConfigItem(id=config_id, label=label)
            llm_config_items.append(item)

        if count_not_running == 0 and count_running > 0:
            ollama_status = OllamaStatus.ollama_running
        elif count_not_running > 0 and count_running == 0:
            ollama_status = OllamaStatus.ollama_not_running
        elif count_not_running > 0 and count_running > 0:
            ollama_status = OllamaStatus.mixed
        else:
            ollama_status = OllamaStatus.no_ollama_models

        return LLMInfo(
            llm_config_items=llm_config_items, 
            ollama_status=ollama_status,
            error_message_list=error_message_list,
        )

def get_llm(llm_name: Optional[str] = None, **kwargs: Any) -> LLM:
    """
    Returns an LLM instance based on the config.json file or a fallback default.

    :param llm_name: The name/key of the LLM to instantiate.
                     If None, falls back to DEFAULT_LLM in .env (or 'ollama-llama3.1').
    :param kwargs: Additional keyword arguments to override default model parameters.
    :return: An instance of a LlamaIndex LLM class.
    """
    if not llm_name:
        llm_name = _dotenv_dict.get("DEFAULT_LLM", "ollama-llama3.1")

    if llm_name not in _llm_configs:
        # If llm_name doesn't exits in _llm_configs, then we go through default settings
        logger.error(f"LLM '{llm_name}' not found in config.json. Falling back to hardcoded defaults.")
        raise ValueError(f"Unsupported LLM name: {llm_name}")

    config = _llm_configs[llm_name]
    class_name = config.get("class")
    arguments = config.get("arguments", {})

    # Substitute environment variables
    arguments = substitute_env_vars(arguments, _dotenv_dict)

    # Override with any kwargs passed to get_llm()
    arguments.update(kwargs)

    if class_name == "OpenRouter" and SEND_APP_INFO_TO_OPENROUTER:
        # https://openrouter.ai/rankings
        # https://openrouter.ai/docs/api-reference/overview#headers
        arguments_extra = {
            "additional_kwargs": {
                "extra_headers": {
                    "HTTP-Referer": "https://github.com/neoneye/PlanExe",
                    "X-Title": "PlanExe"
                }
            }
        }
        arguments.update(arguments_extra)

    # Dynamically instantiate the class
    try:
        llm_class = globals()[class_name]  # Get class from global scope
        return llm_class(**arguments)
    except KeyError:
        raise ValueError(f"Invalid LLM class name in config.json: {class_name}")
    except TypeError as e:
        raise ValueError(f"Error instantiating {class_name} with arguments: {e}")

if __name__ == '__main__':
    try:
        llm = get_llm(llm_name="ollama-llama3.1")
        print(f"Successfully loaded LLM: {llm.__class__.__name__}")
        print(llm.complete("Hello, how are you?"))
    except ValueError as e:
        print(f"Error: {e}")