mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
Simplified custom model flow with canonical models (#6934)
This commit is contained in:
@@ -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))?;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1259,6 +1259,78 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/config/provider-catalog": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"super::routes::config_management"
|
||||
],
|
||||
"operationId": "get_provider_catalog",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "format",
|
||||
"in": "query",
|
||||
"description": "Filter by provider format (openai, anthropic, ollama)",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Provider catalog retrieved successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ProviderCatalogEntry"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid format parameter"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/config/provider-catalog/{id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"super::routes::config_management"
|
||||
],
|
||||
"operationId": "get_provider_catalog_template",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "Provider ID from models.dev",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Provider template retrieved successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProviderTemplate"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Provider not found in catalog"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/config/providers": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -4107,6 +4179,10 @@
|
||||
"base_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"catalog_provider_id": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
@@ -5702,6 +5778,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ModelCapabilities": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"tool_call",
|
||||
"reasoning",
|
||||
"attachment",
|
||||
"temperature"
|
||||
],
|
||||
"properties": {
|
||||
"attachment": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reasoning": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"temperature": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tool_call": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ModelConfig": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -6003,6 +6102,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ModelTemplate": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"context_limit",
|
||||
"capabilities",
|
||||
"deprecated"
|
||||
],
|
||||
"properties": {
|
||||
"capabilities": {
|
||||
"$ref": "#/components/schemas/ModelCapabilities"
|
||||
},
|
||||
"context_limit": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"deprecated": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ParseRecipeRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -6110,6 +6237,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProviderCatalogEntry": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"format",
|
||||
"api_url",
|
||||
"model_count",
|
||||
"doc_url",
|
||||
"env_var"
|
||||
],
|
||||
"properties": {
|
||||
"api_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"doc_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"env_var": {
|
||||
"type": "string"
|
||||
},
|
||||
"format": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"model_count": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProviderDetails": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -6190,6 +6353,48 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProviderTemplate": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"format",
|
||||
"api_url",
|
||||
"models",
|
||||
"supports_streaming",
|
||||
"env_var",
|
||||
"doc_url"
|
||||
],
|
||||
"properties": {
|
||||
"api_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"doc_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"env_var": {
|
||||
"type": "string"
|
||||
},
|
||||
"format": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"models": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ModelTemplate"
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"supports_streaming": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProviderType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -7952,6 +8157,10 @@
|
||||
"api_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"catalog_provider_id": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"display_name": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
Generated
+32
-10
@@ -238,6 +238,7 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -607,6 +608,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
@@ -647,6 +649,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
@@ -1104,6 +1107,7 @@
|
||||
"integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.1",
|
||||
"fs-extra": "^9.0.1",
|
||||
@@ -2737,6 +2741,7 @@
|
||||
"integrity": "sha512-yl43JD/86CIj3Mz5mvvLJqAOfIup7ncxfJ0Btnl0/v5TouVUyeEdcpknfgc+yMevS/48oH9WAkkw93m7otLb/A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@inquirer/checkbox": "^3.0.1",
|
||||
"@inquirer/confirm": "^4.0.1",
|
||||
@@ -3212,6 +3217,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
|
||||
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
"ajv": "^8.17.1",
|
||||
@@ -6536,8 +6542,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
@@ -6825,6 +6830,7 @@
|
||||
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@@ -6866,6 +6872,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -6876,6 +6883,7 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -7016,6 +7024,7 @@
|
||||
"integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.55.0",
|
||||
"@typescript-eslint/types": "8.55.0",
|
||||
@@ -7401,6 +7410,7 @@
|
||||
"integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.0.18",
|
||||
"fflate": "^0.8.2",
|
||||
@@ -7649,6 +7659,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -7721,6 +7732,7 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -8261,6 +8273,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -9563,8 +9576,7 @@
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
@@ -9622,6 +9634,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/get": "^2.0.0",
|
||||
"@types/node": "^24.9.0",
|
||||
@@ -10629,6 +10642,7 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -11112,6 +11126,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -12372,6 +12387,7 @@
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.0.tgz",
|
||||
"integrity": "sha512-NekXntS5M94pUfiVZ8oXXK/kkri+5WpX2/Ik+LVsl+uvw+soj4roXIsPqO+XsWrAw20mOzaXOZf3Q7PfB9A/IA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -13428,6 +13444,7 @@
|
||||
"integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.31",
|
||||
"@asamuzakjp/dom-selector": "^6.7.6",
|
||||
@@ -14670,7 +14687,6 @@
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@@ -14691,6 +14707,7 @@
|
||||
"integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
@@ -17081,6 +17098,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -17182,7 +17200,6 @@
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@@ -17198,7 +17215,6 @@
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -17554,6 +17570,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -17563,6 +17580,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -17584,8 +17602,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-markdown": {
|
||||
"version": "10.1.0",
|
||||
@@ -19502,7 +19519,8 @@
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tailwindcss-animate": {
|
||||
"version": "1.0.7",
|
||||
@@ -20033,6 +20051,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -20457,6 +20476,7 @@
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -20547,6 +20567,7 @@
|
||||
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.0.18",
|
||||
"@vitest/mocker": "4.0.18",
|
||||
@@ -21195,6 +21216,7 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -169,6 +169,7 @@ export type CspMetadata = {
|
||||
export type DeclarativeProviderConfig = {
|
||||
api_key_env?: string;
|
||||
base_url: string;
|
||||
catalog_provider_id?: string | null;
|
||||
description?: string | null;
|
||||
display_name: string;
|
||||
engine: ProviderEngine;
|
||||
@@ -671,6 +672,13 @@ export type MessageMetadata = {
|
||||
userVisible: boolean;
|
||||
};
|
||||
|
||||
export type ModelCapabilities = {
|
||||
attachment: boolean;
|
||||
reasoning: boolean;
|
||||
temperature: boolean;
|
||||
tool_call: boolean;
|
||||
};
|
||||
|
||||
export type ModelConfig = {
|
||||
context_limit?: number | null;
|
||||
max_tokens?: number | null;
|
||||
@@ -767,6 +775,14 @@ export type ModelSettings = {
|
||||
use_mlock?: boolean;
|
||||
};
|
||||
|
||||
export type ModelTemplate = {
|
||||
capabilities: ModelCapabilities;
|
||||
context_limit: number;
|
||||
deprecated: boolean;
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type ParseRecipeRequest = {
|
||||
content: string;
|
||||
};
|
||||
@@ -819,6 +835,16 @@ export type PromptsListResponse = {
|
||||
prompts: Array<Template>;
|
||||
};
|
||||
|
||||
export type ProviderCatalogEntry = {
|
||||
api_url: string;
|
||||
doc_url: string;
|
||||
env_var: string;
|
||||
format: string;
|
||||
id: string;
|
||||
model_count: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type ProviderDetails = {
|
||||
is_configured: boolean;
|
||||
metadata: ProviderMetadata;
|
||||
@@ -862,6 +888,17 @@ export type ProviderMetadata = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type ProviderTemplate = {
|
||||
api_url: string;
|
||||
doc_url: string;
|
||||
env_var: string;
|
||||
format: string;
|
||||
id: string;
|
||||
models: Array<ModelTemplate>;
|
||||
name: string;
|
||||
supports_streaming: boolean;
|
||||
};
|
||||
|
||||
export type ProviderType = 'Preferred' | 'Builtin' | 'Declarative' | 'Custom';
|
||||
|
||||
export type ProvidersResponse = {
|
||||
@@ -1435,6 +1472,7 @@ export type UiMetadata = {
|
||||
export type UpdateCustomProviderRequest = {
|
||||
api_key: string;
|
||||
api_url: string;
|
||||
catalog_provider_id?: string | null;
|
||||
display_name: string;
|
||||
engine: string;
|
||||
headers?: {
|
||||
@@ -2470,6 +2508,62 @@ export type SavePromptResponses = {
|
||||
|
||||
export type SavePromptResponse = SavePromptResponses[keyof SavePromptResponses];
|
||||
|
||||
export type GetProviderCatalogData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
query?: {
|
||||
/**
|
||||
* Filter by provider format (openai, anthropic, ollama)
|
||||
*/
|
||||
format?: string | null;
|
||||
};
|
||||
url: '/config/provider-catalog';
|
||||
};
|
||||
|
||||
export type GetProviderCatalogErrors = {
|
||||
/**
|
||||
* Invalid format parameter
|
||||
*/
|
||||
400: unknown;
|
||||
};
|
||||
|
||||
export type GetProviderCatalogResponses = {
|
||||
/**
|
||||
* Provider catalog retrieved successfully
|
||||
*/
|
||||
200: Array<ProviderCatalogEntry>;
|
||||
};
|
||||
|
||||
export type GetProviderCatalogResponse = GetProviderCatalogResponses[keyof GetProviderCatalogResponses];
|
||||
|
||||
export type GetProviderCatalogTemplateData = {
|
||||
body?: never;
|
||||
path: {
|
||||
/**
|
||||
* Provider ID from models.dev
|
||||
*/
|
||||
id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/config/provider-catalog/{id}';
|
||||
};
|
||||
|
||||
export type GetProviderCatalogTemplateErrors = {
|
||||
/**
|
||||
* Provider not found in catalog
|
||||
*/
|
||||
404: unknown;
|
||||
};
|
||||
|
||||
export type GetProviderCatalogTemplateResponses = {
|
||||
/**
|
||||
* Provider template retrieved successfully
|
||||
*/
|
||||
200: ProviderTemplate;
|
||||
};
|
||||
|
||||
export type GetProviderCatalogTemplateResponse = GetProviderCatalogTemplateResponses[keyof GetProviderCatalogTemplateResponses];
|
||||
|
||||
export type ProvidersData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
|
||||
@@ -183,7 +183,8 @@ export function LocalModelSetup({ onSuccess, onCancel }: LocalModelSetupProps) {
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-4xl font-light">Run Locally</h1>
|
||||
<p className="text-text-muted text-base sm:text-lg">
|
||||
Download a model to run Goose entirely on your machine — no API keys, no accounts, completely free and private.
|
||||
Download a model to run Goose entirely on your machine — no API keys, no accounts,
|
||||
completely free and private.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -269,7 +270,12 @@ export function LocalModelSetup({ onSuccess, onCancel }: LocalModelSetupProps) {
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -294,8 +300,12 @@ export function LocalModelSetup({ onSuccess, onCancel }: LocalModelSetupProps) {
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-text-default text-sm">{model.id}</span>
|
||||
<span className="text-xs text-text-muted">{formatSize(model.size_bytes)}</span>
|
||||
<span className="font-medium text-text-default text-sm">
|
||||
{model.id}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">
|
||||
{formatSize(model.size_bytes)}
|
||||
</span>
|
||||
{model.status.state === 'Downloaded' && (
|
||||
<span className="text-xs bg-green-600 text-white px-2 py-0.5 rounded-full">
|
||||
Ready
|
||||
@@ -368,7 +378,8 @@ export function LocalModelSetup({ onSuccess, onCancel }: LocalModelSetupProps) {
|
||||
)}
|
||||
{downloadProgress.eta_seconds != null && downloadProgress.eta_seconds > 0 && (
|
||||
<span>
|
||||
~{downloadProgress.eta_seconds < 60
|
||||
~
|
||||
{downloadProgress.eta_seconds < 60
|
||||
? `${Math.round(downloadProgress.eta_seconds)}s`
|
||||
: `${Math.round(downloadProgress.eta_seconds / 60)}m`}{' '}
|
||||
remaining
|
||||
|
||||
@@ -214,9 +214,7 @@ export const HuggingFaceModelSearch = ({ onDownloadStarted }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && !searching && (
|
||||
<p className="text-xs text-text-muted">{error}</p>
|
||||
)}
|
||||
{error && !searching && <p className="text-xs text-text-muted">{error}</p>}
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="space-y-1 max-h-96 overflow-y-auto">
|
||||
@@ -289,9 +287,7 @@ export const HuggingFaceModelSearch = ({ onDownloadStarted }: Props) => {
|
||||
)}
|
||||
</div>
|
||||
{variant.description && (
|
||||
<span className="text-xs text-text-muted">
|
||||
{variant.description}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">{variant.description}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
@@ -323,7 +319,8 @@ export const HuggingFaceModelSearch = ({ onDownloadStarted }: Props) => {
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-text-default mb-2">Direct Download</h4>
|
||||
<p className="text-xs text-text-muted mb-2">
|
||||
Specify a model directly: <code className="bg-background-subtle px-1 rounded">user/repo:quantization</code>
|
||||
Specify a model directly:{' '}
|
||||
<code className="bg-background-subtle px-1 rounded">user/repo:quantization</code>
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
|
||||
@@ -14,12 +14,7 @@ import {
|
||||
} from '../../../api';
|
||||
import { HuggingFaceModelSearch } from './HuggingFaceModelSearch';
|
||||
import { ModelSettingsPanel } from './ModelSettingsPanel';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../../ui/dialog';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../ui/dialog';
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
@@ -273,9 +268,7 @@ export const LocalInferenceSettings = () => {
|
||||
onChange={() => selectModel(model.id)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm font-medium text-text-default">
|
||||
{model.id}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-text-default">{model.id}</span>
|
||||
<span className="text-xs text-text-muted">
|
||||
{formatBytes(model.size_bytes)}
|
||||
</span>
|
||||
@@ -324,9 +317,7 @@ export const LocalInferenceSettings = () => {
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="text-sm font-medium text-text-default">
|
||||
{model.id}
|
||||
</h4>
|
||||
<h4 className="text-sm font-medium text-text-default">{model.id}</h4>
|
||||
<span className="text-xs text-text-muted">
|
||||
{formatBytes(model.size_bytes)}
|
||||
</span>
|
||||
|
||||
@@ -12,7 +12,14 @@ import {
|
||||
const DEFAULT_SETTINGS: ModelSettings = {
|
||||
context_size: null,
|
||||
max_output_tokens: null,
|
||||
sampling: { type: 'Temperature', temperature: 0.8, top_k: 40, top_p: 0.95, min_p: 0.05, seed: null },
|
||||
sampling: {
|
||||
type: 'Temperature',
|
||||
temperature: 0.8,
|
||||
top_k: 40,
|
||||
top_p: 0.95,
|
||||
min_p: 0.05,
|
||||
seed: null,
|
||||
},
|
||||
repeat_penalty: 1.0,
|
||||
repeat_last_n: 64,
|
||||
frequency_penalty: 0.0,
|
||||
@@ -177,7 +184,14 @@ export const ModelSettingsPanel = ({ modelId }: { modelId: string }) => {
|
||||
} else if (type === 'MirostatV2') {
|
||||
sampling = { type: 'MirostatV2', tau: 5.0, eta: 0.1, seed: null };
|
||||
} else {
|
||||
sampling = { type: 'Temperature', temperature: 0.8, top_k: 40, top_p: 0.95, min_p: 0.05, seed: null };
|
||||
sampling = {
|
||||
type: 'Temperature',
|
||||
temperature: 0.8,
|
||||
top_k: 40,
|
||||
top_p: 0.95,
|
||||
min_p: 0.05,
|
||||
seed: null,
|
||||
};
|
||||
}
|
||||
save({ ...settings, sampling });
|
||||
};
|
||||
@@ -391,7 +405,13 @@ export const ModelSettingsPanel = ({ modelId }: { modelId: string }) => {
|
||||
<SelectField
|
||||
label="Flash attention"
|
||||
description="Enable flash attention optimization"
|
||||
value={settings.flash_attention === null || settings.flash_attention === undefined ? 'auto' : settings.flash_attention ? 'on' : 'off'}
|
||||
value={
|
||||
settings.flash_attention === null || settings.flash_attention === undefined
|
||||
? 'auto'
|
||||
: settings.flash_attention
|
||||
? 'on'
|
||||
: 'off'
|
||||
}
|
||||
options={[
|
||||
{ value: 'auto', label: 'Auto' },
|
||||
{ value: 'on', label: 'On' },
|
||||
|
||||
@@ -38,8 +38,8 @@ const CustomProviderCard = memo(function CustomProviderCard({ onClick }: { onCli
|
||||
<div className="flex flex-col items-center justify-center min-h-[200px]">
|
||||
<Plus className="w-8 h-8 text-gray-400 mb-2" />
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 text-center">
|
||||
<div>Add</div>
|
||||
<div>Custom Provider</div>
|
||||
<div className="font-medium">Add Provider</div>
|
||||
<div className="text-xs text-gray-500 mt-1">From template or manual setup</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -239,6 +239,7 @@ function ProviderCards({
|
||||
supports_streaming: editingProvider.config.supports_streaming ?? true,
|
||||
requires_auth: editingProvider.config.requires_auth ?? true,
|
||||
headers: editingProvider.config.headers ?? undefined,
|
||||
catalog_provider_id: editingProvider.config.catalog_provider_id ?? undefined,
|
||||
};
|
||||
|
||||
const editable = editingProvider ? editingProvider.isEditable : true;
|
||||
@@ -262,7 +263,7 @@ function ProviderCards({
|
||||
isActiveProvider={isActiveProvider}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>{' '}
|
||||
</Dialog>
|
||||
{configuringProvider && (
|
||||
<ProviderConfigurationModal
|
||||
provider={configuringProvider}
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '../../../../ui/button';
|
||||
import { Search, ExternalLink, Check } from 'lucide-react';
|
||||
import { Input } from '../../../../ui/input';
|
||||
import { Select } from '../../../../ui/Select';
|
||||
import {
|
||||
getProviderCatalog,
|
||||
getProviderCatalogTemplate,
|
||||
type ProviderCatalogEntry,
|
||||
type ProviderTemplate,
|
||||
} from '../../../../../api';
|
||||
|
||||
interface ProviderCatalogPickerProps {
|
||||
onSelect: (template: ProviderTemplate) => void;
|
||||
onCancel: () => void;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export default function ProviderCatalogPicker({
|
||||
onSelect,
|
||||
onCancel,
|
||||
embedded,
|
||||
}: ProviderCatalogPickerProps) {
|
||||
const [selectedFormat, setSelectedFormat] = useState<string>('openai');
|
||||
const [providers, setProviders] = useState<ProviderCatalogEntry[]>([]);
|
||||
const [filteredProviders, setFilteredProviders] = useState<ProviderCatalogEntry[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const formatOptions = [
|
||||
{ value: 'openai', label: 'OpenAI Compatible' },
|
||||
{ value: 'anthropic', label: 'Anthropic Compatible' },
|
||||
];
|
||||
|
||||
// Fetch providers when format changes
|
||||
useEffect(() => {
|
||||
fetchProviders(selectedFormat);
|
||||
}, [selectedFormat]);
|
||||
|
||||
// Filter providers based on search query
|
||||
useEffect(() => {
|
||||
if (searchQuery.trim() === '') {
|
||||
setFilteredProviders(providers);
|
||||
} else {
|
||||
const query = searchQuery.toLowerCase();
|
||||
setFilteredProviders(
|
||||
providers.filter(
|
||||
(p) => p.name.toLowerCase().includes(query) || p.id.toLowerCase().includes(query)
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [searchQuery, providers]);
|
||||
|
||||
const fetchProviders = async (format: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { data } = await getProviderCatalog({
|
||||
query: { format },
|
||||
throwOnError: true,
|
||||
});
|
||||
setProviders(data || []);
|
||||
setFilteredProviders(data || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProviderSelect = async (providerId: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { data: template } = await getProviderCatalogTemplate({
|
||||
path: { id: providerId },
|
||||
throwOnError: true,
|
||||
});
|
||||
if (template) {
|
||||
onSelect(template);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-textStandard mb-2">Choose Provider</h3>
|
||||
<p className="text-sm text-textSubtle">
|
||||
Select an API format and provider. We'll auto-fill the configuration for you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Format Selection */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-textStandard mb-2 block">API Format</label>
|
||||
<Select
|
||||
options={formatOptions}
|
||||
value={formatOptions.find((opt) => opt.value === selectedFormat)}
|
||||
onChange={(option: unknown) => {
|
||||
const selectedOption = option as { value: string; label: string } | null;
|
||||
if (selectedOption && selectedOption.value) {
|
||||
setSelectedFormat(selectedOption.value);
|
||||
}
|
||||
}}
|
||||
isSearchable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-textSubtle w-4 h-4" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search providers..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Loading/Error */}
|
||||
{loading && <div className="text-center py-8 text-textSubtle">Loading providers...</div>}
|
||||
{error && <div className="text-center py-8 text-red-500">Error: {error}</div>}
|
||||
|
||||
{/* Provider List */}
|
||||
{!loading && !error && (
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{filteredProviders.length === 0 ? (
|
||||
<div className="text-center py-8 text-textSubtle">
|
||||
{searchQuery ? `No providers found for "${searchQuery}"` : 'No providers available'}
|
||||
</div>
|
||||
) : (
|
||||
filteredProviders.map((provider) => (
|
||||
<button
|
||||
key={provider.id}
|
||||
onClick={() => handleProviderSelect(provider.id)}
|
||||
className="w-full p-4 text-left border border-border rounded-lg hover:bg-surfaceHover hover:border-primary transition-colors group"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium text-textStandard">{provider.name}</div>
|
||||
{provider.doc_url && (
|
||||
<a
|
||||
href={provider.doc_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-textSubtle hover:text-textStandard transition-colors flex-shrink-0"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-textSubtle mt-1 break-all">{provider.api_url}</div>
|
||||
<div className="text-xs text-textSubtle mt-2">
|
||||
{provider.model_count} models available
|
||||
{provider.env_var && ` • Requires ${provider.env_var}`}
|
||||
</div>
|
||||
</div>
|
||||
<Check className="w-5 h-5 text-primary opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{!embedded && (
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+412
-226
@@ -3,9 +3,12 @@ import { Input } from '../../../../../ui/input';
|
||||
import { Select } from '../../../../../ui/Select';
|
||||
import { Button } from '../../../../../ui/button';
|
||||
import { SecureStorageNotice } from '../SecureStorageNotice';
|
||||
import { UpdateCustomProviderRequest } from '../../../../../../api';
|
||||
import { Plus, X, Trash2, AlertTriangle } from 'lucide-react';
|
||||
import { UpdateCustomProviderRequest, type ProviderTemplate } from '../../../../../../api';
|
||||
import { Plus, X, Trash2, AlertTriangle, ExternalLink, Search, Settings } from 'lucide-react';
|
||||
import { cn } from '../../../../../../utils';
|
||||
import ProviderCatalogPicker from '../ProviderCatalogPicker';
|
||||
|
||||
type Step = 'choice' | 'catalog' | 'form';
|
||||
|
||||
interface CustomProviderFormProps {
|
||||
onSubmit: (data: UpdateCustomProviderRequest) => void;
|
||||
@@ -29,7 +32,7 @@ export default function CustomProviderForm({
|
||||
const [apiUrl, setApiUrl] = useState('');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [models, setModels] = useState('');
|
||||
const [requiresApiKey, setRequiresApiKey] = useState(false);
|
||||
const [requiresAuth, setRequiresAuth] = useState(false);
|
||||
const [supportsStreaming, setSupportsStreaming] = useState(true);
|
||||
const [headers, setHeaders] = useState<{ key: string; value: string }[]>([]);
|
||||
const [newHeaderKey, setNewHeaderKey] = useState('');
|
||||
@@ -42,6 +45,10 @@ export default function CustomProviderForm({
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
|
||||
|
||||
// Template + step state
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<ProviderTemplate | null>(null);
|
||||
const [step, setStep] = useState<Step>(initialData ? 'form' : 'choice');
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
const engineMap: Record<string, string> = {
|
||||
@@ -54,7 +61,7 @@ export default function CustomProviderForm({
|
||||
setApiUrl(initialData.api_url);
|
||||
setModels(initialData.models.join(', '));
|
||||
setSupportsStreaming(initialData.supports_streaming ?? true);
|
||||
setRequiresApiKey(initialData.requires_auth ?? true);
|
||||
setRequiresAuth(initialData.requires_auth ?? true);
|
||||
|
||||
if (initialData.headers) {
|
||||
const headerList = Object.entries(initialData.headers).map(([key, value]) => ({
|
||||
@@ -63,11 +70,46 @@ export default function CustomProviderForm({
|
||||
}));
|
||||
setHeaders(headerList);
|
||||
}
|
||||
|
||||
setStep('form');
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
const handleRequiresApiKeyChange = (checked: boolean) => {
|
||||
setRequiresApiKey(checked);
|
||||
const handleTemplateSelect = (template: ProviderTemplate) => {
|
||||
setSelectedTemplate(template);
|
||||
|
||||
// Prefill fields from template
|
||||
setDisplayName(template.name);
|
||||
setApiUrl(template.api_url);
|
||||
setSupportsStreaming(template.supports_streaming);
|
||||
setRequiresAuth(true);
|
||||
|
||||
const formatToEngine: Record<string, string> = {
|
||||
openai: 'openai_compatible',
|
||||
anthropic: 'anthropic_compatible',
|
||||
ollama: 'ollama_compatible',
|
||||
};
|
||||
setEngine(formatToEngine[template.format] || 'openai_compatible');
|
||||
|
||||
const templateModels = template.models.filter((m) => !m.deprecated).map((m) => m.id);
|
||||
setModels(templateModels.join(', '));
|
||||
|
||||
setStep('form');
|
||||
};
|
||||
|
||||
const handleClearTemplate = () => {
|
||||
setSelectedTemplate(null);
|
||||
setDisplayName('');
|
||||
setApiUrl('');
|
||||
setModels('');
|
||||
setEngine('openai_compatible');
|
||||
setSupportsStreaming(true);
|
||||
setRequiresAuth(false);
|
||||
setStep('choice');
|
||||
};
|
||||
|
||||
const handleRequiresAuthChange = (checked: boolean) => {
|
||||
setRequiresAuth(checked);
|
||||
if (!checked) {
|
||||
setApiKey('');
|
||||
}
|
||||
@@ -81,28 +123,19 @@ export default function CustomProviderForm({
|
||||
const isDuplicate = headers.some((h) => h.key.trim().toLowerCase() === normalizedNewKey);
|
||||
|
||||
if (keyEmpty || valueEmpty) {
|
||||
setInvalidHeaderFields({
|
||||
key: keyEmpty,
|
||||
value: valueEmpty,
|
||||
});
|
||||
setInvalidHeaderFields({ key: keyEmpty, value: valueEmpty });
|
||||
setHeaderValidationError('Both header name and value must be entered');
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyHasSpaces) {
|
||||
setInvalidHeaderFields({
|
||||
key: true,
|
||||
value: false,
|
||||
});
|
||||
setInvalidHeaderFields({ key: true, value: false });
|
||||
setHeaderValidationError('Header name cannot contain spaces');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDuplicate) {
|
||||
setInvalidHeaderFields({
|
||||
key: true,
|
||||
value: false,
|
||||
});
|
||||
setInvalidHeaderFields({ key: true, value: false });
|
||||
setHeaderValidationError('A header with this name already exists');
|
||||
return;
|
||||
}
|
||||
@@ -120,16 +153,12 @@ export default function CustomProviderForm({
|
||||
|
||||
const handleHeaderChange = (index: number, field: 'key' | 'value', value: string) => {
|
||||
if (field === 'key') {
|
||||
if (value.includes(' ')) {
|
||||
return;
|
||||
}
|
||||
if (value.includes(' ')) return;
|
||||
const normalizedValue = value.trim().toLowerCase();
|
||||
const isDuplicate = headers.some(
|
||||
(h, i) => i !== index && h.key.trim().toLowerCase() === normalizedValue
|
||||
);
|
||||
if (isDuplicate && normalizedValue !== '') {
|
||||
return;
|
||||
}
|
||||
if (isDuplicate && normalizedValue !== '') return;
|
||||
const updatedHeaders = [...headers];
|
||||
updatedHeaders[index].key = value;
|
||||
setHeaders(updatedHeaders);
|
||||
@@ -159,7 +188,7 @@ export default function CustomProviderForm({
|
||||
if (!displayName) errors.displayName = 'Display name is required';
|
||||
if (!apiUrl) errors.apiUrl = 'API URL is required';
|
||||
const existingHadAuth = initialData && (initialData.requires_auth ?? true);
|
||||
if (requiresApiKey && !apiKey && !existingHadAuth) errors.apiKey = 'API key is required';
|
||||
if (requiresAuth && !apiKey && !existingHadAuth) errors.apiKey = 'API key is required';
|
||||
if (!models) errors.models = 'At least one model is required';
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
@@ -201,102 +230,228 @@ export default function CustomProviderForm({
|
||||
api_key: apiKey,
|
||||
models: modelList,
|
||||
supports_streaming: supportsStreaming,
|
||||
requires_auth: requiresApiKey,
|
||||
requires_auth: requiresAuth,
|
||||
headers: headersObject,
|
||||
catalog_provider_id: selectedTemplate?.id ?? initialData?.catalog_provider_id ?? undefined,
|
||||
});
|
||||
};
|
||||
|
||||
// Aggregate capability badges for template models
|
||||
const templateModelCapabilities = selectedTemplate?.models
|
||||
.filter((m) => !m.deprecated)
|
||||
.reduce(
|
||||
(acc, m) => {
|
||||
if (m.capabilities.tool_call) acc.tool_call = true;
|
||||
if (m.capabilities.reasoning) acc.reasoning = true;
|
||||
if (m.capabilities.attachment) acc.attachment = true;
|
||||
return acc;
|
||||
},
|
||||
{ tool_call: false, reasoning: false, attachment: false }
|
||||
);
|
||||
|
||||
// -- Step: Choice --
|
||||
if (step === 'choice') {
|
||||
return (
|
||||
<div className="mt-4 space-y-3">
|
||||
<p className="text-sm text-textSubtle">Choose how you'd like to set up your provider.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('catalog')}
|
||||
className="w-full p-4 text-left border border-border rounded-lg hover:bg-surfaceHover hover:border-primary transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Search className="w-5 h-5 text-primary flex-shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium text-textStandard">Start from a provider template</div>
|
||||
<div className="text-sm text-textSubtle mt-0.5">
|
||||
Pick a known provider and we'll auto-fill the configuration
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('form')}
|
||||
className="w-full p-4 text-left border border-border rounded-lg hover:bg-surfaceHover hover:border-primary transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Settings className="w-5 h-5 text-textSubtle flex-shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium text-textStandard">Configure manually</div>
|
||||
<div className="text-sm text-textSubtle mt-0.5">
|
||||
Enter all provider details yourself
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -- Step: Catalog picker --
|
||||
if (step === 'catalog') {
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<ProviderCatalogPicker onSelect={handleTemplateSelect} onCancel={onCancel} embedded />
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button type="button" variant="ghost" onClick={() => setStep('choice')}>
|
||||
← Back
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -- Step: Form --
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="mt-4 space-y-4">
|
||||
{isEditable && (
|
||||
<>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="provider-select"
|
||||
className="flex items-center text-sm font-medium text-text-primary mb-2"
|
||||
>
|
||||
Provider Type
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<Select
|
||||
id="provider-select"
|
||||
aria-invalid={!!validationErrors.providerType}
|
||||
aria-describedby={validationErrors.providerType ? 'provider-select-error' : undefined}
|
||||
options={[
|
||||
{ value: 'openai_compatible', label: 'OpenAI Compatible' },
|
||||
{ value: 'anthropic_compatible', label: 'Anthropic Compatible' },
|
||||
{ value: 'ollama_compatible', label: 'Ollama Compatible' },
|
||||
]}
|
||||
value={{
|
||||
value: engine,
|
||||
label:
|
||||
engine === 'openai_compatible'
|
||||
? 'OpenAI Compatible'
|
||||
: engine === 'anthropic_compatible'
|
||||
? 'Anthropic Compatible'
|
||||
: 'Ollama Compatible',
|
||||
}}
|
||||
onChange={(option: unknown) => {
|
||||
const selectedOption = option as { value: string; label: string } | null;
|
||||
if (selectedOption) setEngine(selectedOption.value);
|
||||
}}
|
||||
isSearchable={false}
|
||||
/>
|
||||
{validationErrors.providerType && (
|
||||
<p id="provider-select-error" className="text-red-500 text-sm mt-1">
|
||||
{validationErrors.providerType}
|
||||
</p>
|
||||
)}
|
||||
{/* Template info banner */}
|
||||
{selectedTemplate && (
|
||||
<div className="p-3 bg-surfaceHover border border-border rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-textStandard">
|
||||
Using template: {selectedTemplate.name}
|
||||
</div>
|
||||
<div className="text-textSubtle mt-1">{selectedTemplate.api_url}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedTemplate.doc_url && (
|
||||
<a
|
||||
href={selectedTemplate.doc_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline text-sm flex items-center gap-1"
|
||||
>
|
||||
Docs <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearTemplate}
|
||||
className="text-textSubtle hover:text-textStandard"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="display-name"
|
||||
className="flex items-center text-sm font-medium text-text-primary mb-2"
|
||||
>
|
||||
Display Name
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="display-name"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Your Provider Name"
|
||||
aria-invalid={!!validationErrors.displayName}
|
||||
aria-describedby={validationErrors.displayName ? 'display-name-error' : undefined}
|
||||
className={validationErrors.displayName ? 'border-red-500' : ''}
|
||||
/>
|
||||
{validationErrors.displayName && (
|
||||
<p id="display-name-error" className="text-red-500 text-sm mt-1">
|
||||
{validationErrors.displayName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="api-url"
|
||||
className="flex items-center text-sm font-medium text-text-primary mb-2"
|
||||
>
|
||||
API URL
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="api-url"
|
||||
value={apiUrl}
|
||||
onChange={(e) => setApiUrl(e.target.value)}
|
||||
placeholder="https://api.example.com/v1"
|
||||
aria-invalid={!!validationErrors.apiUrl}
|
||||
aria-describedby={validationErrors.apiUrl ? 'api-url-error' : undefined}
|
||||
className={validationErrors.apiUrl ? 'border-red-500' : ''}
|
||||
/>
|
||||
{validationErrors.apiUrl && (
|
||||
<p id="api-url-error" className="text-red-500 text-sm mt-1">
|
||||
{validationErrors.apiUrl}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Back to choice (create without template only) */}
|
||||
{!initialData && !selectedTemplate && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setStep('choice')}>
|
||||
← Back
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Provider type dropdown */}
|
||||
{isEditable && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="provider-select"
|
||||
className="flex items-center text-sm font-medium text-text-primary mb-2"
|
||||
>
|
||||
Provider Type
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<Select
|
||||
id="provider-select"
|
||||
aria-invalid={!!validationErrors.providerType}
|
||||
aria-describedby={validationErrors.providerType ? 'provider-select-error' : undefined}
|
||||
options={[
|
||||
{ value: 'openai_compatible', label: 'OpenAI Compatible' },
|
||||
{ value: 'anthropic_compatible', label: 'Anthropic Compatible' },
|
||||
{ value: 'ollama_compatible', label: 'Ollama Compatible' },
|
||||
]}
|
||||
value={{
|
||||
value: engine,
|
||||
label:
|
||||
engine === 'openai_compatible'
|
||||
? 'OpenAI Compatible'
|
||||
: engine === 'anthropic_compatible'
|
||||
? 'Anthropic Compatible'
|
||||
: 'Ollama Compatible',
|
||||
}}
|
||||
onChange={(option: unknown) => {
|
||||
const selectedOption = option as { value: string; label: string } | null;
|
||||
if (selectedOption) setEngine(selectedOption.value);
|
||||
}}
|
||||
isSearchable={false}
|
||||
/>
|
||||
{validationErrors.providerType && (
|
||||
<p id="provider-select-error" className="text-red-500 text-sm mt-1">
|
||||
{validationErrors.providerType}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Display name */}
|
||||
{isEditable && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="display-name"
|
||||
className="flex items-center text-sm font-medium text-text-primary mb-2"
|
||||
>
|
||||
Display Name
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="display-name"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Your Provider Name"
|
||||
aria-invalid={!!validationErrors.displayName}
|
||||
aria-describedby={validationErrors.displayName ? 'display-name-error' : undefined}
|
||||
className={validationErrors.displayName ? 'border-red-500' : ''}
|
||||
/>
|
||||
{validationErrors.displayName && (
|
||||
<p id="display-name-error" className="text-red-500 text-sm mt-1">
|
||||
{validationErrors.displayName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API URL */}
|
||||
{isEditable && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="api-url"
|
||||
className="flex items-center text-sm font-medium text-text-primary mb-2"
|
||||
>
|
||||
API URL
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="api-url"
|
||||
value={apiUrl}
|
||||
onChange={(e) => setApiUrl(e.target.value)}
|
||||
placeholder="https://api.example.com/v1"
|
||||
aria-invalid={!!validationErrors.apiUrl}
|
||||
aria-describedby={validationErrors.apiUrl ? 'api-url-error' : undefined}
|
||||
className={validationErrors.apiUrl ? 'border-red-500' : ''}
|
||||
/>
|
||||
{validationErrors.apiUrl && (
|
||||
<p id="api-url-error" className="text-red-500 text-sm mt-1">
|
||||
{validationErrors.apiUrl}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Authentication */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">Authentication</label>
|
||||
<p className="text-sm text-text-secondary mb-3">
|
||||
@@ -305,23 +460,28 @@ export default function CustomProviderForm({
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="requires-api-key"
|
||||
checked={requiresApiKey}
|
||||
onChange={(e) => handleRequiresApiKeyChange(e.target.checked)}
|
||||
id="requires-auth"
|
||||
checked={requiresAuth}
|
||||
onChange={(e) => handleRequiresAuthChange(e.target.checked)}
|
||||
className="rounded border-border-primary"
|
||||
/>
|
||||
<label htmlFor="requires-api-key" className="text-sm text-text-secondary">
|
||||
<label htmlFor="requires-auth" className="text-sm text-text-secondary">
|
||||
This provider requires an API key
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{requiresApiKey && (
|
||||
{requiresAuth && (
|
||||
<div className="mt-3">
|
||||
<label
|
||||
htmlFor="api-key"
|
||||
className="flex items-center text-sm font-medium text-text-primary mb-2"
|
||||
>
|
||||
API Key
|
||||
{selectedTemplate?.env_var && (
|
||||
<span className="text-textSubtle ml-1 font-normal">
|
||||
({selectedTemplate.env_var})
|
||||
</span>
|
||||
)}
|
||||
{!initialData && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
<Input
|
||||
@@ -342,119 +502,145 @@ export default function CustomProviderForm({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Models */}
|
||||
{isEditable && (
|
||||
<>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="available-models"
|
||||
className="flex items-center text-sm font-medium text-text-primary mb-2"
|
||||
>
|
||||
Available Models (comma-separated)
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="available-models"
|
||||
value={models}
|
||||
onChange={(e) => setModels(e.target.value)}
|
||||
placeholder="model-a, model-b, model-c"
|
||||
aria-invalid={!!validationErrors.models}
|
||||
aria-describedby={validationErrors.models ? 'available-models-error' : undefined}
|
||||
className={validationErrors.models ? 'border-red-500' : ''}
|
||||
/>
|
||||
{validationErrors.models && (
|
||||
<p id="available-models-error" className="text-red-500 text-sm mt-1">
|
||||
{validationErrors.models}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 mb-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="supports-streaming"
|
||||
checked={supportsStreaming}
|
||||
onChange={(e) => setSupportsStreaming(e.target.checked)}
|
||||
className="rounded border-border-primary"
|
||||
/>
|
||||
<label htmlFor="supports-streaming" className="text-sm text-text-secondary">
|
||||
Provider supports streaming responses
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-textStandard mb-2 block">
|
||||
Custom Headers
|
||||
</label>
|
||||
<p className="text-xs text-textSubtle mb-4">
|
||||
Add custom HTTP headers to include in requests to the provider. Click the "+" button
|
||||
to add after filling both fields.
|
||||
<div>
|
||||
<label
|
||||
htmlFor="available-models"
|
||||
className="flex items-center text-sm font-medium text-text-primary mb-2"
|
||||
>
|
||||
Available Models (comma-separated)
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="available-models"
|
||||
value={models}
|
||||
onChange={(e) => setModels(e.target.value)}
|
||||
placeholder="model-a, model-b, model-c"
|
||||
aria-invalid={!!validationErrors.models}
|
||||
aria-describedby={validationErrors.models ? 'available-models-error' : undefined}
|
||||
className={validationErrors.models ? 'border-red-500' : ''}
|
||||
/>
|
||||
{validationErrors.models && (
|
||||
<p id="available-models-error" className="text-red-500 text-sm mt-1">
|
||||
{validationErrors.models}
|
||||
</p>
|
||||
<div className="grid grid-cols-[1fr_1fr_auto] gap-2 items-center">
|
||||
{headers.map((header, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<Input
|
||||
value={header.key}
|
||||
onChange={(e) => handleHeaderChange(index, 'key', e.target.value)}
|
||||
placeholder="Header name"
|
||||
className="w-full text-textStandard border-borderSubtle hover:border-borderStandard"
|
||||
/>
|
||||
<Input
|
||||
value={header.value}
|
||||
onChange={(e) => handleHeaderChange(index, 'value', e.target.value)}
|
||||
placeholder="Value"
|
||||
className="w-full text-textStandard border-borderSubtle hover:border-borderStandard"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleRemoveHeader(index)}
|
||||
variant="ghost"
|
||||
type="button"
|
||||
className="group p-2 h-auto text-iconSubtle hover:bg-transparent"
|
||||
>
|
||||
<X className="h-3 w-3 text-gray-400 group-hover:text-white group-hover:drop-shadow-sm transition-all" />
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
<Input
|
||||
value={newHeaderKey}
|
||||
onChange={(e) => {
|
||||
setNewHeaderKey(e.target.value);
|
||||
clearHeaderValidation();
|
||||
}}
|
||||
onKeyDown={handleHeaderKeyDown}
|
||||
placeholder="Header name"
|
||||
className={cn(
|
||||
'w-full text-textStandard border-borderSubtle hover:border-borderStandard',
|
||||
invalidHeaderFields.key && 'border-red-500 focus:border-red-500'
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
value={newHeaderValue}
|
||||
onChange={(e) => {
|
||||
setNewHeaderValue(e.target.value);
|
||||
clearHeaderValidation();
|
||||
}}
|
||||
onKeyDown={handleHeaderKeyDown}
|
||||
placeholder="Value"
|
||||
className={cn(
|
||||
'w-full text-textStandard border-borderSubtle hover:border-borderStandard',
|
||||
invalidHeaderFields.value && 'border-red-500 focus:border-red-500'
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddHeader}
|
||||
variant="ghost"
|
||||
type="button"
|
||||
className="flex items-center justify-start gap-1 px-2 pr-4 text-sm rounded-full text-textStandard bg-background-primary border border-borderSubtle hover:border-borderStandard transition-colors min-w-[60px] h-9 [&>svg]:!size-4"
|
||||
>
|
||||
<Plus /> Add
|
||||
</Button>
|
||||
)}
|
||||
{/* Capability badges when template is active */}
|
||||
{selectedTemplate && templateModelCapabilities && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
{templateModelCapabilities.tool_call && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300">
|
||||
Tool calling
|
||||
</span>
|
||||
)}
|
||||
{templateModelCapabilities.reasoning && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300">
|
||||
Reasoning
|
||||
</span>
|
||||
)}
|
||||
{templateModelCapabilities.attachment && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300">
|
||||
Attachments
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{headerValidationError && (
|
||||
<div className="mt-2 text-red-500 text-sm">{headerValidationError}</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaming */}
|
||||
{isEditable && (
|
||||
<div className="flex items-center space-x-2 mb-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="supports-streaming"
|
||||
checked={supportsStreaming}
|
||||
onChange={(e) => setSupportsStreaming(e.target.checked)}
|
||||
className="rounded border-border-primary"
|
||||
/>
|
||||
<label htmlFor="supports-streaming" className="text-sm text-text-secondary">
|
||||
Provider supports streaming responses
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom headers */}
|
||||
{isEditable && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-textStandard mb-2 block">Custom Headers</label>
|
||||
<p className="text-xs text-textSubtle mb-4">
|
||||
Add custom HTTP headers to include in requests to the provider. Click the "+" button to
|
||||
add after filling both fields.
|
||||
</p>
|
||||
<div className="grid grid-cols-[1fr_1fr_auto] gap-2 items-center">
|
||||
{headers.map((header, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<Input
|
||||
value={header.key}
|
||||
onChange={(e) => handleHeaderChange(index, 'key', e.target.value)}
|
||||
placeholder="Header name"
|
||||
className="w-full text-textStandard border-borderSubtle hover:border-borderStandard"
|
||||
/>
|
||||
<Input
|
||||
value={header.value}
|
||||
onChange={(e) => handleHeaderChange(index, 'value', e.target.value)}
|
||||
placeholder="Value"
|
||||
className="w-full text-textStandard border-borderSubtle hover:border-borderStandard"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleRemoveHeader(index)}
|
||||
variant="ghost"
|
||||
type="button"
|
||||
className="group p-2 h-auto text-iconSubtle hover:bg-transparent"
|
||||
>
|
||||
<X className="h-3 w-3 text-gray-400 group-hover:text-white group-hover:drop-shadow-sm transition-all" />
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
<Input
|
||||
value={newHeaderKey}
|
||||
onChange={(e) => {
|
||||
setNewHeaderKey(e.target.value);
|
||||
clearHeaderValidation();
|
||||
}}
|
||||
onKeyDown={handleHeaderKeyDown}
|
||||
placeholder="Header name"
|
||||
className={cn(
|
||||
'w-full text-textStandard border-borderSubtle hover:border-borderStandard',
|
||||
invalidHeaderFields.key && 'border-red-500 focus:border-red-500'
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
value={newHeaderValue}
|
||||
onChange={(e) => {
|
||||
setNewHeaderValue(e.target.value);
|
||||
clearHeaderValidation();
|
||||
}}
|
||||
onKeyDown={handleHeaderKeyDown}
|
||||
placeholder="Value"
|
||||
className={cn(
|
||||
'w-full text-textStandard border-borderSubtle hover:border-borderStandard',
|
||||
invalidHeaderFields.value && 'border-red-500 focus:border-red-500'
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddHeader}
|
||||
variant="ghost"
|
||||
type="button"
|
||||
className="flex items-center justify-start gap-1 px-2 pr-4 text-sm rounded-full text-textStandard bg-background-primary border border-borderSubtle hover:border-borderStandard transition-colors min-w-[60px] h-9 [&>svg]:!size-4"
|
||||
>
|
||||
<Plus /> Add
|
||||
</Button>
|
||||
</div>
|
||||
{headerValidationError && (
|
||||
<div className="mt-2 text-red-500 text-sm">{headerValidationError}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SecureStorageNotice />
|
||||
|
||||
{showDeleteConfirmation ? (
|
||||
|
||||
@@ -7,6 +7,7 @@ interface CardContainerProps {
|
||||
grayedOut: boolean;
|
||||
testId?: string;
|
||||
borderStyle?: 'solid' | 'dashed';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function GlowingRing() {
|
||||
@@ -35,6 +36,7 @@ export default function CardContainer({
|
||||
grayedOut = false,
|
||||
testId,
|
||||
borderStyle = 'solid',
|
||||
className = '',
|
||||
}: CardContainerProps) {
|
||||
return (
|
||||
<div
|
||||
@@ -59,7 +61,8 @@ export default function CardContainer({
|
||||
grayedOut
|
||||
? 'border-border-primary'
|
||||
: 'border-border-primary hover:border-border-primary'
|
||||
}`}
|
||||
}
|
||||
${className}`}
|
||||
>
|
||||
{header && (
|
||||
<div style={{ opacity: grayedOut ? '0.5' : '1' }}>
|
||||
|
||||
@@ -70,7 +70,14 @@ export type AnalyticsEvent =
|
||||
| {
|
||||
name: 'onboarding_provider_selected';
|
||||
properties: {
|
||||
method: 'api_key' | 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'ollama' | 'local' | 'other';
|
||||
method:
|
||||
| 'api_key'
|
||||
| 'openrouter'
|
||||
| 'tetrate'
|
||||
| 'chatgpt_codex'
|
||||
| 'ollama'
|
||||
| 'local'
|
||||
| 'other';
|
||||
};
|
||||
}
|
||||
| {
|
||||
@@ -80,7 +87,10 @@ export type AnalyticsEvent =
|
||||
| { name: 'onboarding_abandoned'; properties: { step: string; duration_seconds?: number } }
|
||||
| {
|
||||
name: 'onboarding_setup_failed';
|
||||
properties: { provider: 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'local'; error_message?: string };
|
||||
properties: {
|
||||
provider: 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'local';
|
||||
error_message?: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
name: 'error_occurred';
|
||||
|
||||
Reference in New Issue
Block a user