Podにキーを渡す実装
This commit is contained in:
parent
e1cf8fad0f
commit
61a977779e
43
Cargo.lock
generated
43
Cargo.lock
generated
|
|
@ -1279,6 +1279,8 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"llm-worker",
|
"llm-worker",
|
||||||
"manifest",
|
"manifest",
|
||||||
|
"serial_test",
|
||||||
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1489,6 +1491,15 @@ version = "1.0.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scc"
|
||||||
|
version = "2.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc"
|
||||||
|
dependencies = [
|
||||||
|
"sdd",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schannel"
|
name = "schannel"
|
||||||
version = "0.1.29"
|
version = "0.1.29"
|
||||||
|
|
@ -1529,6 +1540,12 @@ version = "1.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sdd"
|
||||||
|
version = "3.0.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
|
|
@ -1621,6 +1638,32 @@ dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serial_test"
|
||||||
|
version = "3.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f"
|
||||||
|
dependencies = [
|
||||||
|
"futures-executor",
|
||||||
|
"futures-util",
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"parking_lot",
|
||||||
|
"scc",
|
||||||
|
"serial_test_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serial_test_derive"
|
||||||
|
version = "3.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
|
|
|
||||||
2
TODO.md
2
TODO.md
|
|
@ -11,6 +11,6 @@
|
||||||
- [x] Subscriber → クロージャ API 移行
|
- [x] Subscriber → クロージャ API 移行
|
||||||
- [x] JSONL ストリーム変換ユーティリティ (protocol::stream)
|
- [x] JSONL ストリーム変換ユーティリティ (protocol::stream)
|
||||||
- [x] Hook モジュールの llm-worker からの除去 → [tickets/remove-hook-module.md](tickets/remove-hook-module.md)
|
- [x] Hook モジュールの llm-worker からの除去 → [tickets/remove-hook-module.md](tickets/remove-hook-module.md)
|
||||||
- [ ] api_key_file: ファイルパスによるAPIキー解決 → [tickets/api-key-file.md](tickets/api-key-file.md)
|
- [x] api_key_file: ファイルパスによるAPIキー解決 → [tickets/api-key-file.md](tickets/api-key-file.md)
|
||||||
- [ ] コンテキスト圧縮 (Prune + Compact) → [tickets/context-compaction.md](tickets/context-compaction.md)
|
- [ ] コンテキスト圧縮 (Prune + Compact) → [tickets/context-compaction.md](tickets/context-compaction.md)
|
||||||
- [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md)
|
- [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md)
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,9 @@ pub struct PodMeta {
|
||||||
pub struct ProviderConfig {
|
pub struct ProviderConfig {
|
||||||
pub kind: ProviderKind,
|
pub kind: ProviderKind,
|
||||||
pub model: String,
|
pub model: String,
|
||||||
/// Environment variable name holding the API key.
|
/// Path to a file containing the API key (read and trimmed at startup).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub api_key_env: Option<String>,
|
pub api_key_file: Option<PathBuf>,
|
||||||
/// Custom base URL for the provider API.
|
/// Custom base URL for the provider API.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub base_url: Option<String>,
|
pub base_url: Option<String>,
|
||||||
|
|
@ -49,6 +49,21 @@ pub enum ProviderKind {
|
||||||
Ollama,
|
Ollama,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ProviderKind {
|
||||||
|
/// Conventional environment variable name for the API key.
|
||||||
|
///
|
||||||
|
/// Returns `INSOMNIA_API_KEY_{KIND}` (e.g. `INSOMNIA_API_KEY_ANTHROPIC`).
|
||||||
|
pub fn env_var_name(self) -> String {
|
||||||
|
let kind = match self {
|
||||||
|
Self::Anthropic => "ANTHROPIC",
|
||||||
|
Self::Openai => "OPENAI",
|
||||||
|
Self::Gemini => "GEMINI",
|
||||||
|
Self::Ollama => "OLLAMA",
|
||||||
|
};
|
||||||
|
format!("INSOMNIA_API_KEY_{kind}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Worker-level configuration embedded in the manifest.
|
/// Worker-level configuration embedded in the manifest.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct WorkerManifest {
|
pub struct WorkerManifest {
|
||||||
|
|
@ -95,7 +110,7 @@ model = "claude-sonnet-4-20250514"
|
||||||
assert_eq!(manifest.pod.name, "test-agent");
|
assert_eq!(manifest.pod.name, "test-agent");
|
||||||
assert_eq!(manifest.provider.kind, ProviderKind::Anthropic);
|
assert_eq!(manifest.provider.kind, ProviderKind::Anthropic);
|
||||||
assert_eq!(manifest.provider.model, "claude-sonnet-4-20250514");
|
assert_eq!(manifest.provider.model, "claude-sonnet-4-20250514");
|
||||||
assert!(manifest.provider.api_key_env.is_none());
|
assert!(manifest.provider.api_key_file.is_none());
|
||||||
assert!(manifest.scope.is_none());
|
assert!(manifest.scope.is_none());
|
||||||
assert!(manifest.worker.system_prompt.is_none());
|
assert!(manifest.worker.system_prompt.is_none());
|
||||||
}
|
}
|
||||||
|
|
@ -109,7 +124,7 @@ name = "code-reviewer"
|
||||||
[provider]
|
[provider]
|
||||||
kind = "anthropic"
|
kind = "anthropic"
|
||||||
model = "claude-sonnet-4-20250514"
|
model = "claude-sonnet-4-20250514"
|
||||||
api_key_env = "ANTHROPIC_API_KEY"
|
api_key_file = "~/.config/insomnia/keys/anthropic"
|
||||||
|
|
||||||
[worker]
|
[worker]
|
||||||
system_prompt = "You are a code reviewer."
|
system_prompt = "You are a code reviewer."
|
||||||
|
|
@ -122,8 +137,8 @@ root = "./src"
|
||||||
let manifest = PodManifest::from_toml(toml).unwrap();
|
let manifest = PodManifest::from_toml(toml).unwrap();
|
||||||
assert_eq!(manifest.pod.name, "code-reviewer");
|
assert_eq!(manifest.pod.name, "code-reviewer");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
manifest.provider.api_key_env.as_deref(),
|
manifest.provider.api_key_file.as_deref(),
|
||||||
Some("ANTHROPIC_API_KEY")
|
Some(std::path::Path::new("~/.config/insomnia/keys/anthropic"))
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
manifest.worker.system_prompt.as_deref(),
|
manifest.worker.system_prompt.as_deref(),
|
||||||
|
|
@ -151,7 +166,7 @@ model = "llama3"
|
||||||
"#;
|
"#;
|
||||||
let manifest = PodManifest::from_toml(toml).unwrap();
|
let manifest = PodManifest::from_toml(toml).unwrap();
|
||||||
assert_eq!(manifest.provider.kind, ProviderKind::Ollama);
|
assert_eq!(manifest.provider.kind, ProviderKind::Ollama);
|
||||||
assert!(manifest.provider.api_key_env.is_none());
|
assert!(manifest.provider.api_key_file.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ name = "hello-pod"
|
||||||
[provider]
|
[provider]
|
||||||
kind = "anthropic"
|
kind = "anthropic"
|
||||||
model = "claude-sonnet-4-20250514"
|
model = "claude-sonnet-4-20250514"
|
||||||
api_key_env = "ANTHROPIC_API_KEY"
|
|
||||||
|
|
||||||
[worker]
|
[worker]
|
||||||
system_prompt = "You are a concise assistant. Reply in one or two sentences."
|
system_prompt = "You are a concise assistant. Reply in one or two sentences."
|
||||||
|
|
@ -41,7 +40,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let store = FsStore::new(tmp.path()).await?;
|
let store = FsStore::new(tmp.path()).await?;
|
||||||
|
|
||||||
// 3. Build the Pod from manifest
|
// 3. Build the Pod from manifest
|
||||||
let mut pod = Pod::from_manifest(manifest, store, None).await?;
|
let mut pod = Pod::from_manifest(manifest, store, None, None).await?;
|
||||||
println!("Session: {}", pod.session_id());
|
println!("Session: {}", pod.session_id());
|
||||||
|
|
||||||
// 4. Run a prompt
|
// 4. Run a prompt
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ name = "protocol-demo"
|
||||||
[provider]
|
[provider]
|
||||||
kind = "anthropic"
|
kind = "anthropic"
|
||||||
model = "claude-sonnet-4-20250514"
|
model = "claude-sonnet-4-20250514"
|
||||||
api_key_env = "ANTHROPIC_API_KEY"
|
|
||||||
|
|
||||||
[worker]
|
[worker]
|
||||||
system_prompt = "You are a concise assistant. Reply in one or two sentences."
|
system_prompt = "You are a concise assistant. Reply in one or two sentences."
|
||||||
|
|
@ -29,7 +28,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let manifest = PodManifest::from_toml(MANIFEST_TOML)?;
|
let manifest = PodManifest::from_toml(MANIFEST_TOML)?;
|
||||||
let tmp = tempfile::tempdir()?;
|
let tmp = tempfile::tempdir()?;
|
||||||
let store = FsStore::new(tmp.path()).await?;
|
let store = FsStore::new(tmp.path()).await?;
|
||||||
let pod = pod::Pod::from_manifest(manifest, store, None).await?;
|
let pod = pod::Pod::from_manifest(manifest, store, None, None).await?;
|
||||||
|
|
||||||
let runtime_tmp = tempfile::tempdir()?;
|
let runtime_tmp = tempfile::tempdir()?;
|
||||||
let handle = PodController::spawn(pod, runtime_tmp.path()).await?;
|
let handle = PodController::spawn(pod, runtime_tmp.path()).await?;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
@ -84,7 +84,10 @@ async fn main() -> ExitCode {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build the Pod
|
// Build the Pod
|
||||||
let pod = match Pod::from_manifest(manifest, store, scope).await {
|
let manifest_dir = std::fs::canonicalize(&cli.manifest)
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| p.parent().map(Path::to_path_buf));
|
||||||
|
let pod = match Pod::from_manifest(manifest, store, scope, manifest_dir).await {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("error: failed to create pod: {e}");
|
eprintln!("error: failed to create pod: {e}");
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use llm_worker::llm_client::client::LlmClient;
|
use llm_worker::llm_client::client::LlmClient;
|
||||||
|
|
@ -173,8 +174,9 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||||
manifest: PodManifest,
|
manifest: PodManifest,
|
||||||
store: St,
|
store: St,
|
||||||
scope: Option<Scope>,
|
scope: Option<Scope>,
|
||||||
|
manifest_dir: Option<PathBuf>,
|
||||||
) -> Result<Self, PodError> {
|
) -> Result<Self, PodError> {
|
||||||
let client = provider::build_client(&manifest.provider)?;
|
let client = provider::build_client(&manifest.provider, manifest_dir.as_deref())?;
|
||||||
let mut worker = Worker::new(client);
|
let mut worker = Worker::new(client);
|
||||||
apply_worker_manifest(&mut worker, &manifest.worker);
|
apply_worker_manifest(&mut worker, &manifest.worker);
|
||||||
let session = Session::new(worker, store, SessionConfig::default()).await?;
|
let session = Session::new(worker, store, SessionConfig::default()).await?;
|
||||||
|
|
|
||||||
|
|
@ -8,3 +8,7 @@ license.workspace = true
|
||||||
llm-worker = { version = "0.2.1", path = "../llm-worker" }
|
llm-worker = { version = "0.2.1", path = "../llm-worker" }
|
||||||
manifest = { version = "0.1.0", path = "../manifest" }
|
manifest = { version = "0.1.0", path = "../manifest" }
|
||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serial_test = "3.4.0"
|
||||||
|
tempfile = "3.27.0"
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use llm_worker::llm_client::client::LlmClient;
|
use llm_worker::llm_client::client::LlmClient;
|
||||||
use llm_worker::llm_client::providers::anthropic::AnthropicClient;
|
use llm_worker::llm_client::providers::anthropic::AnthropicClient;
|
||||||
use llm_worker::llm_client::providers::gemini::GeminiClient;
|
use llm_worker::llm_client::providers::gemini::GeminiClient;
|
||||||
|
|
@ -11,23 +13,79 @@ use manifest::{ProviderConfig, ProviderKind};
|
||||||
pub enum ProviderError {
|
pub enum ProviderError {
|
||||||
#[error("provider configuration error: {0}")]
|
#[error("provider configuration error: {0}")]
|
||||||
Config(String),
|
Config(String),
|
||||||
|
|
||||||
|
#[error("API key not provided for {provider}")]
|
||||||
|
ApiKeyMissing { provider: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the API key for the given provider configuration.
|
||||||
|
///
|
||||||
|
/// Resolution order:
|
||||||
|
/// 1. Environment variable `INSOMNIA_API_KEY_{KIND}`
|
||||||
|
/// 2. File specified by `api_key_file` (trimmed)
|
||||||
|
/// 3. `None`
|
||||||
|
fn resolve_api_key(
|
||||||
|
config: &ProviderConfig,
|
||||||
|
manifest_dir: Option<&Path>,
|
||||||
|
) -> Result<Option<String>, ProviderError> {
|
||||||
|
// 1. Convention-based environment variable
|
||||||
|
let env_name = config.kind.env_var_name();
|
||||||
|
if let Ok(val) = std::env::var(&env_name) {
|
||||||
|
return Ok(Some(val));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. File
|
||||||
|
if let Some(ref raw_path) = config.api_key_file {
|
||||||
|
let path = expand_key_path(raw_path, manifest_dir)?;
|
||||||
|
let contents = std::fs::read_to_string(&path).map_err(|e| {
|
||||||
|
ProviderError::Config(format!("failed to read api_key_file {}: {e}", path.display()))
|
||||||
|
})?;
|
||||||
|
return Ok(Some(contents.trim().to_owned()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expand `~` and resolve relative paths against `manifest_dir`.
|
||||||
|
fn expand_key_path(
|
||||||
|
raw: &Path,
|
||||||
|
manifest_dir: Option<&Path>,
|
||||||
|
) -> Result<PathBuf, ProviderError> {
|
||||||
|
let path = if raw.starts_with("~") {
|
||||||
|
let home = std::env::var("HOME")
|
||||||
|
.map_err(|_| ProviderError::Config("HOME is not set for ~ expansion".into()))?;
|
||||||
|
PathBuf::from(home).join(raw.strip_prefix("~").unwrap())
|
||||||
|
} else {
|
||||||
|
raw.to_path_buf()
|
||||||
|
};
|
||||||
|
|
||||||
|
if path.is_relative() {
|
||||||
|
match manifest_dir {
|
||||||
|
Some(dir) => Ok(dir.join(&path)),
|
||||||
|
None => Err(ProviderError::Config(format!(
|
||||||
|
"relative api_key_file '{}' requires a manifest directory",
|
||||||
|
path.display()
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build an [`LlmClient`] from a [`ProviderConfig`].
|
/// Build an [`LlmClient`] from a [`ProviderConfig`].
|
||||||
///
|
///
|
||||||
/// Resolves the API key from the environment variable specified in the config.
|
/// Resolves the API key from `INSOMNIA_API_KEY_{KIND}` env var or `api_key_file`.
|
||||||
pub fn build_client(config: &ProviderConfig) -> Result<Box<dyn LlmClient>, ProviderError> {
|
/// `manifest_dir` is used to resolve relative `api_key_file` paths.
|
||||||
let api_key = config
|
pub fn build_client(
|
||||||
.api_key_env
|
config: &ProviderConfig,
|
||||||
.as_deref()
|
manifest_dir: Option<&Path>,
|
||||||
.map(std::env::var)
|
) -> Result<Box<dyn LlmClient>, ProviderError> {
|
||||||
.transpose()
|
let api_key = resolve_api_key(config, manifest_dir)?;
|
||||||
.map_err(|e| ProviderError::Config(format!("env var: {e}")))?;
|
|
||||||
|
|
||||||
match config.kind {
|
match config.kind {
|
||||||
ProviderKind::Anthropic => {
|
ProviderKind::Anthropic => {
|
||||||
let key = api_key.ok_or_else(|| {
|
let key = api_key.ok_or_else(|| ProviderError::ApiKeyMissing {
|
||||||
ProviderError::Config("anthropic requires api_key_env".into())
|
provider: "anthropic".into(),
|
||||||
})?;
|
})?;
|
||||||
let mut client = AnthropicClient::new(key, &config.model);
|
let mut client = AnthropicClient::new(key, &config.model);
|
||||||
if let Some(ref url) = config.base_url {
|
if let Some(ref url) = config.base_url {
|
||||||
|
|
@ -36,8 +94,8 @@ pub fn build_client(config: &ProviderConfig) -> Result<Box<dyn LlmClient>, Provi
|
||||||
Ok(Box::new(client))
|
Ok(Box::new(client))
|
||||||
}
|
}
|
||||||
ProviderKind::Openai => {
|
ProviderKind::Openai => {
|
||||||
let key = api_key.ok_or_else(|| {
|
let key = api_key.ok_or_else(|| ProviderError::ApiKeyMissing {
|
||||||
ProviderError::Config("openai requires api_key_env".into())
|
provider: "openai".into(),
|
||||||
})?;
|
})?;
|
||||||
let mut client = OpenAIClient::new(key, &config.model);
|
let mut client = OpenAIClient::new(key, &config.model);
|
||||||
if let Some(ref url) = config.base_url {
|
if let Some(ref url) = config.base_url {
|
||||||
|
|
@ -46,8 +104,8 @@ pub fn build_client(config: &ProviderConfig) -> Result<Box<dyn LlmClient>, Provi
|
||||||
Ok(Box::new(client))
|
Ok(Box::new(client))
|
||||||
}
|
}
|
||||||
ProviderKind::Gemini => {
|
ProviderKind::Gemini => {
|
||||||
let key = api_key.ok_or_else(|| {
|
let key = api_key.ok_or_else(|| ProviderError::ApiKeyMissing {
|
||||||
ProviderError::Config("gemini requires api_key_env".into())
|
provider: "gemini".into(),
|
||||||
})?;
|
})?;
|
||||||
let mut client = GeminiClient::new(key, &config.model);
|
let mut client = GeminiClient::new(key, &config.model);
|
||||||
if let Some(ref url) = config.base_url {
|
if let Some(ref url) = config.base_url {
|
||||||
|
|
@ -64,3 +122,107 @@ pub fn build_client(config: &ProviderConfig) -> Result<Box<dyn LlmClient>, Provi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serial_test::serial;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
fn anthropic_config() -> ProviderConfig {
|
||||||
|
ProviderConfig {
|
||||||
|
kind: ProviderKind::Anthropic,
|
||||||
|
model: "test-model".into(),
|
||||||
|
api_key_file: None,
|
||||||
|
base_url: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn resolve_from_env() {
|
||||||
|
let env_name = ProviderKind::Anthropic.env_var_name();
|
||||||
|
unsafe { std::env::set_var(&env_name, "sk-from-env") };
|
||||||
|
let key = resolve_api_key(&anthropic_config(), None).unwrap();
|
||||||
|
unsafe { std::env::remove_var(&env_name) };
|
||||||
|
assert_eq!(key.as_deref(), Some("sk-from-env"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_from_file() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let key_path = dir.path().join("key.txt");
|
||||||
|
{
|
||||||
|
let mut f = std::fs::File::create(&key_path).unwrap();
|
||||||
|
write!(f, " sk-from-file\n").unwrap();
|
||||||
|
}
|
||||||
|
let config = ProviderConfig {
|
||||||
|
api_key_file: Some(key_path),
|
||||||
|
..anthropic_config()
|
||||||
|
};
|
||||||
|
let key = resolve_api_key(&config, None).unwrap();
|
||||||
|
assert_eq!(key.as_deref(), Some("sk-from-file"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 = ProviderKind::Anthropic.env_var_name();
|
||||||
|
unsafe { std::env::set_var(&env_name, "sk-from-env") };
|
||||||
|
|
||||||
|
let config = ProviderConfig {
|
||||||
|
api_key_file: Some(key_path),
|
||||||
|
..anthropic_config()
|
||||||
|
};
|
||||||
|
let key = resolve_api_key(&config, None).unwrap();
|
||||||
|
unsafe { std::env::remove_var(&env_name) };
|
||||||
|
assert_eq!(key.as_deref(), Some("sk-from-env"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_path_resolved_against_manifest_dir() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let key_path = dir.path().join("keys").join("anthropic");
|
||||||
|
std::fs::create_dir_all(key_path.parent().unwrap()).unwrap();
|
||||||
|
std::fs::write(&key_path, "sk-relative").unwrap();
|
||||||
|
|
||||||
|
let config = ProviderConfig {
|
||||||
|
api_key_file: Some(PathBuf::from("keys/anthropic")),
|
||||||
|
..anthropic_config()
|
||||||
|
};
|
||||||
|
let key = resolve_api_key(&config, Some(dir.path())).unwrap();
|
||||||
|
assert_eq!(key.as_deref(), Some("sk-relative"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_path_without_manifest_dir_errors() {
|
||||||
|
let config = ProviderConfig {
|
||||||
|
api_key_file: Some(PathBuf::from("keys/anthropic")),
|
||||||
|
..anthropic_config()
|
||||||
|
};
|
||||||
|
let err = resolve_api_key(&config, None).unwrap_err();
|
||||||
|
assert!(matches!(err, ProviderError::Config(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_key_returns_api_key_missing() {
|
||||||
|
let config = anthropic_config();
|
||||||
|
let result = build_client(&config, None);
|
||||||
|
assert!(matches!(result, Err(ProviderError::ApiKeyMissing { .. })));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ollama_succeeds_without_key() {
|
||||||
|
let config = ProviderConfig {
|
||||||
|
kind: ProviderKind::Ollama,
|
||||||
|
model: "llama3".into(),
|
||||||
|
api_key_file: None,
|
||||||
|
base_url: None,
|
||||||
|
};
|
||||||
|
assert!(build_client(&config, None).is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
# api_key_file: ファイルパスによるAPIキー解決
|
|
||||||
|
|
||||||
## 背景
|
|
||||||
|
|
||||||
現状、APIキーの取得手段は `api_key_env`(環境変数名の指定)のみ。
|
|
||||||
永続化やインタラクティブ入力の仕組みがなく、キー管理をユーザーのシェル環境に完全依存している。
|
|
||||||
|
|
||||||
## やること
|
|
||||||
|
|
||||||
マニフェストの `ProviderConfig` に `api_key_file: Option<PathBuf>` を追加し、ファイルからAPIキーを読み取れるようにする。
|
|
||||||
|
|
||||||
### マニフェスト
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[provider]
|
|
||||||
kind = "anthropic"
|
|
||||||
model = "claude-sonnet-4-20250514"
|
|
||||||
api_key_file = "~/.config/insomnia/keys/anthropic"
|
|
||||||
```
|
|
||||||
|
|
||||||
- ファイルにはキーのみを記載(読み込み時に trim)
|
|
||||||
- `~` 展開が必要
|
|
||||||
- 相対パスはマニフェストファイルの位置基準
|
|
||||||
|
|
||||||
### api_key_env との関係
|
|
||||||
|
|
||||||
- 排他。両方指定されたらエラー
|
|
||||||
- Ollama は両方不要のまま
|
|
||||||
|
|
||||||
### 変更箇所
|
|
||||||
|
|
||||||
1. **manifest**: `ProviderConfig` に `api_key_file: Option<PathBuf>` を追加
|
|
||||||
2. **provider**: `build_client()` でファイル読み取りロジックを追加。排他バリデーション
|
|
||||||
3. **provider**: `ProviderError` にキー不在を明示するバリアント追加(将来の TUI フォールバック用)
|
|
||||||
|
|
||||||
### 暗号化について
|
|
||||||
|
|
||||||
現段階では扱わない。ファイルパーミッション(0600)で十分。
|
|
||||||
将来エンドユーザー向けに暗号化が必要になった場合、provider の手前に復号レイヤーを挟む形で対応できる。`api_key_file` の設計自体は変更不要。
|
|
||||||
|
|
||||||
### 将来の拡張
|
|
||||||
|
|
||||||
- TUI サブコマンド(`insomnia key set anthropic` 等)がこのファイルに書き込むラッパーになる
|
|
||||||
- `api_key_cmd`(コマンド実行によるキー取得)は `api_key_file` で不足が生じた時点で検討
|
|
||||||
Loading…
Reference in New Issue
Block a user