Skip to content

Commit 908ad0d

Browse files
anchpopclaude
andauthored
Fix Gemini structured output support (#43)
* Fix Gemini structured output by stripping additionalProperties Gemini's OpenAI-compatible endpoint rejects schemas with additionalProperties. Strip it from the request body when sending to generativelanguage.googleapis.com, without affecting cache keys. Also adds Gemini structured output test and bumps to v0.17.1. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix CI: gate tokio::test behind #[cfg(test)] tokio macros feature is only available in dev-dependencies, so the async test needs #[cfg(test)] to avoid compile errors during cargo check. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Mark gemini_structured_output test as #[ignore] Requires GEMINI_API_KEY which isn't available in CI. Run with `cargo test -- --ignored` to include it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Gate dotenvy usage behind feature flag in test CI runs with --no-default-features where dotenvy is unavailable. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3ef1223 commit 908ad0d

4 files changed

Lines changed: 72 additions & 9 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "tysm"
3-
version = "0.17.0"
3+
version = "0.17.1"
44
edition = "2021"
55
description = "Batteries-included Rust OpenAI Client"
66
license = "MIT"

src/chat_completions.rs

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1262,20 +1262,46 @@ impl ChatClient {
12621262
async fn chat_uncached(&self, chat_request: &ChatRequest) -> Result<String, ChatError> {
12631263
let _permit = self.semaphore.acquire().await.unwrap();
12641264

1265-
let response = self
1265+
let mut request = self
12661266
.http_client
12671267
.post(self.chat_completions_url())
12681268
.header("Authorization", format!("Bearer {}", self.api_key.clone()))
1269-
.header("Content-Type", "application/json")
1270-
.json(chat_request)
1271-
.send()
1272-
.await?
1273-
.text()
1274-
.await?;
1269+
.header("Content-Type", "application/json");
1270+
1271+
// Gemini's OpenAI-compatible endpoint doesn't support additionalProperties
1272+
// in JSON schemas. We strip it from the request body without affecting the
1273+
// cache key (which is computed from the original ChatRequest).
1274+
if self.base_url.host_str() == Some("generativelanguage.googleapis.com") {
1275+
let mut body = serde_json::to_value(chat_request).unwrap();
1276+
Self::strip_additional_properties(&mut body);
1277+
request = request.json(&body);
1278+
} else {
1279+
request = request.json(chat_request);
1280+
}
1281+
1282+
let response = request.send().await?.text().await?;
12751283

12761284
Ok(response)
12771285
}
12781286

1287+
/// Recursively remove all `additionalProperties` keys from a JSON value.
1288+
fn strip_additional_properties(value: &mut serde_json::Value) {
1289+
match value {
1290+
serde_json::Value::Object(map) => {
1291+
map.remove("additionalProperties");
1292+
for v in map.values_mut() {
1293+
Self::strip_additional_properties(v);
1294+
}
1295+
}
1296+
serde_json::Value::Array(arr) => {
1297+
for v in arr.iter_mut() {
1298+
Self::strip_additional_properties(v);
1299+
}
1300+
}
1301+
_ => {}
1302+
}
1303+
}
1304+
12791305
fn decode_json<T: DeserializeOwned>(json: &str) -> Result<T, serde_json::Error> {
12801306
match serde_json::from_str(json) {
12811307
Ok(chat_response) => Ok(chat_response),
@@ -1393,3 +1419,28 @@ fn service_tier_excluded_from_cache_key() {
13931419

13941420
assert_ne!(request1.cache_key(), request4.cache_key());
13951421
}
1422+
1423+
#[cfg(test)]
1424+
#[tokio::test]
1425+
#[ignore]
1426+
async fn gemini_structured_output() {
1427+
use schemars::JsonSchema;
1428+
use serde::Deserialize;
1429+
1430+
#[derive(Deserialize, Debug, JsonSchema)]
1431+
#[allow(dead_code)]
1432+
struct CapitalCity {
1433+
city: String,
1434+
country: String,
1435+
}
1436+
1437+
#[cfg(feature = "dotenvy")]
1438+
dotenvy::dotenv().ok();
1439+
let api_key = std::env::var("GEMINI_API_KEY").expect("GEMINI_API_KEY must be set");
1440+
1441+
let client = ChatClient::new(api_key, "gemini-2.5-flash")
1442+
.with_url("https://generativelanguage.googleapis.com/v1beta/openai/");
1443+
1444+
let result: CapitalCity = client.chat("What is the capital of France?").await.unwrap();
1445+
assert_eq!(result.city, "Paris");
1446+
}

src/model_prices.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,18 @@ pub(crate) const CHAT_COMPLETIONS: &[ModelCost] = &[
204204
output: 1.50,
205205
},
206206
// GPT-5 models
207+
ModelCost {
208+
name: "gpt-5.4-pro",
209+
input: 30.00,
210+
cached_input: None,
211+
output: 180.00,
212+
},
213+
ModelCost {
214+
name: "gpt-5.4",
215+
input: 2.50,
216+
cached_input: Some(0.25),
217+
output: 15.00,
218+
},
207219
ModelCost {
208220
name: "gpt-5.2-chat-latest",
209221
input: 1.75,

0 commit comments

Comments
 (0)