Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 72 additions & 1 deletion chatbot/admin/company_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@
from django.urls import path
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.forms import ModelForm, MultipleChoiceField, CheckboxSelectMultiple
from django.forms import ModelForm, MultipleChoiceField, CheckboxSelectMultiple, Select
from ..utils.admin_config.export_mixin import ExportAllFieldsMixin
from chatbot.llm_models.llm_gateway import get_provider_list, get_model_list, get_openrouter_endpoints


class CompanyStateMachineAdmin(admin.TabularInline):
Expand Down Expand Up @@ -94,6 +95,10 @@ class CompanyBotAdmin(BatchUploadMixin, SimpleHistoryAdmin):
inlines = [VoiceProviderAdmin]
actions = ['duplicate_bot', 'export_selected_bots']

# Hidden from the add/change form in favor of gateway_provider/gateway_model — the
# underlying fields and migrations are unchanged, this is UI-only.
exclude = ('provider', 'llm_model')

enable_batch_upload = True
batch_load_foreign_keys = True
batch_upload_fields = ['name', 'company', 'provider', 'llm_model', 'context', 'max_token', 'route']
Expand Down Expand Up @@ -156,6 +161,17 @@ def get_queryset(self, request):
else:
return qs.none()

def formfield_for_dbfield(self, db_field, request, **kwargs):
if db_field.name == 'gateway_provider':
providers = get_provider_list() or []
choices = [('', '---------')] + [(p['name'], p['name']) for p in providers if p.get('name')]
kwargs['widget'] = Select(choices=choices)
elif db_field.name == 'gateway_model':
kwargs['widget'] = Select(choices=[('', '---------')])
elif db_field.name == 'gateway_sub_provider':
kwargs['widget'] = Select(choices=[('', '---------')])
return super().formfield_for_dbfield(db_field, request, **kwargs)

def get_form(self, request, obj=None, **kwargs):
form = super().get_form(request, obj, **kwargs)
user = request.user
Expand All @@ -167,6 +183,61 @@ def get_form(self, request, obj=None, **kwargs):
form.base_fields['company'].queryset = form.base_fields['company'].queryset.filter(
id=profile[0].company.id)
form.base_fields = {field_name: form.base_fields[field_name] for field_name in form.base_fields}

# Make sure the saved gateway_provider always shows up as a selectable option,
# even if the live provider list is unavailable (gateway down) or no longer
# includes it — the DB value is the source of truth, never drop it silently.
provider_field = form.base_fields.get('gateway_provider')
if provider_field is not None and obj is not None and obj.gateway_provider:
provider_choices = list(provider_field.widget.choices)
if obj.gateway_provider not in dict(provider_choices):
provider_choices.append((obj.gateway_provider, obj.gateway_provider))
provider_field.widget.choices = provider_choices

# Populate the gateway model dropdown from the saved gateway_provider. If the
# provider was just changed but not yet saved, this still reflects the old
# provider's models — save once to refresh the model choices.
model_field = form.base_fields.get('gateway_model')
if model_field is not None and obj is not None and obj.gateway_provider:
models_data = get_model_list(obj.gateway_provider) or []
choices = [('', '---------')] + [
(m['id'], m.get('name') or m['id']) for m in models_data if m.get('id')
]
if obj.gateway_model and obj.gateway_model not in dict(choices):
choices.append((obj.gateway_model, obj.gateway_model))
model_field.widget.choices = choices

# gateway_sub_provider only applies to the 'openrouter' provider (it picks which
# upstream endpoint should serve the model) — hide it entirely for any other
# provider, and populate it from the saved gateway_model's endpoint list otherwise.
# The stored/passed value is 'tag' (e.g. 'google-vertex/europe'), not 'provider_name'
# (e.g. 'Google') — provider_name isn't unique per endpoint (a provider can have
# multiple regional/routing variants), tag is the actual routable identifier.
# Save-then-reload, same pattern as gateway_model.
sub_provider_field = form.base_fields.get('gateway_sub_provider')
if sub_provider_field is not None:
if not obj or obj.gateway_provider != 'openrouter':
form.base_fields.pop('gateway_sub_provider', None)
else:
choices = [('', '---------')]
if obj.gateway_model:
endpoints = get_openrouter_endpoints(obj.gateway_model) or []
seen_tags = []
labels = {}
for endpoint in endpoints:
tag = endpoint.get('tag')
if not tag or tag in seen_tags:
continue
seen_tags.append(tag)
provider_name = endpoint.get('provider_name')
labels[tag] = f'{provider_name} ({tag})' if provider_name else tag
choices += [(tag, labels[tag]) for tag in seen_tags]
# DB value is the source of truth — never drop it silently if the gateway is
# down or the endpoint list no longer includes it.
if obj.gateway_sub_provider and obj.gateway_sub_provider not in dict(choices):
choices.append((obj.gateway_sub_provider, obj.gateway_sub_provider))
sub_provider_field.widget.choices = choices

form.base_fields = {field_name: form.base_fields[field_name] for field_name in form.base_fields}
return form

Expand Down
7 changes: 3 additions & 4 deletions chatbot/celery_tasks/title_tasks.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from celery import shared_task
from chatbot.models import ChatSession, CompanyChat, Voice, VoiceType
from chatbot.models.company_models import Flow
from chatbot.llm_models.llm_gateway import call_llm_gateway, build_gateway_params
from chatbot.llm_models.llm_gateway import call_llm_gateway, build_gateway_params, get_effective_provider_model
from chatbot.utils.chat_utils import get_guided_chat
from chatbot.utils.audio_provider_utils import text_translate_provider
import json_repair
Expand Down Expand Up @@ -73,12 +73,11 @@ def generate_session_title(session_id, language='en'):
tool_choice = 'auto'

system_msg = {'role': 'system', 'content': company_bot.context}
custom_model = (company_bot.other_params or {}).get('custom_model')
effective_model = custom_model.strip() if isinstance(custom_model, str) and custom_model.strip() else company_bot.llm_model
effective_provider, effective_model = get_effective_provider_model(company_bot)

response = call_llm_gateway(
messages=[system_msg] + list(messages),
provider=company_bot.provider,
provider=effective_provider,
model=effective_model,
params=build_gateway_params(company_bot),
tools=tools or None,
Expand Down
119 changes: 117 additions & 2 deletions chatbot/llm_models/llm_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,130 @@ def build_gateway_params(company_bot) -> dict:
params['stop'] = other['stop']
if other.get('seed') is not None:
params['seed'] = other['seed']
if other.get('provider_options') is not None:
params['provider_options'] = other['provider_options']
provider_options = other.get('provider_options')
if provider_options is None and company_bot.gateway_provider == 'openrouter' and company_bot.gateway_sub_provider:
# No explicit provider_options override — derive one from gateway_sub_provider so
# openrouter routes to the endpoint picked in the admin (e.g. a specific region/tag).
provider_options = {
'provider': {
'only': [company_bot.gateway_sub_provider],
'allow_fallbacks': False,
}
}
if provider_options is not None:
params['provider_options'] = provider_options
if getattr(company_bot, 'enable_web_search', False):
params['web_search_options'] = {
'search_context_size': company_bot.web_search_context_size or 'medium'
}
return params


def get_effective_provider_model(company_bot) -> tuple:
"""
Resolve the (provider, model) pair to use for a gateway call, sourced from
gateway_provider/gateway_model — no fallback to the legacy provider/llm_model
fields. other_params.custom_model, when present, overrides gateway_model (e.g.
for a model ID not surfaced by the gateway's catalog for the selected provider).
"""
custom_model = (company_bot.other_params or {}).get('custom_model')
model = custom_model.strip() if isinstance(custom_model, str) and custom_model.strip() else company_bot.gateway_model
return company_bot.gateway_provider, model
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def get_provider_list() -> list | None:
"""
GET /v1/providers on the LLM gateway service. Returns a list of
{'name': ..., 'source': ...} dicts, or None on failure.
"""
url = f"{_BASE_URL.rstrip('/')}/v1/providers"
headers = {
'Authorization': f'Bearer {_API_KEY}',
'X-Tenant-Id': _TENANT_ID,
}

try:
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
return response.json().get('data')
except requests.exceptions.Timeout:
logger.error('LLM gateway provider list request timed out')
except requests.exceptions.HTTPError as e:
logger.error('LLM gateway provider list HTTP error %s: %s', e.response.status_code, e.response.text)
except requests.exceptions.RequestException as e:
logger.error('LLM gateway provider list request failed: %s', e)
except Exception as e:
logger.error('Unexpected error fetching LLM gateway provider list: %s', e, exc_info=True)

return None


def get_model_list(provider: str) -> list | None:
"""
GET /v1/models on the LLM gateway service for the given provider. Returns a
list of model dicts (with at least 'id' and 'name' keys), or None on failure.
"""
url = f"{_BASE_URL.rstrip('/')}/v1/models"
headers = {
'Authorization': f'Bearer {_API_KEY}',
'X-Tenant-Id': _TENANT_ID,
}
query_params = {'provider': provider}

print('[get_model_list] GET', url, 'params:', query_params)
try:
response = requests.get(url, headers=headers, params=query_params, timeout=10)
print('[get_model_list] response status:', response.status_code)
response.raise_for_status()
data = response.json().get('data')
print('[get_model_list] parsed', len(data) if data is not None else 0, 'models')
return data
except requests.exceptions.Timeout:
print('[get_model_list] TIMEOUT for provider', provider)
logger.error('LLM gateway model list request timed out for provider %s', provider)
except requests.exceptions.HTTPError as e:
print('[get_model_list] HTTP ERROR', e.response.status_code, e.response.text)
logger.error('LLM gateway model list HTTP error %s: %s', e.response.status_code, e.response.text)
except requests.exceptions.RequestException as e:
print('[get_model_list] REQUEST EXCEPTION', e)
logger.error('LLM gateway model list request failed: %s', e)
except Exception as e:
print('[get_model_list] UNEXPECTED ERROR', e)
logger.error('Unexpected error fetching LLM gateway model list: %s', e, exc_info=True)

return None


def get_openrouter_endpoints(model: str) -> list | None:
"""
GET /v1/models/endpoints on the LLM gateway service for a given openrouter model.
Returns the list of endpoint dicts (each with at least 'provider_name' and 'tag'
keys), or None on failure.
"""
url = f"{_BASE_URL.rstrip('/')}/v1/models/endpoints"
headers = {
'Authorization': f'Bearer {_API_KEY}',
'X-Tenant-Id': _TENANT_ID,
}
query_params = {'provider': 'openrouter', 'model': model}

try:
response = requests.get(url, headers=headers, params=query_params, timeout=10)
response.raise_for_status()
data = response.json().get('data') or {}
return data.get('endpoints')
except requests.exceptions.Timeout:
logger.error('LLM gateway openrouter endpoints request timed out for model %s', model)
except requests.exceptions.HTTPError as e:
logger.error('LLM gateway openrouter endpoints HTTP error %s: %s', e.response.status_code, e.response.text)
except requests.exceptions.RequestException as e:
logger.error('LLM gateway openrouter endpoints request failed: %s', e)
except Exception as e:
logger.error('Unexpected error fetching LLM gateway openrouter endpoints: %s', e, exc_info=True)

return None


def call_llm_gateway(
messages: list, provider: str, model: str, params: dict = None, tools: list = None,
tool_choice=None,
Expand Down
43 changes: 43 additions & 0 deletions chatbot/migrations/0090_add_companybot_gateway_provider_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Generated by Django 5.2 on 2026-08-11 10:41

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('chatbot', '0089_alter_chatsession_language_alter_story_language_and_more'),
]

operations = [
migrations.AddField(
model_name='companybot',
name='gateway_model',
field=models.CharField(blank=True, help_text='Select the model for the chosen provider. If you just changed the provider, save the bot first — the model list here updates to match the new provider after saving.', max_length=150, null=True),
),
migrations.AddField(
model_name='companybot',
name='gateway_provider',
field=models.CharField(blank=True, help_text='Select the LLM provider to use. Choices are fetched live from the LLM gateway.', max_length=100, null=True),
),
migrations.AddField(
model_name='companybot',
name='gateway_sub_provider',
field=models.CharField(blank=True, help_text="Only used when the gateway provider is 'openrouter'. Select which upstream endpoint (e.g. DeepInfra, Google, Anthropic) should serve the chosen model. If you just changed the model, save the bot first — the choices here update to match after saving.", max_length=100, null=True),
),
migrations.AddField(
model_name='historicalcompanybot',
name='gateway_model',
field=models.CharField(blank=True, help_text='Select the model for the chosen provider. If you just changed the provider, save the bot first — the model list here updates to match the new provider after saving.', max_length=150, null=True),
),
migrations.AddField(
model_name='historicalcompanybot',
name='gateway_provider',
field=models.CharField(blank=True, help_text='Select the LLM provider to use. Choices are fetched live from the LLM gateway.', max_length=100, null=True),
),
migrations.AddField(
model_name='historicalcompanybot',
name='gateway_sub_provider',
field=models.CharField(blank=True, help_text="Only used when the gateway provider is 'openrouter'. Select which upstream endpoint (e.g. DeepInfra, Google, Anthropic) should serve the chosen model. If you just changed the model, save the bot first — the choices here update to match after saving.", max_length=100, null=True),
),
]
34 changes: 34 additions & 0 deletions chatbot/models/company_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,21 @@ def get_file_upload_path(self, filename):
max_length=100, choices=LLMModel.choices, default=LLMModel.GPT4_O_MINI,
help_text="Select the LLM model to be used by the bot (e.g., GPT-4o, GPT-4)."
)
gateway_provider = models.CharField(
max_length=100, null=True, blank=True,
help_text="Select the LLM provider to use. Choices are fetched live from the LLM gateway."
)
gateway_model = models.CharField(
max_length=150, null=True, blank=True,
help_text="Select the model for the chosen provider. If you just changed the provider, save "
"the bot first — the model list here updates to match the new provider after saving."
)
gateway_sub_provider = models.CharField(
max_length=100, null=True, blank=True,
help_text="Only used when the gateway provider is 'openrouter'. Select which upstream endpoint "
"(e.g. DeepInfra, Google, Anthropic) should serve the chosen model. If you just changed "
"the model, save the bot first — the choices here update to match after saving."
)
filter_score = models.FloatField(
default=0.8,
help_text="Set the filter score for bot response selection (0-1). Responses below this score will be "
Expand Down Expand Up @@ -178,6 +193,25 @@ def get_file_upload_path(self, filename):
def __str__(self):
return self.name

def save(self, *args, **kwargs):
update_fields = kwargs.get('update_fields')
gateway_fields_changing = update_fields is None or {'gateway_provider', 'gateway_model'} & set(update_fields)
if self.pk and gateway_fields_changing:
old = CompanyBot.objects.filter(pk=self.pk).only('gateway_provider', 'gateway_model').first()
reset_fields = set()
if old and old.gateway_provider != self.gateway_provider:
self.gateway_model = None
self.gateway_sub_provider = None
reset_fields = {'gateway_model', 'gateway_sub_provider'}
elif old and old.gateway_model != self.gateway_model:
self.gateway_sub_provider = None
reset_fields = {'gateway_sub_provider'}
# update_fields only persists what's listed — make sure resets we just made
# in memory are actually included, otherwise they'd be silently dropped.
if update_fields is not None and reset_fields:
kwargs['update_fields'] = list(set(update_fields) | reset_fields)
super().save(*args, **kwargs)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

class Meta:
indexes = [
models.Index(fields=['company']),
Expand Down
Loading