Upgrade default Claude Opus model to 4.8

Bump the default Opus route/model from claude-opus-4-7 to claude-opus-4-8
across provider presets (claude, claudeDesktop, hermes, openclaw, opencode,
universal), i18n locales (zh/en/ja/zh-TW), pricing seed data, and the
user-manual docs.

- Add claude-opus-4-8 pricing row ($5/$25/$0.50/$6.25); keep the 4-7 row
  for historical usage stats (seeded via INSERT OR IGNORE).
- Claude Desktop proxy: accept bidirectional opus 4-7 <-> 4-8 route alias
  during rollout so previously saved routes keep resolving.
- thinking_optimizer: route opus-4-8 through adaptive thinking and normalize
  dotted model ids (also fixes dotted 4-6/4-7 falling back to legacy).
- usage_stats: normalize Bedrock/Vertex/aggregator opus-4-8 ids to base
  pricing.

Also merge role:"system" messages into the Gemini systemInstruction in the
Anthropic->Gemini transform.
This commit is contained in:
Jason
2026-05-29 12:09:33 +08:00
parent 554e3b4875
commit 0877b9e35d
27 changed files with 386 additions and 221 deletions
@@ -324,14 +324,14 @@ mod tests {
#[test]
fn resolve_falls_back_to_highest_family_version() {
// 用户请求 opus 4.7 但 Copilot 账号只有 opus 4.6
// 用户请求 opus 4.8 但 Copilot 账号只有 opus 4.6
let models = vec![
model("claude-opus-4.5"),
model("claude-opus-4.6"),
model("claude-sonnet-4.6"),
];
assert_eq!(
resolve_against_models("claude-opus-4.7", &models),
resolve_against_models("claude-opus-4.8", &models),
Some("claude-opus-4.6".to_string())
);
}
@@ -57,11 +57,17 @@ pub fn anthropic_to_gemini_with_shadow(
.map(|snapshot| snapshot.turns)
.unwrap_or_default();
if let Some(system) = build_system_instruction(body.get("system"))? {
let messages = body.get("messages").and_then(|value| value.as_array());
let system_instruction = build_system_instruction(
body.get("system"),
messages.map(|messages| messages.as_slice()),
)?;
if let Some(system) = system_instruction {
result["systemInstruction"] = system;
}
if let Some(messages) = body.get("messages").and_then(|value| value.as_array()) {
if let Some(messages) = messages {
result["contents"] = json!(convert_messages_to_contents(messages, &shadow_turns)?);
}
@@ -269,31 +275,26 @@ pub fn extract_gemini_model(body: &Value) -> Option<&str> {
body.get("model").and_then(|value| value.as_str())
}
fn build_system_instruction(system: Option<&Value>) -> Result<Option<Value>, ProxyError> {
let Some(system) = system else {
return Ok(None);
};
fn build_system_instruction(
system: Option<&Value>,
messages: Option<&[Value]>,
) -> Result<Option<Value>, ProxyError> {
let mut texts = Vec::new();
if let Some(text) = system.as_str() {
if text.is_empty() {
return Ok(None);
}
return Ok(Some(json!({
"parts": [{ "text": text }]
})));
if let Some(system) = system {
collect_system_texts(system, &mut texts)?;
}
let Some(blocks) = system.as_array() else {
return Err(ProxyError::TransformError(
"Anthropic system must be a string or an array".to_string(),
));
};
let texts: Vec<&str> = blocks
.iter()
.filter_map(|block| block.get("text").and_then(|value| value.as_str()))
.filter(|text| !text.is_empty())
.collect();
if let Some(messages) = messages {
for message in messages {
if message.get("role").and_then(|value| value.as_str()) != Some("system") {
continue;
}
if let Some(content) = message.get("content") {
collect_system_texts(content, &mut texts)?;
}
}
}
if texts.is_empty() {
return Ok(None);
@@ -304,6 +305,31 @@ fn build_system_instruction(system: Option<&Value>) -> Result<Option<Value>, Pro
})))
}
fn collect_system_texts(value: &Value, texts: &mut Vec<String>) -> Result<(), ProxyError> {
if let Some(text) = value.as_str() {
if !text.is_empty() {
texts.push(text.to_string());
}
return Ok(());
}
let Some(blocks) = value.as_array() else {
return Err(ProxyError::TransformError(
"Anthropic system must be a string or an array".to_string(),
));
};
texts.extend(
blocks
.iter()
.filter_map(|block| block.get("text").and_then(|value| value.as_str()))
.filter(|text| !text.is_empty())
.map(ToString::to_string),
);
Ok(())
}
fn build_generation_config(body: &Value) -> Option<Value> {
let mut config = Map::new();
@@ -383,6 +409,9 @@ fn convert_messages_to_contents(
.get("role")
.and_then(|value| value.as_str())
.unwrap_or("user");
if role == "system" {
continue;
}
let gemini_role = if role == "assistant" { "model" } else { "user" };
@@ -1149,6 +1178,32 @@ mod tests {
assert_eq!(result["generationConfig"]["maxOutputTokens"], 128);
}
#[test]
fn anthropic_to_gemini_merges_system_messages_into_system_instruction() {
let input = json!({
"model": "gemini-3-pro",
"system": [{ "type": "text", "text": "Top level system." }],
"messages": [
{ "role": "system", "content": "Message system." },
{
"role": "system",
"content": [{ "type": "text", "text": "Block system." }]
},
{ "role": "user", "content": "Hello" }
]
});
let result = anthropic_to_gemini(input).unwrap();
assert_eq!(
result["systemInstruction"]["parts"][0]["text"],
"Top level system.\n\nMessage system.\n\nBlock system."
);
assert_eq!(result["contents"].as_array().unwrap().len(), 1);
assert_eq!(result["contents"][0]["role"], "user");
assert_eq!(result["contents"][0]["parts"][0]["text"], "Hello");
}
#[test]
fn anthropic_to_gemini_maps_tools_and_tool_results() {
let input = json!({
+27 -2
View File
@@ -7,7 +7,7 @@ use serde_json::{json, Value};
///
/// 三路径分发:
/// - skip: haiku 模型直接跳过
/// - adaptive: opus-4-7 / opus-4-6 / sonnet-4-6 使用 adaptive thinking
/// - adaptive: opus-4-8 / opus-4-7 / opus-4-6 / sonnet-4-6 使用 adaptive thinking
/// - legacy: 其他模型注入 enabled thinking + budget_tokens
pub fn optimize(body: &mut Value, config: &OptimizerConfig) {
if !config.thinking_optimizer {
@@ -24,7 +24,7 @@ pub fn optimize(body: &mut Value, config: &OptimizerConfig) {
return;
}
if model.contains("opus-4-7") || model.contains("opus-4-6") || model.contains("sonnet-4-6") {
if uses_adaptive_thinking(&model) {
log::info!("[OPT] thinking: adaptive({model})");
body["thinking"] = json!({"type": "adaptive"});
body["output_config"] = json!({"effort": "max"});
@@ -73,6 +73,13 @@ pub fn optimize(body: &mut Value, config: &OptimizerConfig) {
}
}
fn uses_adaptive_thinking(model: &str) -> bool {
let normalized = model.replace('.', "-");
["opus-4-8", "opus-4-7", "opus-4-6", "sonnet-4-6"]
.iter()
.any(|needle| normalized.contains(needle))
}
/// 追加 beta 标识到 anthropic_beta 数组(去重)
fn append_beta(body: &mut Value, beta: &str) {
match body.get_mut("anthropic_beta") {
@@ -114,6 +121,24 @@ mod tests {
}
}
#[test]
fn test_adaptive_opus_4_8() {
let mut body = json!({
"model": "anthropic/claude-opus-4.8",
"max_tokens": 16384,
"thinking": {"type": "enabled", "budget_tokens": 8000},
"messages": [{"role": "user", "content": "hello"}]
});
optimize(&mut body, &enabled_config());
assert_eq!(body["thinking"]["type"], "adaptive");
assert!(body["thinking"].get("budget_tokens").is_none());
assert_eq!(body["output_config"]["effort"], "max");
let betas = body["anthropic_beta"].as_array().unwrap();
assert!(betas.iter().any(|v| v == "context-1m-2025-08-07"));
}
#[test]
fn test_adaptive_opus_4_6() {
let mut body = json!({