mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
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:
1 parent
679a4a3542
commit
53af08ce36
54 files changed
+718
-29
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -400,6 +400,7 @@ pub mod canned {
|
||||
name: name.to_string(),
|
||||
input: Value::Null,
|
||||
caller: None,
|
||||
thought_signature: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -357,6 +357,7 @@ fn compact_content_block(block: &ContentBlock) -> Value {
|
||||
name,
|
||||
input,
|
||||
caller,
|
||||
..
|
||||
} => json!({
|
||||
"type": "tool_use",
|
||||
"id": id,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]));
|
||||
|
||||
|
||||
@@ -6103,6 +6103,7 @@ impl RuntimeThreadManager {
|
||||
name: tool_name,
|
||||
input,
|
||||
caller: None,
|
||||
thought_signature: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -222,6 +222,7 @@ mod tests {
|
||||
name: "read_file".to_string(),
|
||||
input: json!({"path": "README.md"}),
|
||||
caller: None,
|
||||
thought_signature: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,6 +317,7 @@ mod tests {
|
||||
name: name.to_string(),
|
||||
input,
|
||||
caller: None,
|
||||
thought_signature: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1055,6 +1055,7 @@ fn history_replays_update_plan_tool_use_as_plan_card() {
|
||||
]
|
||||
}),
|
||||
caller: None,
|
||||
thought_signature: None,
|
||||
}],
|
||||
};
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ pub(crate) fn push_assistant_message(
|
||||
name,
|
||||
input,
|
||||
caller: None,
|
||||
thought_signature: None,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in new issue
Block a user