- 3649
/// - 3650
/// This used to build descriptors a second time, by hand, and the two - 3651
/// constructions drifted: the same built-in tool came out stamped - 3652
/// `provenance: "vak-core"` here and `"builtin"` through the registry, - 3653
/// so which spelling a session recorded depended on which path admitted - 3654
/// it. That is the parallel-representation defect doc 41 exists to - 3655
/// remove, reintroduced one layer down. - 3656
/// - 3657
/// There is one construction now. `CapabilityProvider::declare` is the - 3658
/// single description of what exists, and both this and the registry - 3659
/// project from it. Note this is the *unresolved* view — anything that - 3660
/// needs probing is described but not yet proven usable — which is why - 3661
/// admission goes through [`Self::admitted_capabilities`] instead and - 3662
/// this remains only the synchronous fallback. - 3663
pub fn capability_descriptors(&self) -> Vec<CapabilityDescriptor> { - 3664
use crate::capability::registry::CapabilityProvider; - 3665
let mut out: Vec<CapabilityDescriptor> = self - 3666
.declare() - 3667
.into_iter() - 3668
.map(|declaration| capability::Capability { - 3669
id: declaration.id, - 3670
origin: declaration.origin, - 3671
summary: declaration.summary, - 3672
serves: declaration.serves, - 3673
digest: declaration.digest, - 3674
source: declaration.source, - 3675
// Unprobed: `Static` describes it without claiming a probe - 3676
// succeeded. Admission is what proves the rest. - 3677
resolution: capability::Resolution::Available, - 3678
configuration: declaration.configuration, - 3679
}) - 3680
.map(|capability| capability.to_descriptor()) - 3681
.collect(); - 3682
- 3683
// Strictly subtractive: `reach` never returns a capability that - 3684
// configuration did not already grant. - 3685
let standings = self.capability_standings(); - 3686
let unreachable_tools = reach::fully_blocked_tools(&standings); - 3687
let unreachable_servers = reach::blocked_mcp_servers(&standings); - 3688
let unreachable_skills = reach::blocked_skills(&standings); - 3689
out.retain(|capability| match capability.kind { - 3690
CapabilityKind::Tool => !unreachable_tools - 3691
.iter() - 3692
.any(|name| name == &capability.name), - 3693
CapabilityKind::McpServer => !unreachable_servers - 3694
.iter() - 3695
.any(|name| name == &capability.name), - 3696
CapabilityKind::Skill => !unreachable_skills - 3697
.iter() - 3698
.any(|name| name == &capability.name), - 3699
_ => true, - 3700
}); - 3701
out.sort_by(|a, b| { - 3702
format!("{:?}:{}", a.kind, a.name).cmp(&format!("{:?}:{}", b.kind, b.name)) - 3703
}); - 3704
out - 3705
} - 3706
- 3707
/// Every capability that was configured/discovered but excluded from - 3708
/// [`capability_descriptors`] — skill parse failures, reach-blocked - 3709
/// tools, channel-policy-filtered capabilities. The observability - 3710
/// counterpart: `capability_descriptors` says what a turn CAN do; - 3711
/// this says what it configured but CANNOT do, and why. - 3712
pub fn capability_diagnostics(&self) -> Vec<CapabilityDiagnostic> { - 3713
let mut out = Vec::new(); - 3714
- 3715
// 1. Skill parse failures. - 3716
let shared_root = self.shared_capability_root(); - 3717
let plugin_roots = self.enabled_plugin_skill_roots(); - 3718
let (_skills, skill_diags) = - 3719
skills::discover_with_diagnostics(&self.inner.cwd, &shared_root, &plugin_roots); - 3720
for diag in skill_diags { - 3721
out.push(CapabilityDiagnostic { - 3722
kind: "skill".into(), - 3723
name: diag - 3724
.path - 3725
.parent() - 3726
.and_then(|p| p.file_name()) - 3727
.map(|n| n.to_string_lossy().into_owned()) - 3728
.unwrap_or_else(|| diag.path.display().to_string()), - 3729
reason: diag.reason, - 3730
source: Some(diag.path.display().to_string()), - 3731
remedy: "fix the SKILL.md frontmatter (name must be lowercase kebab-case, \ - 3732
description must be present and non-empty)" - 3733
.into(), - 3734
// A skill that will not parse is broken, not chosen. - 3735
deliberate: false, - 3736
}); - 3737
} - 3738
- 3739
// 2. Reach-blocked capabilities (MCP servers, network tools). - 3740
let standings = self.capability_standings(); - 3741
for standing in &standings { - 3742
if standing.reach.is_blocked() { - 3743
out.push(CapabilityDiagnostic { - 3744
kind: if standing.tool == "mcp" { - 3745
"mcp-server".into() - 3746
} else if standing.tool == "skill" { - 3747
"skill".into() - 3748
} else { - 3749
"tool".into() - 3750
}, - 3751
name: standing.label.clone(), - 3752
reason: standing.reason.clone(), - 3753
source: None, - 3754
remedy: standing.remedy.clone(), - 3755
// Reach follows from permission mode, approval posture - 3756
// and channel policy — all chosen. The dedicated - 3757
// `capability reach` check already reports these, so - 3758
// this also stops `capability health` double-reporting. - 3759
deliberate: true, - 3760
}); - 3761
} - 3762
} - 3763
- 3764
// 3. Skills filtered by channel policy. - 3765
if let Some(policy) = self.channel_policy() { - 3766
let all_skills = - 3767
skills::discover_with_plugins(&self.inner.cwd, &shared_root, &plugin_roots); - 3768
for skill in &all_skills { - 3769
if !Self::allowed_by(&policy.skills_allow, &policy.skills_deny, &skill.name) { - 3770
out.push(CapabilityDiagnostic { - 3771
kind: "skill".into(), - 3772
name: skill.name.clone(), - 3773
reason: "blocked by channel policy (skills_deny or not in skills_allow)" - 3774
.into(), - 3775
source: Some(skill.path.display().to_string()), - 3776
remedy: "adjust the channel's skills_allow/skills_deny in the \ - 3777
gateway allowlist" - 3778
.into(), - 3779
// The channel allowlist is a policy the operator set. - 3780
deliberate: true, - 3781
}); - 3782
} - 3783
} - 3784
} - 3785
- 3786
// Enabled hooks whose definition cannot be read. A fail-closed one - 3787
// refuses what it guards rather than disappearing (capability::turn), - 3788
// so this is how an operator learns why tools are being refused. - 3789
for hook in self - 3790
.effective_hooks() - 3791
.into_iter() - 3792
.filter(|hook| hook.enabled) - 3793
{ - 3794
if let Err(reason) = hook_def(&hook) { - 3795
out.push(CapabilityDiagnostic { - 3796
kind: "hook".into(), - 3797
name: format!("{}/{}", hook.event, hook.command), - 3798
reason, - 3799
source: None, - 3800
remedy: "fix the hook's event, match rule, or failure_mode".into(), - 3801
deliberate: false, - 3802
}); - 3803
} - 3804
} - 3805
- 3806
// 4. Disabled hooks (present in config but enabled=false). - 3807
for hook in self - 3808
.effective_hooks() - 3809
.into_iter() - 3810
.filter(|hook| !hook.enabled) - 3811
{ - 3812
out.push(CapabilityDiagnostic { - 3813
kind: "hook".into(), - 3814
name: format!("{}/{}", hook.event, hook.command), - 3815
reason: "hook is disabled (enabled = false)".into(), - 3816
source: None, - 3817
remedy: "set enabled = true in .vak/config.toml or the admin console".into(), - 3818
// `enabled = false` is the operator saying so. - 3819
deliberate: true, - 3820
}); - 3821
} - 3822
- 3823
// 5. MCP servers whose last on-demand attempt failed, as the pool - 3824
// observed it. Operator-facing only (`source: None`): the server is - 3825
// still callable — the next demand retries after the pool's backoff — - 3826
// so it must not join the prompt's "NOT usable" list; the model sees - 3827
// the failure on the server's own line in the MCP section instead. - 3828
for capability in self.capability_registry().current_blocking().all() { - 3829
if let Some(reason) = capability::report::mcp_failure(capability) { - 3830
out.push(CapabilityDiagnostic { - 3831
kind: "mcp-server".into(), - 3832
name: capability.id.name.clone(), - 3833
reason: reason.to_string(), - 3834
source: None, - 3835
remedy: capability::report::mcp_remedy(&capability.id.name), - 3836
// A server that will not answer is broken, not chosen. - 3837
deliberate: false, - 3838
}); - 3839
} - 3840
} - 3841
- 3842
out - 3843
} - 3844
- 3845
fn provider_auth(&self) -> Result<ProviderAuth, CoreError> { - 3846
self.provider_auth_for(&self.effective_provider()) - 3847
} - 3848
- 3849
/// Resolve credentials for an arbitrary provider, not just the active - 3850
/// one — model discovery needs to authenticate against whichever - 3851
/// provider the user is inspecting. - 3852
fn provider_auth_for(&self, provider: &str) -> Result<ProviderAuth, CoreError> { - 3853
let provider = provider.to_string(); - 3854
let required_key = |env: &str, provider: &str| { - 3855
let primary = self.provider_secret(env).or_else(|| { - 3856
Self::provider_pool_env_var(provider).and_then(|pool_env| { - 3857
self.provider_secret(pool_env).and_then(|value| { - 3858
value - 3859
.split([',', '\n']) - 3860
.map(str::trim) - 3861
.find(|key| !key.is_empty()) - 3862
.map(str::to_string) - 3863
}) - 3864
}) - 3865
}); - 3866
primary - 3867
.filter(|key| !key.trim().is_empty()) - 3868
.map(|key| key.trim().to_string()) - 3869
.ok_or_else(|| CoreError::MissingAuth { - 3870
env: env.into(), - 3871
provider: provider.into(), - 3872
}) - 3873
}; - 3874
match provider.as_str() { - 3875
"anthropic" => { - 3876
let api_key = required_key("ANTHROPIC_API_KEY", "anthropic")?; - 3877
let base_url = self - 3878
.inner - 3879
.config - 3880
.anthropic_base_url - 3881
.clone() - 3882
.or_else(|| vak_config::get_var("VAK_ANTHROPIC_BASE_URL")); - 3883
Ok(ProviderAuth { - 3884
credential_id: Some(vak_llm::credential_id( - 3885
base_url - 3886
.as_deref() - 3887
.unwrap_or(vak_llm::anthropic::DEFAULT_BASE_URL), - 3888
&api_key, - 3889
)), - 3890
api_key, - 3891
base_url, - 3892
..Default::default() - 3893
}) - 3894
} - 3895
"google" => { - 3896
let api_key = self - 3897
.provider_secret("GEMINI_API_KEY") - 3898
.or_else(|| self.provider_secret("GOOGLE_API_KEY")) - 3899
.or_else(|| { - 3900
self.provider_secret("GEMINI_API_KEYS").and_then(|value| { - 3901
value - 3902
.split([',', '\n']) - 3903
.map(str::trim) - 3904
.find(|key| !key.is_empty()) - 3905
.map(str::to_string) - 3906
}) - 3907
}) - 3908
.filter(|key| !key.trim().is_empty()) - 3909
.map(|key| key.trim().to_string()) - 3910
.ok_or_else(|| CoreError::MissingAuth { - 3911
env: "GEMINI_API_KEY".into(), - 3912
provider: provider.clone(), - 3913
})?; - 3914
Ok(ProviderAuth { - 3915
credential_id: Some(vak_llm::credential_id( - 3916
&vak_config::get_var("VAK_GOOGLE_BASE_URL").unwrap_or_else(|| { - 3917
"https://generativelanguage.googleapis.com/v1beta".into() - 3918
}), - 3919
&api_key, - 3920
)), - 3921
api_key, - 3922
base_url: vak_config::get_var("VAK_GOOGLE_BASE_URL").or_else(|| { - 3923
Some("https://generativelanguage.googleapis.com/v1beta".into()) - 3924
}), - 3925
..Default::default() - 3926
}) - 3927
} - 3928
"openai-responses" => { - 3929
let api_key = required_key("OPENAI_API_KEY", "openai-responses")?; - 3930
Ok(ProviderAuth { - 3931
credential_id: Some(vak_llm::credential_id( - 3932
&vak_config::get_var("VAK_OPENAI_BASE_URL") - 3933
.unwrap_or_else(|| "https://api.openai.com/v1".into()), - 3934
&api_key, - 3935
)), - 3936
api_key, - 3937
base_url: vak_config::get_var("VAK_OPENAI_BASE_URL") - 3938
.or_else(|| Some("https://api.openai.com/v1".into())), - 3939
..Default::default() - 3940
}) - 3941
} - 3942
// get_var (not raw env) so user-level and project secret - 3943
// scopes authenticate these providers exactly like every other one. - 3944
"openai" | "openrouter" | "openrouter-responses" => { - 3945
let (env, default_base, override_env) = if provider == "openai" { - 3946
( - 3947
"OPENAI_API_KEY", - 3948
"https://api.openai.com/v1", - 3949
"VAK_OPENAI_BASE_URL", - 3950
) - 3951
} else { - 3952
( - 3953
"OPENROUTER_API_KEY", - 3954
"https://openrouter.ai/api/v1", - 3955
"VAK_OPENROUTER_BASE_URL", - 3956
) - 3957
}; - 3958
let api_key = required_key(env, &provider)?; - 3959
Ok(ProviderAuth { - 3960
credential_id: Some(vak_llm::credential_id( - 3961
&vak_config::get_var(override_env).unwrap_or_else(|| default_base.into()), - 3962
&api_key, - 3963
)), - 3964
api_key, - 3965
base_url: vak_config::get_var(override_env) - 3966
.or_else(|| Some(default_base.into())), - 3967
..Default::default() - 3968
}) - 3969
} - 3970
"opencode-zen" => { - 3971
let api_key = required_key("OPENCODE_API_KEY", "opencode-zen")?; - 3972
Ok(ProviderAuth { - 3973
credential_id: Some(vak_llm::credential_id( - 3974
&vak_config::get_var("VAK_OPENCODE_ZEN_BASE_URL") - 3975
.unwrap_or_else(|| "https://opencode.ai/zen/v1".into()), - 3976
&api_key, - 3977
)), - 3978
api_key, - 3979
base_url: vak_config::get_var("VAK_OPENCODE_ZEN_BASE_URL") - 3980
.or_else(|| Some("https://opencode.ai/zen/v1".into())), - 3981
..Default::default() - 3982
}) - 3983
} - 3984
"ollama" => { - 3985
// Threaded through generically (registry.rs::ProviderAuth::options) - 3986
// rather than a provider-specific auth variant, per invariant - 3987
// 17 (one configuration contract) and docs/design/68 §8. - 3988
let mut options = std::collections::BTreeMap::new(); - 3989
options.insert( - 3990
"keep_alive".to_string(), - 3991
self.inner.config.ollama.keep_alive.clone(), - 3992
); - 3993
if let Some(num_ctx) = self.inner.config.ollama.num_ctx { - 3994
options.insert("num_ctx".to_string(), num_ctx.to_string()); - 3995
} - 3996
Ok(ProviderAuth { - 3997
api_key: "ollama".into(), - 3998
base_url: vak_config::get_var("VAK_OLLAMA_BASE_URL") - 3999
.or_else(|| Some("http://localhost:11434/v1".into())), - 4000
credential_id: Some(vak_llm::credential_id( - 4001
&vak_config::get_var("VAK_OLLAMA_BASE_URL") - 4002
.unwrap_or_else(|| "http://localhost:11434/v1".into()), - 4003
"ollama", - 4004
)), - 4005
options, - 4006
}) - 4007
} - 4008
"bedrock" => { - 4009
let api_key = required_key("AWS_BEARER_TOKEN_BEDROCK", "bedrock")?; - 4010
let base_url = vak_config::get_var("VAK_BEDROCK_BASE_URL") - 4011
.or_else(|| Some("https://bedrock-mantle.us-east-1.api.aws/v1".into())); - 4012
Ok(ProviderAuth { - 4013
credential_id: Some(vak_llm::credential_id( - 4014
base_url.as_deref().unwrap_or_default(), - 4015
&api_key, - 4016
)), - 4017
api_key, - 4018
base_url, - 4019
..Default::default() - 4020
}) - 4021
} - 4022
other => Err(CoreError::MissingAuth { - 4023
env: format!("(no auth wiring for '{other}' yet)"), - 4024
provider: other.into(), - 4025
}), - 4026
} - 4027
} - 4028
- 4029
pub fn provider_pool_env_var(provider: &str) -> Option<&'static str> { - 4030
match provider { - 4031
"anthropic" => Some("ANTHROPIC_API_KEYS"), - 4032
"google" => Some("GEMINI_API_KEYS"), - 4033
"openai" | "openai-responses" => Some("OPENAI_API_KEYS"), - 4034
"openrouter" | "openrouter-responses" => Some("OPENROUTER_API_KEYS"), - 4035
"opencode-zen" => Some("OPENCODE_API_KEYS"), - 4036
"ollama" => None, - 4037
"bedrock" => Some("AWS_BEARER_TOKEN_BEDROCK"), - 4038
_ => None, - 4039
} - 4040
} - 4041
- 4042
/// Resolve all credentials configured for one provider. The singular - 4043
/// provider variable remains the primary; the plural companion is an - 4044
/// operator-managed secret value separated by commas or newlines. - 4045
/// Returned identities are stable fingerprints, never the credentials. - 4046
fn provider_auth_pool_for(&self, provider: &str) -> Result<Vec<ProviderAuth>, CoreError> { - 4047
let primary = self.provider_auth_for(provider)?; - 4048
let Some(pool_env) = Self::provider_pool_env_var(provider) else { - 4049
return Ok(vec![primary]); - 4050
}; - 4051
let mut keys = vec![primary.api_key.clone()]; - 4052
if let Some(value) = self.provider_secret(pool_env) { - 4053
keys.extend( - 4054
value - 4055
.split([',', '\n']) - 4056
.map(str::trim) - 4057
.filter(|key| !key.is_empty()) - 4058
.map(str::to_string), - 4059
); - 4060
} - 4061
let mut unique_keys = Vec::with_capacity(keys.len()); - 4062
for key in keys { - 4063
if !unique_keys.iter().any(|existing| existing == &key) { - 4064
unique_keys.push(key); - 4065
} - 4066
} - 4067
let base_url = primary.base_url.clone(); - 4068
Ok(unique_keys - 4069
.into_iter() - 4070
.map(|api_key| ProviderAuth { - 4071
credential_id: Some(vak_llm::credential_id( - 4072
base_url.as_deref().unwrap_or_default(), - 4073
&api_key, - 4074
)), - 4075
api_key, - 4076
base_url: base_url.clone(), - 4077
..Default::default() - 4078
}) - 4079
.collect()) - 4080
} - 4081
- 4082
fn provider_auth_for_leg( - 4083
&self, - 4084
provider: &str, - 4085
credential_id: Option<&str>, - 4086
) -> Result<ProviderAuth, CoreError> { - 4087
let pool = self.provider_auth_pool_for(provider)?; - 4088
if let Some(id) = credential_id { - 4089
return pool - 4090
.into_iter() - 4091
.find(|auth| auth.credential_id.as_deref() == Some(id)) - 4092
.ok_or_else(|| CoreError::MissingAuth { - 4093
env: format!("credential pool for {provider}"), - 4094
provider: provider.to_string(), - 4095
}); - 4096
} - 4097
pool.into_iter() - 4098
.next() - 4099
.ok_or_else(|| CoreError::MissingAuth { - 4100
env: format!("credential pool for {provider}"), - 4101
provider: provider.to_string(), - 4102
}) - 4103
} - 4104
- 4105
/// Return only non-secret identities in the configured provider pool. - 4106
/// This is safe for picker/admin surfaces and lets operators verify that - 4107
/// a plural pool variable was actually discovered. - 4108
pub fn provider_credential_ids(&self, provider: &str) -> Vec<String> { - 4109
self.provider_auth_pool_for(provider) - 4110
.unwrap_or_default() - 4111
.into_iter() - 4112
.filter_map(|auth| auth.credential_id) - 4113
.collect() - 4114
} - 4115
- 4116
pub fn provider(&self) -> Result<Arc<dyn Provider>, CoreError> { - 4117
if let Ok(p) = self.inner.provider_instance.lock() - 4118
&& let Some(provider) = p.as_ref() - 4119
{ - 4120
return Ok(provider.clone()); - 4121
} - 4122
let auth = self.provider_auth()?; - 4123
Ok(self.inner.registry.get(&self.effective_provider(), &auth)?) - 4124
} - 4125
- 4126
/// The env var that authenticates `provider`, or None for keyless - 4127
/// providers (ollama). Unknown providers yield None as well — callers - 4128
/// distinguish via `provider_known`. - 4129
pub fn provider_env_var(provider: &str) -> Option<&'static str> { - 4130
match provider { - 4131
"anthropic" => Some("ANTHROPIC_API_KEY"), - 4132
"google" => Some("GEMINI_API_KEY"), - 4133
"openai" | "openai-responses" => Some("OPENAI_API_KEY"), - 4134
"openrouter" | "openrouter-responses" => Some("OPENROUTER_API_KEY"), - 4135
"opencode-zen" => Some("OPENCODE_API_KEY"), - 4136
"bedrock" => Some("AWS_BEARER_TOKEN_BEDROCK"), - 4137
_ => None, - 4138
} - 4139
} - 4140
- 4141
/// The name a person knows `provider` by, for every everyday screen; - 4142
/// the id stays for configuration and technical views. This table is - 4143
/// also the set of known providers (`provider_known`), so a provider - 4144
/// cannot be added without a name. - 4145
pub fn provider_label(provider: &str) -> Option<&'static str> { - 4146
match provider { - 4147
"anthropic" => Some("Anthropic"), - 4148
"google" => Some("Google Gemini"), - 4149
"openai" => Some("OpenAI"), - 4150
"openai-responses" => Some("OpenAI (Responses API)"), - 4151
"openrouter" => Some("OpenRouter"), - 4152
"openrouter-responses" => Some("OpenRouter (Responses API)"), - 4153
"opencode-zen" => Some("OpenCode Zen"), - 4154
"bedrock" => Some("Amazon Bedrock"), - 4155
"ollama" => Some("Ollama"), - 4156
_ => None, - 4157
} - 4158
} - 4159
- 4160
pub fn provider_known(provider: &str) -> bool { - 4161
Self::provider_label(provider).is_some() - 4162
} - 4163
- 4164
/// True when a run on `provider` would find credentials right now. - 4165
pub fn provider_configured(&self, provider: &str) -> bool { - 4166
if self - 4167
.inner - 4168
.provider_instance - 4169
.lock() - 4170
.unwrap_or_else(std::sync::PoisonError::into_inner) - 4171
.is_some() - 4172
{ - 4173
return true; - 4174
} - 4175
self.provider_auth_for_leg(provider, None).is_ok() - 4176
} - 4177
- 4178
/// Secret provenance for administrative displays. Values are deliberately - 4179
/// reduced to booleans; credentials and their fingerprints never leave - 4180
/// the process through this API. - 4181
pub fn provider_key_sources(&self, provider: &str) -> (bool, bool, bool) { - 4182
let Some(env) = Self::provider_env_var(provider) else { - 4183
return (false, false, false); - 4184
}; - 4185
let project = vak_config::read_env_file_var(&self.inner.cwd.join(".env"), env).is_some(); - 4186
let agent = self.agent_identity.is_some() - 4187
&& vak_config::read_env_file_var(&self.sessions_home().join(".env"), env).is_some(); - 4188
let user = agent || vak_config::read_env_file_var(&self.user_env_file(), env).is_some(); - 4189
let process = std::env::var(env) - 4190
.ok() - 4191
.is_some_and(|v| !v.trim().is_empty()); - 4192
(project, user, process) - 4193
} - 4194
- 4195
/// Persists the Shared provider key. Prefer [`Self::set_provider_key_scoped`] - 4196
/// when the caller needs a project-local override. - 4197
pub fn set_provider_key(&self, provider: &str, key: &str) -> Result<String, CoreError> { - 4198
self.set_provider_key_scoped(provider, key, false) - 4199
} - 4200
- 4201
/// Persists a provider credential at Shared or project scope without - 4202
/// placing project credentials in the process-global environment. - 4203
pub fn set_provider_key_scoped( - 4204
&self, - 4205
provider: &str, - 4206
key: &str, - 4207
project: bool, - 4208
) -> Result<String, CoreError> { - 4209
let key = key.trim(); - 4210
if key.is_empty() { - 4211
return Err(CoreError::InvalidConfig("empty api key".into())); - 4212
} - 4213
let env = Self::provider_env_var(provider).ok_or_else(|| { - 4214
CoreError::InvalidConfig(format!( - 4215
"unknown provider '{provider}' (or it needs no key)" - 4216
)) - 4217
})?; - 4218
let path = if project { - 4219
self.inner.cwd.join(".env") - 4220
} else { - 4221
self.user_env_file() - 4222
}; - 4223
vak_config::upsert_env_file(&path, env, key) - 4224
.map_err(|e| CoreError::InvalidConfig(format!("writing {path:?}: {e}")))?; - 4225
// A different key reaches a different set of models, and any cached - 4226
// client still holds the old credential. - 4227
self.invalidate_models_cache(Some(provider)); - 4228
if let Ok(mut p) = self.inner.provider_instance.lock() { - 4229
*p = None; - 4230
} - 4231
Ok(env.to_string()) - 4232
} - 4233
- 4234
/// Removes the Shared provider key. Prefer - 4235
/// [`Self::remove_provider_key_scoped`] for a project-local override. - 4236
pub fn remove_provider_key(&self, provider: &str) -> Result<RemovedKey, CoreError> { - 4237
self.remove_provider_key_scoped(provider, false) - 4238
} - 4239
- 4240
/// Remove one provider-key layer. A removed project value resumes Shared - 4241
/// inheritance; process environment values remain outside Admin control. - 4242
pub fn remove_provider_key_scoped( - 4243
&self, - 4244
provider: &str, - 4245
project: bool, - 4246
) -> Result<RemovedKey, CoreError> { - 4247
let env = Self::provider_env_var(provider).ok_or_else(|| { - 4248
CoreError::InvalidConfig(format!( - 4249
"unknown provider '{provider}' (or it needs no key)" - 4250
)) - 4251
})?; - 4252
let path = if project { - 4253
self.inner.cwd.join(".env") - 4254
} else { - 4255
self.user_env_file() - 4256
}; - 4257
vak_config::remove_env_file_key(&path, env) - 4258
.map_err(|e| CoreError::InvalidConfig(format!("writing {path:?}: {e}")))?; - 4259
self.invalidate_models_cache(Some(provider)); - 4260
// Any cached client was built with the old key. - 4261
if let Ok(mut p) = self.inner.provider_instance.lock() { - 4262
*p = None; - 4263
} - 4264
Ok(RemovedKey { - 4265
env_var: env.to_string(), - 4266
// If it still resolves, it comes from the process environment. - 4267
shadowed_by_env: self.provider_secret(env).is_some(), - 4268
}) - 4269
} - 4270
- 4271
/// Store an MCP credential in the shared user secret file. The MCP config - 4272
/// should contain a `${VAR}` reference, never the credential itself. - 4273
pub fn set_mcp_secret(&self, env_var: &str, key: &str) -> Result<(), CoreError> { - 4274
self.set_mcp_secret_scoped(env_var, key, false) - 4275
} - 4276
- 4277
/// Persist an MCP credential at user or project scope. Project values are - 4278
/// deliberately not registered as process-global overrides: a gateway may - 4279
/// host several workspaces that use the same variable name with different - 4280
/// credentials. - 4281
pub fn set_mcp_secret_scoped( - 4282
&self, - 4283
env_var: &str, - 4284
key: &str, - 4285
project: bool, - 4286
) -> Result<(), CoreError> { - 4287
let env_var = env_var.trim(); - 4288
let key = key.trim(); - 4289
if env_var.is_empty() - 4290
|| !env_var - 4291
.chars() - 4292
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') - 4293
|| key.is_empty() - 4294
{ - 4295
return Err(CoreError::InvalidConfig("invalid MCP secret".into())); - 4296
} - 4297
let path = if project { - 4298
self.inner.cwd.join(".env") - 4299
} else { - 4300
self.user_env_file() - 4301
}; - 4302
vak_config::upsert_env_file(&path, env_var, key) - 4303
.map_err(|e| CoreError::InvalidConfig(format!("writing {path:?}: {e}")))?; - 4304
- 4305
self.invalidate_mcp_cache(); - 4306
// A changed secret changes the server set's fingerprint, so the next - 4307
// demand gets a fresh pool; the registry just needs to re-declare. - 4308
self.capability_registry() - 4309
.hint(capability::Hint::ConfigChanged); - 4310
Ok(()) - 4311
} - 4312
- 4313
/// Remove only the selected MCP secret layer. Removing a project value - 4314
/// resumes user/process inheritance; it never revokes the inherited key. - 4315
pub fn remove_mcp_secret_scoped(&self, env_var: &str, project: bool) -> Result<(), CoreError> { - 4316
let path = if project { - 4317
self.inner.cwd.join(".env") - 4318
} else { - 4319
self.user_env_file() - 4320
}; - 4321
vak_config::remove_env_file_key(&path, env_var) - 4322
.map_err(|e| CoreError::InvalidConfig(format!("writing {path:?}: {e}")))?; - 4323
- 4324
self.invalidate_mcp_cache(); - 4325
// A changed secret changes the server set's fingerprint, so the next - 4326
// demand gets a fresh pool; the registry just needs to re-declare. - 4327
self.capability_registry() - 4328
.hint(capability::Hint::ConfigChanged); - 4329
Ok(()) - 4330
} - 4331
- 4332
pub fn mcp_secret_at_scope(&self, env_var: &str, project: bool) -> bool { - 4333
let path = if project { - 4334
self.inner.cwd.join(".env") - 4335
} else { - 4336
self.user_env_file() - 4337
}; - 4338
vak_config::read_env_file_var(&path, env_var).is_some() - 4339
} - 4340
- 4341
pub fn mcp_secret(&self, env_var: &str) -> Option<String> { - 4342
self.scoped_secret(env_var) - 4343
} - 4344
- 4345
/// A distributed-bus secret (`vak_config::BUS_NATS_JWT_VAR`, - 4346
/// `BUS_NATS_NKEY_SEED_VAR`) through the secrets chain. - 4347
pub fn bus_secret(&self, env_var: &str) -> Option<String> { - 4348
self.scoped_secret(env_var) - 4349
} - 4350
- 4351
fn provider_secret(&self, env_var: &str) -> Option<String> { - 4352
self.scoped_secret(env_var) - 4353
.or_else(|| vak_config::get_var(env_var)) - 4354
} - 4355
- 4356
fn scoped_secret(&self, env_var: &str) -> Option<String> { - 4357
if self.agent_identity.is_some() - 4358
&& let Some(val) = - 4359
vak_config::read_env_file_var(&self.sessions_home().join(".env"), env_var) - 4360
{ - 4361
return Some(val); - 4362
} - 4363
vak_config::read_env_file_var(&self.inner.cwd.join(".env"), env_var) - 4364
.or_else(|| vak_config::read_env_file_var(&self.user_env_file(), env_var)) - 4365
.or_else(|| std::env::var(env_var).ok()) - 4366
} - 4367
- 4368
/// The chat transports a bot can be created on. - 4369
/// - 4370
/// A surface is a **transport, not a credential slot** (AGENTS.md - 4371
/// invariant 23): several bots can share one, each with its own token, - 4372
/// policy, permission mode, and route. The fixed per-surface env vars - 4373
/// this replaces (`TELEGRAM_BOT_TOKEN` and friends) could only ever - 4374
/// describe one bot per transport, which is why they are gone. - 4375
/// - 4376
/// **Alphabetical, and this is the only list.** Every surface — API, - 4377
/// admin console, desktop — renders from here rather than carrying its - 4378
/// own copy, so adding a transport is one edit and no channel can - 4379
/// quietly become the default by being first or by being the one a UI - 4380
/// happens to hardcode. - 4381
pub const SURFACES: &'static [ChatSurface] = &[ - 4382
ChatSurface { - 4383
id: "discord", - 4384
label: "Discord", - 4385
}, - 4386
ChatSurface { - 4387
id: "slack", - 4388
label: "Slack", - 4389
}, - 4390
ChatSurface { - 4391
id: "telegram", - 4392
label: "Telegram", - 4393
}, - 4394
]; - 4395
- 4396
/// True when `surface` names a transport vak can bridge. - 4397
pub fn is_surface(surface: &str) -> bool { - 4398
Self::SURFACES.iter().any(|s| s.id == surface) - 4399
} - 4400
- 4401
/// The human label for a surface id, falling back to the id itself so - 4402
/// an unknown value renders as data rather than as an empty cell. - 4403
pub fn surface_label(id: &str) -> &str { - 4404
Self::SURFACES - 4405
.iter() - 4406
.find(|s| s.id == id) - 4407
.map(|s| s.label) - 4408
.unwrap_or(id) - 4409
} - 4410
- 4411
/// Persist a bot's token into the Shared secret scope and - 4412
/// register a runtime override so an in-process check is correct right - 4413
/// away. `env` is the bot's own `token_env` (`BOT_TOKEN__<ID>`); the - 4414
/// bridge unit re-reads the credential store itself on restart. The - 4415
/// token never re-enters any response. - 4416
pub fn set_bot_token(&self, env: &str, token: &str) -> Result<String, CoreError> { - 4417
let token = token.trim(); - 4418
if token.is_empty() { - 4419
return Err(CoreError::InvalidConfig(format!("empty token for {env}"))); - 4420
} - 4421
let path = self.user_env_file(); - 4422
vak_config::upsert_env_file(&path, env, token) - 4423
.map_err(|e| CoreError::InvalidConfig(format!("writing {path:?}: {e}")))?; - 4424
vak_config::set_override(env, token); - 4425
Ok(env.to_string()) - 4426
} - 4427
- 4428
/// Revoke a bot's stored token: strip it from the user secret scope - 4429
/// and drop the runtime override. A token exported in the real environment - 4430
/// cannot be unset from here — the caller is told so it can say as much. - 4431
pub fn remove_bot_token(&self, env: &str) -> Result<RemovedKey, CoreError> { - 4432
let path = self.user_env_file(); - 4433
vak_config::remove_env_file_key(&path, env) - 4434
.map_err(|e| CoreError::InvalidConfig(format!("writing {path:?}: {e}")))?; - 4435
vak_config::clear_override(env); - 4436
vak_config::forget_dotenv_var(env); - 4437
Ok(RemovedKey { - 4438
env_var: env.to_string(), - 4439
shadowed_by_env: vak_config::get_var(env).is_some(), - 4440
}) - 4441
} - 4442
- 4443
/// Ask `provider` which models its configured key can actually reach. - 4444
/// - 4445
/// There is no baked-in catalogue: an out-of-date table silently hides - 4446
/// models a provider shipped yesterday and offers ones the key cannot - 4447
/// use. Results are cached briefly because pickers poll this. - 4448
pub async fn discover_models(&self, provider: &str) -> Result<Vec<String>, CoreError> { - 4449
const TTL: std::time::Duration = std::time::Duration::from_secs(300); - 4450
let pool = self.provider_auth_pool_for(provider)?; - 4451
let mut all = Vec::new(); - 4452
let mut last_error = None; - 4453
for auth in pool { - 4454
let key = ( - 4455
provider.to_string(), - 4456
auth.credential_id.clone().unwrap_or_default(), - 4457
); - 4458
if let Ok(cache) = self.inner.models_cache.lock() - 4459
&& let Some((at, models)) = cache.get(&key) - 4460
&& at.elapsed() < TTL - 4461
{ - 4462
all.extend(models.iter().cloned()); - 4463
continue; - 4464
} - 4465
match vak_llm::models::list_models(provider, &auth).await { - 4466
Ok(models) => { - 4467
all.extend(models.iter().cloned()); - 4468
if let Ok(mut cache) = self.inner.models_cache.lock() { - 4469
cache.insert(key, (std::time::Instant::now(), models)); - 4470
} - 4471
} - 4472
Err(error) => last_error = Some(error), - 4473
} - 4474
} - 4475
all.sort(); - 4476
all.dedup(); - 4477
if all.is_empty() - 4478
&& let Some(error) = last_error - 4479
{ - 4480
return Err(error.into()); - 4481
} - 4482
Ok(all) - 4483
} - 4484
- 4485
pub async fn bedrock_model_availability( - 4486
&self, - 4487
model_ids: &[String], - 4488
) -> Result<Vec<vak_llm::models::BedrockModelAvailability>, CoreError> { - 4489
let auth = self.provider_auth_for("bedrock")?; - 4490
Ok(vak_llm::models::bedrock_model_availability(&auth, model_ids).await?) - 4491
} - 4492
- 4493
/// Read provider-published account metadata without exposing credentials. - 4494
pub async fn provider_status( - 4495
&self, - 4496
provider: &str, - 4497
) -> Result<vak_llm::provider_status::ProviderStatus, CoreError> { - 4498
let auth = self.provider_auth_for(provider)?; - 4499
Ok(vak_llm::provider_status::inspect(provider, &auth).await?) - 4500
} - 4501
- 4502
/// Long TTL for provider-reported model metadata (context window, - 4503
/// output max): it changes only when the model itself does, so once a - 4504
/// value is cached a turn should virtually never wait on it again. - 4505
/// `route_context_limits` and `capacity_profile_for` are the only two - 4506
/// callers and now share this one cache and this one TTL. - 4507
const MODEL_METADATA_TTL: std::time::Duration = std::time::Duration::from_secs(3600); - 4508
- 4509
/// Provider metadata for `(provider, model, credential_id)`, served - 4510
/// from `self.inner.model_context_cache`. A fresh hit returns with no - 4511
/// I/O. A stale hit still returns immediately — the stale value — and - 4512
/// kicks off a single-flighted background refresh so the *next* call - 4513
/// sees a fresh one; the caller that found it stale never waits on the - 4514
/// network. Only a cold key (nothing cached yet) is fetched inline, - 4515
/// since there is nothing else to serve. - 4516
async fn model_context_cached( - 4517
&self, - 4518
provider: &str, - 4519
model: &str, - 4520
credential_id: Option<&str>, - 4521
) -> Option<vak_llm::models::ModelContext> { - 4522
let cache_key = ( - 4523
provider.to_string(), - 4524
model.to_string(), - 4525
credential_id.unwrap_or_default().to_string(), - 4526
); - 4527
let cached = self - 4528
.inner - 4529
.model_context_cache - 4530
.lock() - 4531
.ok() - 4532
.and_then(|cache| cache.get(&cache_key).cloned()); - 4533
if let Some((fetched_at, value)) = cached { - 4534
if fetched_at.elapsed() < Self::MODEL_METADATA_TTL { - 4535
return value; - 4536
} - 4537
let should_spawn = self - 4538
.inner - 4539
.model_context_refreshing - 4540
.lock() - 4541
.is_ok_and(|mut refreshing| refreshing.insert(cache_key.clone())); - 4542
if should_spawn { - 4543
let core = self.clone(); - 4544
let refresh_key = cache_key.clone(); - 4545
tokio::spawn(async move { - 4546
let (provider, model, credential_id) = refresh_key.clone(); - 4547
let fresh = match core.provider_auth_for_leg( - 4548
&provider, - 4549
(!credential_id.is_empty()).then_some(credential_id.as_str()), - 4550
) { - 4551
Ok(auth) => vak_llm::models::model_context(&provider, &auth, &model) - 4552
.await - 4553
.ok() - 4554
.flatten(), - 4555
Err(_) => None, - 4556
}; - 4557
if let Ok(mut cache) = core.inner.model_context_cache.lock() { - 4558
cache.insert(refresh_key.clone(), (std::time::Instant::now(), fresh)); - 4559
} - 4560
if let Ok(mut refreshing) = core.inner.model_context_refreshing.lock() { - 4561
refreshing.remove(&refresh_key); - 4562
} - 4563
}); - 4564
} - 4565
return value; - 4566
} - 4567
- 4568
// Cold: nothing cached yet, so this call is the one that populates it. - 4569
let value = match self.provider_auth_for_leg(provider, credential_id) { - 4570
Ok(auth) => vak_llm::models::model_context(provider, &auth, model) - 4571
.await - 4572
.ok() - 4573
.flatten(), - 4574
Err(_) => None, - 4575
}; - 4576
if let Ok(mut cache) = self.inner.model_context_cache.lock() { - 4577
cache.insert(cache_key, (std::time::Instant::now(), value.clone())); - 4578
} - 4579
value - 4580
} - 4581
- 4582
async fn route_context_limits( - 4583
&self, - 4584
primary: &vak_llm::RouteLeg, - 4585
fallback: &[vak_llm::RouteLeg], - 4586
) -> (u64, u64) { - 4587
let mut legs = Vec::with_capacity(1 + fallback.len()); - 4588
legs.push(primary.clone()); - 4589
legs.extend(fallback.iter().cloned()); - 4590
let mut context_window = self.inner.config.context_window; - 4591
let mut max_output = u64::from(self.inner.config.max_tokens); - 4592
for leg in legs { - 4593
let metadata = self - 4594
.model_context_cached(&leg.provider, &leg.model, leg.credential_id.as_deref()) - 4595
.await; - 4596
if let Some(metadata) = metadata { - 4597
context_window = context_window.min(metadata.input_tokens); - 4598
if let Some(output) = metadata.output_tokens { - 4599
max_output = max_output.min(output); - 4600
} - 4601
} - 4602
} - 4603
(context_window, max_output.min(context_window).max(1)) - 4604
} - 4605
- 4606
/// Whether `leg` reaches a runner on this machine: named `ollama`, or - 4607
/// its resolved credential's base URL host is loopback. Local models - 4608
/// are always probed in full (docs/design/68-context-engine.md §1: - 4609
/// "the probe is free apart from time, and time is exactly what it - 4610
/// saves"); a hosted model only gets the full horizon ladder when the - 4611
/// operator opts in via `[probe] hosted = "full"`. - 4612
fn is_local_provider(&self, leg: &vak_llm::RouteLeg) -> bool { - 4613
if leg.provider == "ollama" { - 4614
return true; - 4615
} - 4616
self.provider_auth_for_leg(&leg.provider, leg.credential_id.as_deref()) - 4617
.ok() - 4618
.and_then(|auth| auth.base_url) - 4619
.as_deref() - 4620
.and_then(url_host) - 4621
.is_some_and(is_loopback_host) - 4622
} - 4623
- 4624
/// A background probe on a key repeatedly interrupted by real turns is - 4625
/// spaced out rather than respawned on every single turn completion. - 4626
const CAPACITY_PROBE_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60); - 4627
/// Bounds any single probe rung request: a hung provider must not stall - 4628
/// the background probe indefinitely. - 4629
const PROBE_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); - 4630
/// A rung whose expected prefill time (its token target divided by the - 4631
/// measured prefill throughput so far) exceeds this is skipped rather - 4632
/// than sent — every larger rung would be even slower, so the ladder - 4633
/// stops there and reports what it already confirmed. - 4634
const PROBE_MAX_RUNG_SECS: f64 = 45.0; - 4635
- 4636
/// The measured capacity profile for `leg` (docs/design/68-context-engine.md - 4637
/// §1), bound immediately from what is already known — the ledger's or - 4638
/// process cache's last profile for this key (even if stale: stale - 4639
/// beats blocking), or a metadata-only profile on a cold key. Never - 4640
/// runs the horizon-ladder probe itself: that never belongs on a - 4641
/// turn's critical path. When the bound profile is missing, stale, or - 4642
/// `needs_reprobe`, this cancels any in-flight background probe for - 4643
/// the same key (a real turn now wants the model) and leaves it to - 4644
/// `maybe_start_capacity_probe`, called once this turn is done, to - 4645
/// (re)start the ladder in the background. A freshly bound profile not - 4646
/// already in this session's ledger is recorded as a `CapacityProbe` - 4647
/// activity — this is also how a session catches up on a profile a - 4648
/// background probe delivered since it last bound this key.
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.