mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 16:56:16 +08:00
feat(grokbuild): add first-class Grok Build support (#5453)
* feat(grokbuild): add backend integration * feat(grokbuild): add provider and management UI * test(grokbuild): cover configuration and integrations * fix(grokbuild): address backend review feedback * fix(grokbuild): complete UI review feedback * feat(grokbuild): add CLI lifecycle management * fix(grokbuild): align provider icon fallback * test(grokbuild): cover provider icon persistence
This commit is contained in:
@@ -1142,9 +1142,9 @@ impl RequestForwarder {
|
||||
// Codex upstream conversion mode — computed early because the [1m]-suffix strip
|
||||
// below must be skipped on the Anthropic path (the marker has to survive to
|
||||
// catalog matching and to the transform's own strip+beta detection).
|
||||
let codex_responses_to_chat = matches!(app_type, AppType::Codex)
|
||||
let codex_responses_to_chat = matches!(app_type, AppType::Codex | AppType::GrokBuild)
|
||||
&& super::providers::should_convert_codex_responses_to_chat(provider, endpoint);
|
||||
let codex_responses_to_anthropic = matches!(app_type, AppType::Codex)
|
||||
let codex_responses_to_anthropic = matches!(app_type, AppType::Codex | AppType::GrokBuild)
|
||||
&& super::providers::should_convert_codex_responses_to_anthropic(provider, endpoint);
|
||||
let codex_official_auth_passthrough = matches!(app_type, AppType::Codex)
|
||||
&& super::providers::is_codex_official_provider(provider);
|
||||
@@ -1168,6 +1168,13 @@ impl RequestForwarder {
|
||||
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
|
||||
let mut mapped_body = normalize_thinking_type(mapped_body);
|
||||
|
||||
// Grok Build exposes a stable client-side model profile in config.toml.
|
||||
// Route requests to the provider's real upstream model before applying
|
||||
// the optional Responses -> Chat/Anthropic bridge.
|
||||
if matches!(app_type, AppType::GrokBuild) {
|
||||
super::providers::apply_codex_upstream_model(provider, &mut mapped_body);
|
||||
}
|
||||
|
||||
if is_copilot {
|
||||
mapped_body =
|
||||
super::providers::copilot_model_map::apply_copilot_model_normalization(mapped_body);
|
||||
@@ -1511,7 +1518,7 @@ impl RequestForwarder {
|
||||
mapped_body
|
||||
};
|
||||
|
||||
if matches!(app_type, AppType::Codex) {
|
||||
if matches!(app_type, AppType::Codex | AppType::GrokBuild) {
|
||||
self.apply_media_prevention(&mut request_body, provider);
|
||||
}
|
||||
|
||||
|
||||
@@ -758,6 +758,30 @@ pub async fn handle_chat_completions(
|
||||
pub async fn handle_responses(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
handle_responses_for_app(state, request, AppType::Codex, "Codex", "codex").await
|
||||
}
|
||||
|
||||
pub async fn handle_grokbuild_responses(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
handle_responses_for_app(
|
||||
state,
|
||||
request,
|
||||
AppType::GrokBuild,
|
||||
"Grok Build",
|
||||
"grokbuild",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn handle_responses_for_app(
|
||||
state: ProxyState,
|
||||
request: axum::extract::Request,
|
||||
app_type: AppType,
|
||||
tag: &'static str,
|
||||
app_type_str: &'static str,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let method = parts.method.clone();
|
||||
@@ -774,7 +798,7 @@ pub async fn handle_responses(
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
RequestContext::new(&state, &body, &headers, app_type.clone(), tag, app_type_str).await?;
|
||||
let endpoint = endpoint_with_query(&uri, "/responses");
|
||||
|
||||
let is_stream = body
|
||||
@@ -786,7 +810,7 @@ pub async fn handle_responses(
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let mut result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
&app_type,
|
||||
method,
|
||||
&endpoint,
|
||||
body,
|
||||
@@ -849,6 +873,30 @@ pub async fn handle_responses(
|
||||
pub async fn handle_responses_compact(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
handle_responses_compact_for_app(state, request, AppType::Codex, "Codex", "codex").await
|
||||
}
|
||||
|
||||
pub async fn handle_grokbuild_responses_compact(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
handle_responses_compact_for_app(
|
||||
state,
|
||||
request,
|
||||
AppType::GrokBuild,
|
||||
"Grok Build",
|
||||
"grokbuild",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn handle_responses_compact_for_app(
|
||||
state: ProxyState,
|
||||
request: axum::extract::Request,
|
||||
app_type: AppType,
|
||||
tag: &'static str,
|
||||
app_type_str: &'static str,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let method = parts.method.clone();
|
||||
@@ -865,7 +913,7 @@ pub async fn handle_responses_compact(
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
RequestContext::new(&state, &body, &headers, app_type.clone(), tag, app_type_str).await?;
|
||||
let endpoint = endpoint_with_query(&uri, "/responses/compact");
|
||||
|
||||
let is_stream = body
|
||||
@@ -877,7 +925,7 @@ pub async fn handle_responses_compact(
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let mut result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
&app_type,
|
||||
method,
|
||||
&endpoint,
|
||||
body,
|
||||
|
||||
@@ -250,7 +250,11 @@ pub fn codex_provider_upstream_model(provider: &Provider) -> Option<String> {
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(extract_codex_model_from_toml)
|
||||
.and_then(|config| {
|
||||
crate::grok_config::extract_model_config(config)
|
||||
.map(|model| model.model)
|
||||
.or_else(|| extract_codex_model_from_toml(config))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -621,6 +625,9 @@ impl CodexAdapter {
|
||||
}
|
||||
|
||||
if let Some(config_str) = config.as_str() {
|
||||
if let Some((_, key)) = crate::grok_config::extract_credentials(config_str) {
|
||||
return Some(key);
|
||||
}
|
||||
if let Some(key) =
|
||||
crate::codex_config::extract_codex_experimental_bearer_token(config_str)
|
||||
{
|
||||
@@ -675,6 +682,9 @@ impl ProviderAdapter for CodexAdapter {
|
||||
|
||||
// 尝试解析 TOML 字符串格式
|
||||
if let Some(config_str) = config.as_str() {
|
||||
if let Some(url) = crate::grok_config::extract_base_url(config_str) {
|
||||
return Ok(url.trim_end_matches('/').to_string());
|
||||
}
|
||||
if let Some(start) = config_str.find("base_url = \"") {
|
||||
let rest = &config_str[start + 12..];
|
||||
if let Some(end) = rest.find('"') {
|
||||
@@ -796,6 +806,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_build_toml_exposes_upstream_credentials_and_model() {
|
||||
let adapter = CodexAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"config": r#"
|
||||
[models]
|
||||
default = "grok-4.5"
|
||||
|
||||
[model."grok-4.5"]
|
||||
model = "upstream-grok-model"
|
||||
base_url = "https://relay.example.com/v1/"
|
||||
name = "Example Relay"
|
||||
api_key = "grok-secret"
|
||||
api_backend = "responses"
|
||||
context_window = 500000
|
||||
"#
|
||||
}));
|
||||
|
||||
assert_eq!(
|
||||
adapter.extract_base_url(&provider).unwrap(),
|
||||
"https://relay.example.com/v1"
|
||||
);
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.api_key, "grok-secret");
|
||||
assert_eq!(auth.strategy, AuthStrategy::Bearer);
|
||||
assert_eq!(
|
||||
codex_provider_upstream_model(&provider).as_deref(),
|
||||
Some("upstream-grok-model")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_provider_uses_fixed_chatgpt_backend_without_stored_key() {
|
||||
let mut provider = create_provider(json!({ "auth": {}, "config": "" }));
|
||||
|
||||
@@ -192,10 +192,8 @@ impl ProviderType {
|
||||
}
|
||||
ProviderType::Gemini
|
||||
}
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
|
||||
// These apps don't support proxy, fallback to Codex-like type
|
||||
ProviderType::Codex
|
||||
}
|
||||
AppType::GrokBuild => ProviderType::Codex,
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => ProviderType::Codex,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,10 +244,8 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
|
||||
AppType::Claude | AppType::ClaudeDesktop => Box::new(ClaudeAdapter::new()),
|
||||
AppType::Codex => Box::new(CodexAdapter::new()),
|
||||
AppType::Gemini => Box::new(GeminiAdapter::new()),
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
|
||||
// These apps don't support proxy, fallback to Codex adapter
|
||||
Box::new(CodexAdapter::new())
|
||||
}
|
||||
AppType::GrokBuild => Box::new(CodexAdapter::new()),
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Box::new(CodexAdapter::new()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -327,6 +327,12 @@ impl ProxyServer {
|
||||
.route("/v1/responses", post(handlers::handle_responses))
|
||||
.route("/v1/v1/responses", post(handlers::handle_responses))
|
||||
.route("/codex/v1/responses", post(handlers::handle_responses))
|
||||
// Grok Build uses the Responses protocol but has an independent
|
||||
// provider namespace and failover queue.
|
||||
.route(
|
||||
"/grokbuild/v1/responses",
|
||||
post(handlers::handle_grokbuild_responses),
|
||||
)
|
||||
// OpenAI Responses Compact API (Codex CLI 远程压缩,透传)
|
||||
.route(
|
||||
"/responses/compact",
|
||||
@@ -344,6 +350,10 @@ impl ProxyServer {
|
||||
"/codex/v1/responses/compact",
|
||||
post(handlers::handle_responses_compact),
|
||||
)
|
||||
.route(
|
||||
"/grokbuild/v1/responses/compact",
|
||||
post(handlers::handle_grokbuild_responses_compact),
|
||||
)
|
||||
// Gemini API (支持带前缀和不带前缀)
|
||||
//
|
||||
// 用 `any(..)` 覆盖所有 HTTP 方法:除了 POST `:generateContent` /
|
||||
|
||||
@@ -113,6 +113,7 @@ pub struct ProxyTakeoverStatus {
|
||||
pub claude: bool,
|
||||
pub codex: bool,
|
||||
pub gemini: bool,
|
||||
pub grokbuild: bool,
|
||||
pub opencode: bool,
|
||||
pub openclaw: bool,
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ impl CostCalculator {
|
||||
pricing: &ModelPricing,
|
||||
cost_multiplier: Decimal,
|
||||
) -> CostBreakdown {
|
||||
let input_includes_cache_read = matches!(app_type, "codex" | "gemini");
|
||||
let input_includes_cache_read = matches!(app_type, "codex" | "gemini" | "grokbuild");
|
||||
Self::calculate_with_cache_semantics(
|
||||
usage,
|
||||
pricing,
|
||||
@@ -211,6 +211,25 @@ mod tests {
|
||||
assert_eq!(cost.total_cost, Decimal::from_str("0.010035").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grokbuild_does_not_double_bill_cached_input() {
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 600,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
let pricing = ModelPricing::from_strings("10", "0", "1", "0").unwrap();
|
||||
|
||||
let cost = CostCalculator::calculate_for_app("grokbuild", &usage, &pricing, Decimal::ONE);
|
||||
|
||||
assert_eq!(cost.input_cost, Decimal::from_str("0.004").unwrap());
|
||||
assert_eq!(cost.cache_read_cost, Decimal::from_str("0.0006").unwrap());
|
||||
assert_eq!(cost.total_cost, Decimal::from_str("0.0046").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cost_multiplier() {
|
||||
let usage = TokenUsage {
|
||||
|
||||
@@ -71,11 +71,12 @@ impl<'a> UsageLogger<'a> {
|
||||
};
|
||||
|
||||
let created_at = chrono::Utc::now().timestamp();
|
||||
let input_token_semantics = if matches!(log.app_type.as_str(), "codex" | "gemini") {
|
||||
INPUT_TOKEN_SEMANTICS_TOTAL
|
||||
} else {
|
||||
INPUT_TOKEN_SEMANTICS_FRESH
|
||||
};
|
||||
let input_token_semantics =
|
||||
if matches!(log.app_type.as_str(), "codex" | "gemini" | "grokbuild") {
|
||||
INPUT_TOKEN_SEMANTICS_TOTAL
|
||||
} else {
|
||||
INPUT_TOKEN_SEMANTICS_FRESH
|
||||
};
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO proxy_request_logs (
|
||||
@@ -459,4 +460,39 @@ mod tests {
|
||||
assert_eq!(error, Some("Internal Server Error".to_string()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grokbuild_logs_total_input_token_semantics() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
let logger = UsageLogger::new(&db);
|
||||
let log = RequestLog {
|
||||
request_id: "grok-semantics".to_string(),
|
||||
provider_id: "grok-provider".to_string(),
|
||||
app_type: "grokbuild".to_string(),
|
||||
model: "grok-4.5".to_string(),
|
||||
request_model: "grok-4.5".to_string(),
|
||||
pricing_model: String::new(),
|
||||
usage: TokenUsage::default(),
|
||||
cost: None,
|
||||
latency_ms: 1,
|
||||
first_token_ms: None,
|
||||
status_code: 200,
|
||||
error_message: None,
|
||||
session_id: None,
|
||||
provider_type: Some("grokbuild".to_string()),
|
||||
is_streaming: false,
|
||||
cost_multiplier: "1".to_string(),
|
||||
};
|
||||
|
||||
logger.log_request(&log)?;
|
||||
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let semantics: i64 = conn.query_row(
|
||||
"SELECT input_token_semantics FROM proxy_request_logs WHERE request_id = 'grok-semantics'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(semantics, INPUT_TOKEN_SEMANTICS_TOTAL);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user