feat(providers): Google Gemini as its own backend with thought signatures

Google joins as a first-class provider (kind `google`, aliases
`gemini`/`google-gemini`/`ai-studio`) on the official Gemini
OpenAI-compatible endpoint
(generativelanguage.googleapis.com/v1beta/openai/), keyed by
GOOGLE_API_KEY / GEMINI_API_KEY. It is a distinct backend, not an
OpenAI alias.

Thought signatures, required for tool-call replay on that route:

- Capture: non-streaming responses and the first streaming chunk of
  each tool call carry extra_content.google.thought_signature; both
  paths store it on the ToolUse content block and it survives the
  stream-to-message assembly.
- Replay: assistant tool-call messages re-attach the signature as
  extra_content.google.thought_signature on the exact official route
  only. Every other provider - and a google row pointed at another
  gateway - gets the field stripped so nothing Google-specific leaks.
- Fail closed: replaying a tool call whose signature was not captured
  on a thinking model (gemini-3*, gemini-2.5-pro/flash) fails before
  transport with an actionable error. gemini-2.5-flash-lite ships
  thinking off and degrades with a warning instead.

Reasoning effort maps onto the documented google.thinking_config.
thinking_level body field (low/high) on the exact route.

Eight focused tests prove round-trip, fail-closed, flash-lite
degradation, cross-provider stripping, neighbor-gateway neutrality,
thinking-level mapping, and both capture paths. Provider registries,
picker tables, CLI arg, agent model roster, and PROVIDERS.md rows are
wired (39 catalog kinds / 44 registry entries).

WIP against the live endpoint: no Gemini key exists in this
environment, so live calls are UNRUN; behavior is verified offline
only.
This commit is contained in:
CodeWhale Bot committed 2026-08-14 02:04:20 -07:00
1 parent 679a4a3542
commit 53af08ce36
54 files changed
+718 -29

No files matched your search

+49
View File
@@ -1196,6 +1196,55 @@ impl Default for ModelRegistry {
supports_tools: true,
supports_reasoning: false,
},
ModelInfo {
id: "gemini-3.1-pro-preview".to_string(),
provider: ProviderKind::Google,
aliases: vec!["gemini-3.1-pro".to_string()],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "gemini-3-pro-preview".to_string(),
provider: ProviderKind::Google,
aliases: vec!["gemini-3-pro".to_string()],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "gemini-3.6-flash".to_string(),
provider: ProviderKind::Google,
aliases: vec![],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "gemini-3.5-flash".to_string(),
provider: ProviderKind::Google,
aliases: vec![],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "gemini-3.5-flash-lite".to_string(),
provider: ProviderKind::Google,
aliases: vec![],
supports_tools: true,
supports_reasoning: false,
},
ModelInfo {
id: "gemini-2.5-pro".to_string(),
provider: ProviderKind::Google,
aliases: vec![],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "gemini-2.5-flash".to_string(),
provider: ProviderKind::Google,
aliases: vec![],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "mistral-code-latest".to_string(),
provider: ProviderKind::Mistral,
+3
View File
@@ -108,6 +108,8 @@ enum ProviderArg {
alias = "la_plateforme"
)]
Mistral,
/// Google Gemini (official OpenAI-compatible endpoint).
Google,
}
impl From<ProviderArg> for ProviderKind {
@@ -148,6 +150,7 @@ impl From<ProviderArg> for ProviderKind {
ProviderArg::Meta => ProviderKind::Meta,
ProviderArg::Xai => ProviderKind::Xai,
ProviderArg::Mistral => ProviderKind::Mistral,
ProviderArg::Google => ProviderKind::Google,
}
}
}
+35
View File
@@ -412,6 +412,16 @@ pub struct ProvidersToml {
alias = "la_plateforme"
)]
pub mistral: ProviderConfigToml,
/// Google Gemini — official OpenAI-compatible endpoint with thought
/// signatures on tool calls.
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "google-gemini",
alias = "google_gemini",
alias = "gemini"
)]
pub google: ProviderConfigToml,
/// Jiangsu Telecom TokenHub — OpenAI-compatible AI gateway.
#[serde(
default,
@@ -641,6 +651,7 @@ impl ProvidersToml {
ProviderKind::Meta => &self.meta,
ProviderKind::Xai => &self.xai,
ProviderKind::Mistral => &self.mistral,
ProviderKind::Google => &self.google,
ProviderKind::Telecomjs => &self.telecomjs,
ProviderKind::ModelstudioTokenPlan => &self.modelstudio_token_plan,
ProviderKind::ModelstudioTokenPlanAnthropic => &self.modelstudio_token_plan_anthropic,
@@ -689,6 +700,7 @@ impl ProvidersToml {
ProviderKind::Meta => &mut self.meta,
ProviderKind::Xai => &mut self.xai,
ProviderKind::Mistral => &mut self.mistral,
ProviderKind::Google => &mut self.google,
ProviderKind::Telecomjs => &mut self.telecomjs,
ProviderKind::ModelstudioTokenPlan => &mut self.modelstudio_token_plan,
ProviderKind::ModelstudioTokenPlanAnthropic => {
@@ -3209,6 +3221,7 @@ impl ConfigToml {
ProviderKind::Meta => DEFAULT_META_BASE_URL.to_string(),
ProviderKind::Xai => DEFAULT_XAI_BASE_URL.to_string(),
ProviderKind::Mistral => DEFAULT_MISTRAL_BASE_URL.to_string(),
ProviderKind::Google => DEFAULT_GOOGLE_BASE_URL.to_string(),
ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL.to_string(),
ProviderKind::ModelstudioTokenPlan
| ProviderKind::ModelstudioTokenPlanAnthropic
@@ -4300,6 +4313,7 @@ fn default_model_for_provider(provider: ProviderKind) -> &'static str {
ProviderKind::Meta => DEFAULT_META_MODEL,
ProviderKind::Xai => DEFAULT_XAI_MODEL,
ProviderKind::Mistral => DEFAULT_MISTRAL_MODEL,
ProviderKind::Google => DEFAULT_GOOGLE_MODEL,
ProviderKind::Telecomjs => DEFAULT_TELECOMJS_MODEL,
ProviderKind::ModelstudioTokenPlan
| ProviderKind::ModelstudioTokenPlanAnthropic
@@ -4349,6 +4363,7 @@ fn default_base_url_for_provider(provider: ProviderKind) -> &'static str {
ProviderKind::Meta => DEFAULT_META_BASE_URL,
ProviderKind::Xai => DEFAULT_XAI_BASE_URL,
ProviderKind::Mistral => DEFAULT_MISTRAL_BASE_URL,
ProviderKind::Google => DEFAULT_GOOGLE_BASE_URL,
ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL,
ProviderKind::ModelstudioTokenPlan => DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL,
ProviderKind::ModelstudioTokenPlanAnthropic => MODELSTUDIO_TOKEN_PLAN_ANTHROPIC_BASE_URL,
@@ -6608,6 +6623,8 @@ struct EnvRuntimeOverrides {
xai_model: Option<String>,
mistral_base_url: Option<String>,
mistral_model: Option<String>,
google_base_url: Option<String>,
google_model: Option<String>,
telecomjs_base_url: Option<String>,
telecomjs_model: Option<String>,
modelstudio_token_plan_base_url: Option<String>,
@@ -6918,6 +6935,22 @@ impl EnvRuntimeOverrides {
xai_model: std::env::var("XAI_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
google_base_url: std::env::var("GOOGLE_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("GEMINI_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
google_model: std::env::var("GOOGLE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("GEMINI_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
mistral_base_url: std::env::var("MISTRAL_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
@@ -7026,6 +7059,7 @@ impl EnvRuntimeOverrides {
ProviderKind::Meta => self.meta_base_url.clone(),
ProviderKind::Xai => self.xai_base_url.clone(),
ProviderKind::Mistral => self.mistral_base_url.clone(),
ProviderKind::Google => self.google_base_url.clone(),
ProviderKind::Telecomjs => self.telecomjs_base_url.clone(),
ProviderKind::ModelstudioTokenPlan | ProviderKind::ModelstudioTokenPlanAnthropic => {
self.modelstudio_token_plan_base_url.clone()
@@ -7070,6 +7104,7 @@ impl EnvRuntimeOverrides {
ProviderKind::Meta => self.meta_model.clone(),
ProviderKind::Xai => self.xai_model.clone(),
ProviderKind::Mistral => self.mistral_model.clone(),
ProviderKind::Google => self.google_model.clone(),
ProviderKind::Telecomjs => self.telecomjs_model.clone(),
ProviderKind::ModelstudioTokenPlan | ProviderKind::ModelstudioTokenPlanAnthropic => {
self.modelstudio_token_plan_model.clone()
+23 -3
View File
@@ -9,8 +9,9 @@ use super::{
DEFAULT_ATLASCLOUD_MODEL, DEFAULT_DEEPINFRA_BASE_URL, DEFAULT_DEEPINFRA_MODEL,
DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL, DEFAULT_DEEPSEEK_ANTHROPIC_MODEL,
DEFAULT_DEEPSEEK_BASE_URL, DEFAULT_DEEPSEEK_MODEL, DEFAULT_FIREWORKS_BASE_URL,
DEFAULT_FIREWORKS_MODEL, DEFAULT_HUGGINGFACE_BASE_URL, DEFAULT_HUGGINGFACE_MODEL,
DEFAULT_LONGCAT_BASE_URL, DEFAULT_LONGCAT_MODEL, DEFAULT_META_BASE_URL, DEFAULT_META_MODEL,
DEFAULT_FIREWORKS_MODEL, DEFAULT_GOOGLE_BASE_URL, DEFAULT_GOOGLE_MODEL,
DEFAULT_HUGGINGFACE_BASE_URL, DEFAULT_HUGGINGFACE_MODEL, DEFAULT_LONGCAT_BASE_URL,
DEFAULT_LONGCAT_MODEL, DEFAULT_META_BASE_URL, DEFAULT_META_MODEL,
DEFAULT_MINIMAX_ANTHROPIC_BASE_URL, DEFAULT_MINIMAX_BASE_URL, DEFAULT_MINIMAX_MODEL,
DEFAULT_MISTRAL_BASE_URL, DEFAULT_MISTRAL_MODEL, DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL,
DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL, DEFAULT_MODELSTUDIO_TOKEN_PLAN_MODEL,
@@ -420,6 +421,12 @@ pub const fn credential_help(kind: ProviderKind) -> CredentialHelp {
docs_url: Some("https://www.alibabacloud.com/help/en/model-studio/"),
guidance: "Sign in to Alibaba Cloud Model Studio (Bailian console), create or copy an API key, and select the plan endpoint matching your subscription (Token Plan or Coding Plan).",
},
ProviderKind::Google => CredentialHelp {
acquisition: ApiKey,
credential_url: Some("https://aistudio.google.com/apikey"),
docs_url: Some("https://ai.google.dev/gemini-api/docs/openai"),
guidance: "Create a Google AI Studio API key. Codewhale uses the official Gemini OpenAI-compatible endpoint and never reads Google OAuth files.",
},
ProviderKind::Custom => CredentialHelp {
acquisition: Configuration,
credential_url: None,
@@ -952,6 +959,18 @@ provider!(
aliases: ["mistral-ai", "mistral_ai", "mistralai", "la-plateforme", "la_plateforme"]
);
provider!(
Google,
Google,
"google",
"Google Gemini",
DEFAULT_GOOGLE_BASE_URL,
DEFAULT_GOOGLE_MODEL,
["GOOGLE_API_KEY", "GEMINI_API_KEY"],
"google",
aliases: ["google-gemini", "google_gemini", "gemini", "google-ai", "google_ai", "ai-studio", "aistudio"]
);
/// OpenAI Codex / ChatGPT OAuth provider using the Responses API.
pub struct OpenaiCodex;
@@ -1580,7 +1599,7 @@ static MODELSTUDIO_CODING_PLAN_ANTHROPIC: ModelstudioCodingPlanAnthropic =
ModelstudioCodingPlanAnthropic;
static CUSTOM: Custom = Custom;
static PROVIDER_REGISTRY: [&dyn Provider; 43] = [
static PROVIDER_REGISTRY: [&dyn Provider; 44] = [
&DEEPSEEK,
&DEEPSEEK_ANTHROPIC,
&NVIDIA_NIM,
@@ -1623,6 +1642,7 @@ static PROVIDER_REGISTRY: [&dyn Provider; 43] = [
&MODELSTUDIO_TOKEN_PLAN_ANTHROPIC,
&MODELSTUDIO_CODING_PLAN,
&MODELSTUDIO_CODING_PLAN_ANTHROPIC,
&Google,
&CUSTOM,
];
+6
View File
@@ -190,3 +190,9 @@ pub(crate) const DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL: &str =
"https://coding-intl.dashscope.aliyuncs.com/v1";
pub(crate) const MODELSTUDIO_CODING_PLAN_ANTHROPIC_BASE_URL: &str =
"https://coding-intl.dashscope.aliyuncs.com/apps/anthropic";
/// Google Gemini OpenAI-compatible Chat Completions base URL.
pub const DEFAULT_GOOGLE_BASE_URL: &str =
"https://generativelanguage.googleapis.com/v1beta/openai/";
/// Default Gemini model for the Google provider (preview flagship, 2026-08).
pub const DEFAULT_GOOGLE_MODEL: &str = "gemini-3.1-pro-preview";
+15 -1
View File
@@ -189,6 +189,19 @@ pub enum ProviderKind {
alias = "alibaba-coding-plan-anthropic"
)]
ModelstudioCodingPlanAnthropic,
/// Google — Gemini OpenAI-compatible endpoint. Its own backend, not an
/// OpenAI alias: thought signatures on tool calls are captured and
/// replayed per Google's contract.
#[serde(
alias = "google-gemini",
alias = "google_gemini",
alias = "gemini",
alias = "google-ai",
alias = "google_ai",
alias = "ai-studio",
alias = "aistudio"
)]
Google,
/// User-defined OpenAI-compatible endpoint (#1519).
///
/// A single dynamic identity for arbitrary `[providers.<name>]
@@ -206,7 +219,7 @@ impl ProviderKind {
/// stay on the enum for serde and `provider_for_kind`, but they are not
/// first-class catalog rows. Plan is `mode` / base_url; dialect is
/// `wire = openai|anthropic` on the primary provider config.
pub const ALL: [Self; 38] = [
pub const ALL: [Self; 39] = [
Self::Deepseek,
Self::NvidiaNim,
Self::Openai,
@@ -244,6 +257,7 @@ impl ProviderKind {
Self::Mistral,
Self::Telecomjs,
Self::ModelstudioTokenPlan,
Self::Google,
Self::Custom,
];
+2 -2
View File
@@ -4811,9 +4811,9 @@ fn meta_model_api_scopes_both_documented_key_names_to_official_endpoint() {
fn provider_metadata_registry_covers_every_provider_kind_once() {
let providers = provider::all_providers();
// Full registry keeps legacy dialect/plan kinds for provider_for_kind.
assert_eq!(providers.len(), 43);
assert_eq!(providers.len(), 44);
// Catalog surface is one identity per vendor (no dual-wire / plan rows).
assert_eq!(ProviderKind::ALL.len(), 38);
assert_eq!(ProviderKind::ALL.len(), 39);
assert!(ProviderKind::ALL.len() < providers.len());
let mut ids = std::collections::BTreeSet::new();
+6
View File
@@ -158,6 +158,12 @@ pub enum ContentBlock {
input: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
caller: Option<ToolCaller>,
/// Google thought signature captured from the OpenAI-compat route's
/// `extra_content.google.thought_signature` on the tool call. Google
/// requires replaying it with the tool result for thinking models;
/// skipped on the wire and in storage for every other provider.
#[serde(skip_serializing_if = "Option::is_none", default)]
thought_signature: Option<String>,
},
#[serde(rename = "tool_result")]
ToolResult {
+3 -1
View File
@@ -457,7 +457,7 @@ where
match event {
StreamEvent::ContentBlockStart {
index,
content_block: ContentBlockStart::ToolUse { id, name, input, .. },
content_block: ContentBlockStart::ToolUse { id, name, input, ..},
} => {
pending_tool_uses.insert(
index,
@@ -1313,6 +1313,7 @@ where
name: call.name.clone(),
input: call.input.clone(),
caller: None,
thought_signature: None,
});
}
if !assistant_content.is_empty() {
@@ -2652,6 +2653,7 @@ mod tests {
name: name.to_string(),
input: json!({}),
caller: None,
thought_signature: None,
},
},
StreamEvent::ContentBlockDelta {
+15
View File
@@ -3239,6 +3239,7 @@ pub(super) fn apply_reasoning_effort(
ApiProvider::Meta => {}
ApiProvider::Xai => {}
ApiProvider::Mistral => {}
ApiProvider::Google => {}
},
"low" | "minimal" | "medium" | "mid" | "high" | "" => match provider {
// Handled by the shared DeepSeek table above, before this match.
@@ -3348,6 +3349,7 @@ pub(super) fn apply_reasoning_effort(
ApiProvider::Meta => {}
ApiProvider::Xai => {}
ApiProvider::Mistral => {}
ApiProvider::Google => {}
},
"xhigh" | "max" | "highest" | "ultracode" => match provider {
// Handled by the shared DeepSeek table above, before this match.
@@ -3434,6 +3436,7 @@ pub(super) fn apply_reasoning_effort(
ApiProvider::Meta => {}
ApiProvider::Xai => {}
ApiProvider::Mistral => {}
ApiProvider::Google => {}
},
_ => {}
}
@@ -4784,6 +4787,7 @@ mod tests {
name: "read_file".to_string(),
input: json!({"path": "src/lib.rs"}),
caller: None,
thought_signature: None,
},
],
},
@@ -5545,6 +5549,7 @@ mod tests {
name: "read_file".to_string(),
input: json!({"path": "config.toml"}),
caller: None,
thought_signature: None,
}],
},
Message {
@@ -6877,6 +6882,7 @@ mod tests {
name: "get_date".to_string(),
input: json!({}),
caller: None,
thought_signature: None,
},
],
},
@@ -6930,6 +6936,7 @@ mod tests {
name: "get_date".to_string(),
input: json!({}),
caller: None,
thought_signature: None,
},
],
},
@@ -7101,6 +7108,7 @@ mod tests {
name: "read_file".to_string(),
input: json!({"path": "Cargo.toml"}),
caller: None,
thought_signature: None,
}],
},
Message {
@@ -8324,6 +8332,7 @@ mod tests {
name: "list_dir".to_string(),
input: json!({}),
caller: None,
thought_signature: None,
},
],
},
@@ -8366,6 +8375,7 @@ mod tests {
name: "web.run".to_string(),
input: json!({}),
caller: None,
thought_signature: None,
},
],
},
@@ -8411,6 +8421,7 @@ mod tests {
name: "read_file".to_string(),
input: json!({"path": "src/main.rs"}),
caller: None,
thought_signature: None,
}],
},
// No tool result follows — it was removed by compaction.
@@ -8457,6 +8468,7 @@ mod tests {
name: "list_dir".to_string(),
input: json!({}),
caller: None,
thought_signature: None,
},
],
},
@@ -8498,18 +8510,21 @@ mod tests {
name: "read_file".to_string(),
input: json!({"path": "a.rs"}),
caller: None,
thought_signature: None,
},
ContentBlock::ToolUse {
id: "t2".to_string(),
name: "read_file".to_string(),
input: json!({"path": "b.rs"}),
caller: None,
thought_signature: None,
},
ContentBlock::ToolUse {
id: "t3".to_string(),
name: "shell".to_string(),
input: json!({"cmd": "ls"}),
caller: None,
thought_signature: None,
},
],
},
+5 -1
View File
@@ -1176,12 +1176,14 @@ mod tests {
name: "read_file".to_string(),
input: json!({"path": "a.txt"}),
caller: None,
thought_signature: None,
},
ContentBlock::ToolUse {
id: "toolu_orphan".to_string(),
name: "task".to_string(),
input: json!({}),
caller: None,
thought_signature: None,
},
],
},
@@ -1203,6 +1205,7 @@ mod tests {
name: "task".to_string(),
input: json!({}),
caller: None,
thought_signature: None,
}],
},
];
@@ -1236,7 +1239,7 @@ mod tests {
// A fully answered history is left alone.
request.messages.truncate(3);
request.messages[1].content.retain(
|block| !matches!(block, ContentBlock::ToolUse { id, .. } if id == "toolu_orphan"),
|block| !matches!(block, ContentBlock::ToolUse { id, ..} if id == "toolu_orphan"),
);
let body = client.build_anthropic_body(&request, true);
let messages = body["messages"].as_array().expect("messages array");
@@ -1501,6 +1504,7 @@ mod tests {
name: "read_file".to_string(),
input: json!({"path": "a.txt"}),
caller: None,
thought_signature: None,
},
],
},
+407 -1
View File
@@ -555,6 +555,7 @@ pub(super) fn apply_route_reasoning_controls(
apply_kimi_code_k3_reasoning_effort(body, provider, base_url, model, effort);
apply_zai_route_reasoning_controls(body, provider, base_url, model, effort);
apply_mistral_route_reasoning_controls(body, provider, base_url, model, effort);
apply_google_thinking_level(body, provider, base_url, model, effort);
}
/// Mistral's polymorphic reasoning-content contract is only proven on its
@@ -578,6 +579,124 @@ fn is_exact_mistral_chat_route(provider: ApiProvider, base_url: &str) -> bool {
) && path == "v1"
}
/// Google's OpenAI-compatibility route. Thought signatures are captured
/// from tool-call `extra_content.google.thought_signature` and replayed on
/// the assistant tool-call messages of later turns; thinking models fail
/// closed when a replayed call has no signature.
fn is_exact_google_chat_route(provider: ApiProvider, base_url: &str) -> bool {
if provider != ApiProvider::Google {
return false;
}
let trimmed = base_url.trim().trim_end_matches('/').to_ascii_lowercase();
let Some((host, path)) = trimmed
.strip_prefix("https://")
.and_then(|rest| rest.split_once('/'))
else {
return false;
};
host == "generativelanguage.googleapis.com" && path == "v1beta/openai"
}
/// Gemini models whose thinking makes thought signatures load-bearing on
/// the OpenAI-compat route. Gemini 2.5 Flash-Lite ships with thinking off
/// by default, so a missing signature there degrades with a warning
/// instead of failing the turn.
fn google_model_requires_thought_signatures(model: &str) -> bool {
let model = model.trim().to_ascii_lowercase();
if model.starts_with("gemini-3") {
return true;
}
if model.starts_with("gemini-2.5-pro") {
return true;
}
model.starts_with("gemini-2.5-flash") && !model.starts_with("gemini-2.5-flash-lite")
}
/// Thinking level for the OpenAI-compat route rides the documented
/// `google.thinking_config.thinking_level` body field (low/high; Gemini 3
/// cannot disable thinking).
fn apply_google_thinking_level(
body: &mut serde_json::Value,
provider: ApiProvider,
base_url: &str,
_model: &str,
effort: Option<&str>,
) {
if !is_exact_google_chat_route(provider, base_url) || effort.is_none() {
return;
}
let level = match effort
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.as_str()
{
"off" | "disabled" | "none" | "false" | "" | "low" | "minimal" | "medium" | "mid" => "low",
_ => "high",
};
body["google"]["thinking_config"]["thinking_level"] = json!(level);
}
/// Fail closed before transport when the exact Google route would replay
/// tool calls without the thought signatures Google's thinking models
/// require. The error names the model and tells the operator how to
/// recover instead of letting Google reject or corrupt the tool loop.
fn validate_google_thought_signature_replay(
provider: ApiProvider,
base_url: &str,
model: &str,
messages: &[Value],
) -> Result<()> {
if !is_exact_google_chat_route(provider, base_url)
|| !google_model_requires_thought_signatures(model)
{
return Ok(());
}
for message in messages {
let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) else {
continue;
};
for call in tool_calls {
let missing = call
.pointer("/extra_content/google/thought_signature")
.and_then(Value::as_str)
.is_none();
if missing {
let id = call.get("id").and_then(Value::as_str).unwrap_or("?");
anyhow::bail!(
"Gemini model `{model}` requires a thought signature to replay tool call \
`{id}`, but none was captured (the turn predates signature capture, or \
the provider omitted it). Start a new session before using tools on \
this route."
);
}
}
}
Ok(())
}
/// Captured Google signatures ride on tool calls as
/// `extra_content.google.thought_signature`. Only the exact Google route
/// may carry them on the wire; every other provider gets them stripped so
/// a route switch never leaks Google-only fields to a foreign gateway.
fn strip_google_tool_call_extra_content(messages: &mut [Value]) {
for message in messages {
let Some(tool_calls) = message.get_mut("tool_calls").and_then(Value::as_array_mut) else {
continue;
};
for call in tool_calls {
if let Some(extra) = call.get_mut("extra_content") {
if let Some(obj) = extra.as_object_mut() {
obj.remove("google");
if obj.is_empty() {
call.as_object_mut().map(|c| c.remove("extra_content"));
}
}
}
}
}
}
fn mistral_model_has_adjustable_reasoning(model: &str) -> bool {
let model = model.trim().to_ascii_lowercase();
model.starts_with("mistral-medium") || model.starts_with("mistral-small")
@@ -870,6 +989,7 @@ pub(crate) fn build_chat_wire_body(
let wire = wire_model_for_provider_route(provider, base_url, &request.model);
crate::models::effective_muse_wire_id(&wire).to_string()
};
validate_google_thought_signature_replay(provider, base_url, &model, &messages)?;
let mut body = if stream {
json!({
"model": model.clone(),
@@ -1468,6 +1588,9 @@ impl<'a> PromptBuilder<'a> {
if is_exact_mistral_chat_route(provider, base_url) {
reshape_mistral_messages_for_reasoning_replay(&mut messages);
}
if !is_exact_google_chat_route(provider, base_url) {
strip_google_tool_call_extra_content(&mut messages);
}
messages
}
@@ -2328,7 +2451,7 @@ fn build_chat_messages_with_reasoning(
name,
input,
caller,
..
thought_signature,
} => {
let args = serde_json::to_string(input).unwrap_or_else(|_| input.to_string());
let mut call = json!({
@@ -2339,6 +2462,9 @@ fn build_chat_messages_with_reasoning(
"arguments": args,
}
});
if let Some(signature) = thought_signature {
call["extra_content"]["google"]["thought_signature"] = json!(signature);
}
if let Some(caller) = caller {
call["caller"] = json!({
"type": caller.caller_type,
@@ -3317,11 +3443,16 @@ fn parse_chat_message_for_route(
})
});
let thought_signature = call
.pointer("/extra_content/google/thought_signature")
.and_then(Value::as_str)
.map(str::to_string);
content_blocks.push(ContentBlock::ToolUse {
id,
name: from_api_tool_name(&name),
input: arguments,
caller,
thought_signature,
});
}
}
@@ -3771,6 +3902,10 @@ fn parse_sse_chunk_with_reasoning_style(
})
});
let thought_signature = tc
.pointer("/extra_content/google/thought_signature")
.and_then(Value::as_str)
.map(str::to_string);
events.push(StreamEvent::ContentBlockStart {
index: block_index,
content_block: ContentBlockStart::ToolUse {
@@ -3778,6 +3913,7 @@ fn parse_sse_chunk_with_reasoning_style(
name: from_api_tool_name(&name),
input: json!({}),
caller,
thought_signature,
},
});
*content_index = (*content_index).saturating_add(1);
@@ -4201,6 +4337,7 @@ mod minimax_reasoning_replay_tests {
name: "read".to_string(),
input: serde_json::json!({ "path": "widget.rs" }),
caller: None,
thought_signature: None,
},
],
},
@@ -5803,6 +5940,7 @@ mod image_block_wire_tests {
name: "read".to_string(),
input: serde_json::json!({"path": "shot.png"}),
caller: None,
thought_signature: None,
}],
},
Message {
@@ -5874,6 +6012,7 @@ mod mistral_reasoning_tests {
name: "read_file".to_string(),
input: json!({"path": "README.md"}),
caller: None,
thought_signature: None,
},
],
},
@@ -6258,3 +6397,270 @@ mod mistral_reasoning_tests {
}
}
}
#[cfg(test)]
mod google_thought_signature_tests {
use super::*;
// ── Google thought signatures (#v0.9.8 Google backend) ──────────────
use crate::config::{DEFAULT_GOOGLE_BASE_URL, DEFAULT_OPENAI_BASE_URL};
fn google_request_with_signed_tool(signature: Option<&str>) -> MessageRequest {
MessageRequest {
model: "gemini-3.1-pro-preview".to_string(),
messages: vec![
Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "Read the config.".to_string(),
cache_control: None,
}],
},
Message {
role: "assistant".to_string(),
content: vec![
ContentBlock::Text {
text: "Reading now.".to_string(),
cache_control: None,
},
ContentBlock::ToolUse {
id: "call-g-1".to_string(),
name: "read".to_string(),
input: json!({"path": "config.toml"}),
caller: None,
thought_signature: signature.map(str::to_string),
},
],
},
Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: "call-g-1".to_string(),
content: "key = \"value\"".to_string(),
is_error: None,
content_blocks: None,
}],
},
],
max_tokens: 64,
system: None,
tools: None,
tool_choice: None,
metadata: None,
thinking: None,
reasoning_effort: Some("high".to_string()),
stream: None,
temperature: None,
top_p: None,
}
}
#[test]
fn google_route_round_trips_thought_signatures_on_replayed_tool_calls() {
let request = google_request_with_signed_tool(Some("SIG-abc123"));
let messages = build_chat_messages_for_request_and_provider_and_route(
&request,
ApiProvider::Google,
DEFAULT_GOOGLE_BASE_URL,
);
let assistant = messages
.iter()
.find(|m| m.get("role") == Some(&json!("assistant")))
.expect("assistant replay message");
let signature = assistant
.pointer("/tool_calls/0/extra_content/google/thought_signature")
.and_then(serde_json::Value::as_str);
assert_eq!(signature, Some("SIG-abc123"));
}
#[test]
fn google_route_fails_closed_when_replayed_signature_is_missing() {
let request = google_request_with_signed_tool(None);
let error = build_chat_wire_body(
&request,
ApiProvider::Google,
DEFAULT_GOOGLE_BASE_URL,
false,
)
.err()
.expect("missing signature must fail closed before transport");
assert!(
error.to_string().contains("thought signature"),
"error must name the missing signature: {error}"
);
}
#[test]
fn google_missing_signature_is_a_warning_not_an_error_for_flash_lite() {
// 2.5 Flash-Lite ships thinking off; Google may legitimately omit
// signatures there, so replay proceeds.
let mut request = google_request_with_signed_tool(None);
request.model = "gemini-2.5-flash-lite".to_string();
build_chat_wire_body(
&request,
ApiProvider::Google,
DEFAULT_GOOGLE_BASE_URL,
false,
)
.expect("flash-lite replay must not require a signature");
}
#[test]
fn non_google_routes_never_see_google_extra_content() {
let request = google_request_with_signed_tool(Some("SIG-abc123"));
let messages = build_chat_messages_for_request_and_provider_and_route(
&request,
ApiProvider::Openai,
DEFAULT_OPENAI_BASE_URL,
);
for message in &messages {
if let Some(tool_calls) = message.get("tool_calls").and_then(|v| v.as_array()) {
for call in tool_calls {
assert!(
call.get("extra_content").is_none(),
"Google-only fields must not leak to other providers"
);
}
}
}
}
#[test]
fn google_neighbor_base_url_does_not_get_google_dialect() {
// A Google provider row pointed at some other gateway must not
// carry signatures or fail closed: the dialect binds to the exact
// official route, not to provider identity alone.
let request = google_request_with_signed_tool(None);
build_chat_wire_body(
&request,
ApiProvider::Google,
"https://gateway.example.com/v1",
false,
)
.expect("non-official Google base URL must not require signatures");
let messages = build_chat_messages_for_request_and_provider_and_route(
&google_request_with_signed_tool(Some("SIG")),
ApiProvider::Google,
"https://gateway.example.com/v1",
);
assert!(
messages
.iter()
.all(|m| m.pointer("/tool_calls/0/extra_content").is_none()),
"signatures must not be sent to a non-Google endpoint"
);
}
#[test]
fn google_thinking_level_maps_effort_onto_documented_body_field() {
let mut request = google_request_with_signed_tool(Some("SIG"));
request.reasoning_effort = Some("high".to_string());
let body = build_chat_wire_body(
&request,
ApiProvider::Google,
DEFAULT_GOOGLE_BASE_URL,
false,
)
.expect("valid google body");
assert_eq!(
body.body
.pointer("/google/thinking_config/thinking_level")
.and_then(serde_json::Value::as_str),
Some("high")
);
let mut low = google_request_with_signed_tool(Some("SIG"));
low.reasoning_effort = Some("low".to_string());
let body = build_chat_wire_body(&low, ApiProvider::Google, DEFAULT_GOOGLE_BASE_URL, false)
.expect("valid google body");
assert_eq!(
body.body
.pointer("/google/thinking_config/thinking_level")
.and_then(serde_json::Value::as_str),
Some("low")
);
}
#[test]
fn google_signature_captured_from_non_streaming_tool_call() {
let payload = json!({
"id": "resp-1",
"model": "gemini-3.1-pro-preview",
"choices": [{
"index": 0,
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call-g-9",
"type": "function",
"function": {
"name": "read",
"arguments": "{\"path\":\"x\"}"
},
"extra_content": {
"google": { "thought_signature": "SIG-stream" }
}
}]
}
}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
});
let response = parse_chat_message(&payload).expect("parses");
let signature = response.content.iter().find_map(|block| match block {
ContentBlock::ToolUse {
thought_signature, ..
} => thought_signature.clone(),
_ => None,
});
assert_eq!(signature.as_deref(), Some("SIG-stream"));
}
#[test]
fn google_signature_captured_from_streaming_first_chunk() {
let chunk = json!({
"choices": [{
"index": 0,
"delta": {
"tool_calls": [{
"index": 0,
"id": "call-g-7",
"type": "function",
"function": { "name": "read", "arguments": "{}" },
"extra_content": {
"google": { "thought_signature": "SIG-delta" }
}
}]
}
}]
});
let mut content_index = 0u32;
let mut text_started = false;
let mut thinking_started = false;
let mut tool_indices = std::collections::HashMap::new();
let mut reasoning_buffers = std::collections::HashMap::new();
let mut inline_tags = InlineReasoningTagState::default();
let events = parse_sse_chunk_with_reasoning_style(
&chunk,
&mut content_index,
&mut text_started,
&mut thinking_started,
&mut tool_indices,
&mut reasoning_buffers,
&mut inline_tags,
ReasoningStreamStyle::None,
);
let signature = events.iter().find_map(|event| match event {
StreamEvent::ContentBlockStart {
content_block:
ContentBlockStart::ToolUse {
thought_signature, ..
},
..
} => thought_signature.clone(),
_ => None,
});
assert_eq!(signature.as_deref(), Some("SIG-delta"));
}
}
@@ -634,7 +634,7 @@ fn decoder_emits_tool_use_block_for_tool_call_delta() {
events.iter().any(|e| matches!(
e,
StreamEvent::ContentBlockStart {
content_block: ContentBlockStart::ToolUse { name, .. },
content_block: ContentBlockStart::ToolUse { name, ..},
..
} if name == "grep_files"
)),
@@ -662,7 +662,7 @@ fn decoder_uses_fallback_name_for_empty_streaming_tool_name() {
events.iter().any(|event| matches!(
event,
StreamEvent::ContentBlockStart {
content_block: ContentBlockStart::ToolUse { name, .. },
content_block: ContentBlockStart::ToolUse { name, ..},
..
} if name == "unknown_tool"
)),
@@ -784,6 +784,7 @@ fn tool_use_message(id: &str, name: &str, input: Value) -> Message {
name: name.to_string(),
input,
caller: None,
thought_signature: None,
}],
}
}
+2 -2
View File
@@ -121,7 +121,7 @@ fn non_streaming_fixture_preserves_tool_call_and_usage() -> Result<()> {
assert!(matches!(
response.content.as_slice(),
[ContentBlock::ToolUse { id, name, input, .. }]
[ContentBlock::ToolUse { id, name, input, ..}]
if id == "call_ds4_0"
&& name == "read_file"
&& input == &json!({"path": "src/main.rs"})
@@ -152,7 +152,7 @@ fn malformed_tool_arguments_remain_visible_for_feedback() -> Result<()> {
assert!(matches!(
response.content.as_slice(),
[ContentBlock::ToolUse { input: Value::String(raw), .. }] if raw == "{bad json"
[ContentBlock::ToolUse { input: Value::String(raw), ..}] if raw == "{bad json"
));
Ok(())
}
+4 -1
View File
@@ -333,7 +333,8 @@ impl DeepSeekClient {
name: from_api_tool_name(&name),
input: json!({}),
caller: None,
},
thought_signature: None,
},
});
current_block_index =
Some(content_block_counter - 1);
@@ -562,11 +563,13 @@ impl DeepSeekClient {
name,
input,
caller,
thought_signature,
} => ContentBlock::ToolUse {
id,
name,
input,
caller,
thought_signature,
},
ContentBlockStart::ServerToolUse { id, name, input } => {
ContentBlock::ServerToolUse { id, name, input }
+3
View File
@@ -938,6 +938,7 @@ fn responses_input_includes_user_role_tool_results() {
name: "checklist_write".to_string(),
input: json!({"items": []}),
caller: None,
thought_signature: None,
}],
},
Message {
@@ -983,6 +984,7 @@ fn responses_input_encodes_tool_call_names() {
name: "web.run".to_string(),
input: json!({}),
caller: None,
thought_signature: None,
}],
}],
max_tokens: 128,
@@ -1158,6 +1160,7 @@ fn tool_result_image_becomes_native_function_output_content() {
name: "read".to_string(),
input: serde_json::json!({"path": "shot.png"}),
caller: None,
thought_signature: None,
}],
},
Message {
@@ -733,6 +733,7 @@ fn cache_inspect_displays_tool_result_budget_metadata() {
name: "shell_command".to_string(),
input: serde_json::json!({"command": "cargo test"}),
caller: None,
thought_signature: None,
}],
});
app.api_messages.push(Message {
@@ -751,6 +752,7 @@ fn cache_inspect_displays_tool_result_budget_metadata() {
name: "shell_command".to_string(),
input: serde_json::json!({"command": "cargo test"}),
caller: None,
thought_signature: None,
}],
});
app.api_messages.push(Message {
@@ -1466,6 +1468,7 @@ fn test_patch_undo_prunes_tool_turn_context() {
name: "write_file".to_string(),
input: serde_json::json!({"path": "a.txt"}),
caller: None,
thought_signature: None,
},
],
});
@@ -1648,12 +1651,14 @@ fn test_prune_undone_tool_context_preserves_prior_tool_pairs() {
name: "write_file".to_string(),
input: serde_json::json!({"path": "a.txt"}),
caller: None,
thought_signature: None,
},
ContentBlock::ToolUse {
id: "call-b".to_string(),
name: "write_file".to_string(),
input: serde_json::json!({"path": "b.txt"}),
caller: None,
thought_signature: None,
},
],
});
@@ -1691,7 +1696,7 @@ fn test_prune_undone_tool_context_preserves_prior_tool_pairs() {
&app.api_messages[1].content[..],
[
ContentBlock::Text { .. },
ContentBlock::ToolUse { id, .. }
ContentBlock::ToolUse { id, ..}
] if id == "call-a"
));
assert!(matches!(
+1 -1
View File
@@ -61,7 +61,7 @@ pub(crate) fn prune_undone_tool_context(app: &mut App, tool_id: &str) {
msg.content
.iter()
.position(
|block| matches!(block, ContentBlock::ToolUse { id, .. } if id == tool_id),
|block| matches!(block, ContentBlock::ToolUse { id, ..} if id == tool_id),
)
.map(|block_idx| (msg_idx, block_idx))
})
@@ -476,6 +476,7 @@ fn render_content_block(out: &mut String, index: usize, block: &ContentBlock) {
name,
input,
caller,
..
} => {
let _ = writeln!(out, "### Content {index}: Tool call\n");
let _ = writeln!(out, "- ID: {}", inline_text(id));
@@ -961,6 +962,7 @@ mod tests {
caller_type: "code_execution_20250825".to_string(),
tool_id: Some("server-tool-1".to_string()),
}),
thought_signature: None,
},
],
},
@@ -278,6 +278,7 @@ fn block_payload(block: &ContentBlock) -> Value {
name,
input,
caller,
..
} => json!({
"type": "tool_use",
"id": id,
@@ -1294,6 +1295,7 @@ mod tests {
caller_type: "code_execution_20250825".to_string(),
tool_id: None,
}),
thought_signature: None,
},
],
},
@@ -1428,6 +1430,7 @@ mod tests {
name: "view_image".to_string(),
input: json!({}),
caller: None,
thought_signature: None,
});
let json = stdout_json(&execute_structcopy(
&mut app,
@@ -1456,6 +1459,7 @@ mod tests {
input: json!({}),
// No caller recorded: also an unknown, also null.
caller: None,
thought_signature: None,
}],
},
Message {
@@ -1740,6 +1744,7 @@ mod tests {
name: "exec_command".to_string(),
input: json!({}),
caller: None,
thought_signature: None,
});
let json = stdout_json(&execute_structcopy(
&mut app,
@@ -1765,6 +1770,7 @@ mod tests {
name: "exec_command".to_string(),
input: json!({}),
caller: None,
thought_signature: None,
});
let json = stdout_json(&execute_structcopy(
&mut app,
@@ -1804,6 +1810,7 @@ mod tests {
name: "exec_command".to_string(),
input,
caller: None,
thought_signature: None,
}],
}];
@@ -1875,6 +1882,7 @@ mod tests {
"pass\u{7}word": "another-plain-value-that-must-not-leak",
}),
caller: None,
thought_signature: None,
}],
}];
@@ -1940,6 +1948,7 @@ mod tests {
name: "exec_command".to_string(),
input: json!({exact: 1, same_after_flatten: 2}),
caller: None,
thought_signature: None,
}],
}];
@@ -2254,6 +2263,7 @@ mod tests {
"unc": r"\\server\private\customer.txt",
}),
caller: None,
thought_signature: None,
}],
}];
@@ -2411,6 +2421,7 @@ mod tests {
name: "exec_command".to_string(),
input: json!({"a": {"b": {"c": {"d": {"e": "too deep"}}}}}),
caller: None,
thought_signature: None,
}],
}];
let caps = Caps {
@@ -2453,6 +2464,7 @@ mod tests {
"deep": {"one": {"two": ["cut-a", "cut-b"]}},
}),
caller: None,
thought_signature: None,
}],
}];
let caps = Caps {
@@ -2521,6 +2533,7 @@ mod tests {
name: "exec_command".to_string(),
input: json!({"deep": {"one": {long_a: 1, long_b: 2}}}),
caller: None,
thought_signature: None,
}],
}];
let caps = Caps {
+2
View File
@@ -1307,6 +1307,7 @@ mod tests {
name: name.to_string(),
input,
caller: None,
thought_signature: None,
}],
}
}
@@ -1841,6 +1842,7 @@ mod tests {
name: "read_file".to_string(),
input: serde_json::json!({"path": "Cargo.toml"}),
caller: None,
thought_signature: None,
},
],
},
+1
View File
@@ -192,6 +192,7 @@ fn oversized_tool_pair(id: &str, content: String) -> Vec<Message> {
name: "read_file".to_string(),
input: serde_json::json!({"path": "src/compaction.rs"}),
caller: None,
thought_signature: None,
}],
},
Message {
+40 -2
View File
@@ -88,6 +88,10 @@ pub enum ApiProvider {
Xai,
/// Mistral AI — la Plateforme (OpenAI-compatible Chat Completions).
Mistral,
/// Google Gemini — official OpenAI-compatible endpoint. A distinct
/// backend, not an OpenAI alias: thought signatures on tool calls are
/// captured and replayed per Google's contract.
Google,
/// Jiangsu Telecom TokenHub — OpenAI-compatible AI gateway.
Telecomjs,
/// Alibaba Cloud Model Studio — Token Plan (OpenAI-compatible Chat Completions).
@@ -317,7 +321,7 @@ impl ApiProvider {
/// `ApiProvider` discriminant → `ProviderKind` lookup.
/// Index 1 is `None` for the legacy `DeepseekCN` variant.
const KIND_LOOKUP: [Option<codewhale_config::ProviderKind>; 44] = [
const KIND_LOOKUP: [Option<codewhale_config::ProviderKind>; 45] = [
Some(codewhale_config::ProviderKind::Deepseek),
None, // DeepseekCN
Some(codewhale_config::ProviderKind::DeepseekAnthropic),
@@ -356,6 +360,7 @@ impl ApiProvider {
Some(codewhale_config::ProviderKind::Meta),
Some(codewhale_config::ProviderKind::Xai),
Some(codewhale_config::ProviderKind::Mistral),
Some(codewhale_config::ProviderKind::Google),
Some(codewhale_config::ProviderKind::Telecomjs),
Some(codewhale_config::ProviderKind::ModelstudioTokenPlan),
Some(codewhale_config::ProviderKind::ModelstudioTokenPlanAnthropic),
@@ -365,7 +370,7 @@ impl ApiProvider {
];
/// `ProviderKind` discriminant → `ApiProvider` lookup.
const FROM_KIND_LOOKUP: [Self; 43] = [
const FROM_KIND_LOOKUP: [Self; 44] = [
Self::Deepseek,
Self::DeepseekAnthropic,
Self::NvidiaNim,
@@ -408,6 +413,7 @@ impl ApiProvider {
Self::ModelstudioTokenPlanAnthropic,
Self::ModelstudioCodingPlan,
Self::ModelstudioCodingPlanAnthropic,
Self::Google,
Self::Custom,
];
@@ -1549,6 +1555,15 @@ pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'stati
"mistral-small-latest",
"mistral-large-latest",
],
ApiProvider::Google => vec![
DEFAULT_GOOGLE_MODEL,
"gemini-3-pro-preview",
"gemini-3.6-flash",
"gemini-3.5-flash",
"gemini-3.5-flash-lite",
"gemini-2.5-pro",
"gemini-2.5-flash",
],
// Custom endpoints expose no built-in completion names; the user
// supplies their own model id (#1519).
ApiProvider::Custom => Vec::new(),
@@ -3442,6 +3457,13 @@ pub struct ProvidersConfig {
alias = "la_plateforme"
)]
pub mistral: ProviderConfig,
#[serde(
default,
alias = "google-gemini",
alias = "google_gemini",
alias = "gemini"
)]
pub google: ProviderConfig,
#[serde(
default,
alias = "telecom-js",
@@ -4899,6 +4921,7 @@ impl Config {
ApiProvider::Meta => &providers.meta,
ApiProvider::Xai => &providers.xai,
ApiProvider::Mistral => &providers.mistral,
ApiProvider::Google => &providers.google,
ApiProvider::Telecomjs => &providers.telecomjs,
ApiProvider::ModelstudioTokenPlan => &providers.modelstudio_token_plan,
ApiProvider::ModelstudioTokenPlanAnthropic => {
@@ -4976,6 +4999,7 @@ impl Config {
ApiProvider::Meta => &mut providers.meta,
ApiProvider::Xai => &mut providers.xai,
ApiProvider::Mistral => &mut providers.mistral,
ApiProvider::Google => &mut providers.google,
ApiProvider::Telecomjs => &mut providers.telecomjs,
ApiProvider::ModelstudioTokenPlan => &mut providers.modelstudio_token_plan,
ApiProvider::ModelstudioTokenPlanAnthropic => {
@@ -5338,6 +5362,7 @@ impl Config {
ApiProvider::Meta => DEFAULT_META_MODEL,
ApiProvider::Xai => DEFAULT_XAI_MODEL,
ApiProvider::Mistral => DEFAULT_MISTRAL_MODEL,
ApiProvider::Google => DEFAULT_GOOGLE_MODEL,
ApiProvider::Telecomjs => DEFAULT_TELECOMJS_MODEL,
ApiProvider::ModelstudioTokenPlan
| ApiProvider::ModelstudioTokenPlanAnthropic
@@ -5457,6 +5482,7 @@ impl Config {
| ApiProvider::Meta
| ApiProvider::Xai
| ApiProvider::Mistral
| ApiProvider::Google
| ApiProvider::Telecomjs
| ApiProvider::ModelstudioTokenPlan
| ApiProvider::ModelstudioTokenPlanAnthropic
@@ -5563,6 +5589,7 @@ impl Config {
ApiProvider::Meta => DEFAULT_META_BASE_URL,
ApiProvider::Xai => DEFAULT_XAI_BASE_URL,
ApiProvider::Mistral => DEFAULT_MISTRAL_BASE_URL,
ApiProvider::Google => DEFAULT_GOOGLE_BASE_URL,
ApiProvider::Telecomjs => DEFAULT_TELECOMJS_BASE_URL,
ApiProvider::ModelstudioTokenPlan
| ApiProvider::ModelstudioTokenPlanAnthropic
@@ -7133,6 +7160,7 @@ fn provider_env_base_url_override(provider: ApiProvider) -> Option<String> {
ApiProvider::Meta => &["META_MODEL_API_BASE_URL", "MODEL_API_BASE_URL"],
ApiProvider::Xai => &["XAI_BASE_URL"],
ApiProvider::Mistral => &["MISTRAL_BASE_URL"],
ApiProvider::Google => &["GOOGLE_BASE_URL", "GEMINI_BASE_URL"],
ApiProvider::Telecomjs => &["TELECOMJS_BASE_URL"],
ApiProvider::ModelstudioTokenPlan | ApiProvider::ModelstudioTokenPlanAnthropic => {
&["MODELSTUDIO_TOKEN_PLAN_BASE_URL"]
@@ -7477,6 +7505,13 @@ fn apply_env_overrides_unlocked(config: &mut Config, policy: ConfigEnvironmentPo
.mistral
.base_url = Some(value);
}
ApiProvider::Google => {
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.google
.base_url = Some(value);
}
ApiProvider::Telecomjs => {
config
.providers
@@ -7838,6 +7873,7 @@ fn apply_env_overrides_unlocked(config: &mut Config, policy: ConfigEnvironmentPo
ApiProvider::Meta => &mut providers.meta,
ApiProvider::Xai => &mut providers.xai,
ApiProvider::Mistral => &mut providers.mistral,
ApiProvider::Google => &mut providers.google,
ApiProvider::Telecomjs => &mut providers.telecomjs,
ApiProvider::ModelstudioTokenPlan => &mut providers.modelstudio_token_plan,
ApiProvider::ModelstudioTokenPlanAnthropic => {
@@ -8183,6 +8219,7 @@ fn apply_env_overrides_unlocked(config: &mut Config, policy: ConfigEnvironmentPo
ApiProvider::Meta => &mut providers.meta,
ApiProvider::Xai => &mut providers.xai,
ApiProvider::Mistral => &mut providers.mistral,
ApiProvider::Google => &mut providers.google,
ApiProvider::Telecomjs => &mut providers.telecomjs,
ApiProvider::ModelstudioTokenPlan => &mut providers.modelstudio_token_plan,
ApiProvider::ModelstudioTokenPlanAnthropic => {
@@ -9581,6 +9618,7 @@ fn merge_providers(
meta: merge_provider_config(base.meta, override_cfg.meta),
xai: merge_provider_config(base.xai, override_cfg.xai),
mistral: merge_provider_config(base.mistral, override_cfg.mistral),
google: merge_provider_config(base.google, override_cfg.google),
telecomjs: merge_provider_config(base.telecomjs, override_cfg.telecomjs),
modelstudio_token_plan: merge_provider_config(
base.modelstudio_token_plan,
+3
View File
@@ -222,6 +222,9 @@ pub const XAI_GROK_4_20_0309_NON_REASONING_MODEL: &str = "grok-4.20-0309-non-rea
pub const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1";
pub const DEFAULT_MISTRAL_MODEL: &str = "mistral-code-latest";
pub const DEFAULT_MISTRAL_BASE_URL: &str = "https://api.mistral.ai/v1";
pub const DEFAULT_GOOGLE_MODEL: &str = "gemini-3.1-pro-preview";
pub const DEFAULT_GOOGLE_BASE_URL: &str =
"https://generativelanguage.googleapis.com/v1beta/openai/";
pub const DEFAULT_TELECOMJS_MODEL: &str = "deepseek-v4-pro";
pub const DEFAULT_TELECOMJS_BASE_URL: &str = "https://aigw.telecomjs.com/v1";
// Alibaba Cloud Model Studio (DashScope) defaults
+1
View File
@@ -314,6 +314,7 @@ fn provider_base_url_table_key(provider: ApiProvider) -> anyhow::Result<&'static
ApiProvider::Meta => Ok("meta"),
ApiProvider::Xai => Ok("xai"),
ApiProvider::Mistral => Ok("mistral"),
ApiProvider::Google => Ok("google"),
ApiProvider::Telecomjs => Ok("telecomjs"),
ApiProvider::ModelstudioTokenPlan => Ok("modelstudio_token_plan"),
ApiProvider::ModelstudioTokenPlanAnthropic => Ok("modelstudio_token_plan_anthropic"),
+3
View File
@@ -20,6 +20,9 @@ pub(super) struct ToolUseState {
pub(super) name: String,
pub(super) input: serde_json::Value,
pub(super) caller: Option<ToolCaller>,
/// Google thought signature captured on the tool call; replayed with the
/// assistant tool-call message on later turns.
pub(super) thought_signature: Option<String>,
pub(super) input_buffer: String,
pub(super) input_parse_error: Option<String>,
}
+3
View File
@@ -11696,6 +11696,7 @@ async fn session_update_preserves_reasoning_tool_only_turn() {
name: "read_file".to_string(),
input: json!({"path": "Cargo.toml"}),
caller: None,
thought_signature: None,
},
],
};
@@ -14668,6 +14669,7 @@ fn turn_metadata_skips_tool_result_messages() {
name: "read_file".to_string(),
input: serde_json::json!({"path": "src/lib.rs"}),
caller: None,
thought_signature: None,
}],
});
// Tool result, stored as role="user" internally.
@@ -15665,6 +15667,7 @@ fn tool_state(initial: serde_json::Value, buffer: &str) -> ToolUseState {
name: "exec_shell".into(),
input: initial,
caller: None,
thought_signature: None,
input_buffer: buffer.into(),
input_parse_error: None,
}
+10 -4
View File
@@ -1315,6 +1315,7 @@ impl Engine {
name,
input,
caller,
thought_signature,
} => {
crate::logging::info(format!(
"Tool '{name}' block start. Initial input: {input:?}"
@@ -1330,6 +1331,7 @@ impl Engine {
name,
input,
caller,
thought_signature,
input_buffer: String::new(),
input_parse_error: None,
});
@@ -1345,6 +1347,7 @@ impl Engine {
name,
input,
caller: None,
thought_signature: None,
input_buffer: String::new(),
input_parse_error: None,
});
@@ -1691,6 +1694,7 @@ impl Engine {
name: tool.name.clone(),
input: tool.input.clone(),
caller: tool.caller.clone(),
thought_signature: tool.thought_signature.clone(),
});
}
let has_sendable_assistant_content = resume_blocks.iter().any(|block| {
@@ -1768,6 +1772,7 @@ impl Engine {
name: call.name,
input: call.args,
caller: None,
thought_signature: None,
input_buffer: String::new(),
input_parse_error: None,
});
@@ -1797,6 +1802,7 @@ impl Engine {
name: tool.name.clone(),
input: tool.input.clone(),
caller: tool.caller.clone(),
thought_signature: tool.thought_signature.clone(),
});
}
@@ -5094,12 +5100,12 @@ mod tests {
/// assistant message, the streaming parser emits the events in this order:
///
/// ```text
/// ContentBlockStart::ToolUse { index: 0, .. } // tool #1
/// ContentBlockStart::ToolUse { index: 0, ..} // tool #1
/// ContentBlockDelta { index: 0, .. } // its arguments
/// ContentBlockStart::ToolUse { index: 1, .. } // tool #2
/// ContentBlockStart::ToolUse { index: 1, ..} // tool #2
/// ContentBlockDelta { index: 1, .. }
/// …
/// ContentBlockStart::ToolUse { index: N-1, .. }
/// ContentBlockStart::ToolUse { index: N-1, ..}
/// ContentBlockDelta { index: N-1, .. }
/// ContentBlockStop { index: 0 } // ── only flushed at
/// ContentBlockStop { index: 1 } // finish_reason
@@ -5119,7 +5125,7 @@ mod tests {
let mut current_tool_indices: std::collections::HashMap<u32, usize> =
std::collections::HashMap::new();
// Simulate `ContentBlockStart::ToolUse { index: i }` for 7 tools.
// Simulate `ContentBlockStart::ToolUse { index: i, ..}` for 7 tools.
for block_index in 0..7u32 {
current_tool_indices.insert(block_index, block_index as usize);
}
+1
View File
@@ -15760,6 +15760,7 @@ mod terminal_mode_tests {
name: "exec_shell".to_string(),
input: serde_json::json!({"command": "cargo test"}),
caller: None,
thought_signature: None,
},
],
},
+1
View File
@@ -400,6 +400,7 @@ pub mod canned {
name: name.to_string(),
input: Value::Null,
caller: None,
thought_signature: None,
},
}
}
+11
View File
@@ -75,6 +75,8 @@ pub enum ModelProvider {
Xai,
/// Mistral AI la Plateforme models.
Mistral,
/// Google Gemini models (official OpenAI-compatible route).
Google,
/// Anything not otherwise classified (still gets real metadata via the
/// `models.rs` heuristics where possible).
Other,
@@ -201,6 +203,15 @@ const SEED_MODEL_IDS: &[(&str, ModelProvider)] = &[
("grok-4.20-0309-non-reasoning", ModelProvider::Xai),
// --- Mistral AI (current first-party roster; deprecated Magistral remains
// accepted through the long-tail models.rs compatibility path) ---
// --- Google Gemini (official OpenAI-compatible route; preview flagships
// as listed by Google's model pages, 2026-08).
("gemini-3.1-pro-preview", ModelProvider::Google),
("gemini-3-pro-preview", ModelProvider::Google),
("gemini-3.6-flash", ModelProvider::Google),
("gemini-3.5-flash", ModelProvider::Google),
("gemini-3.5-flash-lite", ModelProvider::Google),
("gemini-2.5-pro", ModelProvider::Google),
("gemini-2.5-flash", ModelProvider::Google),
("mistral-code-latest", ModelProvider::Mistral),
("mistral-medium-latest", ModelProvider::Mistral),
("mistral-small-latest", ModelProvider::Mistral),
+4
View File
@@ -651,6 +651,10 @@ pub enum ContentBlockStart {
input: serde_json::Value, // usually empty or partial
#[serde(skip_serializing_if = "Option::is_none")]
caller: Option<ToolCaller>,
/// Google thought signature, when the first streaming chunk of this
/// tool call carried `extra_content.google.thought_signature`.
#[serde(skip_serializing_if = "Option::is_none")]
thought_signature: Option<String>,
},
#[serde(rename = "server_tool_use")]
ServerToolUse {
@@ -13,5 +13,3 @@ pub mod parsers;
#[cfg(test)]
mod tests;
pub mod types;
pub use parsers::MarketplaceDocument;
+1 -1
View File
@@ -6,7 +6,7 @@
use serde_json::Value;
use super::MarketplaceDocument;
use super::parsers::MarketplaceDocument;
use super::parsers::parse_catalog;
use super::types::{
CatalogTier, MarketplaceCatalogId, MarketplaceFormat, MarketplaceInstallPlan,
+2
View File
@@ -636,6 +636,7 @@ mod tests {
name: name.to_string(),
input,
caller: None,
thought_signature: None,
}],
}
}
@@ -827,6 +828,7 @@ mod tests {
name: "purge_context".to_string(),
input: json!({"operations": operations}),
caller: None,
thought_signature: None,
}],
model: "mock-model".to_string(),
stop_reason: None,
+1
View File
@@ -357,6 +357,7 @@ fn compact_content_block(block: &ContentBlock) -> Value {
name,
input,
caller,
..
} => json!({
"type": "tool_use",
"id": id,
+2 -2
View File
@@ -622,6 +622,7 @@ pub(super) fn messages_from_thread_detail(detail: &ThreadDetail) -> Vec<Message>
name: tool_name,
input,
caller: None,
thought_signature: None,
});
}
}
@@ -825,8 +826,7 @@ pub(super) fn session_to_detail(session: SavedSession) -> SessionDetailResponse
id,
name,
input,
caller,
} => {
caller, ..} => {
let mut obj =
json!({ "type": "tool_use", "id": id, "name": name, "input": input });
if let Some(caller) = caller {
+1
View File
@@ -226,6 +226,7 @@ fn session_detail_tool_use_preserves_caller_metadata() {
caller_type: "subagent".to_string(),
tool_id: Some("parent-tool".to_string()),
}),
thought_signature: None,
},
]));
+1
View File
@@ -6103,6 +6103,7 @@ impl RuntimeThreadManager {
name: tool_name,
input,
caller: None,
thought_signature: None,
});
}
}
+2
View File
@@ -3235,12 +3235,14 @@ async fn seed_thread_keeps_tool_results_on_preceding_turn() -> Result<()> {
name: "shell".to_string(),
input: json!({ "cmd": "one" }),
caller: None,
thought_signature: None,
},
ContentBlock::ToolUse {
id: "tool-2".to_string(),
name: "shell".to_string(),
input: json!({ "cmd": "two" }),
caller: None,
thought_signature: None,
},
],
},
+6
View File
@@ -2545,6 +2545,7 @@ mod tests {
name: "read_file".to_string(),
input: serde_json::json!({"path": "README.md"}),
caller: None,
thought_signature: None,
}],
}];
let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
@@ -2579,6 +2580,7 @@ mod tests {
name: "read_file".to_string(),
input: serde_json::json!({"path": "README.md"}),
caller: None,
thought_signature: None,
}],
}];
let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
@@ -2615,6 +2617,7 @@ mod tests {
name: "read_file".to_string(),
input: serde_json::json!({"path": "README.md"}),
caller: None,
thought_signature: None,
}],
}];
let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
@@ -2678,6 +2681,7 @@ mod tests {
]
}),
caller: None,
thought_signature: None,
}],
},
Message {
@@ -2725,6 +2729,7 @@ mod tests {
name: "exec_shell".to_string(),
input: serde_json::json!({"command": "cargo test -p codewhale-tui"}),
caller: None,
thought_signature: None,
}],
},
Message {
@@ -2777,6 +2782,7 @@ mod tests {
name: "exec_shell".to_string(),
input: serde_json::json!({"command": "cargo check"}),
caller: None,
thought_signature: None,
}],
},
Message {
+1
View File
@@ -390,6 +390,7 @@ mod tests {
name: "read_file".to_string(),
input: serde_json::json!({ "path": "/etc/shadow" }),
caller: None,
thought_signature: None,
},
ContentBlock::ToolResult {
tool_use_id: "call-1".to_string(),
+1
View File
@@ -222,6 +222,7 @@ mod tests {
name: "read_file".to_string(),
input: json!({"path": "README.md"}),
caller: None,
thought_signature: None,
}],
}
}
+1
View File
@@ -317,6 +317,7 @@ mod tests {
name: name.to_string(),
input,
caller: None,
thought_signature: None,
}],
}
}
+1
View File
@@ -450,6 +450,7 @@ mod tests {
name: "exec_shell".to_string(),
input: serde_json::json!({"command": format!("echo {i}")}),
caller: None,
thought_signature: None,
}],
});
// user message with ToolResult
+1
View File
@@ -6538,6 +6538,7 @@ async fn small_surface_fork_context_survives_fresh_child_discovery() {
name: TOOL_SEARCH_NAME.to_string(),
input: json!({"query": "web"}),
caller: None,
thought_signature: None,
}],
},
Message {
+1
View File
@@ -1055,6 +1055,7 @@ fn history_replays_update_plan_tool_use_as_plan_card() {
]
}),
caller: None,
thought_signature: None,
}],
};
+1
View File
@@ -90,6 +90,7 @@ pub(crate) fn push_assistant_message(
name,
input,
caller: None,
thought_signature: None,
});
}
+1 -1
View File
@@ -586,7 +586,7 @@ pub(crate) fn live_tool_receipt_messages(
let mut messages = Vec::with_capacity(2);
if let Some(tool_use_msg) = app.api_messages.iter().rev().find(|message| {
message.content.iter().any(|block| {
matches!(block, ContentBlock::ToolUse { id: tool_use_id, .. } if tool_use_id == id)
matches!(block, ContentBlock::ToolUse { id: tool_use_id, ..} if tool_use_id == id)
})
}) {
messages.push(tool_use_msg.clone());
+1
View File
@@ -873,6 +873,7 @@ pub(crate) fn mirror_saved_api_key_in_config(
ApiProvider::Meta => &mut providers.meta,
ApiProvider::Xai => &mut providers.xai,
ApiProvider::Mistral => &mut providers.mistral,
ApiProvider::Google => &mut providers.google,
ApiProvider::Telecomjs => &mut providers.telecomjs,
ApiProvider::ModelstudioTokenPlan => &mut providers.modelstudio_token_plan,
ApiProvider::ModelstudioTokenPlanAnthropic => {
+5 -1
View File
@@ -5282,6 +5282,7 @@ async fn tool_result_api_content_never_advertises_unowned_live_output_as_retriev
name: "exec_shell".to_string(),
input: serde_json::json!({"command": "cargo test"}),
caller: None,
thought_signature: None,
}],
});
@@ -5314,6 +5315,7 @@ fn live_tool_receipt_messages_clones_only_matching_tool_use() {
name: "exec_shell".to_string(),
input: serde_json::json!({"command": "old"}),
caller: None,
thought_signature: None,
}],
});
app.api_messages.push(Message {
@@ -5332,6 +5334,7 @@ fn live_tool_receipt_messages_clones_only_matching_tool_use() {
name: "read_file".to_string(),
input: serde_json::json!({"path": "src/main.rs"}),
caller: None,
thought_signature: None,
}],
});
@@ -5340,7 +5343,7 @@ fn live_tool_receipt_messages_clones_only_matching_tool_use() {
assert_eq!(messages.len(), 2);
assert!(matches!(
&messages[0].content[0],
ContentBlock::ToolUse { id, name, .. } if id == "call-new" && name == "read_file"
ContentBlock::ToolUse { id, name, ..} if id == "call-new" && name == "read_file"
));
assert!(matches!(
&messages[1].content[0],
@@ -21471,6 +21474,7 @@ fn backtrack_cut_index_skips_tool_result_user_messages() {
name: "read_file".into(),
input: serde_json::json!({"path":"x"}),
caller: None,
thought_signature: None,
}],
},
Message {
@@ -93,6 +93,7 @@ fn assistant_tool_call(id: &str, name: &str, input: serde_json::Value) -> Messag
name: name.to_string(),
input,
caller: None,
thought_signature: None,
}],
}
}
@@ -33,6 +33,7 @@ fn assistant_thinking_tool_call(
name: name.to_string(),
input,
caller: None,
thought_signature: None,
},
],
}
+3
View File
@@ -131,6 +131,7 @@ the listed provider env vars.
| `telecomjs` | `[providers.telecomjs]` | OpenAI Chat Completions | `TELECOMJS_API_KEY` |
| `xai` | `[providers.xai]` | OpenAI Chat Completions | `XAI_API_KEY` |
| `mistral` | `[providers.mistral]` | OpenAI Chat Completions | `MISTRAL_API_KEY` |
| `google` | `[providers.google]` | OpenAI Chat Completions (official Gemini OpenAI-compat route; captures and replays thought signatures on tool calls) | `GOOGLE_API_KEY`, `GEMINI_API_KEY` |
| `modelstudio-token-plan` | `[providers.modelstudio_token_plan]` | OpenAI Chat Completions | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |
| `modelstudio-token-plan-anthropic` | `[providers.modelstudio_token_plan_anthropic]` | Anthropic Messages | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |
| `modelstudio-coding-plan` | `[providers.modelstudio_coding_plan]` | OpenAI Chat Completions | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |
@@ -453,6 +454,7 @@ configuration path instead of guessing a vendor page.
| `telecomjs` | [TelecomJS TokenHub](https://aigw.telecomjs.com/) |
| `xai` | [xAI Console](https://console.x.ai/) for an API key, Codewhale-owned device login, or explicitly consented read-only Grok CLI credentials. |
| `mistral` | [Mistral Console (la Plateforme)](https://console.mistral.ai/api-keys) |
| `google` | [Google AI Studio](https://aistudio.google.com/apikey) — Codewhale uses the official Gemini OpenAI-compatible endpoint and never reads Google OAuth files. |
| `modelstudio-token-plan`, `modelstudio-token-plan-anthropic`, `modelstudio-coding-plan`, `modelstudio-coding-plan-anthropic` | [Alibaba Cloud Model Studio (Bailian console)](https://bailian.console.aliyun.com/) — create or copy a Model Studio API key. |
| `custom` | Set the named provider's `base_url` and `api_key_env` or `api_key`; no canonical vendor credential page exists. |
@@ -521,6 +523,7 @@ Kimi remains API-key-only; external consent for Kimi is rejected.
| `siliconflow-CN` | `[providers.siliconflow_cn]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.cn/v1` | Uses the SiliconFlow model set | China regional SiliconFlow route. Falls back to `[providers.siliconflow]` for api_key / base_url / model when unset. Select it with `provider = "siliconflow-CN"` or `CODEWHALE_PROVIDER=siliconflow-CN`. |
| `arcee` | `[providers.arcee]` | `ARCEE_API_KEY` | `ARCEE_BASE_URL`; default `https://api.arcee.ai/api/v1` | `trinity-large-thinking`, `trinity-large-preview` | Arcee AI direct OpenAI-compatible route, tracked as 256K-context BF16 serving. `ARCEE_MODEL` is accepted. OpenRouter's `arcee-ai/trinity-large-thinking` remains the OpenRouter namespaced model ID; direct Arcee uses the bare `trinity-large-thinking` ID. |
| `moonshot` | `[providers.moonshot]` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` | `MOONSHOT_BASE_URL`, `KIMI_BASE_URL`; default `https://api.moonshot.ai/v1` | Direct Moonshot: `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`; Kimi Code membership: `k3`, `kimi-for-coding`, `kimi-for-coding-highspeed` at `https://api.kimi.com/coding/v1` | Moonshot/Kimi route. `kimi` and `kimi-k2` aliases select `kimi-k2.7-code`; `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, and `KIMI_MODEL` are accepted. Kimi thinking streams through `reasoning_content`; Codewhale keeps it in Thinking cells and replays it for thinking/tool-call continuity. For direct K3, use exact `base_url = "https://api.moonshot.ai/v1"` and `model = "kimi-k3"`; it is always-thinking and receives top-level `reasoning_effort = "low" | "high" | "max"` (`off` normalizes to `low`), uses only `max_completion_tokens`, and omits `temperature`/`top_p` per the [K3 quickstart](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart). For Kimi Code K3, use a key from the [Kimi Code console](https://www.kimi.com/code/console), exact `base_url = "https://api.kimi.com/coding/v1"`, and bare `model = "k3"`; `off` becomes enabled `low`, while normal dispatched `auto` selects and sends a concrete Codewhale tier. Only an omitted reasoning setting leaves the provider default in control. That membership route defaults safely to 262,144 context tokens; the [Kimi Code model-tier table](https://www.kimi.com/code/docs/en/kimi-code/models.html) grants Allegretto and higher plans up to 1M, which those plans may express as `context_window = 1048576`. `k3[1m]` is Claude Code-only and Codewhale rejects it. `kimi-for-coding` remains the valid K2.7 membership route, and `kimi-for-coding-highspeed` is its own high-speed roster entry (262,144 context); membership ids are rejected on the direct platform endpoint, and `kimi-k3` stays rejected on the membership endpoint. Billing is decided by the endpoint the route resolves to, judged once against the two exact product endpoints: direct Moonshot (`https://api.moonshot.ai/v1` or the default) bills metered with dollar estimates, the exact Kimi Code membership endpoint bills as Kimi Code quota and never shows dollar estimates, and anything else — a gateway host, a neighboring Kimi-hosted path — reports `cost: unknown` rather than borrowing either product. An imported Kimi Code token with no `base_url` in its table still resolves to the membership endpoint, so it bills as Kimi Code quota and never accrues dollars. A completed turn, parent or sub-agent, is billed from the immutable endpoint receipt its own client was built with, never from a later config re-read: `MOONSHOT_BASE_URL`/`KIMI_BASE_URL` are merged into the *active* provider's table only, and an in-turn provider switch can move the ambient config off the route that actually ran. Legacy `auth_mode = "kimi_oauth"` fails to API-key guidance without probing Kimi CLI files. Codewhale does not impersonate `kimi_cli` or `kimi_code_cli`. **China-region keys:** contributor field evidence (@vFONGv, PR #5229, verified on Windows 10) reports that a China-region Moonshot key must be paired with `base_url = "https://api.moonshot.cn/v1"`; left on the default international host (`https://api.moonshot.ai/v1`) it fails authentication. We have no China-region key to verify this ourselves, so it is recorded as a user report rather than a tested route. Note also that editing `base_url` alone does not take effect until `codewhale auth set` is re-run for that provider. |
| `google` | `[providers.google]` | `GOOGLE_API_KEY`, `GEMINI_API_KEY` | `GOOGLE_BASE_URL`, `GEMINI_BASE_URL`; default `https://generativelanguage.googleapis.com/v1beta/openai/` | `gemini-3.1-pro-preview` (default); `/model` also lists `gemini-3-pro-preview`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-2.5-pro`, `gemini-2.5-flash` | Google Gemini as its own backend on the official OpenAI-compatible Chat Completions route. Thinking models capture `extra_content.google.thought_signature` on tool calls and replay it with the assistant tool-call messages; replaying a tool call whose signature was not captured fails closed with an actionable error instead of letting the tool loop break. `gemini-2.5-flash-lite` ships thinking off and degrades with a warning instead. Reasoning effort maps onto the documented `google.thinking_config.thinking_level` (`low`/`high`). The dialect binds to the exact official base URL: a `google` row pointed at another gateway gets plain OpenAI semantics and no signature requirements. Codewhale never reads Google OAuth files; only an AI Studio API key is used. Not live-tested against the real endpoint in this environment. |
| `zai` | `[providers.zai]` | `ZAI_API_KEY`, `Z_AI_API_KEY` | `ZAI_BASE_URL`, `Z_AI_BASE_URL`; default `https://api.z.ai/api/coding/paas/v4`; general API `https://api.z.ai/api/paas/v4` | `GLM-5.2` default; `/model` also lists `GLM-5.3`, `GLM-5.1`, and `GLM-5-Turbo` | Z.AI GLM Coding Plan route. `GLM-5.2` stays the default. `GLM-5.3` is a first-class picker row (`model = "GLM-5.3"` or `ZAI_MODEL=GLM-5.3`) and is not rewritten to another vendor. Limits and reasoning options are inherited from `GLM-5.2` until Z.ai publishes distinct 5.3 metadata; it carries no price. A live call can still 429 with entitlement code 1311 on accounts that are not provisioned for 5.3. |
| `stepfun` | `[providers.stepfun]` | `STEPFUN_API_KEY`, `STEP_API_KEY` | `STEPFUN_BASE_URL`, `STEP_BASE_URL`; default `https://api.stepfun.ai/v1`; Coding Plan endpoint `https://api.stepfun.ai/step_plan/v1` | `step-3.7-flash` | StepFun / StepFlash direct OpenAI-compatible route. `/provider` setup asks which billing route the key belongs to — pay-as-you-go or Step Plan — validates the key against the chosen endpoint, and writes the answer to `[providers.stepfun].base_url` only. A base URL that is neither recognized route is left alone and the question is skipped. You can also set `[providers.stepfun].base_url` or `STEP_BASE_URL` to the Coding Plan URL by hand. Offline accounting labels recognized routes as `stepfun-payg` or `stepfun-plan` without persisting the raw endpoint, and only the standard PAYG route receives token pricing. `STEPFUN_MODEL` and `STEP_MODEL` are accepted. |
| `minimax` | `[providers.minimax]` | `MINIMAX_API_KEY` | `MINIMAX_BASE_URL`; default `https://api.minimax.io/v1`; China `https://api.minimaxi.com/v1` | `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2.1-highspeed`, `MiniMax-M2` | MiniMax direct OpenAI-compatible route. Codewhale sends `reasoning_split = true` so MiniMax thinking arrives separately from answer text. Both MiniMax dialects sell pay-as-you-go and Token Plan over the same endpoints and the same key, so billing is classified from the credential *product*, never from the endpoint or from a default. `mode = "token-plan"` in `[providers.minimax]`/`[providers.minimax_anthropic]`, or a Token Plan key shaped `sk-cp…`, bills as MiniMax Token Plan quota with no dollar estimates; an explicit pay-as-you-go mode (`pay-as-you-go`/`payg`/`metered`) wins over key shape. The key's product prefix is only visible when the key is in config, bound by `api_key_env`, or exported as `MINIMAX_API_KEY` on an official endpoint — a key saved through `codewhale auth set` (secret store / OS keyring) is deliberately not read to classify billing. With no explicit mode and no visible product marker the route reports `cost: unknown` rather than assuming pay-as-you-go, so a Token Plan account is never charged invented dollars. Custom/gateway endpoints also fail closed with `cost: unknown`. Official M3 input modalities are text, image, and video; M2.7 is text-only. |