yoi/crates/provider/src/capability.rs

212 lines
7.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! `SchemeKind` × `model_id` → [`ModelCapability`] の既知モデル静的テーブル。
//!
//! このテーブルは「モデル ID の知識」であり、wire 実装(`llm-worker`)の
//! 責務ではなく高レベル構築層(`crates/provider`)の責務として置く。
//! llm-worker の scheme には `default_capability()` のみ残し、未知モデル
//! 時は scheme 既定にフォールバックする。
//!
//! 解決順(`build_client` が呼ぶ):
//! 1. `ModelConfig.capability` 明示指定
//! 2. [`lookup`] (本モジュール)
//! 3. `Scheme::default_capability()` (llm-worker)
use llm_worker::llm_client::capability::{
CacheStrategy, ModelCapability, ReasoningSupport, StructuredOutput, ToolCallingSupport,
};
use manifest::SchemeKind;
/// `scheme` と `model_id` から既知モデルの capability を返す。
/// 未知なら `None`(呼び出し側が `Scheme::default_capability()` へフォールバック)。
pub fn lookup(scheme: SchemeKind, model_id: &str) -> Option<ModelCapability> {
match scheme {
SchemeKind::Anthropic => anthropic_lookup(model_id),
SchemeKind::OpenaiChat => openai_chat_lookup(model_id),
SchemeKind::OpenaiResponses => openai_responses_lookup(model_id),
SchemeKind::Gemini => gemini_lookup(model_id),
}
}
// --- Anthropic --------------------------------------------------------------
fn anthropic_lookup(model_id: &str) -> Option<ModelCapability> {
if !model_id.starts_with("claude-") {
return None;
}
Some(ModelCapability {
tool_calling: ToolCallingSupport::Parallel,
structured_output: StructuredOutput::JsonSchema,
reasoning: Some(ReasoningSupport::BudgetTokens),
vision: true,
prompt_caching: CacheStrategy::Explicit { max_breakpoints: 4 },
})
}
// --- OpenAI (chat / responses で共有の family 判定) --------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OpenAiFamily {
/// GPT-5 / o1 / o3 / o4 系 — reasoning 対応
Reasoning,
/// GPT-4o / GPT-4 系
Gpt4,
/// GPT-3.5 系(旧式)
Gpt35,
}
fn openai_classify(model_id: &str) -> Option<OpenAiFamily> {
if model_id.starts_with("gpt-5")
|| model_id.starts_with("o1")
|| model_id.starts_with("o3")
|| model_id.starts_with("o4")
{
return Some(OpenAiFamily::Reasoning);
}
if model_id.starts_with("gpt-4") {
return Some(OpenAiFamily::Gpt4);
}
if model_id.starts_with("gpt-3.5") {
return Some(OpenAiFamily::Gpt35);
}
None
}
fn openai_chat_lookup(model_id: &str) -> Option<ModelCapability> {
openai_classify(model_id).map(|family| match family {
OpenAiFamily::Reasoning => ModelCapability {
tool_calling: ToolCallingSupport::Parallel,
structured_output: StructuredOutput::JsonSchema,
reasoning: Some(ReasoningSupport::Effort),
vision: true,
prompt_caching: CacheStrategy::Auto,
},
OpenAiFamily::Gpt4 => ModelCapability {
tool_calling: ToolCallingSupport::Parallel,
structured_output: StructuredOutput::JsonSchema,
reasoning: None,
vision: true,
prompt_caching: CacheStrategy::Auto,
},
OpenAiFamily::Gpt35 => ModelCapability {
tool_calling: ToolCallingSupport::Parallel,
structured_output: StructuredOutput::JsonObject,
reasoning: None,
vision: false,
prompt_caching: CacheStrategy::Auto,
},
})
}
fn openai_responses_lookup(model_id: &str) -> Option<ModelCapability> {
// `codex-` prefix は ChatGPT backend 経由(CodexOAuth)でのみ使える
// Reasoning モデル family。`gpt-5` 系と同じ扱い。
let family = openai_classify(model_id).or_else(|| {
if model_id.starts_with("codex-") {
Some(OpenAiFamily::Reasoning)
} else {
None
}
})?;
Some(match family {
OpenAiFamily::Reasoning => ModelCapability {
tool_calling: ToolCallingSupport::Parallel,
structured_output: StructuredOutput::JsonSchema,
reasoning: Some(ReasoningSupport::Effort),
vision: true,
prompt_caching: CacheStrategy::Auto,
},
OpenAiFamily::Gpt4 => ModelCapability {
tool_calling: ToolCallingSupport::Parallel,
structured_output: StructuredOutput::JsonSchema,
reasoning: None,
vision: true,
prompt_caching: CacheStrategy::Auto,
},
OpenAiFamily::Gpt35 => ModelCapability {
tool_calling: ToolCallingSupport::Parallel,
structured_output: StructuredOutput::JsonObject,
reasoning: None,
vision: false,
prompt_caching: CacheStrategy::Auto,
},
})
}
// --- Gemini -----------------------------------------------------------------
fn gemini_lookup(model_id: &str) -> Option<ModelCapability> {
if !model_id.starts_with("gemini-") {
return None;
}
// 2.5 系以降は thinking / reasoning を持つ
let reasoning = if model_id.starts_with("gemini-2.5") || model_id.starts_with("gemini-3") {
Some(ReasoningSupport::BudgetTokens)
} else {
None
};
Some(ModelCapability {
tool_calling: ToolCallingSupport::Parallel,
structured_output: StructuredOutput::JsonSchema,
reasoning,
vision: true,
prompt_caching: CacheStrategy::Auto,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn anthropic_known_claude() {
let cap = lookup(SchemeKind::Anthropic, "claude-sonnet-4-6").unwrap();
assert!(matches!(
cap.prompt_caching,
CacheStrategy::Explicit { max_breakpoints: 4 }
));
assert!(cap.reasoning.is_some());
}
#[test]
fn anthropic_unknown_is_none() {
assert!(lookup(SchemeKind::Anthropic, "llama3").is_none());
}
#[test]
fn openai_chat_gpt5_is_reasoning() {
let cap = lookup(SchemeKind::OpenaiChat, "gpt-5-xyz").unwrap();
assert!(cap.reasoning.is_some());
}
#[test]
fn openai_responses_codex_prefix_is_reasoning() {
let cap = lookup(SchemeKind::OpenaiResponses, "codex-mini-latest").unwrap();
assert!(cap.reasoning.is_some());
}
#[test]
fn openai_responses_gpt5_codex_is_reasoning() {
// gpt-5 prefix 経由で classify される
let cap = lookup(SchemeKind::OpenaiResponses, "gpt-5-codex").unwrap();
assert!(cap.reasoning.is_some());
}
#[test]
fn gemini_25_has_reasoning() {
let cap = lookup(SchemeKind::Gemini, "gemini-2.5-pro").unwrap();
assert!(matches!(cap.reasoning, Some(ReasoningSupport::BudgetTokens)));
}
#[test]
fn gemini_legacy_has_no_reasoning() {
let cap = lookup(SchemeKind::Gemini, "gemini-1.5-pro").unwrap();
assert!(cap.reasoning.is_none());
}
#[test]
fn unknown_model_is_none_across_schemes() {
assert!(lookup(SchemeKind::OpenaiChat, "foo-bar").is_none());
assert!(lookup(SchemeKind::OpenaiResponses, "foo-bar").is_none());
assert!(lookup(SchemeKind::Gemini, "foo-bar").is_none());
}
}