Simplified custom model flow with canonical models (#6934)

This commit is contained in:
David Katz
2026-02-23 20:12:10 -05:00
committed by GitHub
parent edede637d7
commit 716085ce09
26 changed files with 73986 additions and 531 deletions
+4 -4
View File
@@ -163,14 +163,14 @@ pub async fn run_model_list<C: Connection>() {
"o3-mini-2025-01-31",
"o1",
"o1-2024-12-17",
"gpt-4o-mini",
"gpt-4o-mini-2024-07-18",
"o4-mini-deep-research",
"o4-mini-deep-research-2025-06-26",
"gpt-4o",
"gpt-4o-2024-05-13",
"gpt-4o-2024-08-06",
"gpt-4o-2024-11-20",
"gpt-4o-mini",
"gpt-4o-mini-2024-07-18",
"o4-mini-deep-research",
"o4-mini-deep-research-2025-06-26",
"gpt-4",
"gpt-4-0613",
"gpt-4-turbo",
@@ -1969,6 +1969,7 @@ fn add_provider() -> anyhow::Result<()> {
supports_streaming: Some(supports_streaming),
headers,
requires_auth,
catalog_provider_id: None,
})?;
cliclack::outro(format!("Custom provider added: {}", display_name))?;
+6
View File
@@ -354,6 +354,8 @@ derive_utoipa!(Icon as IconSchema);
super::routes::config_management::get_custom_provider,
super::routes::config_management::update_custom_provider,
super::routes::config_management::remove_custom_provider,
super::routes::config_management::get_provider_catalog,
super::routes::config_management::get_provider_catalog_template,
super::routes::config_management::check_provider,
super::routes::config_management::set_config_provider,
super::routes::config_management::configure_provider_oauth,
@@ -450,6 +452,10 @@ derive_utoipa!(Icon as IconSchema);
super::routes::config_management::ToolPermission,
super::routes::config_management::UpsertPermissionsQuery,
super::routes::config_management::UpdateCustomProviderRequest,
goose::providers::catalog::ProviderCatalogEntry,
goose::providers::catalog::ProviderTemplate,
goose::providers::catalog::ModelTemplate,
goose::providers::catalog::ModelCapabilities,
super::routes::config_management::CheckProviderRequest,
super::routes::config_management::SetProviderRequest,
super::routes::config_management::ModelInfoQuery,
@@ -15,6 +15,10 @@ use goose::model::ModelConfig;
use goose::providers::auto_detect::detect_provider_from_api_key;
use goose::providers::base::{ProviderMetadata, ProviderType};
use goose::providers::canonical::maybe_get_canonical_model;
use goose::providers::catalog::{
get_provider_template, get_providers_by_format, ProviderCatalogEntry, ProviderFormat,
ProviderTemplate,
};
use goose::providers::create_with_default_model;
use goose::providers::providers as get_providers;
use goose::{
@@ -94,6 +98,8 @@ pub struct UpdateCustomProviderRequest {
pub headers: Option<std::collections::HashMap<String, String>>,
#[serde(default = "default_requires_auth")]
pub requires_auth: bool,
#[serde(default)]
pub catalog_provider_id: Option<String>,
}
fn default_requires_auth() -> bool {
@@ -648,6 +654,7 @@ pub async fn create_custom_provider(
supports_streaming: request.supports_streaming,
headers: request.headers,
requires_auth: request.requires_auth,
catalog_provider_id: request.catalog_provider_id,
},
)?;
@@ -718,6 +725,7 @@ pub async fn update_custom_provider(
supports_streaming: request.supports_streaming,
headers: request.headers,
requires_auth: request.requires_auth,
catalog_provider_id: request.catalog_provider_id,
},
)?;
@@ -770,6 +778,54 @@ pub async fn set_config_provider(
Ok(())
}
#[utoipa::path(
get,
path = "/config/provider-catalog",
params(
("format" = Option<String>, Query, description = "Filter by provider format (openai, anthropic, ollama)")
),
responses(
(status = 200, description = "Provider catalog retrieved successfully", body = [ProviderCatalogEntry]),
(status = 400, description = "Invalid format parameter")
)
)]
pub async fn get_provider_catalog(
axum::extract::Query(params): axum::extract::Query<HashMap<String, String>>,
) -> Result<Json<Vec<ProviderCatalogEntry>>, ErrorResponse> {
let format_str = params.get("format").map(|s| s.as_str()).unwrap_or("openai");
let format = format_str.parse::<ProviderFormat>().map_err(|_| {
ErrorResponse::bad_request(format!(
"Invalid format '{}'. Must be one of: openai, anthropic, ollama",
format_str
))
})?;
let providers = get_providers_by_format(format).await;
Ok(Json(providers))
}
#[utoipa::path(
get,
path = "/config/provider-catalog/{id}",
params(
("id" = String, Path, description = "Provider ID from models.dev")
),
responses(
(status = 200, description = "Provider template retrieved successfully", body = ProviderTemplate),
(status = 404, description = "Provider not found in catalog")
)
)]
pub async fn get_provider_catalog_template(
Path(id): Path<String>,
) -> Result<Json<ProviderTemplate>, ErrorResponse> {
let template = get_provider_template(&id).ok_or_else(|| {
ErrorResponse::not_found(format!("Provider '{}' not found in catalog", id))
})?;
Ok(Json(template))
}
#[utoipa::path(
post,
path = "/config/providers/{name}/oauth",
@@ -836,6 +892,11 @@ pub fn routes(state: Arc<AppState>) -> Router {
.route("/config/extensions/{name}", delete(remove_extension))
.route("/config/providers", get(providers))
.route("/config/providers/{name}/models", get(get_provider_models))
.route("/config/provider-catalog", get(get_provider_catalog))
.route(
"/config/provider-catalog/{id}",
get(get_provider_catalog_template),
)
.route("/config/detect-provider", post(detect_provider))
.route("/config/slash_commands", get(get_slash_commands))
.route(
@@ -42,6 +42,8 @@ pub struct DeclarativeProviderConfig {
pub supports_streaming: Option<bool>,
#[serde(default = "default_requires_auth")]
pub requires_auth: bool,
#[serde(default)]
pub catalog_provider_id: Option<String>,
}
fn default_requires_auth() -> bool {
@@ -102,6 +104,7 @@ pub struct CreateCustomProviderParams {
pub supports_streaming: Option<bool>,
pub headers: Option<HashMap<String, String>>,
pub requires_auth: bool,
pub catalog_provider_id: Option<String>,
}
#[derive(Debug, Clone)]
@@ -115,6 +118,7 @@ pub struct UpdateCustomProviderParams {
pub supports_streaming: Option<bool>,
pub headers: Option<HashMap<String, String>>,
pub requires_auth: bool,
pub catalog_provider_id: Option<String>,
}
pub fn create_custom_provider(
@@ -154,6 +158,7 @@ pub fn create_custom_provider(
timeout_seconds: None,
supports_streaming: params.supports_streaming,
requires_auth: params.requires_auth,
catalog_provider_id: params.catalog_provider_id,
};
let custom_providers_dir = custom_providers_dir();
@@ -215,6 +220,7 @@ pub fn update_custom_provider(params: UpdateCustomProviderParams) -> Result<()>
timeout_seconds: existing_config.timeout_seconds,
supports_streaming: params.supports_streaming,
requires_auth: params.requires_auth,
catalog_provider_id: params.catalog_provider_id,
};
let file_path = custom_providers_dir().join(format!("{}.json", updated_config.name));
@@ -18,6 +18,17 @@ use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ProviderMetadata {
pub id: String,
pub display_name: String,
pub npm: Option<String>,
pub api: Option<String>,
pub doc: Option<String>,
pub env: Vec<String>,
pub model_count: usize,
}
const MODELS_DEV_API_URL: &str = "https://models.dev/api.json";
const DEFAULT_CONTEXT_LIMIT: usize = 128_000;
const SEPARATOR: &str =
@@ -25,21 +36,9 @@ const SEPARATOR: &str =
const SUBSEPARATOR: &str =
"--------------------------------------------------------------------------------";
const ALLOWED_PROVIDERS: &[&str] = &[
"anthropic",
"google",
"openai",
"openrouter",
"llama",
"mistral",
"xai",
"deepseek",
"cohere",
"azure",
"amazon-bedrock",
"venice",
"google-vertex",
];
fn is_compatible_provider(npm: &str) -> bool {
npm.contains("openai") || npm.contains("anthropic") || npm.contains("ollama")
}
fn normalize_provider_name(provider: &str) -> &str {
match provider {
@@ -366,12 +365,7 @@ fn process_model(
model_id: &str,
model_data: &Value,
normalized_provider: &str,
) -> Result<Option<(String, CanonicalModel)>> {
let cost_data = match model_data.get("cost") {
Some(c) if !c.is_null() => c,
_ => return Ok(None),
};
) -> Result<(String, CanonicalModel)> {
let name = model_data["name"]
.as_str()
.with_context(|| format!("Model {} missing name", model_id))?;
@@ -383,11 +377,19 @@ fn process_model(
output: parse_modalities(model_data, "output"),
};
let cost = Pricing {
input: cost_data.get("input").and_then(|v| v.as_f64()),
output: cost_data.get("output").and_then(|v| v.as_f64()),
cache_read: cost_data.get("cache_read").and_then(|v| v.as_f64()),
cache_write: cost_data.get("cache_write").and_then(|v| v.as_f64()),
let cost = match model_data.get("cost") {
Some(c) if !c.is_null() => Pricing {
input: c.get("input").and_then(|v| v.as_f64()),
output: c.get("output").and_then(|v| v.as_f64()),
cache_read: c.get("cache_read").and_then(|v| v.as_f64()),
cache_write: c.get("cache_write").and_then(|v| v.as_f64()),
},
_ => Pricing {
input: None,
output: None,
cache_read: None,
cache_write: None,
},
};
let limit = Limit {
@@ -428,7 +430,73 @@ fn process_model(
.unwrap_or(model_id)
.to_string();
Ok(Some((model_name, canonical_model)))
Ok((model_name, canonical_model))
}
fn collect_provider_metadata(
providers_obj: &serde_json::Map<String, Value>,
) -> Vec<ProviderMetadata> {
let mut metadata_list = Vec::new();
for (provider_id, provider_data) in providers_obj {
let npm = match provider_data.get("npm").and_then(|v| v.as_str()) {
Some(n) if !n.is_empty() => n,
_ => continue,
};
if !is_compatible_provider(npm) {
continue;
}
let api = provider_data
.get("api")
.and_then(|v| v.as_str())
.map(String::from);
if api.is_none() {
continue;
}
let normalized_provider = normalize_provider_name(provider_id).to_string();
let doc = provider_data
.get("doc")
.and_then(|v| v.as_str())
.map(String::from);
let env = provider_data
.get("env")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(String::from)
.collect()
})
.unwrap_or_default();
let display_name = provider_data
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(provider_id)
.to_string();
let model_count = provider_data
.get("models")
.and_then(|v| v.as_object())
.map(|models| models.len())
.unwrap_or(0);
metadata_list.push(ProviderMetadata {
id: normalized_provider,
display_name,
npm: Some(npm.to_string()),
api,
doc,
env,
model_count,
});
println!(" Added {} ({}) - {} models", provider_id, npm, model_count);
}
metadata_list
}
async fn build_canonical_models() -> Result<()> {
@@ -441,28 +509,25 @@ async fn build_canonical_models() -> Result<()> {
let mut registry = CanonicalModelRegistry::new();
let mut total_models = 0;
for provider_key in ALLOWED_PROVIDERS {
if let Some(provider_data) = providers_obj.get(*provider_key) {
let models = provider_data["models"]
.as_object()
.with_context(|| format!("Provider {} missing models object", provider_key))?;
for (provider_key, provider_data) in providers_obj {
let models = match provider_data.get("models").and_then(|v| v.as_object()) {
Some(m) => m,
None => continue,
};
let normalized_provider = normalize_provider_name(provider_key);
let normalized_provider = normalize_provider_name(provider_key);
println!(
"\nProcessing {} ({} models)...",
normalized_provider,
models.len()
);
println!(
"\nProcessing {} ({} models)...",
normalized_provider,
models.len()
);
for (model_id, model_data) in models {
if let Some((model_name, canonical_model)) =
process_model(model_id, model_data, normalized_provider)?
{
registry.register(normalized_provider, &model_name, canonical_model);
total_models += 1;
}
}
for (model_id, model_data) in models {
let (model_name, canonical_model) =
process_model(model_id, model_data, normalized_provider)?;
registry.register(normalized_provider, &model_name, canonical_model);
total_models += 1;
}
}
@@ -474,6 +539,18 @@ async fn build_canonical_models() -> Result<()> {
output_path.display()
);
println!("\n\nCollecting provider metadata from models.dev...");
let provider_metadata_list = collect_provider_metadata(providers_obj);
let provider_metadata_path = data_file_path("provider_metadata.json");
let provider_metadata_json = serde_json::to_string_pretty(&provider_metadata_list)?;
std::fs::write(&provider_metadata_path, provider_metadata_json)?;
println!(
"✓ Wrote {} providers metadata to {}",
provider_metadata_list.len(),
provider_metadata_path.display()
);
Ok(())
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,807 @@
[
{
"id": "evroc",
"display_name": "evroc",
"npm": "@ai-sdk/openai-compatible",
"api": "https://models.think.evroc.com/v1",
"doc": "https://docs.evroc.com/products/think/overview.html",
"env": [
"EVROC_API_KEY"
],
"model_count": 13
},
{
"id": "privatemode-ai",
"display_name": "Privatemode AI",
"npm": "@ai-sdk/openai-compatible",
"api": "http://localhost:8080/v1",
"doc": "https://docs.privatemode.ai/api/overview",
"env": [
"PRIVATEMODE_API_KEY",
"PRIVATEMODE_ENDPOINT"
],
"model_count": 5
},
{
"id": "stepfun",
"display_name": "StepFun",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.stepfun.com/v1",
"doc": "https://platform.stepfun.com/docs/zh/overview/concept",
"env": [
"STEPFUN_API_KEY"
],
"model_count": 3
},
{
"id": "moonshotai-cn",
"display_name": "Moonshot AI (China)",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.moonshot.cn/v1",
"doc": "https://platform.moonshot.cn/docs/api/chat",
"env": [
"MOONSHOT_API_KEY"
],
"model_count": 6
},
{
"id": "firmware",
"display_name": "Firmware",
"npm": "@ai-sdk/openai-compatible",
"api": "https://app.firmware.ai/api/v1",
"doc": "https://docs.firmware.ai",
"env": [
"FIRMWARE_API_KEY"
],
"model_count": 16
},
{
"id": "nova",
"display_name": "Nova",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.nova.amazon.com/v1",
"doc": "https://nova.amazon.com/dev/documentation",
"env": [
"NOVA_API_KEY"
],
"model_count": 2
},
{
"id": "lucidquery",
"display_name": "LucidQuery AI",
"npm": "@ai-sdk/openai-compatible",
"api": "https://lucidquery.com/api/v1",
"doc": "https://lucidquery.com/api/docs",
"env": [
"LUCIDQUERY_API_KEY"
],
"model_count": 2
},
{
"id": "moonshotai",
"display_name": "Moonshot AI",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.moonshot.ai/v1",
"doc": "https://platform.moonshot.ai/docs/api/chat",
"env": [
"MOONSHOT_API_KEY"
],
"model_count": 6
},
{
"id": "302ai",
"display_name": "302.AI",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.302.ai/v1",
"doc": "https://doc.302.ai",
"env": [
"302AI_API_KEY"
],
"model_count": 64
},
{
"id": "qihang-ai",
"display_name": "QiHang",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.qhaigc.net/v1",
"doc": "https://www.qhaigc.net/docs",
"env": [
"QIHANG_API_KEY"
],
"model_count": 9
},
{
"id": "zai-coding-plan",
"display_name": "Z.AI Coding Plan",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.z.ai/api/coding/paas/v4",
"doc": "https://docs.z.ai/devpack/overview",
"env": [
"ZHIPU_API_KEY"
],
"model_count": 10
},
{
"id": "ollama-cloud",
"display_name": "Ollama Cloud",
"npm": "@ai-sdk/openai-compatible",
"api": "https://ollama.com/v1",
"doc": "https://docs.ollama.com/cloud",
"env": [
"OLLAMA_API_KEY"
],
"model_count": 33
},
{
"id": "xiaomi",
"display_name": "Xiaomi",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.xiaomimimo.com/v1",
"doc": "https://platform.xiaomimimo.com/#/docs",
"env": [
"XIAOMI_API_KEY"
],
"model_count": 1
},
{
"id": "alibaba",
"display_name": "Alibaba",
"npm": "@ai-sdk/openai-compatible",
"api": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
"doc": "https://www.alibabacloud.com/help/en/model-studio/models",
"env": [
"DASHSCOPE_API_KEY"
],
"model_count": 41
},
{
"id": "vultr",
"display_name": "Vultr",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.vultrinference.com/v1",
"doc": "https://api.vultrinference.com/",
"env": [
"VULTR_API_KEY"
],
"model_count": 5
},
{
"id": "nvidia",
"display_name": "Nvidia",
"npm": "@ai-sdk/openai-compatible",
"api": "https://integrate.api.nvidia.com/v1",
"doc": "https://docs.api.nvidia.com/nim/",
"env": [
"NVIDIA_API_KEY"
],
"model_count": 71
},
{
"id": "upstage",
"display_name": "Upstage",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.upstage.ai/v1/solar",
"doc": "https://developers.upstage.ai/docs/apis/chat",
"env": [
"UPSTAGE_API_KEY"
],
"model_count": 3
},
{
"id": "bailing",
"display_name": "Bailing",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.tbox.cn/api/llm/v1/chat/completions",
"doc": "https://alipaytbox.yuque.com/sxs0ba/ling/intro",
"env": [
"BAILING_API_TOKEN"
],
"model_count": 2
},
{
"id": "github-copilot",
"display_name": "GitHub Copilot",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.githubcopilot.com",
"doc": "https://docs.github.com/en/copilot",
"env": [
"GITHUB_TOKEN"
],
"model_count": 22
},
{
"id": "jiekou",
"display_name": "Jiekou.AI",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.jiekou.ai/openai",
"doc": "https://docs.jiekou.ai/docs/support/quickstart?utm_source=github_models.dev",
"env": [
"JIEKOU_API_KEY"
],
"model_count": 61
},
{
"id": "cloudferro-sherlock",
"display_name": "CloudFerro Sherlock",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api-sherlock.cloudferro.com/openai/v1/",
"doc": "https://docs.sherlock.cloudferro.com/",
"env": [
"CLOUDFERRO_SHERLOCK_API_KEY"
],
"model_count": 4
},
{
"id": "abacus",
"display_name": "Abacus",
"npm": "@ai-sdk/openai-compatible",
"api": "https://routellm.abacus.ai/v1",
"doc": "https://abacus.ai/help/api",
"env": [
"ABACUS_API_KEY"
],
"model_count": 55
},
{
"id": "nebius",
"display_name": "Nebius Token Factory",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.tokenfactory.nebius.com/v1",
"doc": "https://docs.tokenfactory.nebius.com/",
"env": [
"NEBIUS_API_KEY"
],
"model_count": 46
},
{
"id": "deepseek",
"display_name": "DeepSeek",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.deepseek.com",
"doc": "https://platform.deepseek.com/api-docs/pricing",
"env": [
"DEEPSEEK_API_KEY"
],
"model_count": 2
},
{
"id": "alibaba-cn",
"display_name": "Alibaba (China)",
"npm": "@ai-sdk/openai-compatible",
"api": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"doc": "https://www.alibabacloud.com/help/en/model-studio/models",
"env": [
"DASHSCOPE_API_KEY"
],
"model_count": 65
},
{
"id": "novita-ai",
"display_name": "NovitaAI",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.novita.ai/openai",
"doc": "https://novita.ai/docs/guides/introduction",
"env": [
"NOVITA_API_KEY"
],
"model_count": 84
},
{
"id": "siliconflow-cn",
"display_name": "SiliconFlow (China)",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.siliconflow.cn/v1",
"doc": "https://cloud.siliconflow.com/models",
"env": [
"SILICONFLOW_CN_API_KEY"
],
"model_count": 72
},
{
"id": "vivgrid",
"display_name": "Vivgrid",
"npm": "@ai-sdk/openai",
"api": "https://api.vivgrid.com/v1",
"doc": "https://docs.vivgrid.com/models",
"env": [
"VIVGRID_API_KEY"
],
"model_count": 8
},
{
"id": "chutes",
"display_name": "Chutes",
"npm": "@ai-sdk/openai-compatible",
"api": "https://llm.chutes.ai/v1",
"doc": "https://llm.chutes.ai/v1/models",
"env": [
"CHUTES_API_KEY"
],
"model_count": 67
},
{
"id": "kimi-for-coding",
"display_name": "Kimi For Coding",
"npm": "@ai-sdk/anthropic",
"api": "https://api.kimi.com/coding/v1",
"doc": "https://www.kimi.com/coding/docs/en/third-party-agents.html",
"env": [
"KIMI_API_KEY"
],
"model_count": 2
},
{
"id": "kuae-cloud-coding-plan",
"display_name": "KUAE Cloud Coding Plan",
"npm": "@ai-sdk/openai-compatible",
"api": "https://coding-plan-endpoint.kuaecloud.net/v1",
"doc": "https://docs.mthreads.com/kuaecloud/kuaecloud-doc-online/coding_plan/",
"env": [
"KUAE_API_KEY"
],
"model_count": 1
},
{
"id": "cortecs",
"display_name": "Cortecs",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.cortecs.ai/v1",
"doc": "https://api.cortecs.ai/v1/models",
"env": [
"CORTECS_API_KEY"
],
"model_count": 21
},
{
"id": "github-models",
"display_name": "GitHub Models",
"npm": "@ai-sdk/openai-compatible",
"api": "https://models.github.ai/inference",
"doc": "https://docs.github.com/en/github-models",
"env": [
"GITHUB_TOKEN"
],
"model_count": 55
},
{
"id": "baseten",
"display_name": "Baseten",
"npm": "@ai-sdk/openai-compatible",
"api": "https://inference.baseten.co/v1",
"doc": "https://docs.baseten.co/development/model-apis/overview",
"env": [
"BASETEN_API_KEY"
],
"model_count": 8
},
{
"id": "moark",
"display_name": "Moark",
"npm": "@ai-sdk/openai-compatible",
"api": "https://moark.com/v1",
"doc": "https://moark.com/docs/openapi/v1#tag/%E6%96%87%E6%9C%AC%E7%94%9F%E6%88%90",
"env": [
"MOARK_API_KEY"
],
"model_count": 2
},
{
"id": "siliconflow",
"display_name": "SiliconFlow",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.siliconflow.com/v1",
"doc": "https://cloud.siliconflow.com/models",
"env": [
"SILICONFLOW_API_KEY"
],
"model_count": 70
},
{
"id": "helicone",
"display_name": "Helicone",
"npm": "@ai-sdk/openai-compatible",
"api": "https://ai-gateway.helicone.ai/v1",
"doc": "https://helicone.ai/models",
"env": [
"HELICONE_API_KEY"
],
"model_count": 91
},
{
"id": "huggingface",
"display_name": "Hugging Face",
"npm": "@ai-sdk/openai-compatible",
"api": "https://router.huggingface.co/v1",
"doc": "https://huggingface.co/docs/inference-providers",
"env": [
"HF_TOKEN"
],
"model_count": 20
},
{
"id": "opencode",
"display_name": "OpenCode Zen",
"npm": "@ai-sdk/openai-compatible",
"api": "https://opencode.ai/zen/v1",
"doc": "https://opencode.ai/docs/zen",
"env": [
"OPENCODE_API_KEY"
],
"model_count": 37
},
{
"id": "meganova",
"display_name": "Meganova",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.meganova.ai/v1",
"doc": "https://docs.meganova.ai",
"env": [
"MEGANOVA_API_KEY"
],
"model_count": 19
},
{
"id": "fastrouter",
"display_name": "FastRouter",
"npm": "@ai-sdk/openai-compatible",
"api": "https://go.fastrouter.ai/api/v1",
"doc": "https://fastrouter.ai/models",
"env": [
"FASTROUTER_API_KEY"
],
"model_count": 14
},
{
"id": "minimax",
"display_name": "MiniMax (minimax.io)",
"npm": "@ai-sdk/anthropic",
"api": "https://api.minimax.io/anthropic/v1",
"doc": "https://platform.minimax.io/docs/guides/quickstart",
"env": [
"MINIMAX_API_KEY"
],
"model_count": 4
},
{
"id": "cloudflare-workers-ai",
"display_name": "Cloudflare Workers AI",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
"doc": "https://developers.cloudflare.com/workers-ai/models/",
"env": [
"CLOUDFLARE_ACCOUNT_ID",
"CLOUDFLARE_API_KEY"
],
"model_count": 39
},
{
"id": "inception",
"display_name": "Inception",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.inceptionlabs.ai/v1/",
"doc": "https://platform.inceptionlabs.ai/docs",
"env": [
"INCEPTION_API_KEY"
],
"model_count": 2
},
{
"id": "wandb",
"display_name": "Weights & Biases",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.inference.wandb.ai/v1",
"doc": "https://weave-docs.wandb.ai/guides/integrations/inference/",
"env": [
"WANDB_API_KEY"
],
"model_count": 10
},
{
"id": "zhipuai-coding-plan",
"display_name": "Zhipu AI Coding Plan",
"npm": "@ai-sdk/openai-compatible",
"api": "https://open.bigmodel.cn/api/coding/paas/v4",
"doc": "https://docs.bigmodel.cn/cn/coding-plan/overview",
"env": [
"ZHIPU_API_KEY"
],
"model_count": 9
},
{
"id": "minimax-cn",
"display_name": "MiniMax (minimaxi.com)",
"npm": "@ai-sdk/anthropic",
"api": "https://api.minimaxi.com/anthropic/v1",
"doc": "https://platform.minimaxi.com/docs/guides/quickstart",
"env": [
"MINIMAX_API_KEY"
],
"model_count": 4
},
{
"id": "zenmux",
"display_name": "ZenMux",
"npm": "@ai-sdk/anthropic",
"api": "https://zenmux.ai/api/anthropic/v1",
"doc": "https://docs.zenmux.ai",
"env": [
"ZENMUX_API_KEY"
],
"model_count": 67
},
{
"id": "minimax-coding-plan",
"display_name": "MiniMax Coding Plan (minimax.io)",
"npm": "@ai-sdk/anthropic",
"api": "https://api.minimax.io/anthropic/v1",
"doc": "https://platform.minimax.io/docs/coding-plan/intro",
"env": [
"MINIMAX_API_KEY"
],
"model_count": 4
},
{
"id": "ovhcloud",
"display_name": "OVHcloud AI Endpoints",
"npm": "@ai-sdk/openai-compatible",
"api": "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1",
"doc": "https://www.ovhcloud.com/en/public-cloud/ai-endpoints/catalog//",
"env": [
"OVHCLOUD_API_KEY"
],
"model_count": 13
},
{
"id": "iflowcn",
"display_name": "iFlow",
"npm": "@ai-sdk/openai-compatible",
"api": "https://apis.iflow.cn/v1",
"doc": "https://platform.iflow.cn/en/docs",
"env": [
"IFLOW_API_KEY"
],
"model_count": 14
},
{
"id": "synthetic",
"display_name": "Synthetic",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.synthetic.new/v1",
"doc": "https://synthetic.new/pricing",
"env": [
"SYNTHETIC_API_KEY"
],
"model_count": 26
},
{
"id": "zhipuai",
"display_name": "Zhipu AI",
"npm": "@ai-sdk/openai-compatible",
"api": "https://open.bigmodel.cn/api/paas/v4",
"doc": "https://docs.z.ai/guides/overview/pricing",
"env": [
"ZHIPU_API_KEY"
],
"model_count": 9
},
{
"id": "kilo",
"display_name": "Kilo Gateway",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.kilo.ai/api/gateway",
"doc": "https://kilo.ai",
"env": [
"KILO_API_KEY"
],
"model_count": 263
},
{
"id": "submodel",
"display_name": "submodel",
"npm": "@ai-sdk/openai-compatible",
"api": "https://llm.submodel.ai/v1",
"doc": "https://submodel.gitbook.io",
"env": [
"SUBMODEL_INSTAGEN_ACCESS_KEY"
],
"model_count": 9
},
{
"id": "nano-gpt",
"display_name": "NanoGPT",
"npm": "@ai-sdk/openai-compatible",
"api": "https://nano-gpt.com/api/v1",
"doc": "https://docs.nano-gpt.com",
"env": [
"NANO_GPT_API_KEY"
],
"model_count": 33
},
{
"id": "zai",
"display_name": "Z.AI",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.z.ai/api/paas/v4",
"doc": "https://docs.z.ai/guides/overview/pricing",
"env": [
"ZHIPU_API_KEY"
],
"model_count": 9
},
{
"id": "berget",
"display_name": "Berget.AI",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.berget.ai/v1",
"doc": "https://api.berget.ai",
"env": [
"BERGET_API_KEY"
],
"model_count": 8
},
{
"id": "stackit",
"display_name": "STACKIT",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.openai-compat.model-serving.eu01.onstackit.cloud/v1",
"doc": "https://docs.stackit.cloud/products/data-and-ai/ai-model-serving/basics/available-shared-models",
"env": [
"STACKIT_API_KEY"
],
"model_count": 8
},
{
"id": "inference",
"display_name": "Inference",
"npm": "@ai-sdk/openai-compatible",
"api": "https://inference.net/v1",
"doc": "https://inference.net/models",
"env": [
"INFERENCE_API_KEY"
],
"model_count": 9
},
{
"id": "requesty",
"display_name": "Requesty",
"npm": "@ai-sdk/openai-compatible",
"api": "https://router.requesty.ai/v1",
"doc": "https://requesty.ai/solution/llm-routing/models",
"env": [
"REQUESTY_API_KEY"
],
"model_count": 20
},
{
"id": "morph",
"display_name": "Morph",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.morphllm.com/v1",
"doc": "https://docs.morphllm.com/api-reference/introduction",
"env": [
"MORPH_API_KEY"
],
"model_count": 3
},
{
"id": "qiniu-ai",
"display_name": "Qiniu",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.qnaigc.com.com/v1",
"doc": "https://developer.qiniu.com/aitokenapi",
"env": [
"Qiniu_API_KEY"
],
"model_count": 76
},
{
"id": "lmstudio",
"display_name": "LMStudio",
"npm": "@ai-sdk/openai-compatible",
"api": "http://127.0.0.1:1234/v1",
"doc": "https://lmstudio.ai/models",
"env": [
"LMSTUDIO_API_KEY"
],
"model_count": 3
},
{
"id": "friendli",
"display_name": "Friendli",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.friendli.ai/serverless/v1",
"doc": "https://friendli.ai/docs/guides/serverless_endpoints/introduction",
"env": [
"FRIENDLI_TOKEN"
],
"model_count": 8
},
{
"id": "aihubmix",
"display_name": "AIHubMix",
"npm": "@ai-sdk/openai-compatible",
"api": "https://aihubmix.com/v1",
"doc": "https://docs.aihubmix.com",
"env": [
"AIHUBMIX_API_KEY"
],
"model_count": 43
},
{
"id": "fireworks-ai",
"display_name": "Fireworks AI",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.fireworks.ai/inference/v1/",
"doc": "https://fireworks.ai/docs/",
"env": [
"FIREWORKS_API_KEY"
],
"model_count": 13
},
{
"id": "io-net",
"display_name": "IO.NET",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.intelligence.io.solutions/api/v1",
"doc": "https://io.net/docs/guides/intelligence/io-intelligence",
"env": [
"IOINTELLIGENCE_API_KEY"
],
"model_count": 17
},
{
"id": "modelscope",
"display_name": "ModelScope",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api-inference.modelscope.cn/v1",
"doc": "https://modelscope.cn/docs/model-service/API-Inference/intro",
"env": [
"MODELSCOPE_API_KEY"
],
"model_count": 7
},
{
"id": "meta-llama",
"display_name": "Llama",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.llama.com/compat/v1/",
"doc": "https://llama.developer.meta.com/docs/models",
"env": [
"LLAMA_API_KEY"
],
"model_count": 7
},
{
"id": "scaleway",
"display_name": "Scaleway",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.scaleway.ai/v1",
"doc": "https://www.scaleway.com/en/docs/generative-apis/",
"env": [
"SCALEWAY_API_KEY"
],
"model_count": 14
},
{
"id": "poe",
"display_name": "Poe",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.poe.com/v1",
"doc": "https://creator.poe.com/docs/external-applications/openai-compatible-api",
"env": [
"POE_API_KEY"
],
"model_count": 114
},
{
"id": "minimax-cn-coding-plan",
"display_name": "MiniMax Coding Plan (minimaxi.com)",
"npm": "@ai-sdk/anthropic",
"api": "https://api.minimaxi.com/anthropic/v1",
"doc": "https://platform.minimaxi.com/docs/coding-plan/intro",
"env": [
"MINIMAX_API_KEY"
],
"model_count": 4
}
]
@@ -83,6 +83,14 @@ impl CanonicalModelRegistry {
self.models.get(&(provider.to_string(), model.to_string()))
}
pub fn get_all_models_for_provider(&self, provider: &str) -> Vec<CanonicalModel> {
self.models
.iter()
.filter(|((p, _), _)| p == provider)
.map(|(_, model)| model.clone())
.collect()
}
pub fn all_models(&self) -> Vec<&CanonicalModel> {
self.models.values().collect()
}
+245
View File
@@ -0,0 +1,245 @@
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use super::canonical::CanonicalModelRegistry;
const PROVIDER_METADATA_JSON: &str = include_str!("canonical/data/provider_metadata.json");
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ProviderMetadataEntry {
pub id: String,
pub display_name: String,
pub npm: Option<String>,
pub api: Option<String>,
pub doc: Option<String>,
pub env: Vec<String>,
pub model_count: usize,
}
static PROVIDER_METADATA: Lazy<HashMap<String, ProviderMetadataEntry>> = Lazy::new(|| {
serde_json::from_str::<Vec<ProviderMetadataEntry>>(PROVIDER_METADATA_JSON)
.unwrap_or_else(|e| {
eprintln!("Failed to parse provider metadata: {}", e);
Vec::new()
})
.into_iter()
.map(|p| (p.id.clone(), p))
.collect()
});
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProviderFormat {
OpenAI,
Anthropic,
Ollama,
}
impl ProviderFormat {
pub fn as_str(&self) -> &str {
match self {
ProviderFormat::OpenAI => "openai",
ProviderFormat::Anthropic => "anthropic",
ProviderFormat::Ollama => "ollama",
}
}
}
impl std::str::FromStr for ProviderFormat {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"openai" | "openai_compatible" => Ok(ProviderFormat::OpenAI),
"anthropic" | "anthropic_compatible" => Ok(ProviderFormat::Anthropic),
"ollama" | "ollama_compatible" => Ok(ProviderFormat::Ollama),
_ => Err(format!("unknown provider format: {}", s)),
}
}
}
fn detect_format_from_npm(npm: &str) -> Option<ProviderFormat> {
if npm.contains("openai") {
Some(ProviderFormat::OpenAI)
} else if npm.contains("anthropic") {
Some(ProviderFormat::Anthropic)
} else if npm.contains("ollama") {
Some(ProviderFormat::Ollama)
} else {
None
}
}
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct ProviderCatalogEntry {
pub id: String,
pub name: String,
pub format: String,
pub api_url: String,
pub model_count: usize,
pub doc_url: String,
pub env_var: String,
}
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct ProviderTemplate {
pub id: String,
pub name: String,
pub format: String,
pub api_url: String,
pub models: Vec<ModelTemplate>,
pub supports_streaming: bool,
pub env_var: String,
pub doc_url: String,
}
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct ModelTemplate {
pub id: String,
pub name: String,
pub context_limit: usize,
pub capabilities: ModelCapabilities,
pub deprecated: bool,
}
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct ModelCapabilities {
pub tool_call: bool,
pub reasoning: bool,
pub attachment: bool,
pub temperature: bool,
}
pub async fn get_providers_by_format(format: ProviderFormat) -> Vec<ProviderCatalogEntry> {
let native_provider_ids = super::init::providers()
.await
.into_iter()
.map(|(metadata, _)| metadata.name)
.collect::<std::collections::HashSet<_>>();
let mut entries: Vec<ProviderCatalogEntry> = PROVIDER_METADATA
.values()
.filter_map(|metadata| {
if native_provider_ids.contains(&metadata.id) {
return None;
}
let npm = metadata.npm.as_ref()?;
let detected_format = detect_format_from_npm(npm)?;
if detected_format != format {
return None;
}
let api_url = metadata.api.as_ref()?.clone();
let env_var = metadata.env.first().cloned().unwrap_or_else(|| {
format!("{}_API_KEY", metadata.id.to_uppercase().replace('-', "_"))
});
Some(ProviderCatalogEntry {
id: metadata.id.clone(),
name: metadata.display_name.clone(),
format: detected_format.as_str().to_string(),
api_url,
model_count: metadata.model_count,
doc_url: metadata.doc.clone().unwrap_or_default(),
env_var,
})
})
.collect();
// Sort by name
entries.sort_by(|a, b| a.name.cmp(&b.name));
entries
}
pub fn get_provider_template(provider_id: &str) -> Option<ProviderTemplate> {
let metadata = PROVIDER_METADATA.get(provider_id)?;
let npm = metadata.npm.as_ref()?;
let format = detect_format_from_npm(npm)?;
let api_url = metadata.api.as_ref()?.clone();
let models: Vec<ModelTemplate> = CanonicalModelRegistry::bundled()
.ok()
.map(|registry| {
registry
.get_all_models_for_provider(provider_id)
.into_iter()
.map(|model| {
// Extract just the model ID (without provider prefix)
let model_id = model
.id
.strip_prefix(&format!("{}/", provider_id))
.unwrap_or(&model.id)
.to_string();
ModelTemplate {
id: model_id,
name: model.name.clone(),
context_limit: model.limit.context,
capabilities: ModelCapabilities {
tool_call: model.tool_call,
reasoning: model.reasoning.unwrap_or(false),
attachment: model.attachment.unwrap_or(false),
temperature: model.temperature.unwrap_or(false),
},
deprecated: false, // Canonical models don't have deprecated flag
}
})
.collect()
})
.unwrap_or_default();
let env_var = metadata
.env
.first()
.cloned()
.unwrap_or_else(|| format!("{}_API_KEY", provider_id.to_uppercase().replace('-', "_")));
Some(ProviderTemplate {
id: metadata.id.clone(),
name: metadata.display_name.clone(),
format: format.as_str().to_string(),
api_url,
models,
supports_streaming: true, // Default to true
env_var,
doc_url: metadata.doc.clone().unwrap_or_default(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_zai_provider() {
let openai_providers = get_providers_by_format(ProviderFormat::OpenAI).await;
let zai = openai_providers.iter().find(|p| p.id == "zai");
assert!(zai.is_some(), "z.ai should be in catalog");
let zai = zai.unwrap();
println!("Z.AI: {} models", zai.model_count);
assert!(zai.model_count > 0, "z.ai should have models");
let template = get_provider_template("zai");
assert!(template.is_some(), "z.ai should have a template");
let template = template.unwrap();
println!("Z.AI template: {} models", template.models.len());
for model in template.models.iter().take(3) {
println!(
" - {} ({}K context)",
model.name,
model.context_limit / 1000
);
}
assert!(
!template.models.is_empty(),
"z.ai template should have models"
);
}
}
+1
View File
@@ -6,6 +6,7 @@ pub mod azureauth;
pub mod base;
pub mod bedrock;
pub mod canonical;
pub mod catalog;
pub mod chatgpt_codex;
pub mod claude_code;
pub(crate) mod cli_common;