KeiSeiKit-1.0/_primitives/_rust/kei-model/src/model.rs
Parfii-bot af46684330 feat(secrets+catalog): orphan-detector for env vars + image/video/voice models
Two parallel agents (both Sonnet 4.6 via the just-activated tier system)
extended the substrate-unified-registry. First end-to-end proof that the
Phase 4 router refactor saves money: no Opus spawns this round.

PART 1 — `kei-registry secrets` subcommand (Agent A — code-implementer)

Reads env-var NAMES from `~/.claude/secrets/.env` (RULE 0.8 SSoT) and
per-project `secrets/*.env`, greps the kit tree for usages, reports
orphans (defined but unreferenced). Live run on this kit found 26 keys,
11 ORPHAN — actionable cleanup candidates incl. GitHub OAuth client
creds, Godaddy keys, KeiGit admin creds, KEI_MEMORY_TOKEN.

Files:
- `_primitives/_rust/kei-registry/src/secrets.rs` (152 LOC) — pure
  read-side cube. SecretsReport + KeyRow types, env-file parser
  (KEY=value lines, validates `^[A-Z][A-Z0-9_]*$`), walkdir-based
  scanner with skips (target/ node_modules/ .git/ _generated/),
  word-boundary regex per key. ASCII + JSON render.
- `_primitives/_rust/kei-registry/src/secrets_tests.rs` (125 LOC) —
  5 unit tests covering env parse, scan correctness, word-boundary
  regression (`MY_KEY` ≠ `MY_KEY_EXTRA`), JSON roundtrip, ORPHAN marker.
- `_primitives/_rust/kei-registry/src/secrets_handler.rs` (58 LOC) —
  CLI dispatch handler.
- `cli.rs`, `handlers.rs`, `lib.rs` extended with Secrets variant.

Resolves the asymmetry called out in the design discussion: paths got
atomization (commit 3422bdc), keys get a query-layer instead. Reason:
env-var NAMES are already public and stable; opaque atom-DNA over them
adds zero security and full overhead. Orphan detection is the unique
value, and a 30-LOC subcommand delivers it without a per-key atom file.

PART 2 — kei-model catalog extension (Agent B — fal-ai-runner)

Adds 10 generation-model entries with VERIFIED pricing per RULE 0.4:
- google: gemini-3-1-flash-image, gemini-3-pro-image
- fal.ai: flux-2-pro, flux-pro-1-1, kling-o3, veo-3, ideogram-v3, recraft-v3
- elevenlabs: elevenlabs-v3, elevenlabs-multilingual-v2

Pricing sourced from each provider's public pricing page (URLs cited
per row in `notes` + `source_url` fields); 8/10 verified, 2 marked
needs-verification (gemini-3-pro-image price not found on public page).

Schema additions to `_primitives/_rust/kei-model/src/model.rs` to
support the new entries without `provider = "local"` placeholder:
- Provider enum + 3 variants: Google, Fal, Elevenlabs (with as_str
  + parse impls).
- Capability enum + 9 variants: image-gen, text-to-image, image-edit,
  video-gen, text-to-video, image-to-video, voice-gen, text-to-speech,
  voice-clone (with serde rename + as_str + parse).

Pricing struct unchanged: per-image / per-second / per-1k-chars unit
costs ride existing `output_per_mtok_micro` field with the unit
documented in `notes` (e.g. "Per-image cost. 1 unit = 1 image."). A
proper Pricing.unit field is a follow-up.

Files:
- `_primitives/_rust/kei-model/src/model.rs` (+24 LOC enum extensions)
- `_primitives/_rust/kei-model/data/models.toml` (+216 LOC, 471 total)

`kei-model list` returns the full 21-model catalog incl. new providers.

Tests:
- kei-registry: 25 passed (existing + 5 secrets tests + 10 status)
- kei-model: 0 (no unit tests in crate, parser smoke via list)
- agent-assembler: 29 passed (no regressions)

Verification (cited):
- `./target/release/kei-registry secrets --env-file ~/.claude/secrets/.env`
  emits real report 26/11 orphan.
- `./target/release/kei-model list` parses all 21 entries cleanly.
- `cargo build --release --workspace` clean.

What this does NOT do (deferred):
- Pricing.unit field (per-mtok / per-image / per-second / per-1k-chars
  discriminator) — needs Rust struct refactor + cost-estimator update.
- `secrets` skip-list extension (worktrees, _ts_packages/node_modules
  duplicate counts) — minor noise.
- gemini-3-pro-image pricing (no public page; vendor-specific quote
  needed).

=== STATUS-TRUTH MARKER ===
shipped: functional
stubs: 0
cargo-check: PASS
behaviour-verified: yes
follow-up-required:
  - Pricing.unit field for cost-estimator correctness on gen models
  - secrets scan: skip .claude/worktrees/ to avoid duplicate counts
  - gemini-3-pro-image price verification

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:06:16 +08:00

216 lines
7 KiB
Rust

//! Core types: `Model`, `Provider`, `Capability`, `Status`.
//!
//! Constructor Pattern: each enum is its own type with a single responsibility
//! (rendering / parsing / matching). Pricing lives in a sibling module so this
//! file stays focused on identity + capability shape.
use serde::{Deserialize, Serialize};
use crate::pricing::Pricing;
/// One row in the model catalog.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Model {
/// Canonical id, e.g. "claude-opus-4-7".
pub id: String,
pub provider: Provider,
pub display_name: String,
pub context_tokens: u32,
#[serde(default)]
pub capabilities: Vec<Capability>,
pub pricing: Pricing,
pub status: Status,
#[serde(default)]
pub role_tags: Vec<String>,
/// Next model_id in the fallback chain. Empty string = chain terminates.
#[serde(default)]
pub fallback: String,
#[serde(default)]
pub notes: Option<String>,
}
impl Model {
/// True if this model has every capability in `caps`.
pub fn has_all_caps(&self, caps: &[Capability]) -> bool {
caps.iter().all(|c| self.capabilities.contains(c))
}
/// True if `tag` matches any role tag (case-sensitive, exact match).
pub fn has_role(&self, tag: &str) -> bool {
self.role_tags.iter().any(|t| t == tag)
}
/// `Some(id)` if a non-empty fallback target is set, else `None`.
pub fn fallback_target(&self) -> Option<&str> {
if self.fallback.is_empty() {
None
} else {
Some(&self.fallback)
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "lowercase")]
pub enum Provider {
Anthropic,
Openai,
Kimi,
Mistral,
Deepseek,
Local,
/// Google: Gemini text-LLM family + image generation (nano-banana CLI
/// consumes Gemini 3.1 Flash Image / Gemini 3 Pro Image).
Google,
/// fal.ai — image / video / 3D generation aggregator. Hosts Flux,
/// Kling O3, Veo 3, Ideogram, Recraft, etc.
Fal,
/// ElevenLabs — text-to-speech and voice cloning.
Elevenlabs,
}
impl Provider {
pub fn as_str(&self) -> &'static str {
match self {
Provider::Anthropic => "anthropic",
Provider::Openai => "openai",
Provider::Kimi => "kimi",
Provider::Mistral => "mistral",
Provider::Deepseek => "deepseek",
Provider::Local => "local",
Provider::Google => "google",
Provider::Fal => "fal",
Provider::Elevenlabs => "elevenlabs",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"anthropic" => Some(Provider::Anthropic),
"openai" => Some(Provider::Openai),
"kimi" => Some(Provider::Kimi),
"mistral" => Some(Provider::Mistral),
"deepseek" => Some(Provider::Deepseek),
"local" => Some(Provider::Local),
"google" => Some(Provider::Google),
"fal" => Some(Provider::Fal),
"elevenlabs" => Some(Provider::Elevenlabs),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub enum Capability {
#[serde(rename = "code")]
Code,
#[serde(rename = "vision")]
Vision,
#[serde(rename = "streaming")]
Streaming,
#[serde(rename = "function-call")]
FunctionCall,
#[serde(rename = "long-context-200k")]
LongContext200k,
#[serde(rename = "long-context-1m")]
LongContext1m,
#[serde(rename = "system-prompt")]
SystemPrompt,
// Generation capabilities (image / video / voice). Pricing for these
// is per-image / per-second / per-1k-chars rather than per-mtok; the
// existing Pricing struct stores the unit price in
// `output_per_mtok_micro` and the unit semantics live in `notes`.
#[serde(rename = "image-gen")]
ImageGen,
#[serde(rename = "text-to-image")]
TextToImage,
#[serde(rename = "image-edit")]
ImageEdit,
#[serde(rename = "video-gen")]
VideoGen,
#[serde(rename = "text-to-video")]
TextToVideo,
#[serde(rename = "image-to-video")]
ImageToVideo,
#[serde(rename = "voice-gen")]
VoiceGen,
#[serde(rename = "text-to-speech")]
TextToSpeech,
#[serde(rename = "voice-clone")]
VoiceClone,
}
impl Capability {
pub fn as_str(&self) -> &'static str {
match self {
Capability::Code => "code",
Capability::Vision => "vision",
Capability::Streaming => "streaming",
Capability::FunctionCall => "function-call",
Capability::LongContext200k => "long-context-200k",
Capability::LongContext1m => "long-context-1m",
Capability::SystemPrompt => "system-prompt",
Capability::ImageGen => "image-gen",
Capability::TextToImage => "text-to-image",
Capability::ImageEdit => "image-edit",
Capability::VideoGen => "video-gen",
Capability::TextToVideo => "text-to-video",
Capability::ImageToVideo => "image-to-video",
Capability::VoiceGen => "voice-gen",
Capability::TextToSpeech => "text-to-speech",
Capability::VoiceClone => "voice-clone",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"code" => Some(Capability::Code),
"vision" => Some(Capability::Vision),
"streaming" => Some(Capability::Streaming),
"function-call" => Some(Capability::FunctionCall),
"long-context-200k" => Some(Capability::LongContext200k),
"long-context-1m" => Some(Capability::LongContext1m),
"system-prompt" => Some(Capability::SystemPrompt),
"image-gen" => Some(Capability::ImageGen),
"text-to-image" => Some(Capability::TextToImage),
"image-edit" => Some(Capability::ImageEdit),
"video-gen" => Some(Capability::VideoGen),
"text-to-video" => Some(Capability::TextToVideo),
"image-to-video" => Some(Capability::ImageToVideo),
"voice-gen" => Some(Capability::VoiceGen),
"text-to-speech" => Some(Capability::TextToSpeech),
"voice-clone" => Some(Capability::VoiceClone),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Active,
Deprecated,
Preview,
Beta,
}
impl Status {
pub fn as_str(&self) -> &'static str {
match self {
Status::Active => "active",
Status::Deprecated => "deprecated",
Status::Preview => "preview",
Status::Beta => "beta",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"active" => Some(Status::Active),
"deprecated" => Some(Status::Deprecated),
"preview" => Some(Status::Preview),
"beta" => Some(Status::Beta),
_ => None,
}
}
}