llm-provider-catalog実装
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
//! プロバイダ/モデルカタログ。
|
||||
//!
|
||||
//! builtin (`assets/providers.toml`) と user override
|
||||
//! (`$XDG_CONFIG_HOME/insomnia/providers.toml`) を読み、
|
||||
//! `Vec<ProviderEntry>` を返す。user override がある場合は builtin を
|
||||
//! 置き換える(マージしない)。
|
||||
//!
|
||||
//! `ProviderEntry` から [`ModelConfig`] への変換は
|
||||
//! [`ProviderEntry::to_model_config`] で行う。`auth_hint` はここでは
|
||||
//! UI 表示用のヒントで、実際の認証解決は従来通り [`crate::build_client`]
|
||||
//! が `AuthRef` から行う。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use manifest::{AuthRef, ModelConfig, SchemeKind};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const BUILTIN_CATALOG: &str = include_str!("../assets/providers.toml");
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CatalogError {
|
||||
#[error("failed to read catalog at {path}: {source}")]
|
||||
Io {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("failed to parse catalog at {path}: {source}")]
|
||||
Parse {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: toml::de::Error,
|
||||
},
|
||||
#[error("failed to parse builtin catalog: {0}")]
|
||||
BuiltinParse(#[source] toml::de::Error),
|
||||
}
|
||||
|
||||
/// UI 向けの認証ヒント。
|
||||
///
|
||||
/// 「何を表示・要求するか」のメタ情報で、ランタイムの [`AuthRef`]
|
||||
/// とは責務が別。1:1 の対応関係にあり、
|
||||
/// [`ProviderEntry::to_model_config`] で相互変換される。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum AuthHint {
|
||||
/// 認証不要(ローカル Ollama 等)
|
||||
None,
|
||||
/// API key。`env` が指定されていれば UI はその env 名を提示する
|
||||
ApiKey {
|
||||
#[serde(default)]
|
||||
env: Option<String>,
|
||||
},
|
||||
/// ChatGPT OAuth(`~/.codex/auth.json`)
|
||||
#[serde(rename = "codex_oauth")]
|
||||
CodexOAuth,
|
||||
}
|
||||
|
||||
/// カタログ 1 エントリ。
|
||||
///
|
||||
/// 将来 `discover: Option<DiscoverMode>` を任意で追加予定(Ollama
|
||||
/// `/api/tags` 等の動的モデル列挙)。別チケットで実装する。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ProviderEntry {
|
||||
pub id: String,
|
||||
pub display_name: String,
|
||||
pub scheme: SchemeKind,
|
||||
#[serde(default)]
|
||||
pub base_url: Option<String>,
|
||||
pub auth_hint: AuthHint,
|
||||
#[serde(default)]
|
||||
pub default_models: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CatalogFile {
|
||||
#[serde(default)]
|
||||
provider: Vec<ProviderEntry>,
|
||||
}
|
||||
|
||||
impl ProviderEntry {
|
||||
/// 選ばれた `model_id` と組み合わせて [`ModelConfig`] を構築する。
|
||||
pub fn to_model_config(&self, model_id: impl Into<String>) -> ModelConfig {
|
||||
let auth = match &self.auth_hint {
|
||||
AuthHint::None => AuthRef::None,
|
||||
AuthHint::ApiKey { env } => AuthRef::ApiKey {
|
||||
env: env.clone(),
|
||||
file: None,
|
||||
},
|
||||
AuthHint::CodexOAuth => AuthRef::CodexOAuth,
|
||||
};
|
||||
ModelConfig {
|
||||
scheme: self.scheme,
|
||||
base_url: self.base_url.clone(),
|
||||
model_id: model_id.into(),
|
||||
auth,
|
||||
capability: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// builtin + user override を解決してカタログを返す。
|
||||
///
|
||||
/// user override (`$XDG_CONFIG_HOME/insomnia/providers.toml`) が
|
||||
/// 存在すれば builtin を置き換える。存在しなければ builtin のみ。
|
||||
/// user override が存在するが壊れている場合はエラーを返す(silent
|
||||
/// fallback はしない — ユーザーが書いた設定が silent に無視されて
|
||||
/// builtin に戻る挙動は気付きにくいため)。
|
||||
pub fn load() -> Result<Vec<ProviderEntry>, CatalogError> {
|
||||
if let Some(path) = user_override_path()
|
||||
&& path.is_file()
|
||||
{
|
||||
return load_from_path(&path);
|
||||
}
|
||||
load_builtin()
|
||||
}
|
||||
|
||||
/// builtin カタログ (`assets/providers.toml`) のみを返す。
|
||||
pub fn load_builtin() -> Result<Vec<ProviderEntry>, CatalogError> {
|
||||
let parsed: CatalogFile =
|
||||
toml::from_str(BUILTIN_CATALOG).map_err(CatalogError::BuiltinParse)?;
|
||||
Ok(parsed.provider)
|
||||
}
|
||||
|
||||
/// 指定パスから読む(テスト・明示指定用)。
|
||||
pub fn load_from_path(path: &Path) -> Result<Vec<ProviderEntry>, CatalogError> {
|
||||
let text = std::fs::read_to_string(path).map_err(|source| CatalogError::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
let parsed: CatalogFile = toml::from_str(&text).map_err(|source| CatalogError::Parse {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
Ok(parsed.provider)
|
||||
}
|
||||
|
||||
fn user_override_path() -> Option<PathBuf> {
|
||||
if let Ok(dir) = std::env::var("XDG_CONFIG_HOME")
|
||||
&& !dir.is_empty()
|
||||
{
|
||||
return Some(PathBuf::from(dir).join("insomnia").join("providers.toml"));
|
||||
}
|
||||
if let Ok(home) = std::env::var("HOME")
|
||||
&& !home.is_empty()
|
||||
{
|
||||
return Some(
|
||||
PathBuf::from(home)
|
||||
.join(".config")
|
||||
.join("insomnia")
|
||||
.join("providers.toml"),
|
||||
);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::build_client;
|
||||
use serial_test::serial;
|
||||
|
||||
#[test]
|
||||
fn builtin_has_four_entries() {
|
||||
let entries = load_builtin().unwrap();
|
||||
let ids: Vec<&str> = entries.iter().map(|e| e.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec!["anthropic", "ollama-local", "codex-oauth", "openrouter"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_ollama_shape() {
|
||||
let entries = load_builtin().unwrap();
|
||||
let ollama = entries.iter().find(|e| e.id == "ollama-local").unwrap();
|
||||
assert_eq!(ollama.scheme, SchemeKind::Anthropic);
|
||||
assert_eq!(
|
||||
ollama.base_url.as_deref(),
|
||||
Some("http://localhost:11434")
|
||||
);
|
||||
assert_eq!(ollama.auth_hint, AuthHint::None);
|
||||
assert!(!ollama.default_models.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_codex_oauth_shape() {
|
||||
let entries = load_builtin().unwrap();
|
||||
let codex = entries.iter().find(|e| e.id == "codex-oauth").unwrap();
|
||||
assert_eq!(codex.scheme, SchemeKind::OpenaiResponses);
|
||||
assert_eq!(codex.auth_hint, AuthHint::CodexOAuth);
|
||||
// base_url 未指定 → Codex OAuth のデフォルト backend に解決される
|
||||
assert!(codex.base_url.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_openrouter_uses_explicit_env() {
|
||||
let entries = load_builtin().unwrap();
|
||||
let router = entries.iter().find(|e| e.id == "openrouter").unwrap();
|
||||
match &router.auth_hint {
|
||||
AuthHint::ApiKey { env } => {
|
||||
assert_eq!(env.as_deref(), Some("INSOMNIA_API_KEY_OPENROUTER"));
|
||||
}
|
||||
_ => panic!("openrouter should use ApiKey hint"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_model_config_maps_auth_hint() {
|
||||
let entries = load_builtin().unwrap();
|
||||
|
||||
let ollama = entries.iter().find(|e| e.id == "ollama-local").unwrap();
|
||||
let cfg = ollama.to_model_config("llama3");
|
||||
assert_eq!(cfg.auth, AuthRef::None);
|
||||
assert_eq!(cfg.model_id, "llama3");
|
||||
|
||||
let router = entries.iter().find(|e| e.id == "openrouter").unwrap();
|
||||
let cfg = router.to_model_config("openai/gpt-5");
|
||||
match cfg.auth {
|
||||
AuthRef::ApiKey { env, file } => {
|
||||
assert_eq!(env.as_deref(), Some("INSOMNIA_API_KEY_OPENROUTER"));
|
||||
assert!(file.is_none());
|
||||
}
|
||||
_ => panic!("expected ApiKey"),
|
||||
}
|
||||
|
||||
let codex = entries.iter().find(|e| e.id == "codex-oauth").unwrap();
|
||||
let cfg = codex.to_model_config("gpt-5");
|
||||
assert_eq!(cfg.auth, AuthRef::CodexOAuth);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ollama_entry_builds_client() {
|
||||
// カタログ読取 → ProviderEntry 選択 → ModelConfig 生成 →
|
||||
// build_client が成功する end-to-end path。
|
||||
let entries = load_builtin().unwrap();
|
||||
let ollama = entries.iter().find(|e| e.id == "ollama-local").unwrap();
|
||||
let cfg = ollama.to_model_config("llama3");
|
||||
assert!(build_client(&cfg).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_path_reads_override() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("providers.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
[[provider]]
|
||||
id = "custom"
|
||||
display_name = "Custom"
|
||||
scheme = "anthropic"
|
||||
base_url = "http://example.com"
|
||||
auth_hint = { kind = "none" }
|
||||
default_models = ["model-x"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let entries = load_from_path(&path).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].id, "custom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_override_returns_parse_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("providers.toml");
|
||||
std::fs::write(&path, "this is not valid ][ toml").unwrap();
|
||||
let err = load_from_path(&path).unwrap_err();
|
||||
assert!(matches!(err, CatalogError::Parse { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn load_prefers_override_over_builtin() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let insomnia_dir = dir.path().join("insomnia");
|
||||
std::fs::create_dir_all(&insomnia_dir).unwrap();
|
||||
std::fs::write(
|
||||
insomnia_dir.join("providers.toml"),
|
||||
r#"
|
||||
[[provider]]
|
||||
id = "only-one"
|
||||
display_name = "Only"
|
||||
scheme = "anthropic"
|
||||
auth_hint = { kind = "none" }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
|
||||
unsafe { std::env::set_var("XDG_CONFIG_HOME", dir.path()) };
|
||||
let entries = load().unwrap();
|
||||
match prev_xdg {
|
||||
Some(v) => unsafe { std::env::set_var("XDG_CONFIG_HOME", v) },
|
||||
None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
|
||||
}
|
||||
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].id, "only-one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn load_falls_back_to_builtin_when_override_absent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// override ファイルは作らない
|
||||
let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
|
||||
unsafe { std::env::set_var("XDG_CONFIG_HOME", dir.path()) };
|
||||
let entries = load().unwrap();
|
||||
match prev_xdg {
|
||||
Some(v) => unsafe { std::env::set_var("XDG_CONFIG_HOME", v) },
|
||||
None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
|
||||
}
|
||||
assert_eq!(entries.len(), 4);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
//! このクレートに追加する。
|
||||
|
||||
pub mod capability;
|
||||
pub mod catalog;
|
||||
pub mod codex_oauth;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
Reference in New Issue
Block a user