secrets: add local key store
This commit is contained in:
@@ -10,6 +10,7 @@ base64 = "0.22.1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"] }
|
||||
llm-worker = { workspace = true }
|
||||
manifest = { workspace = true }
|
||||
secrets = { workspace = true }
|
||||
reqwest = { version = "0.13", features = ["json", "native-tls"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# provider
|
||||
|
||||
マニフェストの `ModelManifest` から適切な `LlmClient`(`HttpTransport<S>`)を構築するファクトリクレート。プロバイダ / モデルカタログの解決、API キーの環境変数 / ファイル解決、scheme ↔ auth の整合検証を担う。
|
||||
マニフェストの `ModelManifest` から適切な `LlmClient`(`HttpTransport<S>`)を構築するファクトリクレート。プロバイダ / モデルカタログの解決、API キーの local secret store / 明示ファイル解決、scheme ↔ auth の整合検証を担う。
|
||||
|
||||
## 公開型
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
- プロバイダ / モデルカタログの builtin (`resources/{providers,models}/builtin.toml`) と user override (`$XDG_CONFIG_HOME/insomnia/{providers,models}.toml`) の解決
|
||||
- `ModelManifest` の ref 形を `(provider, model_id)` に split し、`ModelConfig` へ展開
|
||||
- `AuthRef::ApiKey` を `ResolvedAuth::ApiKey` に解決(env → file の優先順位)
|
||||
- `AuthRef::SecretRef` / `AuthRef::ApiKey` を `ResolvedAuth::ApiKey` に解決(通常は local secret store、低レベル manifest では明示ファイルも可)
|
||||
- `AuthRef::None` / `AuthRef::CodexOAuth` の解決
|
||||
- `Scheme::required_auth()` と `ResolvedAuth` の妥当性検証(非対応組合せは構築エラー)
|
||||
- capability は manifest 明示 > model catalog > provider.default_capability > `Scheme::default_capability()` の順で解決
|
||||
|
||||
@@ -72,10 +72,15 @@ pub enum ResolveError {
|
||||
pub enum AuthHint {
|
||||
/// 認証不要(ローカル Ollama 等)
|
||||
None,
|
||||
/// API key。`env` が指定されていれば UI はその env 名を提示する
|
||||
ApiKey {
|
||||
#[serde(default)]
|
||||
env: Option<String>,
|
||||
/// API key file reference. Normal credential configuration should prefer
|
||||
/// [`AuthHint::SecretRef`] so plaintext credentials stay out of manifests.
|
||||
ApiKey,
|
||||
/// Local secret-store reference. The catalog/profile explicitly chooses the
|
||||
/// logical key id; the secret store itself has no provider semantics.
|
||||
#[serde(rename = "secret_ref")]
|
||||
SecretRef {
|
||||
#[serde(rename = "ref")]
|
||||
ref_: String,
|
||||
},
|
||||
/// ChatGPT OAuth(`~/.codex/auth.json`)
|
||||
#[serde(rename = "codex_oauth")]
|
||||
@@ -153,16 +158,12 @@ struct ModelCatalogFile {
|
||||
model: Vec<ModelEntry>,
|
||||
}
|
||||
|
||||
/// `auth_hint` に対応する [`AuthRef`] のひな型を返す。env / file は
|
||||
/// マニフェスト側で override 可能なので、ここでは hint そのままを
|
||||
/// 反映した最小形だけを返す(`AuthRef::ApiKey { env: hint_env, file: None }`)。
|
||||
/// `auth_hint` に対応する [`AuthRef`] のひな型を返す。
|
||||
fn auth_hint_to_ref(hint: &AuthHint) -> AuthRef {
|
||||
match hint {
|
||||
AuthHint::None => AuthRef::None,
|
||||
AuthHint::ApiKey { env } => AuthRef::ApiKey {
|
||||
env: env.clone(),
|
||||
file: None,
|
||||
},
|
||||
AuthHint::ApiKey => AuthRef::ApiKey { file: None },
|
||||
AuthHint::SecretRef { ref_ } => AuthRef::SecretRef { ref_: ref_.clone() },
|
||||
AuthHint::CodexOAuth => AuthRef::CodexOAuth,
|
||||
}
|
||||
}
|
||||
@@ -415,11 +416,10 @@ mod tests {
|
||||
assert_eq!(cfg.model_id, "claude-sonnet-4-6");
|
||||
assert_eq!(cfg.base_url.as_deref(), Some("https://api.anthropic.com"));
|
||||
match cfg.auth {
|
||||
AuthRef::ApiKey { env, file } => {
|
||||
assert_eq!(env.as_deref(), Some("INSOMNIA_API_KEY_ANTHROPIC"));
|
||||
assert!(file.is_none());
|
||||
AuthRef::SecretRef { ref_ } => {
|
||||
assert_eq!(ref_, "providers/anthropic/default");
|
||||
}
|
||||
_ => panic!("expected ApiKey auth from provider hint"),
|
||||
_ => panic!("expected SecretRef auth from provider hint"),
|
||||
}
|
||||
assert!(
|
||||
cfg.capability.is_some(),
|
||||
@@ -493,15 +493,13 @@ mod tests {
|
||||
let manifest = ModelManifest {
|
||||
ref_: Some("anthropic/claude-sonnet-4-6".into()),
|
||||
auth: Some(AuthRef::ApiKey {
|
||||
env: None,
|
||||
file: Some(PathBuf::from("/tmp/sk-ant")),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap();
|
||||
match cfg.auth {
|
||||
AuthRef::ApiKey { env, file } => {
|
||||
assert!(env.is_none());
|
||||
AuthRef::ApiKey { file } => {
|
||||
assert_eq!(file.as_deref(), Some(Path::new("/tmp/sk-ant")));
|
||||
}
|
||||
_ => panic!("override auth should win"),
|
||||
@@ -555,7 +553,6 @@ mod tests {
|
||||
scheme: Some(SchemeKind::Anthropic),
|
||||
model_id: Some("claude-sonnet-4-6".into()),
|
||||
auth: Some(AuthRef::ApiKey {
|
||||
env: None,
|
||||
file: Some(PathBuf::from("/tmp/sk")),
|
||||
}),
|
||||
..Default::default()
|
||||
@@ -575,7 +572,6 @@ mod tests {
|
||||
scheme: Some(SchemeKind::Anthropic),
|
||||
model_id: Some("claude-sonnet-4-6".into()),
|
||||
auth: Some(AuthRef::ApiKey {
|
||||
env: None,
|
||||
file: Some(PathBuf::from("/tmp/sk")),
|
||||
}),
|
||||
context_window: Some(777_000),
|
||||
|
||||
+90
-56
@@ -4,7 +4,7 @@
|
||||
//! 段階:
|
||||
//! 1. `ModelManifest` を [`catalog::resolve_model_manifest`] で
|
||||
//! カタログ込み [`ModelConfig`] に解決(ref → 展開 / inline → 検証)
|
||||
//! 2. `AuthRef` を環境変数 / ファイルから解決して [`ResolvedAuth`] に
|
||||
//! 2. `AuthRef` を local secret store / ファイルから解決して [`ResolvedAuth`] に
|
||||
//! 3. `scheme.required_auth()` と解決値を照合(非対応組合せは構築エラー)
|
||||
//! 4. `ModelCapability` は manifest 明示 > model catalog > provider
|
||||
//! default_capability > scheme 既定 の順でフォールバック(上位 3 段は
|
||||
@@ -30,6 +30,7 @@ use llm_worker::llm_client::{
|
||||
};
|
||||
|
||||
use manifest::{AuthRef, ModelManifest, SchemeKind};
|
||||
use secrets::{SecretStore, SecretValue};
|
||||
|
||||
pub use catalog::{ModelConfig, ResolveError as CatalogResolveError};
|
||||
|
||||
@@ -42,6 +43,13 @@ pub enum ProviderError {
|
||||
#[error("API key not provided for scheme {scheme:?}")]
|
||||
ApiKeyMissing { scheme: SchemeKind },
|
||||
|
||||
#[error("failed to resolve secret `{id}`: {source}")]
|
||||
SecretStore {
|
||||
id: String,
|
||||
#[source]
|
||||
source: secrets::Error,
|
||||
},
|
||||
|
||||
#[error("scheme {scheme:?} does not support this auth")]
|
||||
AuthMismatch { scheme: SchemeKind },
|
||||
|
||||
@@ -53,21 +61,38 @@ pub enum ProviderError {
|
||||
}
|
||||
|
||||
/// `AuthRef` をランタイムで使える [`ResolvedAuth`] に解決する。
|
||||
///
|
||||
/// 解決順:
|
||||
/// 1. `AuthRef::ApiKey { env, .. }` で env が指定されていればその変数を参照
|
||||
/// 2. そうでなければ scheme 既定の環境変数 (`SchemeKind::default_env_var`)
|
||||
/// 3. それでも無ければ `file` を読む(絶対パスのみ)
|
||||
fn resolve_auth(scheme: SchemeKind, auth: &AuthRef) -> Result<ResolvedAuth, ProviderError> {
|
||||
let resolver = DefaultSecretResolver;
|
||||
resolve_auth_with_resolver(scheme, auth, &resolver)
|
||||
}
|
||||
|
||||
trait SecretResolver {
|
||||
fn get_secret(&self, id: &str) -> Result<SecretValue, secrets::Error>;
|
||||
}
|
||||
|
||||
struct DefaultSecretResolver;
|
||||
|
||||
impl SecretResolver for DefaultSecretResolver {
|
||||
fn get_secret(&self, id: &str) -> Result<SecretValue, secrets::Error> {
|
||||
let data_dir = manifest::paths::data_dir().ok_or_else(|| secrets::Error::Read {
|
||||
path: std::path::PathBuf::from("<data_dir>"),
|
||||
source: std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"could not determine insomnia data directory",
|
||||
),
|
||||
})?;
|
||||
SecretStore::new(data_dir).get(id)
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_auth_with_resolver(
|
||||
scheme: SchemeKind,
|
||||
auth: &AuthRef,
|
||||
resolver: &dyn SecretResolver,
|
||||
) -> Result<ResolvedAuth, ProviderError> {
|
||||
match auth {
|
||||
AuthRef::None => Ok(ResolvedAuth::None),
|
||||
AuthRef::ApiKey { env, file } => {
|
||||
let env_name = env.as_deref().unwrap_or(scheme.default_env_var());
|
||||
if let Ok(val) = std::env::var(env_name)
|
||||
&& !val.is_empty()
|
||||
{
|
||||
return Ok(ResolvedAuth::ApiKey(val));
|
||||
}
|
||||
AuthRef::ApiKey { file } => {
|
||||
if let Some(path) = file {
|
||||
if !path.is_absolute() {
|
||||
return Err(ProviderError::Config(format!(
|
||||
@@ -90,9 +115,15 @@ fn resolve_auth(scheme: SchemeKind, auth: &AuthRef) -> Result<ResolvedAuth, Prov
|
||||
.map_err(|e| ProviderError::Config(e.to_string()))?;
|
||||
Ok(ResolvedAuth::Custom(Arc::new(provider)))
|
||||
}
|
||||
AuthRef::SecretRef { ref_ } => Err(ProviderError::Config(format!(
|
||||
"secret store references are not implemented yet: {ref_}"
|
||||
))),
|
||||
AuthRef::SecretRef { ref_ } => {
|
||||
let value = resolver
|
||||
.get_secret(ref_)
|
||||
.map_err(|source| ProviderError::SecretStore {
|
||||
id: ref_.clone(),
|
||||
source,
|
||||
})?;
|
||||
Ok(ResolvedAuth::ApiKey(value.into_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,15 +210,24 @@ mod tests {
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct TestSecrets(std::collections::BTreeMap<String, String>);
|
||||
|
||||
impl SecretResolver for TestSecrets {
|
||||
fn get_secret(&self, id: &str) -> Result<SecretValue, secrets::Error> {
|
||||
self.0
|
||||
.get(id)
|
||||
.cloned()
|
||||
.map(SecretValue::new)
|
||||
.ok_or_else(|| secrets::Error::NotFound { id: id.to_string() })
|
||||
}
|
||||
}
|
||||
|
||||
fn anthropic_config() -> ModelConfig {
|
||||
ModelConfig {
|
||||
scheme: SchemeKind::Anthropic,
|
||||
base_url: None,
|
||||
model_id: "claude-sonnet-4-20250514".into(),
|
||||
auth: AuthRef::ApiKey {
|
||||
env: None,
|
||||
file: None,
|
||||
},
|
||||
auth: AuthRef::ApiKey { file: None },
|
||||
capability: None,
|
||||
context_window: 200_000,
|
||||
max_context_window: None,
|
||||
@@ -195,18 +235,41 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_from_env() {
|
||||
let env_name = SchemeKind::Anthropic.default_env_var();
|
||||
unsafe { std::env::set_var(env_name, "sk-from-env") };
|
||||
let auth = resolve_auth(SchemeKind::Anthropic, &anthropic_config().auth).unwrap();
|
||||
unsafe { std::env::remove_var(env_name) };
|
||||
fn resolve_from_secret_ref() {
|
||||
let resolver = TestSecrets(std::collections::BTreeMap::from([(
|
||||
"providers/anthropic/default".to_string(),
|
||||
"sk-from-store".to_string(),
|
||||
)]));
|
||||
let auth = resolve_auth_with_resolver(
|
||||
SchemeKind::Anthropic,
|
||||
&AuthRef::SecretRef {
|
||||
ref_: "providers/anthropic/default".into(),
|
||||
},
|
||||
&resolver,
|
||||
)
|
||||
.unwrap();
|
||||
match auth {
|
||||
ResolvedAuth::ApiKey(k) => assert_eq!(k, "sk-from-env"),
|
||||
ResolvedAuth::ApiKey(k) => assert_eq!(k, "sk-from-store"),
|
||||
_ => panic!("expected ApiKey"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_secret_names_only_id() {
|
||||
let resolver = TestSecrets(Default::default());
|
||||
let err = resolve_auth_with_resolver(
|
||||
SchemeKind::Anthropic,
|
||||
&AuthRef::SecretRef {
|
||||
ref_: "providers/anthropic/missing".into(),
|
||||
},
|
||||
&resolver,
|
||||
)
|
||||
.unwrap_err();
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("providers/anthropic/missing"));
|
||||
assert!(!message.contains("sk-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_from_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -217,7 +280,6 @@ mod tests {
|
||||
}
|
||||
let config = ModelConfig {
|
||||
auth: AuthRef::ApiKey {
|
||||
env: Some("INSOMNIA_API_KEY_NONEXISTENT".into()),
|
||||
file: Some(key_path),
|
||||
},
|
||||
..anthropic_config()
|
||||
@@ -229,36 +291,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn env_takes_precedence_over_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let key_path = dir.path().join("key.txt");
|
||||
std::fs::write(&key_path, "sk-from-file").unwrap();
|
||||
|
||||
let env_name = SchemeKind::Anthropic.default_env_var();
|
||||
unsafe { std::env::set_var(env_name, "sk-from-env") };
|
||||
|
||||
let config = ModelConfig {
|
||||
auth: AuthRef::ApiKey {
|
||||
env: None,
|
||||
file: Some(key_path),
|
||||
},
|
||||
..anthropic_config()
|
||||
};
|
||||
let auth = resolve_auth(config.scheme, &config.auth).unwrap();
|
||||
unsafe { std::env::remove_var(env_name) };
|
||||
match auth {
|
||||
ResolvedAuth::ApiKey(k) => assert_eq!(k, "sk-from-env"),
|
||||
_ => panic!("expected ApiKey"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_auth_file_is_rejected() {
|
||||
let config = ModelConfig {
|
||||
auth: AuthRef::ApiKey {
|
||||
env: Some("INSOMNIA_API_KEY_NONEXISTENT".into()),
|
||||
file: Some(PathBuf::from("keys/anthropic")),
|
||||
},
|
||||
..anthropic_config()
|
||||
@@ -270,8 +306,6 @@ mod tests {
|
||||
#[test]
|
||||
#[serial]
|
||||
fn missing_key_returns_api_key_missing() {
|
||||
let env_name = SchemeKind::Anthropic.default_env_var();
|
||||
unsafe { std::env::remove_var(env_name) };
|
||||
let result = build_client_from_config(&anthropic_config());
|
||||
assert!(matches!(result, Err(ProviderError::ApiKeyMissing { .. })));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user