Skip to content
Open
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
# Novita AI (multi-model gateway)
# NOVITA_API_KEY=...

# aimlapi.com (multi-model gateway)
# AIMLAPI_API_KEY=...

# ─── Local LLM Providers (no API key needed) ─────────────────────────

# Ollama (default: http://localhost:11434)
Expand Down
4 changes: 2 additions & 2 deletions crates/openfang-api/static/js/pages/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ function settingsPage() {
var id = (p.id || '').toLowerCase();
var FRONTIER = ['anthropic','openai','gemini','google','xai','bedrock','azure','vertex'];
var OSS = ['groq','together','fireworks','cerebras','sambanova','deepseek','mistral','perplexity','cohere','ai21','huggingface','replicate','nvidia','venice','novita','chutes'];
var AGG = ['openrouter','litellm','github-copilot','claude-code'];
var AGG = ['aimlapi','openrouter','litellm','github-copilot','claude-code'];
var REGIONAL = ['qwen','minimax','zhipu','zai','moonshot','qianfan','volcengine','kimi'];
if (FRONTIER.indexOf(id) !== -1) return 'frontier';
if (REGIONAL.indexOf(id) !== -1) return 'regional';
Expand All @@ -362,7 +362,7 @@ function settingsPage() {
switch (cat) {
case 'frontier': return 'Frontier (Anthropic, OpenAI, Google, xAI, Bedrock)';
case 'oss': return 'Open-Weight Hosts (Groq, Together, Fireworks, DeepSeek, etc.)';
case 'aggregator': return 'Aggregators & Gateways (OpenRouter, GitHub Copilot)';
case 'aggregator': return 'Aggregators & Gateways (aimlapi.com, OpenRouter, GitHub Copilot)';
case 'regional': return 'Regional / China (Qwen, Zhipu, Moonshot, MiniMax)';
case 'local': return 'Local / Self-Hosted (Ollama, vLLM, LM Studio, Lemonade)';
default: return 'Other Providers';
Expand Down
8 changes: 8 additions & 0 deletions crates/openfang-cli/src/tui/screens/init_wizard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ struct ProviderInfo {
}

const PROVIDERS: &[ProviderInfo] = &[
ProviderInfo {
name: "aimlapi",
display: "aimlapi.com",
env_var: "AIMLAPI_API_KEY",
default_model: "aimlapi/google/gemini-2.5-flash",
needs_key: true,
hint: "",
},
ProviderInfo {
name: "groq",
display: "Groq",
Expand Down
67 changes: 67 additions & 0 deletions crates/openfang-kernel/src/metering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,28 @@ pub struct BudgetStatus {
/// Order matters: more specific patterns must come before generic ones
/// (e.g. "gpt-4o-mini" before "gpt-4o", "gpt-4.1-mini" before "gpt-4.1").
fn estimate_cost_rates(model: &str) -> (f64, f64) {
// ── aimlapi.com ────────────────────────────────────────────
// Router-style gateway. Catalog IDs are `aimlapi/<vendor>/<model>` and the
// gateway charges its own rates, which are not the upstream vendor's — a
// bare `contains("sonnet")` fallthrough would bill Anthropic's direct rate
// and understate the real cost, so the shipped models are priced here.
// Matching is restricted to the `aimlapi/` prefix so the bare
// `<vendor>/<model>` spelling, which other gateways also resolve to, cannot
// pick up these rates. `estimate_cost` lowercases before dispatching, hence
// the lowercase arms even though the catalog IDs are mixed case.
// Any other aimlapi model falls through to the substring patterns below,
// which is an approximation, exactly as Requesty does.
if let Some(upstream) = model.strip_prefix("aimlapi/") {
match upstream {
"anthropic/claude-sonnet-4.6" => return (4.13, 20.63),
"openai/gpt-5-5" => return (6.50, 39.00),
"google/gemini-2.5-flash" => return (0.39, 3.25),
"alibaba/qwen-max" => return (2.08, 8.32),
"meta-llama/llama-3.3-70b-instruct-turbo" => return (1.144, 1.144),
_ => {}
}
}

// ── Requesty (issue #995) ──────────────────────────────────
// Router-style gateway. IDs are `requesty/<upstream>/<model>` and
// resolve via substring match on the upstream model name below
Expand Down Expand Up @@ -812,4 +834,49 @@ mod tests {
assert_eq!(summary.call_count, 1);
assert_eq!(summary.total_input_tokens, 500);
}

// ── aimlapi.com ───────────────────────────────────────────────────

/// The gateway's rates live in two places: the model catalog (used in
/// production via `estimate_cost_with_catalog`) and the substring table
/// above (the fallback path). They must not drift apart, so every aimlapi
/// catalog row is checked against the fallback here rather than trusting
/// two hand-maintained lists to stay in sync.
#[test]
fn test_aimlapi_fallback_rates_match_catalog() {
let catalog = openfang_runtime::model_catalog::ModelCatalog::new();
let rows: Vec<_> = catalog
.list_models()
.iter()
.filter(|m| m.provider == "aimlapi")
.map(|m| (m.id.clone(), m.input_cost_per_m, m.output_cost_per_m))
.collect();
assert!(!rows.is_empty(), "aimlapi must ship catalog rows");
for (id, cat_in, cat_out) in rows {
let cost = MeteringEngine::estimate_cost(&id, 1_000_000, 0);
assert!(
(cost - cat_in).abs() < 1e-6,
"input rate for {id} drifted: fallback {cost} vs catalog {cat_in}"
);
let cost = MeteringEngine::estimate_cost(&id, 0, 1_000_000);
assert!(
(cost - cat_out).abs() < 1e-6,
"output rate for {id} drifted: fallback {cost} vs catalog {cat_out}"
);
}
}

/// The aimlapi rates must stay scoped to the `aimlapi/` prefix: the bare
/// upstream spelling belongs to whatever provider actually serves it.
#[test]
fn test_aimlapi_rates_do_not_leak_to_bare_ids() {
let gateway =
MeteringEngine::estimate_cost("aimlapi/anthropic/claude-sonnet-4.6", 0, 1_000_000);
let direct = MeteringEngine::estimate_cost("anthropic/claude-sonnet-4.6", 0, 1_000_000);
assert!((gateway - 20.63).abs() < 1e-6);
assert!(
(direct - 15.0).abs() < 1e-6,
"bare id must keep Anthropic's own rate, got {direct}"
);
}
}
187 changes: 172 additions & 15 deletions crates/openfang-runtime/src/drivers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@ pub mod vertex;

use crate::llm_driver::{DriverConfig, LlmDriver, LlmError};
use openfang_types::model_catalog::{
AI21_BASE_URL, ANTHROPIC_BASE_URL, AZURE_OPENAI_BASE_URL, CEREBRAS_BASE_URL, CHUTES_BASE_URL,
COHERE_BASE_URL, DEEPSEEK_BASE_URL, FIREWORKS_BASE_URL, GEMINI_BASE_URL, GROQ_BASE_URL,
HUGGINGFACE_BASE_URL, KIMI_CODING_BASE_URL, LEMONADE_BASE_URL, LMSTUDIO_BASE_URL,
MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, NOVITA_BASE_URL, NVIDIA_NIM_BASE_URL,
OLLAMA_BASE_URL, OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL,
QWEN_BASE_URL, REPLICATE_BASE_URL, REQUESTY_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL,
VENICE_BASE_URL, VLLM_BASE_URL, VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL,
ZAI_BASE_URL, ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
AI21_BASE_URL, AIMLAPI_BASE_URL, ANTHROPIC_BASE_URL, AZURE_OPENAI_BASE_URL, CEREBRAS_BASE_URL,
CHUTES_BASE_URL, COHERE_BASE_URL, DEEPSEEK_BASE_URL, FIREWORKS_BASE_URL, GEMINI_BASE_URL,
GROQ_BASE_URL, HUGGINGFACE_BASE_URL, KIMI_CODING_BASE_URL, LEMONADE_BASE_URL,
LMSTUDIO_BASE_URL, MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, NOVITA_BASE_URL,
NVIDIA_NIM_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL, OPENROUTER_BASE_URL,
PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL, REPLICATE_BASE_URL, REQUESTY_BASE_URL,
SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL, VOLCENGINE_BASE_URL,
VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL, ZAI_CODING_BASE_URL, ZHIPU_BASE_URL,
ZHIPU_CODING_BASE_URL,
};
use std::sync::Arc;

Expand Down Expand Up @@ -111,6 +112,11 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
api_key_env: "REQUESTY_API_KEY",
key_required: true,
}),
"aimlapi" => Some(ProviderDefaults {
base_url: AIMLAPI_BASE_URL,
api_key_env: "AIMLAPI_API_KEY",
key_required: true,
}),
"deepseek" => Some(ProviderDefaults {
base_url: DEEPSEEK_BASE_URL,
api_key_env: "DEEPSEEK_API_KEY",
Expand Down Expand Up @@ -306,13 +312,61 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
}
}

/// Partner identifier sent with AI/ML API traffic originating from OpenFang.
///
/// The gateway accepts `^part_[A-Za-z0-9]{1,64}$` and silently ignores anything
/// else — a typo costs attribution without producing any runtime error, which is
/// why the shape is asserted in a unit test rather than only reviewed by eye.
pub const AIMLAPI_PARTNER_ID: &str = "part_Z6HbToJ3l2ht1dFSzHC98vk6";

/// The one host that owns the AI/ML API attribution headers.
const AIMLAPI_HOST: &str = "api.aimlapi.com";

/// True when `base_url` resolves to AI/ML API's own origin.
///
/// Matching the *origin* rather than the configured provider name is deliberate:
/// a user may reach any OpenAI-compatible endpoint through `base_url`, and the
/// attribution headers must not ride a request to a different vendor, nor to a
/// third-party proxy that merely fronts the same API.
fn is_aimlapi_origin(base_url: &str) -> bool {
reqwest::Url::parse(base_url)
.ok()
.and_then(|u| u.host_str().map(|h| h.eq_ignore_ascii_case(AIMLAPI_HOST)))
.unwrap_or(false)
}

/// Attribution headers identifying OpenFang as the calling application to
/// AI/ML API. Empty for every other origin.
///
/// `HTTP-Referer` and `X-Title` follow the OpenRouter convention and name the
/// *host* project, not the gateway. A fresh `Vec` is built per driver, so no
/// shared constant is ever mutated.
fn aimlapi_attribution_headers(base_url: &str) -> Vec<(String, String)> {
if !is_aimlapi_origin(base_url) {
return Vec::new();
}
vec![
(
"HTTP-Referer".to_string(),
"https://github.com/RightNow-AI/openfang".to_string(),
),
("X-Title".to_string(), "OpenFang".to_string()),
(
"X-AIMLAPI-Partner-ID".to_string(),
AIMLAPI_PARTNER_ID.to_string(),
),
("X-AIMLAPI-Source".to_string(), "agent/openfang".to_string()),
]
}

/// Create an LLM driver based on provider name and configuration.
///
/// Supported providers:
/// - `anthropic` — Anthropic Claude (Messages API)
/// - `openai` — OpenAI GPT models
/// - `groq` — Groq (ultra-fast inference)
/// - `openrouter` — OpenRouter (multi-model gateway)
/// - `aimlapi` — aimlapi.com (multi-model gateway)
/// - `deepseek` — DeepSeek
/// - `together` — Together AI
/// - `mistral` — Mistral AI
Expand Down Expand Up @@ -546,7 +600,9 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
.or_else(|| local_provider_url_from_env(provider))
.unwrap_or_else(|| defaults.base_url.to_string());

return Ok(Arc::new(openai::OpenAIDriver::new(api_key, base_url)));
let attribution = aimlapi_attribution_headers(&base_url);
let driver = openai::OpenAIDriver::new(api_key, base_url);
return Ok(Arc::new(driver.with_extra_headers(attribution)));
}

// Unknown provider — if base_url is set, treat as custom OpenAI-compatible.
Expand All @@ -558,10 +614,9 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
let env_var = format!("{}_API_KEY", provider.to_uppercase().replace('-', "_"));
std::env::var(&env_var).unwrap_or_default()
});
return Ok(Arc::new(openai::OpenAIDriver::new(
api_key,
base_url.clone(),
)));
let attribution = aimlapi_attribution_headers(base_url);
let driver = openai::OpenAIDriver::new(api_key, base_url.clone());
return Ok(Arc::new(driver.with_extra_headers(attribution)));
}

// No base_url either — last resort: check if the user set an API key env var
Expand All @@ -587,8 +642,8 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
status: 0,
message: format!(
"Unknown provider '{}'. Supported: anthropic, gemini, openai, azure, bedrock, groq, \
openrouter, deepseek, together, mistral, fireworks, ollama, vllm, lmstudio, \
perplexity, cohere, ai21, cerebras, sambanova, huggingface, xai, replicate, \
openrouter, aimlapi, deepseek, together, mistral, fireworks, ollama, vllm, \
lmstudio, perplexity, cohere, ai21, cerebras, sambanova, huggingface, xai, replicate, \
github-copilot, chutes, venice, nvidia, codex, claude-code. \
Or set base_url for a custom OpenAI-compatible endpoint.",
provider
Expand Down Expand Up @@ -1307,4 +1362,106 @@ mod tests {
let driver = create_driver(&config);
assert!(driver.is_ok(), "lmstudio default should construct");
}

// ── aimlapi.com ───────────────────────────────────────────────────

#[test]
fn test_provider_defaults_aimlapi() {
let d = provider_defaults("aimlapi").unwrap();
assert_eq!(d.base_url, "https://api.aimlapi.com/v1");
assert_eq!(d.api_key_env, "AIMLAPI_API_KEY");
assert!(d.key_required);
}

/// The gateway credits usage only when the partner id matches its pattern.
/// A malformed id is accepted and then ignored — no error, no traffic
/// attributed — so this assertion is the only thing that catches a typo.
#[test]
fn test_aimlapi_partner_id_shape() {
let re = regex_lite::Regex::new(r"^part_[A-Za-z0-9]{1,64}$")
.expect("partner id pattern must compile");
assert!(
re.is_match(AIMLAPI_PARTNER_ID),
"partner id {AIMLAPI_PARTNER_ID:?} must match ^part_[A-Za-z0-9]{{1,64}}$"
);
}

#[test]
fn test_aimlapi_attribution_headers_for_our_origin() {
let headers = aimlapi_attribution_headers(AIMLAPI_BASE_URL);
let names: Vec<&str> = headers.iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(
names,
vec![
"HTTP-Referer",
"X-Title",
"X-AIMLAPI-Partner-ID",
"X-AIMLAPI-Source",
]
);
let get = |k: &str| {
headers
.iter()
.find(|(n, _)| n == k)
.map(|(_, v)| v.clone())
.unwrap_or_default()
};
assert_eq!(get("X-AIMLAPI-Partner-ID"), AIMLAPI_PARTNER_ID);
assert_eq!(get("X-AIMLAPI-Source"), "agent/openfang");
// HTTP-Referer / X-Title identify the calling app, not the gateway.
assert_eq!(get("X-Title"), "OpenFang");
assert!(get("HTTP-Referer").contains("RightNow-AI/openfang"));
}

/// Attribution is keyed on the request origin, so it cannot ride a request
/// to another vendor, to a look-alike host, or to a proxy fronting the API.
#[test]
fn test_aimlapi_attribution_headers_scoped_to_origin() {
for url in [
OPENROUTER_BASE_URL,
OPENAI_BASE_URL,
"https://gateway.example.com/aimlapi/v1",
"https://api.aimlapi.com.example.net/v1",
"http://localhost:8000/v1",
"not a url",
"",
] {
assert!(
aimlapi_attribution_headers(url).is_empty(),
"attribution must not leak to {url:?}"
);
}
}

/// A user pointing a custom provider at our origin still gets attribution,
/// because the check is on the URL rather than on the provider name.
#[test]
fn test_aimlapi_attribution_follows_custom_base_url() {
assert!(!aimlapi_attribution_headers("https://api.aimlapi.com/v1").is_empty());
assert!(!aimlapi_attribution_headers("https://API.AIMLAPI.COM/v1").is_empty());
}

#[test]
fn test_create_driver_aimlapi_requires_key() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _g = EnvVarGuard::remove("AIMLAPI_API_KEY");

let config = DriverConfig {
provider: "aimlapi".to_string(),
api_key: None,
base_url: None,
skip_permissions: true,
subprocess_timeout_secs: None,
};
assert!(
matches!(create_driver(&config), Err(LlmError::MissingApiKey(_))),
"aimlapi must report a missing key rather than silently sending none"
);

let with_key = DriverConfig {
api_key: Some("test-key".to_string()),
..config
};
assert!(create_driver(&with_key).is_ok());
}
}
Loading