mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 21:30:17 +08:00
5658d93924
Add OpenCode as the 4th supported application with additive provider management: - Add OpenCode variant to AppType enum with all related match statements - Add enabled_opencode field to McpApps and SkillApps structures - Add opencode field to McpRoot and PromptRoot - Add database schema migration v3→v4 with enabled_opencode columns - Add settings.rs support for opencode_config_dir and current_provider_opencode - Create opencode_config.rs module for config file I/O operations - Update all services (proxy, mcp, skill, provider, stream_check) for OpenCode - Add OpenCode support to deeplink provider and MCP parsing - Update commands/config.rs for OpenCode config status and paths Key design decisions: - OpenCode uses additive mode (no is_current needed, no proxy support) - Config path: ~/.config/opencode/opencode.json - MCP format: stdio→local, sse/http→remote conversion planned - Stream check returns error (not yet implemented for OpenCode)
638 lines
21 KiB
Rust
638 lines
21 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 mut merged_request = parse_and_merge_config(&request)?;
|
|
|
|
// Extract required fields (now as Option)
|
|
let app_str = merged_request
|
|
.app
|
|
.clone()
|
|
.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(),
|
|
));
|
|
}
|
|
|
|
// Get endpoint: supports comma-separated multiple URLs (first is primary)
|
|
let endpoint_str = merged_request.endpoint.as_ref().ok_or_else(|| {
|
|
AppError::InvalidInput("Endpoint is required (either in URL or config file)".to_string())
|
|
})?;
|
|
|
|
// Parse endpoints: split by comma, first is primary
|
|
let all_endpoints: Vec<String> = endpoint_str
|
|
.split(',')
|
|
.map(|e| e.trim().to_string())
|
|
.filter(|e| !e.is_empty())
|
|
.collect();
|
|
|
|
let primary_endpoint = all_endpoints
|
|
.first()
|
|
.ok_or_else(|| AppError::InvalidInput("Endpoint cannot be empty".to_string()))?;
|
|
|
|
// Auto-infer homepage from endpoint if not provided
|
|
if merged_request
|
|
.homepage
|
|
.as_ref()
|
|
.is_none_or(|s| s.is_empty())
|
|
{
|
|
merged_request.homepage = infer_homepage_from_endpoint(primary_endpoint);
|
|
}
|
|
|
|
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
|
|
.clone()
|
|
.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)?;
|
|
|
|
// Add extra endpoints as custom endpoints (skip first one as it's the primary)
|
|
for ep in all_endpoints.iter().skip(1) {
|
|
let normalized = ep.trim().trim_end_matches('/').to_string();
|
|
if !normalized.is_empty() {
|
|
if let Err(e) = ProviderService::add_custom_endpoint(
|
|
state,
|
|
app_type.clone(),
|
|
&provider_id,
|
|
normalized.clone(),
|
|
) {
|
|
log::warn!("Failed to add custom endpoint '{normalized}': {e}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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),
|
|
AppType::OpenCode => build_opencode_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)
|
|
}
|
|
|
|
/// Get primary endpoint from request (first one if comma-separated)
|
|
fn get_primary_endpoint(request: &DeepLinkImportRequest) -> String {
|
|
request
|
|
.endpoint
|
|
.as_ref()
|
|
.and_then(|ep| ep.split(',').next())
|
|
.map(|s| s.trim().to_string())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// 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
|
|
// Note: use primary endpoint only (first one if comma-separated)
|
|
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(|| {
|
|
let primary = get_primary_endpoint(request);
|
|
if primary.is_empty() {
|
|
None
|
|
} else {
|
|
Some(primary)
|
|
}
|
|
}),
|
|
access_token: request.usage_access_token.clone(),
|
|
user_id: request.usage_user_id.clone(),
|
|
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
|
|
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!(get_primary_endpoint(request)),
|
|
);
|
|
|
|
// 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 (use primary endpoint only)
|
|
let endpoint = get_primary_endpoint(request)
|
|
.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!(get_primary_endpoint(request)),
|
|
);
|
|
|
|
// Add model if provided
|
|
if let Some(model) = &request.model {
|
|
env.insert("GEMINI_MODEL".to_string(), json!(model));
|
|
}
|
|
|
|
json!({ "env": env })
|
|
}
|
|
|
|
/// Build OpenCode settings configuration
|
|
fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
|
let endpoint = get_primary_endpoint(request);
|
|
|
|
// Build options object
|
|
let mut options = serde_json::Map::new();
|
|
if !endpoint.is_empty() {
|
|
options.insert("baseURL".to_string(), json!(endpoint));
|
|
}
|
|
if let Some(api_key) = &request.api_key {
|
|
options.insert("apiKey".to_string(), json!(api_key));
|
|
}
|
|
|
|
// Build models object
|
|
let mut models = serde_json::Map::new();
|
|
if let Some(model) = &request.model {
|
|
models.insert(model.clone(), json!({ "name": model }));
|
|
}
|
|
|
|
// Default to openai-compatible npm package
|
|
json!({
|
|
"npm": "@ai-sdk/openai-compatible",
|
|
"options": options,
|
|
"models": models
|
|
})
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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.as_ref().is_none_or(|s| s.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.as_ref().is_none_or(|s| s.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.as_ref().is_none_or(|s| s.is_empty()) {
|
|
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
|
|
request.homepage = infer_homepage_from_endpoint(endpoint);
|
|
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.as_ref().is_none_or(|s| s.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.as_ref().is_none_or(|s| s.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.as_ref().is_none_or(|s| s.is_empty()) {
|
|
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
|
|
request.homepage = infer_homepage_from_endpoint(endpoint);
|
|
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.as_ref().is_none_or(|s| s.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.as_ref().is_none_or(|s| s.is_empty()) {
|
|
if let Some(base_url) = config
|
|
.get("GOOGLE_GEMINI_BASE_URL")
|
|
.or_else(|| 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.as_ref().is_none_or(|s| s.is_empty()) {
|
|
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
|
|
request.homepage = infer_homepage_from_endpoint(endpoint);
|
|
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
|
|
}
|