Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ pub(crate) fn normalize_provider_type_input(value: &str) -> Result<String, Strin
let normalized = value.trim().to_ascii_lowercase();
match normalized.as_str() {
"custom" | "claude_code" | "kiro" | "codex" | "chatgpt_web" | "gemini_cli"
| "antigravity" | "vertex_ai" | "grok" | "windsurf" => Ok(normalized),
| "antigravity" | "vertex_ai" | "grok" | "windsurf" | "opencode_free" | "kilo_free" => Ok(normalized),
_ => Err(
"provider_type 仅支持 custom / claude_code / kiro / codex / chatgpt_web / gemini_cli / antigravity / vertex_ai / grok / windsurf"
"provider_type 仅支持 custom / claude_code / kiro / codex / chatgpt_web / gemini_cli / antigravity / vertex_ai / grok / windsurf / opencode_free / kilo_free"
.to_string(),
),
}
Expand Down
8 changes: 8 additions & 0 deletions crates/aether-model-fetch/src/logic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1233,6 +1233,14 @@ mod tests {
.is_none());
}

#[test]
fn preset_models_for_provider_returns_none_for_free_providers() {
assert!(preset_models_for_provider("opencode_free").is_none());
assert!(preset_models_for_provider("kilo_free").is_none());
assert!(preset_models_for_provider("OPENCODE_FREE").is_none());
assert!(preset_models_for_provider(" Kilo_Free ").is_none());
}

#[test]
fn preset_models_cover_codex_catalog() {
let models = preset_models_for_provider("codex").expect("preset models should exist");
Expand Down
23 changes: 23 additions & 0 deletions crates/aether-model-fetch/src/strategy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1581,6 +1581,20 @@ mod tests {
transport
}

fn sample_kilo_free_transport() -> GatewayProviderTransportSnapshot {
let mut transport = sample_custom_aiplatform_transport();
transport.provider.provider_type = "kilo_free".to_string();
transport.provider.name = "Kilo Free".to_string();
transport.endpoint.api_format = "openai:chat".to_string();
transport.endpoint.api_family = Some("openai".to_string());
transport.endpoint.endpoint_kind = Some("chat".to_string());
transport.endpoint.base_url = "https://api.kilo.ai/api/gateway".to_string();
transport.endpoint.custom_path = None;
transport.key.api_formats = Some(vec!["openai:chat".to_string()]);
transport.key.decrypted_api_key = "kilo-secret".to_string();
transport
}

fn sample_openai_transport(
endpoint_id: &str,
api_format: &str,
Expand Down Expand Up @@ -1641,6 +1655,15 @@ mod tests {
assert_eq!(strategy.kind(), ModelFetchStrategyKind::Windsurf);
}

#[test]
fn strategy_selection_uses_standard_transport_for_kilo_free() {
let strategy = select_model_fetch_strategy(&[sample_kilo_free_transport()])
.expect("strategy should select");

assert_eq!(strategy.provider_id(), "kilo_free");
assert_eq!(strategy.kind(), ModelFetchStrategyKind::StandardTransport);
}

#[tokio::test]
async fn custom_aiplatform_transport_uses_vertex_models_fetch_path_and_normalizes_chat_format()
{
Expand Down
5 changes: 4 additions & 1 deletion crates/aether-provider-pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ pub use providers::{
grok_supported_quota_windows_for_tier, normalize_chatgpt_web_image_quota_limit,
AntigravityProviderPoolAdapter, ChatGptWebProviderPoolAdapter, CodexProviderPoolAdapter,
DefaultProviderPoolAdapter, GeminiCliProviderPoolAdapter, GrokProviderPoolAdapter,
KiroPoolQuotaAuthInput, KiroProviderPoolAdapter, UnsupportedQuotaProviderPoolAdapter,
KiloFreeProviderPoolAdapter, KiroPoolQuotaAuthInput, KiroProviderPoolAdapter,
OpenCodeFreeProviderPoolAdapter, UnsupportedQuotaProviderPoolAdapter,
ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH, CHATGPT_WEB_CONVERSATION_INIT_PATH,
CHATGPT_WEB_DEFAULT_BASE_URL, CODEX_WHAM_USAGE_URL, GEMINI_CLI_RETRIEVE_USER_QUOTA_PATH,
GEMINI_CLI_USER_AGENT, KIRO_USAGE_LIMITS_PATH, KIRO_USAGE_SDK_VERSION,
Expand Down Expand Up @@ -73,7 +74,9 @@ mod tests {
"codex",
"gemini_cli",
"grok",
"kilo_free",
"kiro",
"opencode_free",
"vertex_ai",
"windsurf"
]
Expand Down
40 changes: 40 additions & 0 deletions crates/aether-provider-pool/src/providers/kilo_free.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
use crate::provider::ProviderPoolAdapter;

#[derive(Debug, Clone, Default)]
pub struct KiloFreeProviderPoolAdapter;

impl ProviderPoolAdapter for KiloFreeProviderPoolAdapter {
fn provider_type(&self) -> &'static str {
"kilo_free"
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::capability::{ProviderPoolCapability, ProviderPoolCapabilities};

#[test]
fn test_kilo_free_provider_type() {
let adapter = KiloFreeProviderPoolAdapter;
assert_eq!(adapter.provider_type(), "kilo_free");
}

#[test]
fn test_kilo_free_no_capabilities() {
let adapter = KiloFreeProviderPoolAdapter;
let caps: ProviderPoolCapabilities = adapter.capabilities();
assert!(!caps.plan_tier);
assert!(!caps.quota_reset);
assert!(!caps.quota_refresh);
assert!(!caps.supports(ProviderPoolCapability::PlanTier));
assert!(!caps.supports(ProviderPoolCapability::QuotaReset));
assert!(!caps.supports(ProviderPoolCapability::QuotaRefresh));
}

#[test]
fn test_kilo_free_no_quota_refresh() {
let adapter = KiloFreeProviderPoolAdapter;
assert!(!adapter.supports_quota_refresh());
}
}
4 changes: 4 additions & 0 deletions crates/aether-provider-pool/src/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ pub mod codex;
pub mod default;
pub mod gemini_cli;
pub mod grok;
pub mod kilo_free;
pub mod kiro;
pub mod opencode_free;
pub mod unsupported;
pub mod windsurf;

Expand All @@ -29,11 +31,13 @@ pub use grok::{
grok_mode_id_for_model, grok_pool_tier_from_quota_bucket, grok_quota_window_key_for_model,
grok_supported_quota_windows_for_tier, GrokProviderPoolAdapter,
};
pub use kilo_free::KiloFreeProviderPoolAdapter;
pub use kiro::KiroProviderPoolAdapter;
pub use kiro::{
build_kiro_pool_quota_request, KiroPoolQuotaAuthInput, KIRO_USAGE_LIMITS_PATH,
KIRO_USAGE_SDK_VERSION,
};
pub use opencode_free::OpenCodeFreeProviderPoolAdapter;
pub use unsupported::{
UnsupportedQuotaProviderPoolAdapter, CLAUDE_CODE_PROVIDER_POOL_ADAPTER,
VERTEX_AI_PROVIDER_POOL_ADAPTER,
Expand Down
37 changes: 37 additions & 0 deletions crates/aether-provider-pool/src/providers/opencode_free.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
use crate::provider::ProviderPoolAdapter;

#[derive(Debug, Clone, Default)]
pub struct OpenCodeFreeProviderPoolAdapter;

impl ProviderPoolAdapter for OpenCodeFreeProviderPoolAdapter {
fn provider_type(&self) -> &'static str {
"opencode_free"
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::capability::ProviderPoolCapability;

#[test]
fn test_opencode_free_provider_type() {
let adapter = OpenCodeFreeProviderPoolAdapter;
assert_eq!(adapter.provider_type(), "opencode_free");
}

#[test]
fn test_opencode_free_no_capabilities() {
let adapter = OpenCodeFreeProviderPoolAdapter;
let capabilities = adapter.capabilities();

assert!(!capabilities.supports(ProviderPoolCapability::PlanTier));
assert!(!capabilities.supports(ProviderPoolCapability::QuotaRefresh));
}

#[test]
fn test_opencode_free_no_quota_refresh() {
let adapter = OpenCodeFreeProviderPoolAdapter;
assert!(!adapter.supports_quota_refresh());
}
}
5 changes: 4 additions & 1 deletion crates/aether-provider-pool/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ use crate::provider::{ProviderPoolAdapter, ProviderPoolMemberInput};
use crate::providers::{
AntigravityProviderPoolAdapter, ChatGptWebProviderPoolAdapter, CodexProviderPoolAdapter,
DefaultProviderPoolAdapter, GeminiCliProviderPoolAdapter, GrokProviderPoolAdapter,
KiroProviderPoolAdapter, WindsurfProviderPoolAdapter, CLAUDE_CODE_PROVIDER_POOL_ADAPTER,
KiloFreeProviderPoolAdapter, KiroProviderPoolAdapter, OpenCodeFreeProviderPoolAdapter,
WindsurfProviderPoolAdapter, CLAUDE_CODE_PROVIDER_POOL_ADAPTER,
VERTEX_AI_PROVIDER_POOL_ADAPTER,
};

Expand Down Expand Up @@ -53,6 +54,8 @@ impl ProviderPoolService {
.with_adapter(Arc::new(GeminiCliProviderPoolAdapter))
.with_adapter(Arc::new(GrokProviderPoolAdapter))
.with_adapter(Arc::new(KiroProviderPoolAdapter))
.with_adapter(Arc::new(KiloFreeProviderPoolAdapter))
.with_adapter(Arc::new(OpenCodeFreeProviderPoolAdapter))
.with_adapter(Arc::new(ChatGptWebProviderPoolAdapter))
.with_adapter(Arc::new(WindsurfProviderPoolAdapter))
.with_adapter(Arc::new(VERTEX_AI_PROVIDER_POOL_ADAPTER))
Expand Down
32 changes: 32 additions & 0 deletions crates/aether-provider-transport/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -563,4 +563,36 @@ mod tests {
Some(("x-goog-api-key".to_string(), "sk-gemini".to_string(),))
);
}

#[test]
fn standard_auth_uses_format_override_for_openai_embedding() {
let mut transport = sample_transport();
transport.endpoint.api_format = "openai:embedding".to_string();
transport.key.auth_type = "api_key".to_string();
transport.key.auth_type_by_format = Some(serde_json::json!({
"openai:embedding": "bearer"
}));
transport.key.decrypted_api_key = "ms-test-key".to_string();

assert_eq!(
resolve_local_standard_auth(&transport),
Some((
"authorization".to_string(),
"Bearer ms-test-key".to_string(),
))
);
}

#[test]
fn standard_auth_defaults_to_x_api_key_for_openai_embedding_without_override() {
let mut transport = sample_transport();
transport.endpoint.api_format = "openai:embedding".to_string();
transport.key.auth_type = "api_key".to_string();
transport.key.decrypted_api_key = "ms-test-key".to_string();

assert_eq!(
resolve_local_standard_auth(&transport),
Some(("x-api-key".to_string(), "ms-test-key".to_string(),))
);
}
}
3 changes: 1 addition & 2 deletions crates/aether-provider-transport/src/conversion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,14 +219,13 @@ fn request_direct_auth_for_provider_format(
"openai:chat" if is_windsurf_provider_transport(transport) => {
resolve_windsurf_cascade_auth(transport)
}
"openai:embedding" | "openai:rerank" => resolve_local_standard_auth(transport),
"openai:chat"
| "openai:responses"
| "openai:responses:compact"
| "openai:embedding"
| "jina:embedding"
| "doubao:embedding"
| "aliyun:multimodal_embedding"
| "openai:rerank"
| "jina:rerank" => resolve_local_openai_bearer_auth(transport),
"gemini:generate_content" | "gemini:embedding" => {
if is_vertex_api_key_transport_context(transport) {
Expand Down
128 changes: 128 additions & 0 deletions crates/aether-provider-transport/src/kilo_free/auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
use super::super::snapshot::GatewayProviderTransportSnapshot;

pub const KILO_FREE_PROVIDER_TYPE: &str = "kilo_free";
pub const KILO_FREE_BEARER_TOKEN: &str = "free";

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KiloFreeRequestAuth {
pub bearer_token: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KiloFreeRequestAuthSupport {
Supported(KiloFreeRequestAuth),
Unsupported(KiloFreeRequestAuthUnsupportedReason),
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KiloFreeRequestAuthUnsupportedReason {
WrongProviderType,
}

pub fn resolve_local_kilo_free_request_auth(
transport: &GatewayProviderTransportSnapshot,
) -> KiloFreeRequestAuthSupport {
if !transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case(KILO_FREE_PROVIDER_TYPE)
{
return KiloFreeRequestAuthSupport::Unsupported(
KiloFreeRequestAuthUnsupportedReason::WrongProviderType,
);
}

KiloFreeRequestAuthSupport::Supported(KiloFreeRequestAuth {
bearer_token: KILO_FREE_BEARER_TOKEN.to_string(),
})
}

#[cfg(test)]
mod tests {
use super::*;
use crate::snapshot::{
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
};

fn sample_transport(provider_type: &str) -> GatewayProviderTransportSnapshot {
GatewayProviderTransportSnapshot {
provider: GatewayProviderTransportProvider {
id: "provider-1".to_string(),
name: "Kilo Free".to_string(),
provider_type: provider_type.to_string(),
website: None,
is_active: true,
keep_priority_on_conversion: false,
enable_format_conversion: true,
concurrent_limit: None,
max_retries: None,
proxy: None,
request_timeout_secs: None,
stream_first_byte_timeout_secs: None,
config: None,
},
endpoint: GatewayProviderTransportEndpoint {
id: "endpoint-1".to_string(),
provider_id: "provider-1".to_string(),
api_format: "openai:chat".to_string(),
api_family: None,
endpoint_kind: None,
is_active: true,
base_url: "https://api.kilo.ai/api/gateway".to_string(),
header_rules: None,
body_rules: None,
max_retries: None,
custom_path: None,
config: None,
format_acceptance_config: None,
proxy: None,
},
key: GatewayProviderTransportKey {
id: "key-1".to_string(),
provider_id: "provider-1".to_string(),
name: "free-key".to_string(),
auth_type: "bearer".to_string(),
is_active: true,
api_formats: None,
auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None,
capabilities: None,
rate_multipliers: None,
global_priority_by_format: None,
expires_at_unix_secs: None,
proxy: None,
fingerprint: None,
upstream_metadata: None,
decrypted_api_key: String::new(),
decrypted_auth_config: None,
},
}
}

#[test]
fn resolves_bearer_free_for_kilo_free_provider() {
let transport = sample_transport("kilo_free");
let result = resolve_local_kilo_free_request_auth(&transport);
assert_eq!(
result,
KiloFreeRequestAuthSupport::Supported(KiloFreeRequestAuth {
bearer_token: "free".to_string(),
})
);
}

#[test]
fn rejects_wrong_provider_type() {
let transport = sample_transport("openai");
let result = resolve_local_kilo_free_request_auth(&transport);
assert_eq!(
result,
KiloFreeRequestAuthSupport::Unsupported(
KiloFreeRequestAuthUnsupportedReason::WrongProviderType
)
);
}
}
Loading
Loading