llm-provider-catalog実装
This commit is contained in:
parent
e21d2041ef
commit
ccf1f2b6bf
|
|
@ -15,10 +15,10 @@ serde = { version = "1.0.228", features = ["derive"] }
|
|||
serde_json = "1.0.149"
|
||||
thiserror = "2.0"
|
||||
tokio = { version = "1.52.1", features = ["sync", "fs", "rt"] }
|
||||
toml = "1.1.2"
|
||||
tracing = "0.1.44"
|
||||
|
||||
[dev-dependencies]
|
||||
serial_test = "3.4.0"
|
||||
tempfile = "3.27.0"
|
||||
toml = "1.1.2"
|
||||
wiremock = "0.6.5"
|
||||
|
|
|
|||
30
crates/provider/assets/providers.toml
Normal file
30
crates/provider/assets/providers.toml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
[[provider]]
|
||||
id = "anthropic"
|
||||
display_name = "Anthropic"
|
||||
scheme = "anthropic"
|
||||
base_url = "https://api.anthropic.com"
|
||||
auth_hint = { kind = "api_key", env = "INSOMNIA_API_KEY_ANTHROPIC" }
|
||||
default_models = ["claude-sonnet-4-5", "claude-opus-4-1"]
|
||||
|
||||
[[provider]]
|
||||
id = "ollama-local"
|
||||
display_name = "Ollama (local)"
|
||||
scheme = "anthropic"
|
||||
base_url = "http://localhost:11434"
|
||||
auth_hint = { kind = "none" }
|
||||
default_models = ["llama3.1", "qwen2.5-coder"]
|
||||
|
||||
[[provider]]
|
||||
id = "codex-oauth"
|
||||
display_name = "ChatGPT (Codex OAuth)"
|
||||
scheme = "openai_responses"
|
||||
auth_hint = { kind = "codex_oauth" }
|
||||
default_models = ["gpt-5-codex", "gpt-5"]
|
||||
|
||||
[[provider]]
|
||||
id = "openrouter"
|
||||
display_name = "OpenRouter"
|
||||
scheme = "openai_chat"
|
||||
base_url = "https://openrouter.ai/api/v1"
|
||||
auth_hint = { kind = "api_key", env = "INSOMNIA_API_KEY_OPENROUTER" }
|
||||
default_models = ["anthropic/claude-sonnet-4", "openai/gpt-5"]
|
||||
316
crates/provider/src/catalog.rs
Normal file
316
crates/provider/src/catalog.rs
Normal file
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -100,3 +100,8 @@ Ollama `/api/tags` を叩いてモデル一覧を動的に取る機構は欲し
|
|||
- `crates/provider/src/`: カタログ読取 + `ProviderEntry` 型 + 変換関数を追加
|
||||
- `crates/provider/assets/providers.toml`: 新設(builtin カタログ)
|
||||
- 他クレートに型は露出するが、既存 API に破壊的変更は入れない
|
||||
|
||||
## Review
|
||||
- 状態: Approve
|
||||
- レビュー詳細: [./llm-provider-catalog.review.md](./llm-provider-catalog.review.md)
|
||||
- 日付: 2026-04-23
|
||||
|
|
|
|||
60
tickets/llm-provider-catalog.review.md
Normal file
60
tickets/llm-provider-catalog.review.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# Review: LLM プロバイダ/モデルカタログ
|
||||
|
||||
## 前提・要件の確認
|
||||
|
||||
### 要件 1: TOML 形式の定義(`[[provider]]` 配列、`auth_hint = { kind, env? }`)
|
||||
満たされている。
|
||||
- `crates/provider/assets/providers.toml` の 4 エントリがチケット例(`tickets/llm-provider-catalog.md:15-46`)と一字一句合致している(id / display_name / scheme / base_url / auth_hint.kind / env 名 / default_models の順序と値)。
|
||||
- `AuthHint` 側は `#[serde(tag = "kind", rename_all = "snake_case")]` でインラインテーブル `{ kind = "api_key", env = "..." }` を受け付ける(`crates/provider/src/catalog.rs:44-56`)。`kind = "codex_oauth"` はデフォルトの `snake_case` 変換が `codex_o_auth` になってしまう問題を `#[serde(rename = "codex_oauth")]` で明示的に回避しており、既存 `AuthRef::CodexOAuth`(`crates/manifest/src/model.rs:70`)と同じ書き方になっている。整合している。
|
||||
|
||||
### 要件 2: `Vec<ProviderEntry>` を返す読取 API を `crates/provider` に 1 本公開
|
||||
満たされている。
|
||||
- `pub fn load() -> Result<Vec<ProviderEntry>, CatalogError>` が主 API(`catalog.rs:108`)。補助として `load_builtin` / `load_from_path` も公開されており、後者はテストと将来の明示パス指定に合理的。
|
||||
- 配置は `crates/provider` 直下(`lib.rs:14` で `pub mod catalog;`)。`llm-worker` に下りていない。Memory の「llm-worker は低レベル基盤に留める」方針に整合。
|
||||
|
||||
### 要件 3: 配置パス(builtin の `include_str!` / user override 置換 / 両方読めなければ builtin)
|
||||
満たされている。
|
||||
- `BUILTIN_CATALOG: &str = include_str!("../assets/providers.toml")`(`catalog.rs:18`)。
|
||||
- user override は `$XDG_CONFIG_HOME/insomnia/providers.toml`、未設定時は `$HOME/.config/insomnia/providers.toml`(`catalog.rs:137-154`)。チケットでは `$HOME` フォールバックは明示されていないが、XDG Base Directory spec の既定挙動として妥当で、過剰とも言えない。
|
||||
- マージではなく置換(`catalog.rs:108-115`)。`load_prefers_override_over_builtin` / `load_falls_back_to_builtin_when_override_absent` テストで両経路をカバー。
|
||||
|
||||
### 要件 4: `ProviderEntry` + `model_id` → `ModelConfig` の変換
|
||||
満たされている。
|
||||
- `ProviderEntry::to_model_config(&self, model_id: impl Into<String>) -> ModelConfig`(`catalog.rs:82-99`)が `AuthHint` の 3 バリアントを `AuthRef` に 1:1 で写す。`capability` は常に `None` で返し、`build_client` 側の 3 段階 fallback(`ModelConfig.capability` → `capability::lookup` → `scheme default`、`lib.rs:120-124`)に委ねるのはチケット設計判断「`capability` 宣言をカタログに入れない」(`llm-provider-catalog.md:77-79`)と整合。
|
||||
- `to_model_config_maps_auth_hint` テスト(`catalog.rs:208-229`)で 3 バリアント全部を確認。
|
||||
|
||||
### 要件 5: 完了時の動作(builtin 取得 / override 置換 / UI 未実装段階で unit test e2e)
|
||||
満たされている。
|
||||
- `ollama_entry_builds_client`(`catalog.rs:231-239`)が「カタログ読取 → `ProviderEntry` 選択 → `ModelConfig` 生成 → `build_client` が成功」の経路を端から端まで通している。Ollama は `AuthRef::None` なので環境変数依存がなく、常に成功する選択として適切。
|
||||
- `cargo test -p provider` が 43 件全部 pass、ワークスペース `cargo build` もクリーン。
|
||||
|
||||
## アーキテクチャ・スコープ
|
||||
|
||||
- **レイヤ分離**: `crates/provider` 内に閉じ、`llm-worker` には漏れていない。`build_client` / `resolve_auth` の既存シグネチャは一切変更していない(`lib.rs:53-146` は今回の差分外)。非破壊性 OK。
|
||||
- **責務分離**: `AuthHint`(UI メタ)と `AuthRef`(ランタイム解決)を別型として定義し、`to_model_config` で 1:1 変換する設計は、チケット「設計判断: `auth_hint` と `AuthRef` の二重定義」(`llm-provider-catalog.md:73-75`)の意図を正確に反映している。型を流用する代替案もあり得たが、UI ヒントに `file: Option<PathBuf>` が紛れ込むとカタログのスコープが広がるため、別型にしたのは妥当。
|
||||
- **過剰抽象の有無**: `discover: Option<DiscoverMode>` のような先取り実装はされていない(コメントで言及のみ、`catalog.rs:60-61`)。チケットの「auto_discover は別チケット」方針に従っている。
|
||||
- **依存追加**: `toml` を dev-dependencies から regular dependencies に「移動」する形で `cargo add` 相当の編集がされている。手動編集ではあるが、既存行を move しただけなので Memory の `Use cargo add` 方針からの逸脱は軽微。
|
||||
- **モジュール分割**: `pod.rs` 等の既存 primary ファイルに詰め込まず、`catalog.rs` として独立モジュール化している。Memory の「feature モジュール分割」方針に合致。
|
||||
- **Scope 外の侵食**: UI / auto_discover / 編集 UI / プロファイルのどれにも触れていない。境界遵守。
|
||||
|
||||
## 指摘事項
|
||||
|
||||
### Non-blocking / Follow-up
|
||||
- `$HOME` フォールバック(`catalog.rs:143-152`)がチケット本文に明示されていない。妥当な挙動ではあるが、チケットの「配置パス」記述と実装の解像度に乖離があり、ユーザーが後から読んだとき驚きがある。ticket 側に一行追記するか、コード側のドキュメント(モジュール冒頭 doc)に「XDG 既定挙動として `$HOME/.config/...` にフォールバック」を明記しておくと親切。
|
||||
|
||||
### Nits
|
||||
- `catalog.rs:106` の doc コメントに "sigh" というタイポがある("silent" の意図と思われる)。該当箇所:
|
||||
|
||||
```
|
||||
/// 存在すれば builtin を置き換える。存在しなければ builtin のみ。
|
||||
/// user override が存在するが壊れている場合はエラーを返す(silent
|
||||
/// fallback はしない — ユーザーが書いた設定が sigh なく無視されて
|
||||
/// builtin に戻る挙動は気付きにくいため)。
|
||||
```
|
||||
|
||||
`sigh` → `silent` に直すか、日本語化で「気付かれず」等に差し替え。
|
||||
- `builtin_ollama_shape`(`catalog.rs:173-183`)の assert が `scheme == Anthropic` を確認しているが、コメントの「Ollama = Anthropic scheme」の意図は `lib.rs:278-290` の `ollama_succeeds_without_key` と重複する情報。コメントで相互参照しておくと将来の読み手に親切。
|
||||
|
||||
## 判断
|
||||
|
||||
**Approve(完了可)** — チケット要件 1〜5 は全て明確に満たされており、`AuthHint`/`AuthRef` の責務分離・レイヤ境界・既存 API 非破壊といったアーキテクチャ観点も適切。指摘は `sigh` タイポと `$HOME` フォールバックのドキュメント明示の 2 点のみで、どちらも blocking ではない。
|
||||
Loading…
Reference in New Issue
Block a user