Skip to content

Commit cb948e7

Browse files
authored
feat(llm, google): Add support for Gemini models (#133)
This commit introduces support for Google's Gemini models as a new LLM provider. This allows users to leverage the Gemini family of models, including `gemini-1.5-pro` and `gemini-1.5-flash`, for chat completions. This was possible already, using the `openrouter` provider, but this adds first-party support for Google's API (reducing cost, not having to agree to Openrouter's terms of service, improving latency, leveraging Google-specific APIs). The integration is built on top of the `gemini_client_rs` crate (which was fairly heavily modified for this purpose in Adriftdev/gemini-client#8). A new `Google` provider struct implements the `Provider` trait, handling the translation between the internal conversation format and the Gemini API's request and response structure. This includes full support for streaming responses, tool use, and model parameters like temperature and token limits. The provider also correctly handles system prompts and attachments by formatting them into the multi-turn conversation format expected by Gemini. Configuration for the Google provider, including the API key environment variable (`GEMINI_API_KEY`) and a customizable `base_url`, has been added to `jp_config`. As a proof-of-concept, this commit was generated using the following command: ```sh jp query \ --model=google/gemini-2.5-pro-preview-06-05 \ --no-persist \ --new \ --context=commit \ --hide-reasoning \ --no-tool "Give me a commit message" ``` Signed-off-by: Jean Mertz <git@jeanmertz.com>
1 parent 9c2cbf8 commit cb948e7

File tree

16 files changed

+1447
-30
lines changed

16 files changed

+1447
-30
lines changed

Cargo.lock

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ duct = { version = "1", default-features = false }
3939
dyn-clone = { version = "1", default-features = false }
4040
dyn-hash = { version = "0.2", default-features = false }
4141
futures = { version = "0.3", default-features = false }
42+
# See: <https://github.com/Adriftdev/gemini-client/pull/8>
43+
gemini_client_rs = { git = "https://github.com/JeanMertz/gemini-client", default-features = false }
4244
glob = { version = "0.3", default-features = false }
4345
httpmock = { git = "https://github.com/alexliesenfeld/httpmock", default-features = false }
4446
ignore = { version = "0.4", default-features = false }
@@ -114,6 +116,7 @@ format_push_string = "allow"
114116
missing_errors_doc = "allow" # Temporary
115117
option_option = "allow"
116118
pedantic = { level = "warn", priority = -1 }
119+
result_large_err = "allow"
117120
similar_names = "allow"
118121
struct_excessive_bools = "allow"
119122
struct_field_names = "allow"

crates/jp_cli/src/cmd.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,11 @@ impl From<jp_llm::Error> for Error {
399399
("error", error.to_string()),
400400
]
401401
.into(),
402+
Gemini(error) => [
403+
("message", "Gemini error".into()),
404+
("error", error.to_string()),
405+
]
406+
.into(),
402407
};
403408

404409
Self::from(metadata)

crates/jp_config/src/llm/provider/google.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,20 @@ pub struct Config {
88
/// Environment variable that contains the API key.
99
#[config(default = "GEMINI_API_KEY", env = "JP_LLM_PROVIDER_GOOGLE_API_KEY_ENV")]
1010
pub api_key_env: String,
11+
12+
/// The base URL to use for API requests.
13+
#[config(
14+
default = "https://generativelanguage.googleapis.com/v1beta",
15+
env = "JP_LLM_PROVIDER_GOOGLE_BASE_URL"
16+
)]
17+
pub base_url: String,
1118
}
1219

1320
impl Default for Config {
1421
fn default() -> Self {
1522
Self {
1623
api_key_env: "GEMINI_API_KEY".to_owned(),
24+
base_url: "https://generativelanguage.googleapis.com/v1beta".to_owned(),
1725
}
1826
}
1927
}
@@ -23,6 +31,7 @@ impl Config {
2331
pub fn set(&mut self, path: &str, key: &str, value: impl Into<String>) -> Result<()> {
2432
match key {
2533
"api_key_env" => self.api_key_env = value.into(),
34+
"base_url" => self.base_url = value.into(),
2635
_ => return crate::set_error(path, key),
2736
}
2837

crates/jp_llm/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ async-anthropic = { workspace = true }
2323
async-stream = { workspace = true }
2424
async-trait = { workspace = true }
2525
futures = { workspace = true }
26+
gemini_client_rs = { workspace = true }
2627
ollama-rs = { workspace = true, features = ["rustls", "stream"] }
2728
openai_responses = { workspace = true, features = ["stream"] }
2829
reqwest = { workspace = true }

crates/jp_llm/src/error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ pub enum Error {
3535
response: String,
3636
},
3737

38+
#[error("Gemini error: {0}")]
39+
Gemini(#[from] gemini_client_rs::GeminiError),
40+
3841
#[error("Ollama error: {0}")]
3942
Ollama(#[from] ollama_rs::error::OllamaError),
4043

crates/jp_llm/src/provider.rs

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// pub mod deepseek;
2-
// pub mod google;
2+
pub mod google;
33
// pub mod xai;
44
pub mod anthropic;
55
pub mod ollama;
@@ -11,6 +11,7 @@ use std::{mem, pin::Pin};
1111
use anthropic::Anthropic;
1212
use async_trait::async_trait;
1313
use futures::{Stream, StreamExt as _};
14+
use google::Google;
1415
use jp_config::llm::provider;
1516
use jp_conversation::{message::ToolCallRequest, model::ProviderId, Model};
1617
use jp_query::query::{ChatQuery, StructuredQuery};
@@ -40,13 +41,21 @@ pub struct ModelDetails {
4041
/// The maximum output tokens, if known.
4142
pub max_output_tokens: Option<u32>,
4243

43-
/// Whether the model supports reasoning, if known.
44-
pub reasoning: Option<bool>,
44+
/// Whether the model supports reasoning, if unknown, it is assumed to not
45+
/// be supported.
46+
pub reasoning: Option<ReasoningDetails>,
4547

4648
/// The knowledge cutoff date, if known.
4749
pub knowledge_cutoff: Option<Date>,
4850
}
4951

52+
/// Details about the reasoning capabilities of a model.
53+
#[derive(Debug, Clone, Copy, Default, PartialEq)]
54+
pub struct ReasoningDetails {
55+
pub min_tokens: u32,
56+
pub max_tokens: Option<u32>,
57+
}
58+
5059
/// Represents an event yielded by the chat completion stream.
5160
#[derive(Debug, Clone)]
5261
pub enum StreamEvent {
@@ -98,6 +107,19 @@ impl Event {
98107
}
99108
}
100109

110+
impl From<Event> for StreamEvent {
111+
fn from(event: Event) -> Self {
112+
match event {
113+
Event::Content(content) => StreamEvent::ChatChunk(CompletionChunk::Content(content)),
114+
Event::Reasoning(reasoning) => {
115+
StreamEvent::ChatChunk(CompletionChunk::Reasoning(reasoning))
116+
}
117+
Event::ToolCall(call) => StreamEvent::ToolCall(call),
118+
Event::Metadata(key, metadata) => StreamEvent::Metadata(key, metadata),
119+
}
120+
}
121+
}
122+
101123
impl From<Delta> for Option<Result<Event>> {
102124
fn from(delta: Delta) -> Self {
103125
if let Some(content) = delta.content {
@@ -248,14 +270,13 @@ pub trait Provider: std::fmt::Debug + Send + Sync {
248270

249271
pub fn get_provider(id: ProviderId, config: &provider::Config) -> Result<Box<dyn Provider>> {
250272
let provider: Box<dyn Provider> = match id {
251-
// ProviderId::Deepseek => Box::new(Deepseek::try_from(&config.deepseek)?),
252-
// ProviderId::Google => Box::new(Google::try_from(&config.google)?),
253-
// ProviderId::Xai => Box::new(Xai::try_from(&config.xai)?),
254-
ProviderId::Ollama => Box::new(Ollama::try_from(&config.ollama)?),
255273
ProviderId::Anthropic => Box::new(Anthropic::try_from(&config.anthropic)?),
274+
ProviderId::Deepseek => todo!(),
275+
ProviderId::Google => Box::new(Google::try_from(&config.google)?),
276+
ProviderId::Ollama => Box::new(Ollama::try_from(&config.ollama)?),
256277
ProviderId::Openai => Box::new(Openai::try_from(&config.openai)?),
257278
ProviderId::Openrouter => Box::new(Openrouter::try_from(&config.openrouter)?),
258-
_ => todo!(),
279+
ProviderId::Xai => todo!(),
259280
};
260281

261282
Ok(provider)

crates/jp_llm/src/provider/anthropic.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use serde_json::Value;
1919
use time::macros::date;
2020
use tracing::{trace, warn};
2121

22-
use super::{Event, EventStream, ModelDetails, Provider, StreamEvent};
22+
use super::{Event, EventStream, ModelDetails, Provider, ReasoningDetails, StreamEvent};
2323
use crate::{
2424
error::{Error, Result},
2525
provider::{handle_delta, AccumulationState, Delta},
@@ -163,31 +163,31 @@ fn map_model(model: types::Model) -> ModelDetails {
163163
slug: model.id,
164164
context_window: Some(200_000),
165165
max_output_tokens: Some(32_000),
166-
reasoning: Some(true),
166+
reasoning: Some(ReasoningDetails::default()),
167167
knowledge_cutoff: Some(date!(2025 - 3 - 1)),
168168
},
169169
"claude-sonnet-4-0" | "claude-sonnet-4-20250514" => ModelDetails {
170170
provider: ProviderId::Anthropic,
171171
slug: model.id,
172172
context_window: Some(200_000),
173173
max_output_tokens: Some(64_000),
174-
reasoning: Some(true),
174+
reasoning: Some(ReasoningDetails::default()),
175175
knowledge_cutoff: Some(date!(2025 - 3 - 1)),
176176
},
177177
"claude-3-7-sonnet-latest" | "claude-3-7-sonnet-20250219" => ModelDetails {
178178
provider: ProviderId::Anthropic,
179179
slug: model.id,
180180
context_window: Some(200_000),
181181
max_output_tokens: Some(64_000),
182-
reasoning: Some(true),
182+
reasoning: Some(ReasoningDetails::default()),
183183
knowledge_cutoff: Some(date!(2024 - 11 - 1)),
184184
},
185185
"claude-3-5-haiku-latest" | "claude-3-5-haiku-20241022" => ModelDetails {
186186
provider: ProviderId::Anthropic,
187187
slug: model.id,
188188
context_window: Some(200_000),
189189
max_output_tokens: Some(8_192),
190-
reasoning: Some(false),
190+
reasoning: None,
191191
knowledge_cutoff: Some(date!(2024 - 7 - 1)),
192192
},
193193
"claude-3-5-sonnet-latest"
@@ -197,23 +197,23 @@ fn map_model(model: types::Model) -> ModelDetails {
197197
slug: model.id,
198198
context_window: Some(200_000),
199199
max_output_tokens: Some(8_192),
200-
reasoning: Some(false),
200+
reasoning: None,
201201
knowledge_cutoff: Some(date!(2024 - 4 - 1)),
202202
},
203203
"claude-3-opus-latest" | "claude-3-opus-20240229" => ModelDetails {
204204
provider: ProviderId::Anthropic,
205205
slug: model.id,
206206
context_window: Some(200_000),
207207
max_output_tokens: Some(4_096),
208-
reasoning: Some(false),
208+
reasoning: None,
209209
knowledge_cutoff: Some(date!(2023 - 8 - 1)),
210210
},
211211
"claude-3-haiku-20240307" => ModelDetails {
212212
provider: ProviderId::Anthropic,
213213
slug: model.id,
214214
context_window: Some(200_000),
215215
max_output_tokens: Some(4_096),
216-
reasoning: Some(false),
216+
reasoning: None,
217217
knowledge_cutoff: Some(date!(2024 - 8 - 1)),
218218
},
219219
id => {

0 commit comments

Comments
 (0)