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>
216 lines
7 KiB
Rust
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,
|
|
}
|
|
}
|
|
}
|