mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-06 01:17:46 +08:00
feat(usage): surface Gemini thinkingConfig as reasoning effort
Usage records show a reasoning badge next to the model name for OpenAI and Claude requests, but Gemini requests never got one. The extraction only read the OpenAI/Claude shapes (`reasoning_effort`, `reasoning.effort`, `output_config.effort`), while Gemini states its reasoning depth inside `generationConfig.thinkingConfig` — so nothing was written to the usage metadata and the list and detail views had no badge to render. Read the Gemini shape too, as a fallback after the existing three so the OpenAI and Claude paths are untouched: - `thinkingLevel` / `thinking_level` wins when present, trimmed and lowercased, with the protobuf enum prefix stripped so `THINKING_LEVEL_HIGH` resolves like `high`. - Otherwise `thinkingBudget` / `thinking_budget` goes through the existing shared budget ladder, yielding the same `low|medium|high|xhigh` vocabulary the badge already understands. - Both camelCase and snake_case spellings are read, so a captured client body and a converted provider body resolve to the same label. - `includeThoughts` alone is a visibility flag, not a depth, and produces no badge. Two cases are handled explicitly rather than through the shared ladder: - `thinkingBudget: 0` disables reasoning outright. The shared ladder maps `0..=1664` to `low`, which would report an explicitly disabled request as a shallow one, so it reports `none` instead. - `THINKING_LEVEL_UNSPECIFIED` is the enum's "no explicit level" member, not a depth; it is rejected rather than surfaced as an `unspecified` badge. The frontend needs no change: `UsageModelDisplay` already renders the badge whenever the fields are present, and keeps the `high -> xhigh` mapping format when the requested and upstream efforts differ.
This commit is contained in:
@@ -51,6 +51,75 @@ pub fn extract_provider_reasoning_effort_from_body(value: Option<&Value>) -> Opt
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.and_then(normalize_provider_reasoning_effort)
|
||||
.or_else(|| {
|
||||
// Gemini also nests its payload one level down, so both the flat
|
||||
// `generateContent` body and the `v1internal` envelope that carries it are read.
|
||||
extract_gemini_reasoning_effort_from_body(object).or_else(|| {
|
||||
object
|
||||
.get("request")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(extract_gemini_reasoning_effort_from_body)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Gemini `generateContent` states its reasoning depth inside
|
||||
/// `generationConfig.thinkingConfig`, either as a symbolic `thinkingLevel` or as a token
|
||||
/// `thinkingBudget`. Both camelCase and snake_case spellings are read so that a captured client
|
||||
/// body and a converted provider body resolve to the same label.
|
||||
///
|
||||
/// `includeThoughts` alone is a visibility flag, not a depth, so it never produces a label.
|
||||
fn extract_gemini_reasoning_effort_from_body(
|
||||
object: &serde_json::Map<String, Value>,
|
||||
) -> Option<String> {
|
||||
let generation_config = object
|
||||
.get("generationConfig")
|
||||
.or_else(|| object.get("generation_config"))
|
||||
.and_then(Value::as_object)?;
|
||||
let thinking_config = generation_config
|
||||
.get("thinkingConfig")
|
||||
.or_else(|| generation_config.get("thinking_config"))
|
||||
.and_then(Value::as_object)?;
|
||||
|
||||
if let Some(level) = thinking_config
|
||||
.get("thinkingLevel")
|
||||
.or_else(|| thinking_config.get("thinking_level"))
|
||||
.and_then(Value::as_str)
|
||||
.and_then(normalize_gemini_thinking_level)
|
||||
{
|
||||
return Some(level);
|
||||
}
|
||||
|
||||
thinking_config
|
||||
.get("thinkingBudget")
|
||||
.or_else(|| thinking_config.get("thinking_budget"))
|
||||
.and_then(Value::as_u64)
|
||||
.map(|budget| {
|
||||
// `0` disables reasoning outright. The shared budget ladder collapses it into `low`,
|
||||
// which would report an explicitly disabled request as a shallow one.
|
||||
if budget == 0 {
|
||||
"none".to_string()
|
||||
} else {
|
||||
aether_ai_formats::formats::openai::shared::map_thinking_budget_to_openai_reasoning_effort(budget)
|
||||
.to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Gemini also emits the protobuf enum spelling (`THINKING_LEVEL_HIGH`); the level itself is what
|
||||
/// the badge vocabulary understands, so the enum prefix is stripped before normalizing.
|
||||
///
|
||||
/// `THINKING_LEVEL_UNSPECIFIED` is the enum's "no explicit level" member, not a depth. It is
|
||||
/// rejected rather than surfaced, otherwise the badge would read `unspecified`.
|
||||
fn normalize_gemini_thinking_level(value: &str) -> Option<String> {
|
||||
let normalized = value.trim().to_ascii_lowercase();
|
||||
let normalized = normalized
|
||||
.strip_prefix("thinking_level_")
|
||||
.unwrap_or(normalized.as_str());
|
||||
if normalized == "unspecified" {
|
||||
return None;
|
||||
}
|
||||
normalize_provider_reasoning_effort(normalized)
|
||||
}
|
||||
|
||||
fn normalize_provider_reasoning_effort(value: &str) -> Option<String> {
|
||||
@@ -3239,6 +3308,157 @@ mod tests {
|
||||
assert_eq!(usage.provider_service_tier(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_thinking_level_supplies_reasoning_effort_for_both_body_spellings() {
|
||||
let mut usage = sample_usage();
|
||||
usage.provider_request_body = Some(json!({
|
||||
"generationConfig": {
|
||||
"thinkingConfig": { "includeThoughts": true, "thinkingLevel": "HIGH" }
|
||||
}
|
||||
}));
|
||||
|
||||
assert_eq!(usage.provider_reasoning_effort().as_deref(), Some("high"));
|
||||
|
||||
// The converted provider body keeps snake_case keys, and the client body may carry the
|
||||
// protobuf enum spelling. Both must land on the same badge vocabulary.
|
||||
usage.provider_request_body = Some(json!({
|
||||
"generation_config": {
|
||||
"thinking_config": { "thinking_level": "thinking_level_medium" }
|
||||
}
|
||||
}));
|
||||
|
||||
assert_eq!(usage.provider_reasoning_effort().as_deref(), Some("medium"));
|
||||
|
||||
usage.provider_request_body = Some(json!({
|
||||
"generationConfig": {
|
||||
"thinkingConfig": { "thinkingLevel": " low " }
|
||||
}
|
||||
}));
|
||||
|
||||
assert_eq!(usage.provider_reasoning_effort().as_deref(), Some("low"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_thinking_budget_supplies_reasoning_effort_without_collapsing_zero() {
|
||||
let mut usage = sample_usage();
|
||||
usage.provider_request_body = Some(json!({
|
||||
"generationConfig": { "thinkingConfig": { "thinkingBudget": 8192 } }
|
||||
}));
|
||||
|
||||
assert_eq!(usage.provider_reasoning_effort().as_deref(), Some("xhigh"));
|
||||
|
||||
// `0` disables reasoning. The shared budget ladder maps 0..=1664 to `low`, which would
|
||||
// report an explicitly disabled request as shallow, so the Gemini path reports `none`.
|
||||
usage.provider_request_body = Some(json!({
|
||||
"generationConfig": { "thinkingConfig": { "thinkingBudget": 0 } }
|
||||
}));
|
||||
|
||||
assert_eq!(usage.provider_reasoning_effort().as_deref(), Some("none"));
|
||||
|
||||
usage.provider_request_body = Some(json!({
|
||||
"generation_config": { "thinking_config": { "thinking_budget": 1280 } }
|
||||
}));
|
||||
|
||||
assert_eq!(usage.provider_reasoning_effort().as_deref(), Some("low"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_thinking_config_without_level_or_budget_yields_no_reasoning_effort() {
|
||||
let mut usage = sample_usage();
|
||||
usage.provider_request_body = Some(json!({
|
||||
"generationConfig": { "thinkingConfig": { "includeThoughts": true } }
|
||||
}));
|
||||
|
||||
assert_eq!(usage.provider_reasoning_effort(), None);
|
||||
|
||||
// A level-less, budget-less config must not fall back to metadata either: the captured
|
||||
// body is authoritative and it says nothing about depth.
|
||||
usage.request_metadata = Some(json!({ "provider_reasoning_effort": "max" }));
|
||||
assert_eq!(usage.provider_reasoning_effort(), None);
|
||||
|
||||
// `THINKING_LEVEL_UNSPECIFIED` is the enum's "no explicit level" member, not a depth.
|
||||
usage.request_metadata = None;
|
||||
usage.provider_request_body = Some(json!({
|
||||
"generationConfig": {
|
||||
"thinkingConfig": { "thinkingLevel": "THINKING_LEVEL_UNSPECIFIED" }
|
||||
}
|
||||
}));
|
||||
|
||||
assert_eq!(usage.provider_reasoning_effort(), None);
|
||||
}
|
||||
|
||||
/// The v1internal envelope nests the real `generateContent` payload under `request`. This is
|
||||
/// the shape the Antigravity/Gemini CLI transports actually send upstream, so the extraction
|
||||
/// has to descend into it or every converted `openai:chat -> gemini` request loses its badge.
|
||||
#[test]
|
||||
fn gemini_thinking_config_is_read_from_the_v1internal_envelope() {
|
||||
let mut usage = sample_usage();
|
||||
usage.provider_request_body = Some(json!({
|
||||
"model": "gemini-3.8-flash-tiered",
|
||||
"project": "aicode-consumers",
|
||||
"requestId": "req-1",
|
||||
"requestType": "agent",
|
||||
"userAgent": "vscode/1.X.X (Antigravity/4.3.0)",
|
||||
"request": {
|
||||
"contents": [{ "role": "user", "parts": [{ "text": "hi" }] }],
|
||||
"generationConfig": {
|
||||
"maxOutputTokens": 65536,
|
||||
"thinkingConfig": { "includeThoughts": true, "thinkingLevel": "high" }
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
assert_eq!(usage.provider_reasoning_effort().as_deref(), Some("high"));
|
||||
|
||||
usage.provider_request_body = Some(json!({
|
||||
"model": "gemini-3.8-flash-tiered",
|
||||
"request": {
|
||||
"generation_config": {
|
||||
"thinking_config": { "include_thoughts": true, "thinking_budget": 32768 }
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
assert_eq!(usage.provider_reasoning_effort().as_deref(), Some("xhigh"));
|
||||
|
||||
usage.provider_request_body = Some(json!({
|
||||
"model": "gemini-3.8-flash-tiered",
|
||||
"request": {
|
||||
"generationConfig": { "thinkingConfig": { "thinkingBudget": 0 } }
|
||||
}
|
||||
}));
|
||||
|
||||
assert_eq!(usage.provider_reasoning_effort().as_deref(), Some("none"));
|
||||
}
|
||||
|
||||
/// A converted request that carries only `maxOutputTokens` must stay badge-less rather than
|
||||
/// picking up a depth from somewhere else in the envelope.
|
||||
#[test]
|
||||
fn v1internal_envelope_without_thinking_config_yields_no_reasoning_effort() {
|
||||
let mut usage = sample_usage();
|
||||
usage.provider_request_body = Some(json!({
|
||||
"model": "gemini-3.8-flash-tiered",
|
||||
"project": "aicode-consumers",
|
||||
"request": {
|
||||
"contents": [{ "role": "user", "parts": [{ "text": "hi" }] }],
|
||||
"generationConfig": { "maxOutputTokens": 65536 }
|
||||
}
|
||||
}));
|
||||
|
||||
assert_eq!(usage.provider_reasoning_effort(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_thinking_config_does_not_shadow_explicit_effort_fields() {
|
||||
let mut usage = sample_usage();
|
||||
usage.provider_request_body = Some(json!({
|
||||
"reasoning_effort": "max",
|
||||
"generationConfig": { "thinkingConfig": { "thinkingLevel": "low" } }
|
||||
}));
|
||||
|
||||
assert_eq!(usage.provider_reasoning_effort().as_deref(), Some("max"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requested_and_provider_reasoning_efforts_remain_independent() {
|
||||
let mut usage = sample_usage();
|
||||
|
||||
Reference in New Issue
Block a user