secrets: add local key store

This commit is contained in:
2026-06-01 07:07:39 +09:00
parent 6e5ed683d6
commit cc2c9a2973
25 changed files with 1197 additions and 151 deletions
+1
View File
@@ -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 }
+2 -2
View File
@@ -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()` の順で解決
+16 -20
View File
@@ -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
View File
@@ -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 { .. })));
}