mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-04-08 15:10:34 +08:00
* feat(failover): add auto-failover master switch with proxy integration
- Add persistent auto_failover_enabled setting in database
- Add get/set_auto_failover_enabled commands
- Provider router respects master switch state
- Proxy shutdown automatically disables failover
- Enabling failover auto-starts proxy server
- Optimistic updates for failover queue toggle
* feat(proxy): persist proxy takeover state across app restarts
- Add proxy_takeover_{app_type} settings for per-app state tracking
- Restore proxy takeover state automatically on app startup
- Preserve state on normal exit, clear on manual stop
- Add stop_with_restore_keep_state method for graceful shutdown
* fix(proxy): set takeover state for all apps in start_with_takeover
* fix(windows): hide console window when checking CLI versions
Add CREATE_NO_WINDOW flag to prevent command prompt from flashing
when detecting claude/codex/gemini CLI versions on Windows.
* refactor(failover): make auto-failover toggle per-app independent
- Change setting key from 'auto_failover_enabled' to 'auto_failover_enabled_{app_type}'
- Update provider_router to check per-app failover setting
- When failover disabled, use current provider only; when enabled, use queue order
- Add unit tests for failover enabled/disabled behavior
* feat(failover): auto-switch to higher priority provider on recovery
- After circuit breaker reset, check if recovered provider has higher priority
- Automatically switch back if queue_order is lower (higher priority)
- Stream health check now resets circuit breaker on success/degraded
* chore: remove unused start_proxy_with_takeover command
- Remove command registration from lib.rs
- Add comment clarifying failover queue is preserved on proxy stop
* feat(ui): integrate failover controls into provider cards
- Add failover toggle button to provider card actions
- Show priority badge (P1, P2, ...) for queued providers
- Highlight active provider with green border in failover mode
- Sync drag-drop order with failover queue
- Move per-app failover toggle to FailoverQueueManager
- Simplify SettingsPage failover section
* test(providers): add mocks for failover hooks in ProviderList tests
* refactor(failover): merge failover_queue table into providers
- Add in_failover_queue field to providers table
- Remove standalone failover_queue table and related indexes
- Simplify queue ordering by reusing sort_index field
- Remove reorder_failover_queue and set_failover_item_enabled commands
- Update frontend to use simplified FailoverQueueItem type
* fix(database): ensure in_failover_queue column exists for v2 databases
Add column check in create_tables to handle existing v2 databases
that were created before the failover queue refactor.
* fix(ui): differentiate active provider border color by proxy mode
- Use green border/gradient when proxy takeover is active
- Use blue border/gradient in normal mode (no proxy)
- Improves visual distinction between proxy and non-proxy states
* fix(database): clear provider health record when removing from failover queue
When a provider is removed from the failover queue, its health monitoring
is no longer needed. This change ensures the health record is also deleted
from the database to prevent stale data.
* fix(failover): improve cache cleanup for provider health and circuit breaker
- Use removeQueries instead of invalidateQueries when stopping proxy to
completely clear health and circuit breaker caches
- Clear provider health and circuit breaker caches when removing from
failover queue
- Refresh failover queue after drag-sort since queue order depends on
sort_index
- Only show health badge when provider is in failover queue
* style: apply prettier formatting to App.tsx and ProviderList.tsx
* fix(proxy): handle missing health records and clear health on proxy stop
- Return default healthy state when provider health record not found
- Add clear_provider_health_for_app to clear health for specific app
- Clear app health records when stopping proxy takeover
* fix(proxy): track actual provider used in forwarding for accurate logging
Introduce ForwardResult and ForwardError structs to return the actual
provider that handled the request. This ensures usage statistics and
error logs reflect the correct provider after failover.
566 lines
18 KiB
Rust
566 lines
18 KiB
Rust
//! Provider import from deep link
|
|
//!
|
|
//! Handles importing provider configurations via ccswitch:// URLs.
|
|
|
|
use super::utils::{decode_base64_param, infer_homepage_from_endpoint};
|
|
use super::DeepLinkImportRequest;
|
|
use crate::error::AppError;
|
|
use crate::provider::{Provider, ProviderMeta, UsageScript};
|
|
use crate::services::ProviderService;
|
|
use crate::store::AppState;
|
|
use crate::AppType;
|
|
use serde_json::json;
|
|
use std::str::FromStr;
|
|
|
|
/// Import a provider from a deep link request
|
|
///
|
|
/// This function:
|
|
/// 1. Validates the request
|
|
/// 2. Merges config file if provided (v3.8+)
|
|
/// 3. Converts it to a Provider structure
|
|
/// 4. Delegates to ProviderService for actual import
|
|
/// 5. Optionally sets as current provider if enabled=true
|
|
pub fn import_provider_from_deeplink(
|
|
state: &AppState,
|
|
request: DeepLinkImportRequest,
|
|
) -> Result<String, AppError> {
|
|
// Verify this is a provider request
|
|
if request.resource != "provider" {
|
|
return Err(AppError::InvalidInput(format!(
|
|
"Expected provider resource, got '{}'",
|
|
request.resource
|
|
)));
|
|
}
|
|
|
|
// Step 1: Merge config file if provided (v3.8+)
|
|
let merged_request = parse_and_merge_config(&request)?;
|
|
|
|
// Extract required fields (now as Option)
|
|
let app_str = merged_request
|
|
.app
|
|
.as_ref()
|
|
.ok_or_else(|| AppError::InvalidInput("Missing 'app' field for provider".to_string()))?;
|
|
|
|
let api_key = merged_request.api_key.as_ref().ok_or_else(|| {
|
|
AppError::InvalidInput("API key is required (either in URL or config file)".to_string())
|
|
})?;
|
|
|
|
if api_key.is_empty() {
|
|
return Err(AppError::InvalidInput(
|
|
"API key cannot be empty".to_string(),
|
|
));
|
|
}
|
|
|
|
let endpoint = merged_request.endpoint.as_ref().ok_or_else(|| {
|
|
AppError::InvalidInput("Endpoint is required (either in URL or config file)".to_string())
|
|
})?;
|
|
|
|
if endpoint.is_empty() {
|
|
return Err(AppError::InvalidInput(
|
|
"Endpoint cannot be empty".to_string(),
|
|
));
|
|
}
|
|
|
|
let homepage = merged_request.homepage.as_ref().ok_or_else(|| {
|
|
AppError::InvalidInput("Homepage is required (either in URL or config file)".to_string())
|
|
})?;
|
|
|
|
if homepage.is_empty() {
|
|
return Err(AppError::InvalidInput(
|
|
"Homepage cannot be empty".to_string(),
|
|
));
|
|
}
|
|
|
|
let name = merged_request
|
|
.name
|
|
.as_ref()
|
|
.ok_or_else(|| AppError::InvalidInput("Missing 'name' field for provider".to_string()))?;
|
|
|
|
// Parse app type
|
|
let app_type = AppType::from_str(app_str)
|
|
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {app_str}")))?;
|
|
|
|
// Build provider configuration based on app type
|
|
let mut provider = build_provider_from_request(&app_type, &merged_request)?;
|
|
|
|
// Generate a unique ID for the provider using timestamp + sanitized name
|
|
let timestamp = chrono::Utc::now().timestamp_millis();
|
|
let sanitized_name = name
|
|
.chars()
|
|
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
|
|
.collect::<String>()
|
|
.to_lowercase();
|
|
provider.id = format!("{sanitized_name}-{timestamp}");
|
|
|
|
let provider_id = provider.id.clone();
|
|
|
|
// Use ProviderService to add the provider
|
|
ProviderService::add(state, app_type.clone(), provider)?;
|
|
|
|
// If enabled=true, set as current provider
|
|
if merged_request.enabled.unwrap_or(false) {
|
|
ProviderService::switch(state, app_type.clone(), &provider_id)?;
|
|
log::info!("Provider '{provider_id}' set as current for {app_type:?}");
|
|
}
|
|
|
|
Ok(provider_id)
|
|
}
|
|
|
|
/// Build a Provider structure from a deep link request
|
|
pub(crate) fn build_provider_from_request(
|
|
app_type: &AppType,
|
|
request: &DeepLinkImportRequest,
|
|
) -> Result<Provider, AppError> {
|
|
let settings_config = match app_type {
|
|
AppType::Claude => build_claude_settings(request),
|
|
AppType::Codex => build_codex_settings(request),
|
|
AppType::Gemini => build_gemini_settings(request),
|
|
};
|
|
|
|
// Build usage script configuration if provided
|
|
let meta = build_provider_meta(request)?;
|
|
|
|
let provider = Provider {
|
|
id: String::new(), // Will be generated by caller
|
|
name: request.name.clone().unwrap_or_default(),
|
|
settings_config,
|
|
website_url: request.homepage.clone(),
|
|
category: None,
|
|
created_at: None,
|
|
sort_index: None,
|
|
notes: request.notes.clone(),
|
|
meta,
|
|
icon: request.icon.clone(),
|
|
icon_color: None,
|
|
in_failover_queue: false,
|
|
};
|
|
|
|
Ok(provider)
|
|
}
|
|
|
|
/// Build provider meta with usage script configuration
|
|
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
|
|
// Check if any usage script fields are provided
|
|
if request.usage_script.is_none()
|
|
&& request.usage_enabled.is_none()
|
|
&& request.usage_api_key.is_none()
|
|
&& request.usage_base_url.is_none()
|
|
&& request.usage_access_token.is_none()
|
|
&& request.usage_user_id.is_none()
|
|
&& request.usage_auto_interval.is_none()
|
|
{
|
|
return Ok(None);
|
|
}
|
|
|
|
// Decode usage script code if provided
|
|
let code = if let Some(script_b64) = &request.usage_script {
|
|
let decoded = decode_base64_param("usage_script", script_b64)?;
|
|
String::from_utf8(decoded)
|
|
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in usage_script: {e}")))?
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
// Determine enabled state: explicit param > has code > false
|
|
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
|
|
|
|
// Build UsageScript - use provider's API key and endpoint as defaults
|
|
let usage_script = UsageScript {
|
|
enabled,
|
|
language: "javascript".to_string(),
|
|
code,
|
|
timeout: Some(10),
|
|
api_key: request
|
|
.usage_api_key
|
|
.clone()
|
|
.or_else(|| request.api_key.clone()),
|
|
base_url: request
|
|
.usage_base_url
|
|
.clone()
|
|
.or_else(|| request.endpoint.clone()),
|
|
access_token: request.usage_access_token.clone(),
|
|
user_id: request.usage_user_id.clone(),
|
|
auto_query_interval: request.usage_auto_interval,
|
|
};
|
|
|
|
Ok(Some(ProviderMeta {
|
|
usage_script: Some(usage_script),
|
|
..Default::default()
|
|
}))
|
|
}
|
|
|
|
/// Build Claude settings configuration
|
|
fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
|
let mut env = serde_json::Map::new();
|
|
env.insert(
|
|
"ANTHROPIC_AUTH_TOKEN".to_string(),
|
|
json!(request.api_key.clone().unwrap_or_default()),
|
|
);
|
|
env.insert(
|
|
"ANTHROPIC_BASE_URL".to_string(),
|
|
json!(request.endpoint.clone().unwrap_or_default()),
|
|
);
|
|
|
|
// Add default model if provided
|
|
if let Some(model) = &request.model {
|
|
env.insert("ANTHROPIC_MODEL".to_string(), json!(model));
|
|
}
|
|
|
|
// Add Claude-specific model fields (v3.7.1+)
|
|
if let Some(haiku_model) = &request.haiku_model {
|
|
env.insert(
|
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(),
|
|
json!(haiku_model),
|
|
);
|
|
}
|
|
if let Some(sonnet_model) = &request.sonnet_model {
|
|
env.insert(
|
|
"ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(),
|
|
json!(sonnet_model),
|
|
);
|
|
}
|
|
if let Some(opus_model) = &request.opus_model {
|
|
env.insert(
|
|
"ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(),
|
|
json!(opus_model),
|
|
);
|
|
}
|
|
|
|
json!({ "env": env })
|
|
}
|
|
|
|
/// Build Codex settings configuration
|
|
fn build_codex_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
|
// Generate a safe provider name identifier
|
|
let clean_provider_name = {
|
|
let raw: String = request
|
|
.name
|
|
.clone()
|
|
.unwrap_or_else(|| "custom".to_string())
|
|
.chars()
|
|
.filter(|c| !c.is_control())
|
|
.collect();
|
|
let lower = raw.to_lowercase();
|
|
let mut key: String = lower
|
|
.chars()
|
|
.map(|c| match c {
|
|
'a'..='z' | '0'..='9' | '_' => c,
|
|
_ => '_',
|
|
})
|
|
.collect();
|
|
|
|
// Remove leading/trailing underscores
|
|
while key.starts_with('_') {
|
|
key.remove(0);
|
|
}
|
|
while key.ends_with('_') {
|
|
key.pop();
|
|
}
|
|
|
|
if key.is_empty() {
|
|
"custom".to_string()
|
|
} else {
|
|
key
|
|
}
|
|
};
|
|
|
|
// Model name: use deeplink model or default
|
|
let model_name = request
|
|
.model
|
|
.as_deref()
|
|
.unwrap_or("gpt-5-codex")
|
|
.to_string();
|
|
|
|
// Endpoint: normalize trailing slashes
|
|
let endpoint = request
|
|
.endpoint
|
|
.as_deref()
|
|
.unwrap_or("")
|
|
.trim()
|
|
.trim_end_matches('/')
|
|
.to_string();
|
|
|
|
// Build config.toml content
|
|
let config_toml = format!(
|
|
r#"model_provider = "{clean_provider_name}"
|
|
model = "{model_name}"
|
|
model_reasoning_effort = "high"
|
|
disable_response_storage = true
|
|
|
|
[model_providers.{clean_provider_name}]
|
|
name = "{clean_provider_name}"
|
|
base_url = "{endpoint}"
|
|
wire_api = "responses"
|
|
requires_openai_auth = true
|
|
"#
|
|
);
|
|
|
|
json!({
|
|
"auth": {
|
|
"OPENAI_API_KEY": request.api_key,
|
|
},
|
|
"config": config_toml
|
|
})
|
|
}
|
|
|
|
/// Build Gemini settings configuration
|
|
fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
|
let mut env = serde_json::Map::new();
|
|
env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key));
|
|
env.insert(
|
|
"GOOGLE_GEMINI_BASE_URL".to_string(),
|
|
json!(request.endpoint),
|
|
);
|
|
|
|
// Add model if provided
|
|
if let Some(model) = &request.model {
|
|
env.insert("GEMINI_MODEL".to_string(), json!(model));
|
|
}
|
|
|
|
json!({ "env": env })
|
|
}
|
|
|
|
// =============================================================================
|
|
// Config Merge Logic
|
|
// =============================================================================
|
|
|
|
/// Parse and merge configuration from Base64 encoded config or remote URL
|
|
///
|
|
/// Priority: URL params > inline config > remote config
|
|
pub fn parse_and_merge_config(
|
|
request: &DeepLinkImportRequest,
|
|
) -> Result<DeepLinkImportRequest, AppError> {
|
|
// If no config provided, return original request
|
|
if request.config.is_none() && request.config_url.is_none() {
|
|
return Ok(request.clone());
|
|
}
|
|
|
|
// Step 1: Get config content
|
|
let config_content = if let Some(config_b64) = &request.config {
|
|
// Decode Base64 inline config
|
|
let decoded = decode_base64_param("config", config_b64)?;
|
|
String::from_utf8(decoded)
|
|
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in config: {e}")))?
|
|
} else if let Some(_config_url) = &request.config_url {
|
|
// Fetch remote config (TODO: implement remote fetching in next phase)
|
|
return Err(AppError::InvalidInput(
|
|
"Remote config URL is not yet supported. Use inline config instead.".to_string(),
|
|
));
|
|
} else {
|
|
return Ok(request.clone());
|
|
};
|
|
|
|
// Step 2: Parse config based on format
|
|
let format = request.config_format.as_deref().unwrap_or("json");
|
|
let config_value: serde_json::Value = match format {
|
|
"json" => serde_json::from_str(&config_content)
|
|
.map_err(|e| AppError::InvalidInput(format!("Invalid JSON config: {e}")))?,
|
|
"toml" => {
|
|
let toml_value: toml::Value = toml::from_str(&config_content)
|
|
.map_err(|e| AppError::InvalidInput(format!("Invalid TOML config: {e}")))?;
|
|
// Convert TOML to JSON for uniform processing
|
|
serde_json::to_value(toml_value)
|
|
.map_err(|e| AppError::Message(format!("Failed to convert TOML to JSON: {e}")))?
|
|
}
|
|
_ => {
|
|
return Err(AppError::InvalidInput(format!(
|
|
"Unsupported config format: {format}"
|
|
)))
|
|
}
|
|
};
|
|
|
|
// Step 3: Extract values from config based on app type and merge with URL params
|
|
let mut merged = request.clone();
|
|
|
|
// MCP, Skill and other resource types don't need config merging
|
|
if request.resource != "provider" {
|
|
return Ok(merged);
|
|
}
|
|
|
|
match request.app.as_deref().unwrap_or("") {
|
|
"claude" => merge_claude_config(&mut merged, &config_value)?,
|
|
"codex" => merge_codex_config(&mut merged, &config_value)?,
|
|
"gemini" => merge_gemini_config(&mut merged, &config_value)?,
|
|
"" => {
|
|
// No app specified, skip merging
|
|
return Ok(merged);
|
|
}
|
|
_ => {
|
|
return Err(AppError::InvalidInput(format!(
|
|
"Invalid app type: {:?}",
|
|
request.app
|
|
)))
|
|
}
|
|
}
|
|
|
|
Ok(merged)
|
|
}
|
|
|
|
/// Merge Claude configuration from config file
|
|
fn merge_claude_config(
|
|
request: &mut DeepLinkImportRequest,
|
|
config: &serde_json::Value,
|
|
) -> Result<(), AppError> {
|
|
let env = config
|
|
.get("env")
|
|
.and_then(|v| v.as_object())
|
|
.ok_or_else(|| {
|
|
AppError::InvalidInput("Claude config must have 'env' object".to_string())
|
|
})?;
|
|
|
|
// Auto-fill API key if not provided in URL
|
|
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() {
|
|
if let Some(token) = env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()) {
|
|
request.api_key = Some(token.to_string());
|
|
}
|
|
}
|
|
|
|
// Auto-fill endpoint if not provided in URL
|
|
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() {
|
|
if let Some(base_url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
|
|
request.endpoint = Some(base_url.to_string());
|
|
}
|
|
}
|
|
|
|
// Auto-fill homepage from endpoint if not provided
|
|
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty())
|
|
&& request.endpoint.is_some()
|
|
&& !request.endpoint.as_ref().unwrap().is_empty()
|
|
{
|
|
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap());
|
|
if request.homepage.is_none() {
|
|
request.homepage = Some("https://anthropic.com".to_string());
|
|
}
|
|
}
|
|
|
|
// Auto-fill model fields (URL params take priority)
|
|
if request.model.is_none() {
|
|
request.model = env
|
|
.get("ANTHROPIC_MODEL")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string());
|
|
}
|
|
if request.haiku_model.is_none() {
|
|
request.haiku_model = env
|
|
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string());
|
|
}
|
|
if request.sonnet_model.is_none() {
|
|
request.sonnet_model = env
|
|
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string());
|
|
}
|
|
if request.opus_model.is_none() {
|
|
request.opus_model = env
|
|
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string());
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Merge Codex configuration from config file
|
|
fn merge_codex_config(
|
|
request: &mut DeepLinkImportRequest,
|
|
config: &serde_json::Value,
|
|
) -> Result<(), AppError> {
|
|
// Auto-fill API key from auth.OPENAI_API_KEY
|
|
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() {
|
|
if let Some(api_key) = config
|
|
.get("auth")
|
|
.and_then(|v| v.get("OPENAI_API_KEY"))
|
|
.and_then(|v| v.as_str())
|
|
{
|
|
request.api_key = Some(api_key.to_string());
|
|
}
|
|
}
|
|
|
|
// Auto-fill endpoint and model from config string
|
|
if let Some(config_str) = config.get("config").and_then(|v| v.as_str()) {
|
|
// Parse TOML config string to extract base_url and model
|
|
if let Ok(toml_value) = toml::from_str::<toml::Value>(config_str) {
|
|
// Extract base_url from model_providers section
|
|
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() {
|
|
if let Some(base_url) = extract_codex_base_url(&toml_value) {
|
|
request.endpoint = Some(base_url);
|
|
}
|
|
}
|
|
|
|
// Extract model
|
|
if request.model.is_none() {
|
|
if let Some(model) = toml_value.get("model").and_then(|v| v.as_str()) {
|
|
request.model = Some(model.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Auto-fill homepage from endpoint
|
|
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty())
|
|
&& request.endpoint.is_some()
|
|
&& !request.endpoint.as_ref().unwrap().is_empty()
|
|
{
|
|
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap());
|
|
if request.homepage.is_none() {
|
|
request.homepage = Some("https://openai.com".to_string());
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Merge Gemini configuration from config file
|
|
fn merge_gemini_config(
|
|
request: &mut DeepLinkImportRequest,
|
|
config: &serde_json::Value,
|
|
) -> Result<(), AppError> {
|
|
// Gemini uses flat env structure
|
|
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() {
|
|
if let Some(api_key) = config.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
|
|
request.api_key = Some(api_key.to_string());
|
|
}
|
|
}
|
|
|
|
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() {
|
|
if let Some(base_url) = config.get("GEMINI_BASE_URL").and_then(|v| v.as_str()) {
|
|
request.endpoint = Some(base_url.to_string());
|
|
}
|
|
}
|
|
|
|
if request.model.is_none() {
|
|
request.model = config
|
|
.get("GEMINI_MODEL")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string());
|
|
}
|
|
|
|
// Auto-fill homepage from endpoint
|
|
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty())
|
|
&& request.endpoint.is_some()
|
|
&& !request.endpoint.as_ref().unwrap().is_empty()
|
|
{
|
|
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap());
|
|
if request.homepage.is_none() {
|
|
request.homepage = Some("https://ai.google.dev".to_string());
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Extract base_url from Codex TOML config
|
|
fn extract_codex_base_url(toml_value: &toml::Value) -> Option<String> {
|
|
// Try to find base_url in model_providers section
|
|
if let Some(providers) = toml_value.get("model_providers").and_then(|v| v.as_table()) {
|
|
for (_key, provider) in providers.iter() {
|
|
if let Some(base_url) = provider.get("base_url").and_then(|v| v.as_str()) {
|
|
return Some(base_url.to_string());
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|