From d474e46937144f81dfee1e9f04dd897749080867 Mon Sep 17 00:00:00 2001 From: Stan Date: Thu, 3 Sep 2026 12:52:30 +0500 Subject: [PATCH 1/3] feat(providers): add aimlapi.com as an OpenAI-compatible gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenFang already routes every OpenAI-compatible vendor through the shared driver tables, so a gateway needs a base-URL constant, a provider-defaults arm, a ProviderInfo row and a handful of catalog rows rather than a new driver file. This follows the Requesty (issue #995) and Novita wiring exactly so there is one shape to maintain, not two. The five catalog rows are gateway-priced rather than upstream-priced: the same model costs a different amount through a router than direct, and letting `aimlapi/anthropic/claude-sonnet-4.6` fall through to the generic `contains("sonnet")` arm would silently bill it at Anthropic's own rate. The metering fallback carries matching arms scoped to the `aimlapi/` prefix, with a test that fails if those rates ever drift from the catalog. Attribution headers are keyed on the request *origin*, not on the configured provider name. A user can point any provider at any base_url, and partner headers must never ride a request to a different vendor or to a third-party proxy that merely fronts the same API. `with_extra_headers` now appends instead of assigning, so attribution cannot silently discard headers an earlier caller configured (Copilot's IDE auth is the existing caller). The partner id shape is asserted in a unit test because a malformed id is accepted by the gateway and then ignored — it fails silently, earning nothing, with no runtime error to notice. --- .env.example | 3 + .../openfang-api/static/js/pages/settings.js | 2 +- .../src/tui/screens/init_wizard.rs | 8 + crates/openfang-kernel/src/metering.rs | 67 +++++++ crates/openfang-runtime/src/drivers/mod.rs | 187 ++++++++++++++++-- crates/openfang-runtime/src/drivers/openai.rs | 64 +++++- crates/openfang-runtime/src/model_catalog.rs | 167 +++++++++++++++- crates/openfang-types/src/model_catalog.rs | 3 + 8 files changed, 473 insertions(+), 28 deletions(-) diff --git a/.env.example b/.env.example index 6a3882a224..6a50b6ce77 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/crates/openfang-api/static/js/pages/settings.js b/crates/openfang-api/static/js/pages/settings.js index 7b46c2d001..72e36dd414 100644 --- a/crates/openfang-api/static/js/pages/settings.js +++ b/crates/openfang-api/static/js/pages/settings.js @@ -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'; diff --git a/crates/openfang-cli/src/tui/screens/init_wizard.rs b/crates/openfang-cli/src/tui/screens/init_wizard.rs index 53d9c557d6..fef6ac99b1 100644 --- a/crates/openfang-cli/src/tui/screens/init_wizard.rs +++ b/crates/openfang-cli/src/tui/screens/init_wizard.rs @@ -76,6 +76,14 @@ const PROVIDERS: &[ProviderInfo] = &[ needs_key: true, hint: "", }, + 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: "together", display: "Together", diff --git a/crates/openfang-kernel/src/metering.rs b/crates/openfang-kernel/src/metering.rs index 1671794edb..20650e095a 100644 --- a/crates/openfang-kernel/src/metering.rs +++ b/crates/openfang-kernel/src/metering.rs @@ -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//` 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 + // `/` 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//` and // resolve via substring match on the upstream model name below @@ -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}" + ); + } } diff --git a/crates/openfang-runtime/src/drivers/mod.rs b/crates/openfang-runtime/src/drivers/mod.rs index ca1e0701c7..7d45060311 100644 --- a/crates/openfang-runtime/src/drivers/mod.rs +++ b/crates/openfang-runtime/src/drivers/mod.rs @@ -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; @@ -111,6 +112,11 @@ fn provider_defaults(provider: &str) -> Option { 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", @@ -306,6 +312,53 @@ fn provider_defaults(provider: &str) -> Option { } } +/// 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_openfang"; + +/// 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: @@ -313,6 +366,7 @@ fn provider_defaults(provider: &str) -> Option { /// - `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 @@ -546,7 +600,9 @@ pub fn create_driver(config: &DriverConfig) -> Result, 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. @@ -558,10 +614,9 @@ pub fn create_driver(config: &DriverConfig) -> Result, 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 @@ -587,8 +642,8 @@ pub fn create_driver(config: &DriverConfig) -> Result, 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 @@ -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()); + } } diff --git a/crates/openfang-runtime/src/drivers/openai.rs b/crates/openfang-runtime/src/drivers/openai.rs index 73210f2757..048f45d31c 100644 --- a/crates/openfang-runtime/src/drivers/openai.rs +++ b/crates/openfang-runtime/src/drivers/openai.rs @@ -66,9 +66,13 @@ impl OpenAIDriver { || model.to_lowercase().contains("reasoner") } - /// Create a driver with additional HTTP headers (e.g. for Copilot IDE auth). + /// Create a driver with additional HTTP headers (e.g. for Copilot IDE auth, + /// or gateway attribution headers). + /// + /// Headers are *appended*, so a second call merges rather than silently + /// discarding whatever an earlier caller configured. pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self { - self.extra_headers = headers; + self.extra_headers.extend(headers); self } @@ -1705,6 +1709,62 @@ fn parse_groq_failed_tool_call(body: &str) -> Option { mod tests { use super::*; + /// Unset optional parameters must be *omitted* from the request body, not + /// serialised as `null`. Several OpenAI-compatible gateways reject an + /// explicit `null` on these fields with a 400 while accepting the key being + /// absent; `"tools": null` is the worst of them, because a host that clears + /// tools between turns succeeds on turn 1 and fails on turn 2 of an agent + /// loop. `skip_serializing_if` is what keeps that from happening here. + #[test] + fn test_unset_request_fields_are_omitted_not_null() { + let req = OaiRequest { + model: "gpt-4o-mini".to_string(), + messages: vec![], + max_tokens: None, + max_completion_tokens: None, + temperature: None, + tools: vec![], + tool_choice: None, + stream: false, + stream_options: None, + thinking: None, + }; + let body = serde_json::to_value(&req).expect("request must serialise"); + let obj = body.as_object().expect("request must be a JSON object"); + for key in [ + "max_tokens", + "max_completion_tokens", + "temperature", + "tools", + "tool_choice", + "stream", + "stream_options", + "thinking", + ] { + assert!( + !obj.contains_key(key), + "unset `{key}` must be omitted, not sent as null: {body}" + ); + } + assert!(obj.contains_key("model")); + assert!(obj.contains_key("messages")); + } + + /// Extra headers merge rather than overwrite, so one caller's attribution + /// cannot silently drop another caller's headers. + #[test] + fn test_with_extra_headers_merges() { + let driver = OpenAIDriver::new("k".to_string(), "https://example.com/v1".to_string()) + .with_extra_headers(vec![("A".to_string(), "1".to_string())]) + .with_extra_headers(vec![("B".to_string(), "2".to_string())]); + let names: Vec<&str> = driver + .extra_headers + .iter() + .map(|(k, _)| k.as_str()) + .collect(); + assert_eq!(names, vec!["A", "B"]); + } + #[test] fn test_openai_driver_creation() { let driver = OpenAIDriver::new("test-key".to_string(), "http://localhost".to_string()); diff --git a/crates/openfang-runtime/src/model_catalog.rs b/crates/openfang-runtime/src/model_catalog.rs index 4bf6fc7ec8..728b80abbb 100644 --- a/crates/openfang-runtime/src/model_catalog.rs +++ b/crates/openfang-runtime/src/model_catalog.rs @@ -4,15 +4,16 @@ //! with alias resolution, auth status detection, and pricing lookups. use openfang_types::model_catalog::{ - AuthStatus, ModelCatalogEntry, ModelTier, ProviderInfo, AI21_BASE_URL, ANTHROPIC_BASE_URL, - AZURE_OPENAI_BASE_URL, BEDROCK_BASE_URL, CEREBRAS_BASE_URL, CHUTES_BASE_URL, COHERE_BASE_URL, - DEEPSEEK_BASE_URL, FIREWORKS_BASE_URL, GEMINI_BASE_URL, GITHUB_COPILOT_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, 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, + AuthStatus, ModelCatalogEntry, ModelTier, ProviderInfo, AI21_BASE_URL, AIMLAPI_BASE_URL, + ANTHROPIC_BASE_URL, AZURE_OPENAI_BASE_URL, BEDROCK_BASE_URL, CEREBRAS_BASE_URL, + CHUTES_BASE_URL, COHERE_BASE_URL, DEEPSEEK_BASE_URL, FIREWORKS_BASE_URL, GEMINI_BASE_URL, + GITHUB_COPILOT_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, + 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::collections::HashMap; @@ -634,6 +635,15 @@ fn builtin_providers() -> Vec { auth_status: AuthStatus::Missing, model_count: 0, }, + ProviderInfo { + id: "aimlapi".into(), + display_name: "aimlapi.com".into(), + api_key_env: "AIMLAPI_API_KEY".into(), + base_url: AIMLAPI_BASE_URL.into(), + key_required: true, + auth_status: AuthStatus::Missing, + model_count: 0, + }, ProviderInfo { id: "mistral".into(), display_name: "Mistral AI".into(), @@ -2154,6 +2164,88 @@ fn builtin_models() -> Vec { aliases: vec![], }, // ══════════════════════════════════════════════════════════════ + // aimlapi.com (5) — router-style OpenAI-compatible gateway + // 350+ chat models behind https://api.aimlapi.com/v1. IDs below are + // `aimlapi/`; `strip_provider_prefix` removes the + // `aimlapi/` segment so the wire model is the gateway's canonical id. + // Every id here was checked against GET /v1/models (id-or-alias). + // Rates and context windows come from that same catalog + // (`pricing.units[]`, `info.contextLength`); the gateway's rates are its + // own and are not the upstream vendors' direct rates. + // ══════════════════════════════════════════════════════════════ + ModelCatalogEntry { + id: "aimlapi/anthropic/claude-sonnet-4.6".into(), + display_name: "Claude Sonnet 4.6 (aimlapi.com)".into(), + provider: "aimlapi".into(), + tier: ModelTier::Smart, + context_window: 200_000, + max_output_tokens: 64_000, + input_cost_per_m: 4.13, + output_cost_per_m: 20.63, + supports_tools: true, + supports_vision: true, + supports_streaming: true, + aliases: vec![], + }, + ModelCatalogEntry { + id: "aimlapi/openai/gpt-5-5".into(), + display_name: "GPT-5.5 (aimlapi.com)".into(), + provider: "aimlapi".into(), + tier: ModelTier::Frontier, + context_window: 1_050_000, + max_output_tokens: 128_000, + input_cost_per_m: 6.50, + output_cost_per_m: 39.00, + supports_tools: true, + supports_vision: true, + supports_streaming: true, + aliases: vec![], + }, + ModelCatalogEntry { + id: "aimlapi/google/gemini-2.5-flash".into(), + display_name: "Gemini 2.5 Flash (aimlapi.com)".into(), + provider: "aimlapi".into(), + tier: ModelTier::Smart, + context_window: 1_000_000, + max_output_tokens: 65_536, + input_cost_per_m: 0.39, + output_cost_per_m: 3.25, + supports_tools: true, + supports_vision: true, + supports_streaming: true, + aliases: vec![], + }, + ModelCatalogEntry { + id: "aimlapi/alibaba/qwen-max".into(), + display_name: "Qwen Max (aimlapi.com)".into(), + provider: "aimlapi".into(), + tier: ModelTier::Smart, + context_window: 32_000, + max_output_tokens: 8_192, + input_cost_per_m: 2.08, + output_cost_per_m: 8.32, + supports_tools: true, + supports_vision: false, + supports_streaming: true, + aliases: vec![], + }, + ModelCatalogEntry { + id: "aimlapi/meta-llama/Llama-3.3-70B-Instruct-Turbo".into(), + display_name: "Llama 3.3 70B Turbo (aimlapi.com)".into(), + provider: "aimlapi".into(), + tier: ModelTier::Balanced, + context_window: 128_000, + // The catalog reports outputMax 127_000 here, which is all but the + // whole context window; capped at a realistic value for this model. + max_output_tokens: 32_768, + input_cost_per_m: 1.144, + output_cost_per_m: 1.144, + supports_tools: true, + supports_vision: false, + supports_streaming: true, + aliases: vec![], + }, + // ══════════════════════════════════════════════════════════════ // Mistral (6) // ══════════════════════════════════════════════════════════════ ModelCatalogEntry { @@ -4083,7 +4175,7 @@ mod tests { #[test] fn test_catalog_has_providers() { let catalog = ModelCatalog::new(); - assert_eq!(catalog.list_providers().len(), 42); + assert_eq!(catalog.list_providers().len(), 43); } #[test] @@ -4831,6 +4923,61 @@ mod tests { assert!(entry.supports_tools); } + // ── aimlapi.com provider ────────────────────────────────────────────── + + /// aimlapi.com must be registered with the correct base URL and env var, + /// and its catalog models must resolve. + #[test] + fn test_aimlapi_provider_and_models_present() { + let catalog = ModelCatalog::new(); + + let provider = catalog + .list_providers() + .iter() + .find(|p| p.id == "aimlapi") + .expect("aimlapi provider must be registered"); + assert_eq!(provider.display_name, "aimlapi.com"); + assert_eq!(provider.api_key_env, "AIMLAPI_API_KEY"); + assert_eq!(provider.base_url, "https://api.aimlapi.com/v1"); + assert!(provider.key_required); + assert!( + provider.model_count >= 1, + "aimlapi must have at least one model in catalog" + ); + + let entry = catalog + .find_model("aimlapi/anthropic/claude-sonnet-4.6") + .expect("aimlapi/anthropic/claude-sonnet-4.6 must resolve"); + assert_eq!(entry.provider, "aimlapi"); + assert!(entry.supports_tools); + } + + /// Every aimlapi catalog id must carry the `aimlapi/` prefix, because + /// `strip_provider_prefix` is what turns it into the gateway's own model id + /// on the wire. A row without the prefix would be sent verbatim and 400. + #[test] + fn test_aimlapi_model_ids_are_prefixed() { + let catalog = ModelCatalog::new(); + let rows: Vec<_> = catalog + .list_models() + .iter() + .filter(|m| m.provider == "aimlapi") + .collect(); + assert!(!rows.is_empty(), "aimlapi must ship catalog rows"); + for m in rows { + assert!( + m.id.starts_with("aimlapi/"), + "aimlapi model id must be prefixed: {}", + m.id + ); + assert!( + m.display_name.ends_with("(aimlapi.com)"), + "aimlapi model label must name the provider: {}", + m.display_name + ); + } + } + // ── Issue #1154: env-var overrides for local provider URLs ── /// Local guard so this catalog test doesn't clash with the driver tests diff --git a/crates/openfang-types/src/model_catalog.rs b/crates/openfang-types/src/model_catalog.rs index 1eb5985ee4..6b61129e45 100644 --- a/crates/openfang-types/src/model_catalog.rs +++ b/crates/openfang-types/src/model_catalog.rs @@ -15,6 +15,9 @@ pub const DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/v1"; pub const GROQ_BASE_URL: &str = "https://api.groq.com/openai/v1"; pub const OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1"; pub const REQUESTY_BASE_URL: &str = "https://router.requesty.ai/v1"; +/// AI/ML API — OpenAI-compatible aggregator. Chat is `{base}/chat/completions`. +/// There is no `{base}/completions` endpoint (it 404s), so never derive one. +pub const AIMLAPI_BASE_URL: &str = "https://api.aimlapi.com/v1"; pub const MISTRAL_BASE_URL: &str = "https://api.mistral.ai/v1"; pub const TOGETHER_BASE_URL: &str = "https://api.together.xyz/v1"; pub const FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1"; From a6742a35fcc519c5ee7dd2b25835f9a22d01e639 Mon Sep 17 00:00:00 2001 From: Stan Date: Thu, 3 Sep 2026 12:54:20 +0500 Subject: [PATCH 2/3] =?UTF-8?q?chore(aimlapi):=20fork-only=20placement=20?= =?UTF-8?q?=E2=80=94=20do=20not=20send=20upstream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves aimlapi.com to the head of the two hand-ordered provider lists (the builtin ProviderInfo table and the `openfang init` wizard) and names it in the aggregator group label on the settings page. This is presentation only and carries no functional change, which is why it is isolated here: it reflects our own preference, not the project's, and should be dropped before the provider wiring is offered upstream. The web settings page orders providers within each category by configured-first then alphabetically, and the API returns the table unsorted; that ordering machinery is left untouched. --- .../openfang-api/static/js/pages/settings.js | 2 +- .../src/tui/screens/init_wizard.rs | 16 ++++++++-------- crates/openfang-runtime/src/model_catalog.rs | 18 +++++++++--------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/crates/openfang-api/static/js/pages/settings.js b/crates/openfang-api/static/js/pages/settings.js index 72e36dd414..62b97f63e4 100644 --- a/crates/openfang-api/static/js/pages/settings.js +++ b/crates/openfang-api/static/js/pages/settings.js @@ -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'; diff --git a/crates/openfang-cli/src/tui/screens/init_wizard.rs b/crates/openfang-cli/src/tui/screens/init_wizard.rs index fef6ac99b1..36a75def64 100644 --- a/crates/openfang-cli/src/tui/screens/init_wizard.rs +++ b/crates/openfang-cli/src/tui/screens/init_wizard.rs @@ -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", @@ -76,14 +84,6 @@ const PROVIDERS: &[ProviderInfo] = &[ needs_key: true, hint: "", }, - 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: "together", display: "Together", diff --git a/crates/openfang-runtime/src/model_catalog.rs b/crates/openfang-runtime/src/model_catalog.rs index 728b80abbb..2bc841469e 100644 --- a/crates/openfang-runtime/src/model_catalog.rs +++ b/crates/openfang-runtime/src/model_catalog.rs @@ -572,6 +572,15 @@ pub fn read_codex_credential() -> Option { fn builtin_providers() -> Vec { vec![ + ProviderInfo { + id: "aimlapi".into(), + display_name: "aimlapi.com".into(), + api_key_env: "AIMLAPI_API_KEY".into(), + base_url: AIMLAPI_BASE_URL.into(), + key_required: true, + auth_status: AuthStatus::Missing, + model_count: 0, + }, ProviderInfo { id: "anthropic".into(), display_name: "Anthropic".into(), @@ -635,15 +644,6 @@ fn builtin_providers() -> Vec { auth_status: AuthStatus::Missing, model_count: 0, }, - ProviderInfo { - id: "aimlapi".into(), - display_name: "aimlapi.com".into(), - api_key_env: "AIMLAPI_API_KEY".into(), - base_url: AIMLAPI_BASE_URL.into(), - key_required: true, - auth_status: AuthStatus::Missing, - model_count: 0, - }, ProviderInfo { id: "mistral".into(), display_name: "Mistral AI".into(), From e9c232d1406f5408f15cfd021fcdc10de9afc6ee Mon Sep 17 00:00:00 2001 From: Stan Date: Thu, 3 Sep 2026 18:15:13 +0500 Subject: [PATCH 3/3] fix(aimlapi): use the registered partner id The placeholder part_openfang was a readable stand-in chosen before the partner was registered. Registration mints the id server-side, so the real value is part_Z6HbToJ3l2ht1dFSzHC98vk6. A wrong or unknown partner id is accepted with a 200 and silently not attributed, so this would not have surfaced at runtime. --- crates/openfang-runtime/src/drivers/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/openfang-runtime/src/drivers/mod.rs b/crates/openfang-runtime/src/drivers/mod.rs index 7d45060311..7d292583e9 100644 --- a/crates/openfang-runtime/src/drivers/mod.rs +++ b/crates/openfang-runtime/src/drivers/mod.rs @@ -317,7 +317,7 @@ fn provider_defaults(provider: &str) -> Option { /// 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_openfang"; +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";