1use std::collections::HashSet;
9use std::path::Path;
10
11use serde::{Deserialize, Serialize};
12
13use super::FoundationError;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct ProfilesFile {
18 pub schema_version: u32,
20 #[serde(default)]
22 pub profiles: Vec<Profile>,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct Profile {
29 pub id: String,
31 pub provider: String,
33 pub model: String,
35 #[serde(default)]
37 pub roles: Vec<String>,
38 pub credential: CredentialLocator,
40}
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct CredentialLocator {
43 pub service: String,
44 pub account: String,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct ResolvedProfile {
51 pub profile: Profile,
52 pub source: super::CredentialSource,
53}
54
55pub fn read(path: &Path) -> Result<ProfilesFile, FoundationError> {
57 let raw = std::fs::read_to_string(path)?;
58 let parsed: ProfilesFile = serde_json::from_str(&raw)?;
59 parsed.validate()?;
60 Ok(parsed)
61}
62
63impl ProfilesFile {
64 pub fn validate(&self) -> Result<(), FoundationError> {
66 if self.schema_version != 1 {
67 return Err(FoundationError::UnsupportedSchema(self.schema_version));
68 }
69 let mut seen = HashSet::new();
70 for p in &self.profiles {
71 if p.id.is_empty() {
72 return Err(FoundationError::Parse("profile id is empty".to_string()));
73 }
74 if !seen.insert(p.id.clone()) {
75 return Err(FoundationError::DuplicateProfileId(p.id.clone()));
76 }
77 if p.provider.is_empty() {
78 return Err(FoundationError::Parse(format!(
79 "profile {} has empty provider",
80 p.id
81 )));
82 }
83 if p.model.is_empty() {
84 return Err(FoundationError::Parse(format!(
85 "profile {} has empty model",
86 p.id
87 )));
88 }
89 if p.roles.is_empty() {
90 return Err(FoundationError::Parse(format!(
91 "profile {} has no roles",
92 p.id
93 )));
94 }
95 for r in &p.roles {
96 if r.is_empty() {
97 return Err(FoundationError::Parse(format!(
98 "profile {} has empty role",
99 p.id
100 )));
101 }
102 }
103 p.credential.validate()?;
104 check_secret_keys_absent(p)?;
105 }
106 Ok(())
107 }
108
109 pub fn find(&self, id: &str) -> Option<&Profile> {
111 self.profiles.iter().find(|p| p.id == id)
112 }
113
114 pub fn for_role(&self, role: &str) -> Vec<&Profile> {
116 self.profiles
117 .iter()
118 .filter(|p| p.roles.iter().any(|r| r == role))
119 .collect()
120 }
121}
122
123impl CredentialLocator {
124 pub fn validate(&self) -> Result<(), FoundationError> {
125 if self.service.is_empty() {
126 return Err(FoundationError::Parse(
127 "credential.service is empty".to_string(),
128 ));
129 }
130 if self.account.is_empty() {
131 return Err(FoundationError::Parse(
132 "credential.account is empty".to_string(),
133 ));
134 }
135 Ok(())
136 }
137}
138
139pub fn resolve_profile(input: ResolveInput<'_>) -> Result<ResolvedProfile, FoundationError> {
142 if let Some(env) = input.explicit_environment_override
144 && let Some(profile) = env_into_profile(env)
145 {
146 return Ok(ResolvedProfile {
147 profile,
148 source: super::CredentialSource::Environment,
149 });
150 }
151
152 if let Some(id) = input.explicit_profile {
154 let profile = input
155 .foundation_profiles
156 .find(id)
157 .ok_or_else(|| FoundationError::UnknownProfile(id.to_string()))?
158 .clone();
159 return Ok(ResolvedProfile {
160 profile,
161 source: super::CredentialSource::Profile,
162 });
163 }
164
165 if let Some(role) = input.requested_role {
167 let matches = input.foundation_profiles.for_role(role);
168 match matches.as_slice() {
169 [] => return Err(FoundationError::UnknownRole(role.to_string())),
170 [one] => {
171 return Ok(ResolvedProfile {
172 profile: (*one).clone(),
173 source: super::CredentialSource::Role,
174 });
175 }
176 _ => return Err(FoundationError::AmbiguousRole(role.to_string())),
177 }
178 }
179
180 if let Some(import) = input.compatibility_import {
182 return Ok(ResolvedProfile {
183 profile: import.profile.clone(),
184 source: super::CredentialSource::CompatibilityImport,
185 });
186 }
187
188 Err(FoundationError::UnknownProfile(
189 "no profile, role, environment override, or compatibility import provided".to_string(),
190 ))
191}
192
193#[derive(Debug, Clone)]
195pub struct ResolveInput<'a> {
196 pub explicit_profile: Option<&'a str>,
198 pub explicit_environment_override: Option<&'a EnvironmentOverride>,
200 pub requested_role: Option<&'a str>,
202 pub foundation_profiles: &'a ProfilesFile,
204 pub compatibility_import: Option<&'a CompatibilityImport>,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct EnvironmentOverride {
211 pub provider: String,
212 pub model: String,
213}
214
215impl EnvironmentOverride {
216 pub fn from_env() -> Option<Self> {
218 let provider = std::env::var("OXICODE_PROVIDER").ok()?;
219 let model = std::env::var("OXICODE_MODEL").ok()?;
220 if provider.trim().is_empty() || model.trim().is_empty() {
221 return None;
222 }
223 Some(Self {
224 provider: provider.trim().to_string(),
225 model: model.trim().to_string(),
226 })
227 }
228}
229
230#[derive(Debug, Clone)]
233pub struct CompatibilityImport {
234 pub profile: Profile,
235}
236
237fn env_into_profile(env: &EnvironmentOverride) -> Option<Profile> {
238 if env.provider.is_empty() || env.model.is_empty() {
239 return None;
240 }
241 Some(Profile {
242 id: "__env_override__".to_string(),
243 provider: env.provider.clone(),
244 model: env.model.clone(),
245 roles: vec![],
246 credential: CredentialLocator {
247 service: "__env__".to_string(),
251 account: "__env__".to_string(),
252 },
253 })
254}
255
256const SECRET_FIELD_NAMES: &[&str] = &[
259 "api_key",
260 "apikey",
261 "api-key",
262 "bearer_token",
263 "bearer",
264 "password",
265 "secret",
266 "secret_value",
267 "private_key",
268 "private-key",
269 "access_token",
270 "refresh_token",
271 "session_token",
272 "oauth_token",
273];
274
275fn check_secret_keys_absent(profile: &Profile) -> Result<(), FoundationError> {
276 let value = serde_json::to_value(profile).map_err(|e| FoundationError::Parse(e.to_string()))?;
281 if let Some(obj) = value.as_object() {
282 for k in obj.keys() {
283 let lower = k.to_ascii_lowercase();
284 if SECRET_FIELD_NAMES
285 .iter()
286 .any(|s| s.eq_ignore_ascii_case(&lower))
287 {
288 return Err(FoundationError::SecretNotAllowed(k.clone()));
289 }
290 }
291 }
292 Ok(())
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 fn pf(json: &str) -> ProfilesFile {
300 let f: ProfilesFile = serde_json::from_str(json).unwrap();
301 f.validate().unwrap();
302 f
303 }
304
305 fn profile(id: &str, provider: &str, model: &str, roles: &[&str]) -> Profile {
306 Profile {
307 id: id.to_string(),
308 provider: provider.to_string(),
309 model: model.to_string(),
310 roles: roles.iter().map(|s| s.to_string()).collect(),
311 credential: CredentialLocator {
312 service: "dev.oxi.foundation".to_string(),
313 account: id.to_string(),
314 },
315 }
316 }
317
318 #[test]
319 fn rejects_duplicate_profile_ids() {
320 let raw = r#"{
321 "schema_version": 1,
322 "profiles": [
323 {"id":"a","provider":"openai","model":"gpt-4o","roles":["x"],"credential":{"service":"s","account":"a"}},
324 {"id":"a","provider":"openai","model":"gpt-4","roles":["y"],"credential":{"service":"s","account":"a"}}
325 ]
326 }"#;
327 let err = serde_json::from_str::<ProfilesFile>(raw)
328 .unwrap()
329 .validate()
330 .unwrap_err();
331 assert!(matches!(err, FoundationError::DuplicateProfileId(id) if id == "a"));
332 }
333 #[test]
334 fn rejects_secret_in_profile() {
335 let raw = r#"{
341 "schema_version": 1,
342 "profiles": [
343 {"id":"a","provider":"openai","model":"gpt-4o","roles":["x"], "api_key":"sk-xxx",
344 "credential":{"service":"s","account":"a"}}
345 ]
346 }"#;
347 let result = serde_json::from_str::<ProfilesFile>(raw);
348 let err = match result {
349 Ok(file) => file.validate().unwrap_err(),
350 Err(e) => FoundationError::Parse(e.to_string()),
351 };
352 assert!(matches!(
353 err,
354 FoundationError::Parse(_) | FoundationError::SecretNotAllowed(_)
355 ));
356 }
357
358 #[test]
359 fn resolve_profile_prefers_env_override() {
360 let f = pf(r#"{"schema_version":1,"profiles":[
361 {"id":"x","provider":"openai","model":"gpt-4o","roles":["coding.primary"],
362 "credential":{"service":"s","account":"x"}}
363 ]}"#);
364 let env = EnvironmentOverride {
365 provider: "anthropic".to_string(),
366 model: "claude-sonnet".to_string(),
367 };
368 let resolved = resolve_profile(ResolveInput {
369 explicit_profile: Some("x"),
370 explicit_environment_override: Some(&env),
371 requested_role: None,
372 foundation_profiles: &f,
373 compatibility_import: None,
374 })
375 .unwrap();
376 assert_eq!(resolved.source, super::super::CredentialSource::Environment);
377 assert_eq!(resolved.profile.provider, "anthropic");
378 }
379
380 #[test]
381 fn resolve_profile_explicit_id() {
382 let f = pf(r#"{"schema_version":1,"profiles":[
383 {"id":"x","provider":"openai","model":"gpt-4o","roles":["coding.primary"],
384 "credential":{"service":"s","account":"x"}}
385 ]}"#);
386 let resolved = resolve_profile(ResolveInput {
387 explicit_profile: Some("x"),
388 explicit_environment_override: None,
389 requested_role: None,
390 foundation_profiles: &f,
391 compatibility_import: None,
392 })
393 .unwrap();
394 assert_eq!(resolved.profile.id, "x");
395 assert_eq!(resolved.source, super::super::CredentialSource::Profile);
396 }
397
398 #[test]
399 fn resolve_profile_unknown_id_is_error() {
400 let f = pf(r#"{"schema_version":1,"profiles":[
401 {"id":"x","provider":"openai","model":"gpt-4o","roles":["coding.primary"],
402 "credential":{"service":"s","account":"x"}}
403 ]}"#);
404 let err = resolve_profile(ResolveInput {
405 explicit_profile: Some("nope"),
406 explicit_environment_override: None,
407 requested_role: None,
408 foundation_profiles: &f,
409 compatibility_import: None,
410 })
411 .unwrap_err();
412 assert!(matches!(err, FoundationError::UnknownProfile(_)));
413 }
414
415 #[test]
416 fn resolve_profile_role_unique() {
417 let f = pf(r#"{"schema_version":1,"profiles":[
418 {"id":"a","provider":"openai","model":"gpt-4o","roles":["coding.primary"],
419 "credential":{"service":"s","account":"a"}}
420 ]}"#);
421 let resolved = resolve_profile(ResolveInput {
422 explicit_profile: None,
423 explicit_environment_override: None,
424 requested_role: Some("coding.primary"),
425 foundation_profiles: &f,
426 compatibility_import: None,
427 })
428 .unwrap();
429 assert_eq!(resolved.source, super::super::CredentialSource::Role);
430 }
431
432 #[test]
433 fn resolve_profile_role_ambiguous() {
434 let f = pf(r#"{"schema_version":1,"profiles":[
435 {"id":"a","provider":"openai","model":"gpt-4o","roles":["coding.primary"],
436 "credential":{"service":"s","account":"a"}},
437 {"id":"b","provider":"anthropic","model":"claude","roles":["coding.primary"],
438 "credential":{"service":"s","account":"b"}}
439 ]}"#);
440 let err = resolve_profile(ResolveInput {
441 explicit_profile: None,
442 explicit_environment_override: None,
443 requested_role: Some("coding.primary"),
444 foundation_profiles: &f,
445 compatibility_import: None,
446 })
447 .unwrap_err();
448 assert!(matches!(err, FoundationError::AmbiguousRole(_)));
449 }
450
451 #[test]
452 fn resolve_profile_role_unknown() {
453 let f = pf(r#"{"schema_version":1,"profiles":[
454 {"id":"a","provider":"openai","model":"gpt-4o","roles":["coding.primary"],
455 "credential":{"service":"s","account":"a"}}
456 ]}"#);
457 let err = resolve_profile(ResolveInput {
458 explicit_profile: None,
459 explicit_environment_override: None,
460 requested_role: Some("nonexistent"),
461 foundation_profiles: &f,
462 compatibility_import: None,
463 })
464 .unwrap_err();
465 assert!(matches!(err, FoundationError::UnknownRole(_)));
466 }
467
468 #[test]
469 fn resolve_profile_no_inputs_is_error() {
470 let f = pf(r#"{"schema_version":1,"profiles":[
471 {"id":"a","provider":"openai","model":"gpt-4o","roles":["coding.primary"],
472 "credential":{"service":"s","account":"a"}}
473 ]}"#);
474 let err = resolve_profile(ResolveInput {
475 explicit_profile: None,
476 explicit_environment_override: None,
477 requested_role: None,
478 foundation_profiles: &f,
479 compatibility_import: None,
480 })
481 .unwrap_err();
482 assert!(matches!(err, FoundationError::UnknownProfile(_)));
483 }
484
485 #[test]
486 fn resolve_profile_compatibility_import_only() {
487 let f = pf(r#"{"schema_version":1,"profiles":[]}"#);
488 let p = profile("legacy", "anthropic", "claude-sonnet", &["coding.primary"]);
489 let import = CompatibilityImport { profile: p.clone() };
490 let resolved = resolve_profile(ResolveInput {
491 explicit_profile: None,
492 explicit_environment_override: None,
493 requested_role: None,
494 foundation_profiles: &f,
495 compatibility_import: Some(&import),
496 })
497 .unwrap();
498 assert_eq!(
499 resolved.source,
500 super::super::CredentialSource::CompatibilityImport
501 );
502 assert_eq!(resolved.profile.id, "legacy");
503 }
504}