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
12 changes: 11 additions & 1 deletion backend/core/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,17 @@
'id': 'gemini',
'label': 'Google Gemini',
'base_url': 'https://generativelanguage.googleapis.com/v1beta'
}
},
{
'id': 'openai',
'label': 'OpenAI',
'base_url': 'https://api.openai.com/v1'
},
# {
# 'id': 'custom',
# 'label': 'Custom Provider',
# 'base_url': ''
# }
]

SUPPORTED_INTEGRATIONS = [
Expand Down
2 changes: 2 additions & 0 deletions backend/core/services/factories/ai_provider_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
from ..contracts.ai_provider_contract import AIProviderContract
from ..providers.ai.custom_provider import CustomProvider
from ..providers.ai.gemini_provider import GeminiProvider
from ..providers.ai.openai_provider import OpenAIProvider


class AIProviderFactory:
PROVIDER_CLASSES = {
'gemini': GeminiProvider,
'openai': OpenAIProvider,
'custom': CustomProvider,
}

Expand Down
191 changes: 191 additions & 0 deletions backend/core/services/providers/ai/openai_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import json
from typing import Optional, Dict, Any
from pydantic import BaseModel
import openai
from ...contracts.ai_provider_contract import AIProviderContract
from core.agent_response_schema import SupportAgentResponse

class OpenAIProvider(AIProviderContract):
def __init__(self, api_key: str, config: Optional[Dict[str, Any]] = None) -> None:
super().__init__(api_key, config)
base_url = self.config.get("base_url") if self.config else None
try:
kwargs: Dict[str, Any] = {"api_key": api_key}
if base_url:
kwargs["base_url"] = base_url
self.client = openai.OpenAI(**kwargs)
except Exception as e:
raise ValueError(f"Failed to initialize OpenAI client: {e}")

def validate_connection(self) -> tuple[bool, list[Dict[str, Any]]]:
try:
models = self.get_models()
return True, models
except Exception:
return False, []

def get_models(self) -> list[Dict[str, Any]]:
try:
raw_models = self.client.models.list().data
except Exception as e:
raise ValueError(f"Failed to retrieve models from OpenAI API: {e}")

result = []
for model in raw_models:
model_id = model.id
result.append({
"id": model_id,
"name": model_id,
"object": model.object,
"created": model.created,
"owned_by": model.owned_by,
})
return result

def generate_with_conversation(
self,
model: str,
messages: list[dict],
tools: list[dict] | None,
response_schema: type[BaseModel],
) -> tuple:
has_tool_history = any(m.get("role") == "tool" for m in messages)

try:
if tools:
response = self.client.chat.completions.create(
model=model,
messages=messages,
tools=tools,
tool_choice="auto",
)
usage_metadata = self._extract_usage(response)
choice = response.choices[0]

if choice.finish_reason == "tool_calls":
raw_tool_calls = [
{
"id": tc.id,
"name": tc.function.name,
"args": json.loads(tc.function.arguments),
}
for tc in (choice.message.tool_calls or [])
]
messages.append({
"role": "assistant",
"content": choice.message.content or "",
"tool_calls": [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in (choice.message.tool_calls or [])
],
})
return choice.message.content or "", raw_tool_calls, usage_metadata

return self._parse_structured_response(choice, response_schema, self._extract_usage(response))

elif has_tool_history:
response = self.client.chat.completions.create(
model=model,
messages=messages,
response_format={"type": "json_object"},
)
usage_metadata = self._extract_usage(response)
choice = response.choices[0]
return self._parse_structured_response(choice, response_schema, usage_metadata)

else:
completion = self.client.beta.chat.completions.parse(
model=model,
messages=messages,
response_format=response_schema,
)
usage_metadata = self._extract_usage(completion)
message = completion.choices[0].message

if message.refusal:
raise ValueError(f"OpenAI refused the request: {message.refusal}")

return message.parsed, [], usage_metadata

except openai.AuthenticationError as e:
raise ValueError(f"Invalid OpenAI API key: {e}")
except openai.RateLimitError as e:
raise ValueError(f"OpenAI rate limit exceeded: {e}")
except openai.APIError as e:
raise ValueError(f"OpenAI API error: {e}")

def _parse_structured_response(self, choice, response_schema, usage_metadata):
raw = choice.message.content or ""
stripped = raw.strip()

if stripped.startswith("```"):
stripped = stripped.split("\n", 1)[-1]
if stripped.rstrip().endswith("```"):
stripped = stripped.rstrip()[:-3].rstrip()

brace_idx = stripped.find("{")
if brace_idx > 0:
stripped = stripped[brace_idx:]
last_brace = stripped.rfind("}")
if last_brace != -1 and last_brace < len(stripped) - 1:
stripped = stripped[:last_brace + 1]

if not stripped.startswith("{"):
stripped = json.dumps({
"answer": raw.strip(),
"status": "ANSWERED",
"escalation": False,
"reason_for_escalation": "",
"sentiment_score": 50,
"escalation_score": 0,
"criticality_score": 0,
})

try:
parsed = response_schema.model_validate_json(stripped)
except Exception as e:
raise ValueError(f"Failed to parse OpenAI response as {response_schema.__name__}: {e}")
return parsed, [], usage_metadata

def _extract_usage(self, response) -> dict:
usage: dict = {}
try:
meta = getattr(response, "usage", None)
if meta is not None:
for key in ("prompt_tokens", "completion_tokens", "total_tokens"):
val = getattr(meta, key, None)
if val is not None:
usage[key] = val
details = getattr(meta, "prompt_tokens_details", None)
if details is not None:
cached = getattr(details, "cached_tokens", None)
if cached is not None:
usage["cached_tokens"] = cached
except Exception:
pass
return usage

def embed(self, model: str, texts: list[str]) -> list[list[float]]:
try:
response = self.client.embeddings.create(input=texts, model=model)
except Exception as e:
raise ValueError(f"OpenAI embedding error: {e}")
sorted_data = sorted(response.data, key=lambda item: item.index)
return [item.embedding for item in sorted_data]

def generate_text(self, model: str, contents: str, **kwargs) -> SupportAgentResponse:
messages = [{"role": "user", "content": contents}]
result, _, _ = self.generate_with_conversation(
model=model,
messages=messages,
tools=None,
response_schema=SupportAgentResponse,
)
return result
36 changes: 29 additions & 7 deletions frontend/components/App/ConfigureAIModels.vue
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ const configuredAIProviderOptions = computed(() =>
})),
)

const EXCLUDED_MODELS = ['text-embedding-3-small']

const getProviderModels = (providerId: number) => {
const providerWithModels = AIProviderModelsStore.providerModels.find(
pm => pm.ai_provider.id === providerId,
Expand All @@ -167,13 +169,33 @@ const getProviderModels = (providerId: number) => {
return []
}

return providerWithModels.ai_provider_models.models_data.map((model) => {
const modelName = model.name || model.displayName || model.id || Object.values(model)[0] || ''
return {
label: modelName,
value: modelName,
}
})
const capability = props.config.capability.toLowerCase()

return providerWithModels.ai_provider_models.models_data
.filter((model) => {
const modelName = (model.name || model.displayName || model.id || Object.values(model)[0] || '').toLowerCase()

if (EXCLUDED_MODELS.some(excluded => modelName.includes(excluded.toLowerCase()))) {
return false
}

if (capability === 'embedding') {
return modelName.includes('embedding')
}

if (capability === 'text') {
return !modelName.includes('embedding')
}

return true
})
.map((model) => {
const modelName = model.name || model.displayName || model.id || Object.values(model)[0] || ''
return {
label: modelName,
value: modelName,
}
})
}

const configureModel = form.handleSubmit(async (values) => {
Expand Down
6 changes: 6 additions & 0 deletions frontend/components/icons/OpenAIIcon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<template>
<svg fill="#000000" viewBox="0 0 24 24" role="img" xmlns="http://www.w3.org/2000/svg">
<title>OpenAI icon</title>
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"/>
</svg>
</template>
3 changes: 3 additions & 0 deletions frontend/composables/useAIProviderIcon.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { computed, defineComponent, h } from 'vue'
import { Sparkles } from 'lucide-vue-next'
import GeminiIcon from '~/components/icons/GeminiIcon.vue'
import OpenAIIcon from '~/components/icons/OpenAIIcon.vue'

export function useAIProviderIcon(provider: string) {
return computed(() => {
switch (provider?.toLowerCase()) {
case 'gemini':
return GeminiIcon
case 'openai':
return OpenAIIcon
case 'custom':
default:
return defineComponent({
Expand Down
Loading