Skip to content

Commit 6dd4de8

Browse files
committed
feat(#54): add support for gemini model wildcards
1 parent 1652095 commit 6dd4de8

6 files changed

Lines changed: 108 additions & 21 deletions

File tree

README.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,8 @@ These are the main supported models, though the CLI may support additional ones
147147

148148
| Model name | Description |
149149
| -------- | ----- |
150-
| **gpt-4.1** * | Powerful and reliable model for detailed code analysis. Strong at coding tasks. |
151-
| **gpt-4.1-mini** | Lightweight variant of GPT-4.1 offering faster responses and lower cost—ideal for iterative or high-volume reviews. |
150+
| gpt4.1 | Powerful and reliable model for detailed code analysis. Strong at coding tasks. |
151+
| gpt-4.1-mini | Lightweight variant of GPT-4.1 offering faster responses and lower cost—ideal for iterative or high-volume reviews. |
152152
| gpt-4.1-nano | Ultra-light model focused on speed and affordability—best for basic code checks or initial feedback. |
153153
| gpt-4o * | Cutting-edge model with strong reasoning and code capabilities—ideal for detailed, context-aware reviews. |
154154
| gpt-4o-mini | Streamlined GPT-4o variant optimized for fast, cost-effective feedback on code. |
@@ -174,16 +174,20 @@ To use Gemini LLMs, you need to provide lgtm an API Key, which can be generated
174174
These are the main supported models, though the CLI may support additional ones due to the use of [pydantic-ai](https://ai.pydantic.dev). Gemini timestamps models, so be sure to always use the latest model of each family, if possible.
175175

176176

177+
For Gemini models exclusively, you can provide a wildcard and lgtm will attempt to select the latest model (e.g., `gemini-2.5-pro*`)
178+
179+
177180
<details>
178181

179182
<summary>Supported Google's Gemini models</summary>
180183

181184

182185
| Model name | Description |
183186
| ----------- | --- |
184-
| gemini-2.5-pro-preview-05-06 | Most advanced publicly available Gemini model. Strong code reasoning and long-context support. Ideal for complex or large reviews. |
185-
| **gemini-2.0-pro-exp-02-05** | High-performing general-purpose model. Balances accuracy and efficiency—ideal for robust reviews without 2.5's higher cost. |
186-
| **gemini-2.0-flash** | Optimized for low-latency, lower-cost analysis. Ideal for iterative feedback and smaller reviews. |
187+
| gemini-2.5-pro-preview-06-05 | Most advanced publicly available Gemini model. Strong code reasoning and long-context support. Ideal for complex or large reviews. |
188+
| gemini-2.5-pro-preview-06-05 | Deprecated. Most advanced publicly available Gemini model. Strong code reasoning and long-context support. Ideal for complex or large reviews. |
189+
| gemini-2.0-pro-exp-02-05 | High-performing general-purpose model. Balances accuracy and efficiency—ideal for robust reviews without 2.5's higher cost. |
190+
| gemini-2.0-flash | Optimized for low-latency, lower-cost analysis. Ideal for iterative feedback and smaller reviews. |
187191
| gemini-1.5-pro | Proven performer with solid context and reasoning. Still excellent for general code understanding. |
188192
| gemini-1.5-flash | Lightweight and fast—suited for real-time or continuous code review loops. |
189193

@@ -222,7 +226,7 @@ These are the main supported models, though the CLI may support additional ones
222226

223227
| Model name | Description |
224228
| ------------------ | --------------------------------------------------------------------------------------------------- |
225-
| **mistral-large-latest** | Mistral's top-tier reasoning model for high-complexity tasks. |
229+
| mistral-large-latest | Mistral's top-tier reasoning model for high-complexity tasks. |
226230
| mistral-small | Lightweight and fast. Best used for simple syntax or formatting checks where cost and speed are priorities. |
227231
| codestrallatest | Codestral specializes in low-latency, high-frequency tasks such as fill-in-the-middle (FIM), code correction and test generation.
228232

@@ -242,8 +246,8 @@ To get an API key for DeepSeek, create one at [DeepSeek Platform](https://platfo
242246

243247
| Model name | Description |
244248
----------- | --------------------------------------------------------------------------------------------------- |
245-
| **deepseek-chat** | General-purpose LLM optimized for chat and code assistance. Ideal for standard reviews and developer interactions. |
246-
| **deepseek-reasoner** | Advanced model specialized in reasoning and problem solving—ideal for complex code analysis and critical thinking tasks. |
249+
| deepseek-chat | General-purpose LLM optimized for chat and code assistance. Ideal for standard reviews and developer interactions. |
250+
| deepseek-reasoner | Advanced model specialized in reasoning and problem solving—ideal for complex code analysis and critical thinking tasks. |
247251

248252
</details>
249253

src/lgtm_ai/ai/agent.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import logging
2-
from typing import Any, TypeGuard, get_args
2+
from typing import Any, TypeGuard, cast, get_args
33

4-
from lgtm_ai.ai.exceptions import MissingAIAPIKey, MissingModelUrl
4+
from lgtm_ai.ai.exceptions import InvalidModelName, MissingAIAPIKey, MissingModelUrl
55
from lgtm_ai.ai.prompts import GUIDE_SYSTEM_PROMPT, REVIEWER_SYSTEM_PROMPT, SUMMARIZING_SYSTEM_PROMPT
66
from lgtm_ai.ai.schemas import (
77
AgentSettings,
@@ -14,6 +14,7 @@
1414
SupportedAIModelsList,
1515
SupportedGeminiModel,
1616
)
17+
from lgtm_ai.ai.utils import match_model_by_wildcard, select_latest_gemini_model
1718
from openai.types import ChatModel
1819
from pydantic_ai import Agent, RunContext
1920
from pydantic_ai.models import Model
@@ -32,7 +33,8 @@
3233

3334
def get_ai_model(model_name: SupportedAIModels | str, api_key: str, model_url: str | None = None) -> Model: # noqa: C901
3435
def _is_gemini_model(model_name: SupportedAIModels) -> TypeGuard[SupportedGeminiModel]:
35-
return model_name in get_args(SupportedGeminiModel)
36+
matched_model = match_model_by_wildcard(model_name, get_args(SupportedGeminiModel))
37+
return bool(matched_model)
3638

3739
def _is_openai_model(model_name: SupportedAIModels) -> TypeGuard[ChatModel]:
3840
return model_name in get_args(ChatModel)
@@ -54,7 +56,13 @@ def _is_deepseek_model(model_name: SupportedAIModels) -> TypeGuard[DeepSeekModel
5456
raise MissingAIAPIKey(model_name=model_name)
5557

5658
if _is_gemini_model(model_name):
57-
return GoogleModel(model_name, provider=GoogleProvider(api_key=api_key))
59+
matches = match_model_by_wildcard(
60+
model_name,
61+
cast(tuple[SupportedGeminiModel, ...], get_args(SupportedGeminiModel)),
62+
)
63+
if not matches:
64+
raise InvalidModelName(model_name=model_name)
65+
return GoogleModel(select_latest_gemini_model(matches), provider=GoogleProvider(api_key=api_key))
5866
elif _is_openai_model(model_name):
5967
return OpenAIModel(model_name=model_name, provider=OpenAIProvider(api_key=api_key))
6068
elif _is_anthropic_model(model_name):

src/lgtm_ai/ai/exceptions.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import click
2+
from lgtm_ai.ai.schemas import SupportedGeminiModel
23

34

45
class MissingModelUrl(click.BadParameter): # not a LGTMException because we want click to handle it gracefully
@@ -15,3 +16,19 @@ class MissingAIAPIKey(click.BadParameter):
1516
def __init__(self, model_name: str) -> None:
1617
msg = f"Model '{model_name}' requires an AI API key to be provided"
1718
super().__init__(msg)
19+
20+
21+
class InvalidModelName(click.BadParameter):
22+
"""Exception raised when an invalid AI model name is provided."""
23+
24+
def __init__(self, model_name: str) -> None:
25+
msg = f"Model '{model_name}' is not a valid AI model name"
26+
super().__init__(msg)
27+
28+
29+
class InvalidGeminiWildcard(click.BadParameter):
30+
"""Exception raised when a Gemini model name with wildcard is invalid."""
31+
32+
def __init__(self, matches: list[SupportedGeminiModel]) -> None:
33+
msg = f"The provided Gemini model name matches multiple models that cannot be narrowed down based on when they were released: {', '.join(matches)}. Please specify a more specific model name."
34+
super().__init__(msg)

src/lgtm_ai/ai/schemas.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,11 @@
4646
"gemini-2.5-pro-preview-06-05",
4747
]
4848

49-
LocalAIModel = str
49+
AnyModel = str
5050
"""Users may use any model name in their local AI server, so we just allow any string."""
5151

5252
SupportedAIModels = (
53-
ChatModel
54-
| SupportedGeminiModel
55-
| LatestAnthropicModelNames
56-
| LatestMistralModelNames
57-
| DeepSeekModel
58-
| LocalAIModel
53+
ChatModel | SupportedGeminiModel | LatestAnthropicModelNames | LatestMistralModelNames | DeepSeekModel | AnyModel
5954
)
6055
"""Type of all supported AI models in lgtm."""
6156

@@ -65,7 +60,7 @@
6560
+ get_args(LatestAnthropicModelNames)
6661
+ get_args(LatestMistralModelNames)
6762
+ get_args(DeepSeekModel)
68-
) # Keep in sync with SupportedAIModels except for LocalAIModel
63+
) # Keep in sync with SupportedAIModels except for AnyModel
6964
"""Tuple of all known supported AI models in lgtm."""
7065

7166

src/lgtm_ai/ai/utils.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from typing import cast
2+
3+
from lgtm_ai.ai.exceptions import InvalidGeminiWildcard
4+
from lgtm_ai.ai.schemas import SupportedGeminiModel
5+
6+
7+
def match_model_by_wildcard[T](model_name: str, model_list: tuple[T, ...]) -> list[T] | None:
8+
"""Match a model name against a list of models with wildcard support."""
9+
if model_name in model_list:
10+
return [cast(T, model_name)]
11+
12+
if "*" not in model_name:
13+
return None
14+
15+
all_matches = [
16+
model for model in model_list if str(model).startswith(model_name.replace("*", "")) or model_name == model
17+
]
18+
if not all_matches:
19+
return None
20+
21+
return all_matches
22+
23+
24+
def select_latest_gemini_model(matches: list[SupportedGeminiModel]) -> SupportedGeminiModel:
25+
if len(matches) == 1:
26+
return matches[0]
27+
28+
model_to_date = {model: model.split("-")[-2:] for model in matches}
29+
30+
# If the values that are supposed to be date-like (06-05, 04-17) are not in the format we expect,
31+
# we just raise an error.
32+
def _looks_like_date(date: list[str]) -> bool:
33+
return len(date) == 2 and all(elem.isdigit() for elem in date)
34+
35+
if not all(_looks_like_date(date) for date in model_to_date.values()):
36+
raise InvalidGeminiWildcard(matches)
37+
38+
# Get the latest model by date
39+
latest_model = max(
40+
matches,
41+
key=lambda model: tuple(int(x) for x in model_to_date[model]),
42+
)
43+
return latest_model

tests/ai/test_utils.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import pytest
55
from lgtm_ai.ai.agent import get_ai_model
6-
from lgtm_ai.ai.exceptions import MissingAIAPIKey, MissingModelUrl
6+
from lgtm_ai.ai.exceptions import InvalidGeminiWildcard, MissingAIAPIKey, MissingModelUrl
77
from pydantic_ai.models.google import GoogleModel
88
from pydantic_ai.models.openai import OpenAIModel
99

@@ -28,3 +28,23 @@ def test_get_ai_model(model: str, model_url: str | None, ai_api_key: str, expect
2828
ai_model = get_ai_model(model, ai_api_key, model_url=model_url)
2929
assert isinstance(ai_model, expected_type)
3030
assert ai_model.model_name == model
31+
32+
33+
@pytest.mark.parametrize(
34+
("model", "expected_model_name", "expectation"),
35+
[
36+
# Normal matches
37+
("gemini-2.5-flash-*", "gemini-2.5-flash-preview-05-20", does_not_raise()),
38+
("gemini-2.5-pro-preview-*", "gemini-2.5-pro-preview-06-05", does_not_raise()),
39+
# Exact match
40+
("gemini-2.5-pro-preview-06-05", "gemini-2.5-pro-preview-06-05", does_not_raise()),
41+
# No wildcard results in no attempt to actually match
42+
("gemini-2.5-pro-preview-", "gemini-2.5-pro-preview-06-05", pytest.raises(MissingModelUrl)),
43+
# Multiple matches that we cannot narrow down by date
44+
("gemini-2*", None, pytest.raises(InvalidGeminiWildcard)),
45+
],
46+
)
47+
def test_get_ai_model_with_wildcard(model: str, expected_model_name: str, expectation: Any) -> None:
48+
with expectation:
49+
ai_model = get_ai_model(model, "fake", model_url=None)
50+
assert ai_model.model_name == expected_model_name

0 commit comments

Comments
 (0)