Skip to content

Commit 1f89f54

Browse files
Add support for self-hosted models (#66)
* Add support for self-hosted models * Update README.md for connectors * Fixes by comments * Small fixes * Update version
1 parent 5c8bba3 commit 1f89f54

7 files changed

Lines changed: 332 additions & 239 deletions

File tree

examples/connector_creator_usage_example.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,8 @@ class Joke(BaseModel):
243243
# model_url_and_name = os.getenv("DEEPSEEK_R1_URL")
244244
# model_url_and_name = os.getenv("GPT4_URL")
245245
# model_url_and_name = os.getenv("OPENAI_URL")
246-
model_url_and_name = os.getenv("OLLAMA_URL")
246+
# model_url_and_name = os.getenv("OLLAMA_URL")
247+
model_url_and_name = os.getenv("SELF_HOSTED_LLM")
247248

248249
# Uncomment the example you want to run
249250
basic_call_example(model_url_and_name)

protollm/connectors/README.md

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,33 @@ corresponding system prompt is applied. Lists of models that have problems are k
1313
It is also important to note that additional certifications are required to use Gigachat models. Instructions on how to
1414
install them can be found [here](https://developers.sber.ru/docs/ru/gigachat/certificates).
1515

16+
## Supported providers/LLM hosting services:
17+
1. https://api.vsepgt.ru/v1
18+
- VSE_GPT_KEY env variable
19+
- example of an argument for a function: `https://api.vsegpt.ru/v1;openai/gpt-4o-mini`
20+
2. https://api.openai.com/v1
21+
- OPENAI_KEY env variable
22+
- example of an argument for a function: `https://api.openai.com/v1;gpt-4o-mini`
23+
3. https://gigachat.devices.sberbank.ru/api/v1
24+
- AUTHORIZATION_KEY env variable, which can be obtained from your personal account
25+
- example of an argument for a function: `https://gigachat.devices.sberbank.ru/api/v1/chat/completions;GigaChat-Pro`
26+
4. Ollama (no API key required)
27+
- example of an argument for a function: `ollama;http://localhost:11434;llama3.2`
28+
5. Self-hosted LLM (under FastAPI, no API key required)
29+
- example of an argument for a function: `self_hosted;http://99.99.99.99:9999;example_model`
30+
31+
Before use, make sure that your config file has the necessary API key or set it in the environment yourself.
32+
1633
## Examples of usage
1734

1835
To create a connector it is necessary to pass to the function the URL of the corresponding service combined with the
1936
model name with a semicolon (;), for example: `https://api.vsegpt.ru/v1;openai/gpt-4o-mini`
2037

2138
It is also possible to pass additional parameters for the model. Available parameters:
2239
- `temperature`
23-
- `top_p`
40+
- `top_p` (not available for self-hosted models)
2441
- `max_tokens`
2542

26-
Before use, make sure that your config file has the necessary API key (`VSE_GPT_KEY` by default or `OPENAI_KEY`), or in
27-
the case of Gigachat models, an authorisation key (`AUTHORIZATION_KEY`), which can be obtained from your personal
28-
account.
29-
3043
Example of how to use the function:
3144
```codeblock
3245
from protollm.connectors.connector_creator import create_llm_connector
@@ -35,11 +48,10 @@ model = create_llm_connector("https://api.vsegpt.ru/v1;openai/gpt-4o-mini", temp
3548
res = model.invoke("Tell me a joke")
3649
print(res.content)
3750
```
38-
The rest of the examples are located in the `examples/connector_creator_usage_examples.py` module of the repository.
51+
You can find the rest of the examples [here](https://github.com/ITMO-NSS-team/ProtoLLM/tree/main/examples/connector_creator_usage_examples.py)
3952

4053
## New connectors
4154

42-
For now connectors are available for services supporting the OpenAI API format, as well as for Gigachat family models.
4355
If you want to add a new connector, you need to implement a class based on the BaseChatModel class with all the
4456
necessary methods. Instructions for implementation available
4557
[here](https://python.langchain.com/docs/how_to/custom_chat_model/).

protollm/connectors/connector_creator.py

Lines changed: 19 additions & 172 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
1-
import json
21
import os
3-
from typing import Any, Dict, List
4-
import re
2+
from typing import Any
53

64
from dotenv import load_dotenv
7-
from langchain_core.messages import AIMessage, SystemMessage, HumanMessage
8-
from langchain_core.tools import BaseTool
5+
from langchain_core.messages import AIMessage
96
from langchain_core.runnables import Runnable
107
from langchain_gigachat import GigaChat
118
from langchain_ollama import ChatOllama
129
from langchain_openai import ChatOpenAI
13-
from pydantic import BaseModel, ValidationError
10+
from pydantic import BaseModel
1411

12+
from protollm.connectors.rest_server import ChatRESTServer
1513
from protollm.connectors.utils import (get_access_token,
1614
models_without_function_calling,
17-
models_without_structured_output)
15+
models_without_structured_output,
16+
generate_system_prompt_with_schema,
17+
generate_system_prompt_with_tools,
18+
parse_function_calls,
19+
parse_custom_structure,
20+
handle_system_prompt)
1821
from protollm.definitions import CONFIG_PATH
1922

2023

@@ -45,23 +48,23 @@ def __init__(self, *args: Any, **kwargs: Any):
4548
def invoke(self, messages: str | list, *args, **kwargs) -> AIMessage | dict | BaseModel:
4649

4750
if self._requires_custom_handling_for_tools() and self._tools:
48-
system_prompt = self._generate_system_prompt_with_tools()
49-
messages = self._handle_system_prompt(messages, system_prompt)
51+
system_prompt = generate_system_prompt_with_tools(self._tools, self._tool_choice_mode)
52+
messages = handle_system_prompt(messages, system_prompt)
5053

5154
if self._requires_custom_handling_for_structured_output() and self._response_format:
52-
system_prompt = self._generate_system_prompt_with_schema()
53-
messages = self._handle_system_prompt(messages, system_prompt)
55+
system_prompt = generate_system_prompt_with_schema(self._response_format)
56+
messages = handle_system_prompt(messages, system_prompt)
5457

5558
response = self._super_invoke(messages, *args, **kwargs)
5659

5760
match response:
5861
case AIMessage() if ("<function=" in response.content):
59-
tool_calls = self._parse_function_calls(response.content)
62+
tool_calls = parse_function_calls(response.content)
6063
if tool_calls:
6164
response.tool_calls = tool_calls
6265
response.content = ""
6366
case AIMessage() if self._response_format:
64-
response = self._parse_custom_structure(response)
67+
response = parse_custom_structure(self._response_format, response)
6568

6669
return response
6770

@@ -83,87 +86,6 @@ def with_structured_output(self, *args, **kwargs: Any) -> Runnable:
8386
else:
8487
return super().with_structured_output(*args, **kwargs)
8588

86-
def _generate_system_prompt_with_tools(self) -> str:
87-
"""
88-
Generates a system prompt with function descriptions and instructions for the model.
89-
90-
Returns:
91-
System prompt with instructions for calling functions and descriptions of the functions themselves.
92-
93-
Raises:
94-
ValueError: If tools in an unsupported format have been passed.
95-
"""
96-
tool_descriptions = []
97-
match self._tool_choice_mode:
98-
case "auto" | None | "any" | "required" | True:
99-
tool_choice_mode = str(self._tool_choice_mode)
100-
case _:
101-
tool_choice_mode = f"<<{self._tool_choice_mode}>>"
102-
for tool in self._tools:
103-
match tool:
104-
case dict():
105-
tool_descriptions.append(
106-
f"Function name: {tool['name']}\n"
107-
f"Description: {tool['description']}\n"
108-
f"Parameters: {json.dumps(tool['parameters'], ensure_ascii=False)}"
109-
)
110-
case BaseTool():
111-
tool_descriptions.append(
112-
f"Function name: {tool.name}\n"
113-
f"Description: {tool.description}\n"
114-
f"Parameters: {json.dumps(tool.args, ensure_ascii=False)}"
115-
)
116-
case _:
117-
raise ValueError(
118-
"Unsupported tool type. Try using a dictionary or function with the @tool decorator as tools"
119-
)
120-
tool_prefix = "You have access to the following functions:\n\n"
121-
tool_instructions = (
122-
"There are the following 4 function call options:\n"
123-
"- str of the form <<tool_name>>: call <<tool_name>> tool.\n"
124-
"- 'auto': automatically select a tool (including no tool).\n"
125-
"- 'none': don't call a tool.\n"
126-
"- 'any' or 'required' or 'True': at least one tool have to be called.\n\n"
127-
f"User-selected option - {tool_choice_mode}\n\n"
128-
"If you choose to call a function ONLY reply in the following format with no prefix or suffix:\n"
129-
'<function=example_function_name>{"example_name": "example_value"}</function>'
130-
)
131-
return tool_prefix + "\n\n".join(tool_descriptions) + "\n\n" + tool_instructions
132-
133-
def _generate_system_prompt_with_schema(self) -> str:
134-
"""
135-
Generates a system prompt with response format descriptions and instructions for the model.
136-
137-
Returns:
138-
A system prompt with instructions for structured output and descriptions of the response formats themselves.
139-
140-
Raises:
141-
ValueError: If the structure descriptions for the response were passed in an unsupported format.
142-
"""
143-
schema_descriptions = []
144-
match self._response_format:
145-
case list():
146-
schemas = self._response_format
147-
case _:
148-
schemas = [self._response_format]
149-
for schema in schemas:
150-
match schema:
151-
case dict():
152-
schema_descriptions.append(str(schema))
153-
case _ if issubclass(schema, BaseModel):
154-
schema_descriptions.append(str(schema.model_json_schema()))
155-
case _:
156-
raise ValueError(
157-
"Unsupported schema type. Try using a description of the answer structure as a dictionary or"
158-
" Pydantic model."
159-
)
160-
schema_prefix = "Generate a JSON object that matches one of the following schemas:\n\n"
161-
schema_instructions = (
162-
"Your response must contain ONLY valid JSON, parsable by a standard JSON parser. Do not include any"
163-
" additional text, explanations, or comments."
164-
)
165-
return schema_prefix + "\n\n".join(schema_descriptions) + "\n\n" + schema_instructions
166-
16789
def _requires_custom_handling_for_tools(self) -> bool:
16890
"""
16991
Determines whether additional processing for tool calling is required for the current model.
@@ -175,84 +97,6 @@ def _requires_custom_handling_for_structured_output(self) -> bool:
17597
Determines whether additional processing for structured output is required for the current model.
17698
"""
17799
return any(model_name in self.model_name.lower() for model_name in models_without_structured_output)
178-
179-
def _parse_custom_structure(self, response_from_model) -> dict | BaseModel | None:
180-
"""
181-
Parses the model response into a dictionary or Pydantic class
182-
183-
Args:
184-
response_from_model: response of a model that does not support structured output by default
185-
186-
Raises:
187-
ValueError: If a structured response is not obtained
188-
"""
189-
match [self._response_format][0]:
190-
case dict():
191-
try:
192-
return json.loads(response_from_model.content)
193-
except json.JSONDecodeError as e:
194-
raise ValueError(
195-
"Failed to return structured output. There may have been a problem with loading JSON from the"
196-
f" model.\n{e}"
197-
)
198-
case _ if issubclass([self._response_format][0], BaseModel):
199-
for schema in [self._response_format]:
200-
try:
201-
return schema.model_validate_json(response_from_model.content)
202-
except ValidationError:
203-
continue
204-
raise ValueError(
205-
"Failed to return structured output. There may have been a problem with validating JSON from the"
206-
" model."
207-
)
208-
209-
@staticmethod
210-
def _parse_function_calls(content: str) -> List[Dict[str, Any]]:
211-
"""
212-
Parses LLM answer (HTML string) to extract function calls.
213-
214-
Args:
215-
content: model response as an HTML string
216-
217-
Returns:
218-
A list of dictionaries in tool_calls format
219-
220-
Raises:
221-
ValueError: If the arguments for a function call are returned in an incorrect format
222-
"""
223-
tool_calls = []
224-
pattern = r"<function=(.*?)>(.*?)</function>"
225-
matches = re.findall(pattern, content, re.DOTALL)
226-
227-
for match in matches:
228-
function_name, function_args = match
229-
try:
230-
arguments = json.loads(function_args)
231-
except json.JSONDecodeError as e:
232-
raise ValueError(f"Error when decoding function arguments: {e}")
233-
234-
tool_call = {
235-
"id": f"call_{len(tool_calls) + 1}",
236-
"type": "tool_call",
237-
"name": function_name,
238-
"args": arguments
239-
}
240-
tool_calls.append(tool_call)
241-
242-
return tool_calls
243-
244-
@staticmethod
245-
def _handle_system_prompt(msgs, sys_prompt):
246-
match msgs:
247-
case str():
248-
return [SystemMessage(content=sys_prompt), HumanMessage(content=msgs)]
249-
case list():
250-
if not any(isinstance(msg, SystemMessage) for msg in msgs):
251-
msgs.insert(0, SystemMessage(content=sys_prompt))
252-
else:
253-
idx = next((index for index, obj in enumerate(msgs) if isinstance(obj, SystemMessage)), 0)
254-
msgs[idx].content += "\n\n" + sys_prompt
255-
return msgs
256100

257101

258102
def create_llm_connector(model_url: str, *args: Any, **kwargs: Any) -> CustomChatOpenAI | GigaChat | ChatOpenAI:
@@ -285,6 +129,9 @@ def create_llm_connector(model_url: str, *args: Any, **kwargs: Any) -> CustomCha
285129
elif "ollama" in model_url:
286130
url_and_name = model_url.split(";")
287131
return ChatOllama(model=url_and_name[2], base_url=url_and_name[1], *args, **kwargs)
132+
elif "self_hosted" in model_url:
133+
url_and_name = model_url.split(";")
134+
return ChatRESTServer(model=url_and_name[2], base_url=url_and_name[1], *args, **kwargs)
288135
elif model_url == "test_model":
289136
return CustomChatOpenAI(model_name=model_url, api_key="test")
290137
else:

0 commit comments

Comments
 (0)