Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 2 additions & 1 deletion backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ openai==2.36.0
google-auth==2.0.0
requests==2.32.3
python-multipart==0.0.9
PyYAML==6.0.1
PyYAML==6.0.1
cachetools==5.5.0
32 changes: 31 additions & 1 deletion backend/src/dna/llm_providers/gemini_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@
Gemini implementation of the LLM provider interface.
"""

import logging
import os
from typing import Any

from cachetools import TTLCache
Comment thread
chinmaychahar marked this conversation as resolved.
Outdated
from openai import AsyncOpenAI

from dna.llm_providers.llm_provider_base import LLMProviderBase
from dna.llm_providers.llm_provider_base import MODEL_CACHE_TTL, LLMProviderBase

logger = logging.getLogger(__name__)


class GeminiProvider(LLMProviderBase):
Expand All @@ -18,10 +23,35 @@ class GeminiProvider(LLMProviderBase):
DEFAULT_MODEL = "gemini-2.5-flash"
DEFAULT_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"

def __init__(self, **kwargs):
super().__init__(**kwargs)
self._models_cache: TTLCache = TTLCache(maxsize=1, ttl=MODEL_CACHE_TTL)

def _get_provider_client(self):
"""Construct an instance of the LLM provider's client."""
return AsyncOpenAI(
api_key=self.api_key,
base_url=os.getenv(f"{self.LLM_PROVIDER_NAME }_URL", self.DEFAULT_URL),
timeout=self.timeout,
)

async def get_available_models(self) -> dict[str, Any]:
"""Fetch available models from Gemini API with caching."""
cache_key = "models"
if cache_key in self._models_cache:
return self._models_cache[cache_key]

try:
response = await self.client.models.list()
model_ids = sorted(m.id for m in response.data)
except Exception:
logger.warning("Failed to fetch models from Gemini API, using default")
model_ids = [self.model]

result = {
"provider": "gemini",
"models": model_ids,
"default": self.model,
}
self._models_cache[cache_key] = result
return result
20 changes: 19 additions & 1 deletion backend/src/dna/llm_providers/llm_provider_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

from dna.prompts.generate_note_prompt import GENERATE_NOTE_PROMPT

MODEL_CACHE_TTL = 3600 # 1 hour

logger = logging.getLogger(__name__)

T = TypeVar("T", bound=BaseModel)
Expand Down Expand Up @@ -161,13 +163,26 @@ async def close(self) -> None:
await self._client.close()
self._client = None

async def get_available_models(self) -> dict[str, Any]:
"""Return available models for this provider.

Returns a dict with keys: provider, models, default.
Subclasses should override to provide dynamic discovery with caching.
"""
return {
"provider": (self.LLM_PROVIDER_NAME or "").lower(),
"models": [self.model],
"default": self.model,
}

async def generate_note(
self,
prompt: str,
transcript: str,
context: str,
existing_notes: str,
additional_instructions: Optional[str] = None,
model: Optional[str] = None,
) -> str:
"""Generate a note suggestion from the given inputs.

Expand All @@ -177,10 +192,13 @@ async def generate_note(
context: Version context (entity name, task, status, etc.).
existing_notes: Any notes the user has already written.
additional_instructions: Optional additional instructions to append.
model: Optional model override; falls back to self.model.

Returns:
The generated note suggestion.
"""
use_model = model or self.model

user_message = self._substitute_template(
prompt, transcript, context, existing_notes
)
Expand All @@ -189,7 +207,7 @@ async def generate_note(
user_message += f"\n\nAdditional Instructions: {additional_instructions}"

response = await self.client.chat.completions.create(
model=self.model,
model=use_model,
messages=[
{"role": "system", "content": GENERATE_NOTE_PROMPT},
{"role": "user", "content": user_message},
Expand Down
39 changes: 38 additions & 1 deletion backend/src/dna/llm_providers/openai_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,17 @@
OpenAI implementation of the LLM provider interface.
"""

import logging
from typing import Any

from cachetools import TTLCache
from openai import AsyncOpenAI

from dna.llm_providers.llm_provider_base import LLMProviderBase
from dna.llm_providers.llm_provider_base import MODEL_CACHE_TTL, LLMProviderBase

logger = logging.getLogger(__name__)

OPENAI_CHAT_PREFIXES = ("gpt-", "o1", "o3", "o4", "chatgpt-")


class OpenAIProvider(LLMProviderBase):
Expand All @@ -15,6 +23,35 @@ class OpenAIProvider(LLMProviderBase):

DEFAULT_MODEL = "gpt-4o-mini"

def __init__(self, **kwargs):
super().__init__(**kwargs)
self._models_cache: TTLCache = TTLCache(maxsize=1, ttl=MODEL_CACHE_TTL)

def _get_provider_client(self):
"""Construct an instance of the LLM provider's client."""
return AsyncOpenAI(api_key=self.api_key, timeout=self.timeout)

async def get_available_models(self) -> dict[str, Any]:
"""Fetch available chat-completion models from OpenAI API with caching."""
cache_key = "models"
if cache_key in self._models_cache:
return self._models_cache[cache_key]

try:
response = await self.client.models.list()
model_ids = sorted(
m.id
for m in response.data
if m.id.startswith(OPENAI_CHAT_PREFIXES)
)
except Exception:
logger.warning("Failed to fetch models from OpenAI API, using default")
model_ids = [self.model]

result = {
"provider": "openai",
"models": model_ids,
"default": self.model,
}
self._models_cache[cache_key] = result
return result
4 changes: 4 additions & 0 deletions backend/src/dna/models/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ class GenerateNoteRequest(BaseModel):
default=None,
description="Optional additional instructions to append to the prompt",
)
model: Optional[str] = Field(
default=None,
description="Optional LLM model override; omit to use server default",
)


class GenerateNoteResponse(BaseModel):
Expand Down
5 changes: 5 additions & 0 deletions backend/src/dna/models/user_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ class UserSettingsUpdate(BaseModel):
note_prompt: Optional[str] = Field(
default=None, description="Custom prompt for generating notes"
)
preferred_model: Optional[str] = Field(
default=None,
description="Preferred LLM model for note generation; empty means use server default",
)
regenerate_on_version_change: Optional[bool] = Field(
default=None,
description="Regenerate AI note when switching review versions",
Expand All @@ -38,6 +42,7 @@ class UserSettings(BaseModel):
id: str = Field(alias="_id")
user_email: str
note_prompt: str = ""
preferred_model: str = ""
regenerate_on_version_change: bool = False
regenerate_on_transcript_update: bool = False
sync_prodtrack_tab_on_version_change: bool = True
Expand Down
1 change: 1 addition & 0 deletions backend/src/dna/models/user_settings_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class UserSettingsResponse(BaseModel):
id: str = Field(alias="_id")
user_email: str
note_prompt: str = ""
preferred_model: str = ""
default_note_prompt: str = ""
regenerate_on_version_change: bool = False
regenerate_on_transcript_update: bool = False
Expand Down
21 changes: 21 additions & 0 deletions backend/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1418,6 +1418,7 @@ def _user_settings_to_response(settings: UserSettings) -> UserSettingsResponse:
_id=settings.id,
user_email=settings.user_email,
note_prompt=settings.note_prompt,
preferred_model=settings.preferred_model,
default_note_prompt=get_default_note_prompt(),
regenerate_on_version_change=settings.regenerate_on_version_change,
regenerate_on_transcript_update=settings.regenerate_on_transcript_update,
Expand All @@ -1438,6 +1439,7 @@ def _empty_user_settings_response(user_email: str) -> UserSettingsResponse:
_id="",
user_email=user_email,
note_prompt="",
preferred_model="",
default_note_prompt=default,
regenerate_on_version_change=False,
regenerate_on_transcript_update=False,
Expand Down Expand Up @@ -1787,6 +1789,20 @@ async def get_segments_for_version(
# -----------------------------------------------------------------------------


@app.get(
"/models",
tags=["LLM"],
summary="Get available LLM models",
description="Returns the list of models available from the active LLM provider.",
)
async def get_available_models(
llm_provider: LLMProviderDep,
_: CurrentUserDep,
) -> dict:
"""Get available models from the active LLM provider."""
return await llm_provider.get_available_models()


def _build_full_prompt(
prompt: str,
transcript: str,
Expand Down Expand Up @@ -1852,12 +1868,17 @@ async def generate_note(
prompt, transcript, context, existing_notes, request.additional_instructions
)

model_override = request.model
if not model_override and user_settings:
model_override = user_settings.preferred_model or None

suggestion = await llm_provider.generate_note(
prompt=prompt,
transcript=transcript,
context=context,
existing_notes=existing_notes,
additional_instructions=request.additional_instructions,
model=model_override,
)

return GenerateNoteResponse(
Expand Down
Loading
Loading