1use std::{
8 collections::HashMap,
9 io::ErrorKind,
10 path::{Path, PathBuf},
11 sync::Arc,
12};
13
14use anyhow::{Context, ensure};
15use serde_json::Value;
16use sha2::{Digest, Sha256};
17use tokio::{fs, process::Command, sync::OnceCell};
18
19pub const DEFAULT_CODEX_EXECUTABLE: &str = "codex-safe";
21
22const CACHE_SCHEMA: &str = "kcode-codex-catalog-v1";
23const CACHE_DIRECTORY: &str = "kcode-codex-catalogs";
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub struct ModelLimits {
28 pub context_window_tokens: u64,
30 pub max_input_tokens: u64,
32}
33
34impl ModelLimits {
35 pub fn context_window_tokens(self) -> u64 {
37 self.context_window_tokens
38 }
39
40 pub fn max_input_tokens(self) -> u64 {
42 self.max_input_tokens
43 }
44}
45
46#[derive(Clone, Debug)]
48pub struct Catalog {
49 executable: Arc<str>,
50 path: Arc<PathBuf>,
51 limits: Arc<HashMap<String, ModelLimits>>,
52 cache_key: Arc<str>,
53 cache_directory: Arc<PathBuf>,
54}
55
56impl Catalog {
57 pub fn executable(&self) -> &str {
59 &self.executable
60 }
61
62 pub fn path(&self) -> &Path {
64 &self.path
65 }
66
67 pub fn model_limits(&self, model: &str) -> Option<ModelLimits> {
69 self.limits.get(model).copied()
70 }
71
72 pub async fn validation_is_cached(&self, scope: &str) -> anyhow::Result<bool> {
78 let path = self.validation_path(scope);
79 let expected = self.validation_record(scope);
80 match fs::read(&path).await {
81 Ok(actual) => Ok(actual == expected.as_bytes()),
82 Err(error) if error.kind() == ErrorKind::NotFound => Ok(false),
83 Err(error) => Err(error)
84 .with_context(|| format!("reading Codex validation cache {}", path.display())),
85 }
86 }
87
88 pub async fn cache_validation(&self, scope: &str) -> anyhow::Result<()> {
90 fs::create_dir_all(self.cache_directory.as_ref())
91 .await
92 .with_context(|| {
93 format!(
94 "creating Codex cache directory {}",
95 self.cache_directory.display()
96 )
97 })?;
98 let path = self.validation_path(scope);
99 let temporary = path.with_extension(format!("tmp-{}", std::process::id()));
100 fs::write(&temporary, self.validation_record(scope))
101 .await
102 .with_context(|| {
103 format!(
104 "writing temporary Codex validation cache {}",
105 temporary.display()
106 )
107 })?;
108 fs::rename(&temporary, &path).await.with_context(|| {
109 format!(
110 "publishing Codex validation cache {} as {}",
111 temporary.display(),
112 path.display()
113 )
114 })?;
115 Ok(())
116 }
117
118 fn validation_path(&self, scope: &str) -> PathBuf {
119 let key = digest(&[self.cache_key.as_bytes(), scope.as_bytes()]);
120 self.cache_directory.join(format!("validated-{key}"))
121 }
122
123 fn validation_record(&self, scope: &str) -> String {
124 format!("{}\n{scope}\n", self.cache_key)
125 }
126}
127
128#[derive(Clone, Debug)]
130pub struct CatalogCache {
131 executable: Arc<str>,
132 cache_directory: Arc<PathBuf>,
133 catalog: Arc<OnceCell<Catalog>>,
134}
135
136impl CatalogCache {
137 pub fn new(executable: impl Into<String>) -> Self {
139 let cache_directory = std::env::var_os("CODEX_SAFE_CATALOG_DIR")
140 .map(PathBuf::from)
141 .unwrap_or_else(|| std::env::temp_dir().join(CACHE_DIRECTORY));
142 Self::with_directory(executable, cache_directory)
143 }
144
145 pub fn with_directory(
147 executable: impl Into<String>,
148 cache_directory: impl Into<PathBuf>,
149 ) -> Self {
150 Self {
151 executable: Arc::from(executable.into()),
152 cache_directory: Arc::new(cache_directory.into()),
153 catalog: Arc::new(OnceCell::new()),
154 }
155 }
156
157 pub async fn load(&self) -> anyhow::Result<Catalog> {
159 let executable = self.executable.clone();
160 let cache_directory = self.cache_directory.clone();
161 let catalog = self
162 .catalog
163 .get_or_try_init(|| async move { load_catalog(executable, cache_directory).await })
164 .await?;
165 Ok(catalog.clone())
166 }
167}
168
169pub fn model_catalog_config(path: &Path) -> String {
171 format!(
172 "model_catalog_json={}",
173 serde_json::to_string(path.to_string_lossy().as_ref())
174 .expect("serializing a path string cannot fail")
175 )
176}
177
178async fn load_catalog(
179 executable: Arc<str>,
180 cache_directory: Arc<PathBuf>,
181) -> anyhow::Result<Catalog> {
182 fs::create_dir_all(cache_directory.as_ref())
183 .await
184 .with_context(|| format!("creating Codex catalog cache {}", cache_directory.display()))?;
185 let identity = codex_identity(&executable, &cache_directory).await?;
186 let cache_key = digest(&[
187 CACHE_SCHEMA.as_bytes(),
188 executable.as_bytes(),
189 identity.as_bytes(),
190 ]);
191 let path = cache_directory.join(format!("models-{cache_key}.json"));
192
193 match fs::read(&path).await {
194 Ok(cached) => match verified_limits(&cached) {
195 Ok(limits) => {
196 tracing::info!(path=%path.display(), "Using cached sanitized Codex model catalog");
197 return Ok(Catalog {
198 executable,
199 path: Arc::new(path),
200 limits: Arc::new(limits),
201 cache_key: Arc::from(cache_key),
202 cache_directory,
203 });
204 }
205 Err(error) => {
206 tracing::warn!(path=%path.display(), %error, "Ignoring invalid cached Codex model catalog");
207 }
208 },
209 Err(error) if error.kind() == ErrorKind::NotFound => {}
210 Err(error) => {
211 return Err(error)
212 .with_context(|| format!("reading Codex catalog cache {}", path.display()));
213 }
214 }
215
216 let source = Command::new(executable.as_ref())
217 .args(["debug", "models"])
218 .env("CODEX_SAFE_CATALOG_DIR", cache_directory.as_os_str())
219 .env_remove("OPENAI_API_KEY")
220 .env_remove("CODEX_API_KEY")
221 .output()
222 .await
223 .with_context(|| format!("discovering Codex models through '{executable}'"))?;
224 ensure!(
225 source.status.success(),
226 "Codex model discovery failed through {executable}"
227 );
228 let source_limits = parse_model_limits(&source.stdout)?;
229 let sanitized = sanitize_catalog(&source.stdout)?;
230 let sanitized_limits = verified_limits(&sanitized)?;
231 ensure!(
232 sanitized_limits == source_limits,
233 "sanitized Codex catalog changed advertised model limits"
234 );
235
236 let temporary = path.with_extension(format!("tmp-{}.json", std::process::id()));
237 fs::write(&temporary, &sanitized).await.with_context(|| {
238 format!(
239 "writing temporary sanitized Codex catalog {}",
240 temporary.display()
241 )
242 })?;
243 let probe = Command::new(executable.as_ref())
244 .arg("-c")
245 .arg(model_catalog_config(&temporary))
246 .args(["debug", "models"])
247 .env("CODEX_SAFE_CATALOG_DIR", cache_directory.as_os_str())
248 .env_remove("OPENAI_API_KEY")
249 .env_remove("CODEX_API_KEY")
250 .output()
251 .await
252 .with_context(|| format!("probing sanitized Codex catalog through '{executable}'"))?;
253 let verification = (|| -> anyhow::Result<()> {
254 ensure!(
255 probe.status.success(),
256 "{executable} cannot read {} inside its sandbox",
257 temporary.display()
258 );
259 ensure!(
260 verified_limits(&probe.stdout)? == source_limits,
261 "Codex changed model limits while loading the sanitized catalog"
262 );
263 Ok(())
264 })();
265 if let Err(error) = verification {
266 let _ = fs::remove_file(&temporary).await;
267 return Err(error);
268 }
269 fs::rename(&temporary, &path).await.with_context(|| {
270 format!(
271 "publishing sanitized Codex catalog {} as {}",
272 temporary.display(),
273 path.display()
274 )
275 })?;
276 tracing::info!(path=%path.display(), "Discovered and cached sanitized Codex model catalog");
277 Ok(Catalog {
278 executable,
279 path: Arc::new(path),
280 limits: Arc::new(source_limits),
281 cache_key: Arc::from(cache_key),
282 cache_directory,
283 })
284}
285
286async fn codex_identity(executable: &str, cache_directory: &Path) -> anyhow::Result<String> {
287 let output = Command::new(executable)
288 .arg("--version")
289 .env("CODEX_SAFE_CATALOG_DIR", cache_directory.as_os_str())
290 .env_remove("OPENAI_API_KEY")
291 .env_remove("CODEX_API_KEY")
292 .output()
293 .await
294 .with_context(|| format!("reading Codex version through '{executable}'"))?;
295 ensure!(
296 output.status.success(),
297 "Codex version check failed through {executable}"
298 );
299 let version = String::from_utf8(output.stdout)
300 .context("Codex returned a non-UTF-8 version")?
301 .trim()
302 .to_owned();
303 ensure!(!version.is_empty(), "Codex returned an empty version");
304 Ok(version)
305}
306
307fn digest(parts: &[&[u8]]) -> String {
308 let mut digest = Sha256::new();
309 for part in parts {
310 digest.update((part.len() as u64).to_be_bytes());
311 digest.update(part);
312 }
313 hex::encode(digest.finalize())
314}
315
316fn parse_model_limits(output: &[u8]) -> anyhow::Result<HashMap<String, ModelLimits>> {
317 let catalog: Value =
318 serde_json::from_slice(output).context("Codex returned an invalid model catalog")?;
319 let models = catalog
320 .get("models")
321 .and_then(Value::as_array)
322 .context("Codex model catalog has no models array")?;
323 let mut limits = HashMap::new();
324 for model in models {
325 let slug = model
326 .get("slug")
327 .and_then(Value::as_str)
328 .context("Codex model has no slug")?;
329 let context_window = model
330 .get("context_window")
331 .and_then(Value::as_u64)
332 .context("Codex model has no context window")?;
333 let effective_percent = model
334 .get("effective_context_window_percent")
335 .and_then(Value::as_u64)
336 .context("Codex model has no effective context percentage")?;
337 ensure!(
338 (1..=100).contains(&effective_percent),
339 "Codex model {slug} has an invalid effective context percentage"
340 );
341 let effective = context_window
342 .checked_mul(effective_percent)
343 .context("Codex model context limit overflowed")?
344 / 100;
345 ensure!(effective > 0, "Codex model {slug} has an empty context");
346 limits.insert(
347 slug.to_owned(),
348 ModelLimits {
349 context_window_tokens: effective,
350 max_input_tokens: effective,
351 },
352 );
353 }
354 Ok(limits)
355}
356
357fn sanitize_catalog(output: &[u8]) -> anyhow::Result<Vec<u8>> {
358 let mut catalog: Value =
359 serde_json::from_slice(output).context("Codex returned an invalid model catalog")?;
360 let models = catalog
361 .get_mut("models")
362 .and_then(Value::as_array_mut)
363 .context("Codex model catalog has no models array")?;
364 for model in models {
365 let model = model
366 .as_object_mut()
367 .context("Codex model catalog contains a non-object model")?;
368 model.remove("tool_mode");
369 model.remove("multi_agent_version");
370 model.remove("apply_patch_tool_type");
371 model.remove("model_messages");
372 model.insert("base_instructions".into(), Value::String(String::new()));
373 model.insert(
374 "include_skills_usage_instructions".into(),
375 Value::Bool(false),
376 );
377 }
378 serde_json::to_vec(&catalog).context("serializing the sanitized Codex model catalog")
379}
380
381fn verified_limits(output: &[u8]) -> anyhow::Result<HashMap<String, ModelLimits>> {
382 let catalog: Value =
383 serde_json::from_slice(output).context("Codex returned an invalid model catalog")?;
384 let models = catalog
385 .get("models")
386 .and_then(Value::as_array)
387 .context("Codex model catalog has no models array")?;
388 for model in models {
389 let model = model
390 .as_object()
391 .context("Codex model catalog contains a non-object model")?;
392 let slug = model
393 .get("slug")
394 .and_then(Value::as_str)
395 .unwrap_or("unknown model");
396 ensure!(
397 model.get("base_instructions").and_then(Value::as_str) == Some(""),
398 "Codex retained base instructions for {slug}"
399 );
400 ensure!(
401 !model.contains_key("model_messages"),
402 "Codex retained model messages for {slug}"
403 );
404 ensure!(
405 model
406 .get("include_skills_usage_instructions")
407 .and_then(Value::as_bool)
408 == Some(false),
409 "Codex retained skill usage instructions for {slug}"
410 );
411 }
412 parse_model_limits(output)
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 const SOURCE: &[u8] = br#"{
420 "models":[{
421 "slug":"gpt-5.6-sol",
422 "context_window":272000,
423 "effective_context_window_percent":95,
424 "base_instructions":"provider instructions",
425 "model_messages":{"instructions_template":"more provider instructions"},
426 "include_skills_usage_instructions":true,
427 "tool_mode":"code_mode_only",
428 "multi_agent_version":"v2",
429 "apply_patch_tool_type":"freeform",
430 "unrelated":"preserved"
431 }]
432 }"#;
433
434 #[test]
435 fn sanitization_removes_hidden_prompts_and_preserves_limits() {
436 let sanitized = sanitize_catalog(SOURCE).unwrap();
437 assert_eq!(
438 parse_model_limits(&sanitized).unwrap(),
439 parse_model_limits(SOURCE).unwrap()
440 );
441 verified_limits(&sanitized).unwrap();
442 let catalog: Value = serde_json::from_slice(&sanitized).unwrap();
443 let model = catalog["models"][0].as_object().unwrap();
444 for removed in [
445 "tool_mode",
446 "multi_agent_version",
447 "apply_patch_tool_type",
448 "model_messages",
449 ] {
450 assert!(!model.contains_key(removed));
451 }
452 assert_eq!(model["base_instructions"], "");
453 assert_eq!(model["include_skills_usage_instructions"], false);
454 assert_eq!(model["unrelated"], "preserved");
455 }
456
457 #[test]
458 fn advertised_context_uses_the_effective_percentage() {
459 let limits = parse_model_limits(SOURCE).unwrap();
460 assert_eq!(
461 limits["gpt-5.6-sol"],
462 ModelLimits {
463 context_window_tokens: 258_400,
464 max_input_tokens: 258_400,
465 }
466 );
467 }
468
469 #[test]
470 fn catalog_configuration_quotes_paths() {
471 assert_eq!(
472 model_catalog_config(Path::new("/tmp/a catalog.json")),
473 "model_catalog_json=\"/tmp/a catalog.json\""
474 );
475 }
476
477 #[cfg(unix)]
478 #[tokio::test]
479 async fn concurrent_callers_share_discovery_and_restarts_use_the_disk_cache() {
480 use std::{
481 os::unix::fs::PermissionsExt,
482 time::{SystemTime, UNIX_EPOCH},
483 };
484
485 let nonce = SystemTime::now()
486 .duration_since(UNIX_EPOCH)
487 .unwrap()
488 .as_nanos();
489 let directory = std::env::temp_dir().join(format!(
490 "kcode-codex-runtime-test-{}-{nonce}",
491 std::process::id()
492 ));
493 std::fs::create_dir_all(&directory).unwrap();
494 let executable = directory.join("fake-codex");
495 let calls = directory.join("calls");
496 let sanitized_catalog = r#"{"models":[{"slug":"gpt-5.6-sol","context_window":272000,"effective_context_window_percent":95,"base_instructions":"","include_skills_usage_instructions":false}]}"#;
497 let script = format!(
498 "#!/bin/sh\nprintf '%s\\n' \"$1\" >> '{}'\nif [ \"$1\" = '--version' ]; then\n printf '%s\\n' 'fake-codex 1.0'\nelse\n printf '%s' '{}'\nfi\n",
499 calls.display(),
500 sanitized_catalog
501 );
502 std::fs::write(&executable, script).unwrap();
503 std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o700)).unwrap();
504
505 let cache = CatalogCache::with_directory(
506 executable.to_string_lossy().into_owned(),
507 directory.join("catalog-cache"),
508 );
509 let (left, right) = tokio::join!(cache.load(), cache.load());
510 let first = left.unwrap();
511 assert_eq!(first.path(), right.unwrap().path());
512 let cache_path = first.path().to_owned();
513 assert!(!first.validation_is_cached("prompt-v1").await.unwrap());
514 first.cache_validation("prompt-v1").await.unwrap();
515 assert!(first.validation_is_cached("prompt-v1").await.unwrap());
516 assert!(!first.validation_is_cached("prompt-v2").await.unwrap());
517
518 let restarted = CatalogCache::with_directory(
519 executable.to_string_lossy().into_owned(),
520 directory.join("catalog-cache"),
521 );
522 assert_eq!(restarted.load().await.unwrap().path(), cache_path);
523 assert_eq!(
524 std::fs::read_to_string(&calls)
525 .unwrap()
526 .lines()
527 .collect::<Vec<_>>(),
528 vec!["--version", "debug", "-c", "--version"]
529 );
530
531 std::fs::remove_file(cache_path).unwrap();
532 std::fs::remove_dir_all(directory).unwrap();
533 }
534}