1use std::{
2 collections::BTreeMap,
3 fs, io,
4 path::{Path, PathBuf},
5};
6
7#[derive(Debug)]
8pub struct HarnessEnv {
9 pub environment: BTreeMap<String, String>,
10 pub files: Vec<PathBuf>,
11 pub summary: String,
12}
13
14pub fn prepare_codex_home(
20 root: &Path,
21 gateway_url: &str,
22 model: &str,
23 api_key: Option<&str>,
24) -> Result<HarnessEnv, io::Error> {
25 fs::create_dir_all(root)?;
26 let gateway_url = format!("{}/v1", gateway_url.trim_end_matches('/'));
27 let catalog_path = root.join("model_catalog.json");
28 let config_path = root.join("config.toml");
29 let catalog = serde_json::json!({
30 "models": [{
31 "slug": model,
32 "display_name": model,
33 "supported_in_api": true,
34 "visibility": "list",
35 "priority": 0,
36 "input_modalities": ["text"],
37 "default_reasoning_level": "medium",
38 "supported_reasoning_levels": [
39 {"effort": "low", "description": "Fast responses"},
40 {"effort": "medium", "description": "Balanced responses"},
41 {"effort": "high", "description": "Deep reasoning"}
42 ],
43 "supports_reasoning_summaries": true,
44 "supports_parallel_tool_calls": false,
47 "web_search_tool_type": "text",
51 "shell_type": "local",
52 "context_window": 32768,
53 "max_context_window": 262_144,
54 "base_instructions": "",
55 "support_verbosity": false,
56 "supports_image_detail_original": false,
57 "use_responses_lite": false,
58 "supports_search_tool": false,
59 "include_skills_usage_instructions": false,
60 "truncation_policy": {"limit": 32768, "mode": "tokens"},
61 "experimental_supported_tools": []
62 }]
63 });
64 let catalog_bytes = serde_json::to_vec_pretty(&catalog).map_err(io::Error::other)?;
65 fs::write(&catalog_path, catalog_bytes).map_err(|error| {
66 io::Error::new(
67 error.kind(),
68 format!(
69 "failed to write Codex model catalog {}: {error}",
70 catalog_path.display()
71 ),
72 )
73 })?;
74
75 let requires_auth = api_key.is_some();
76 let config = format!(
77 "model = \"{}\"\nmodel_provider = \"agentic-api\"\nmodel_catalog_json = \"{}\"\n\n\
78[model_providers.agentic-api]\nname = \"Agentic API\"\nbase_url = \"{}\"\n\
79wire_api = \"responses\"\nrequires_openai_auth = {requires_auth}\nsupports_websockets = true\n",
80 toml_escape(model),
81 toml_escape(&catalog_path.display().to_string()),
82 toml_escape(&gateway_url),
83 );
84 fs::write(&config_path, config).map_err(|error| {
85 io::Error::new(
86 error.kind(),
87 format!("failed to write Codex config {}: {error}", config_path.display()),
88 )
89 })?;
90
91 let mut environment = BTreeMap::new();
92 environment.insert("CODEX_HOME".to_owned(), root.display().to_string());
93 if let Some(api_key) = api_key {
94 environment.insert("OPENAI_API_KEY".to_owned(), api_key.to_owned());
95 }
96
97 Ok(HarnessEnv {
98 environment,
99 files: vec![config_path, catalog_path],
100 summary: format!("Codex home: {} (model: {model})", root.display()),
101 })
102}
103
104#[must_use]
105pub fn prepare_claude_env(gateway_url: &str, model: &str, api_key: Option<&str>) -> HarnessEnv {
106 let auth_token = std::env::var("ANTHROPIC_AUTH_TOKEN")
107 .ok()
108 .or_else(|| api_key.map(str::to_owned))
109 .unwrap_or_else(|| "agentic-api-local".to_owned());
110 let mut environment = BTreeMap::from([
111 (
112 "ANTHROPIC_BASE_URL".to_owned(),
113 gateway_url.trim_end_matches('/').to_owned(),
114 ),
115 ("ANTHROPIC_MODEL".to_owned(), model.to_owned()),
116 ("ANTHROPIC_SMALL_FAST_MODEL".to_owned(), model.to_owned()),
117 ("ANTHROPIC_DEFAULT_OPUS_MODEL".to_owned(), model.to_owned()),
118 ("ANTHROPIC_DEFAULT_SONNET_MODEL".to_owned(), model.to_owned()),
119 ("ANTHROPIC_DEFAULT_HAIKU_MODEL".to_owned(), model.to_owned()),
120 (
121 "ANTHROPIC_API_KEY".to_owned(),
122 api_key.unwrap_or("agentic-api-local").to_owned(),
123 ),
124 ("ANTHROPIC_AUTH_TOKEN".to_owned(), auth_token),
125 ]);
126 if let Some(api_key) = api_key {
127 environment.insert("OPENAI_API_KEY".to_owned(), api_key.to_owned());
128 }
129 HarnessEnv {
130 environment,
131 files: Vec::new(),
132 summary: format!(
133 "Claude Code gateway: {} (model: {model})",
134 gateway_url.trim_end_matches('/')
135 ),
136 }
137}
138
139pub fn validate_claude_model(model: &str) -> Result<(), String> {
146 if model.contains('/') {
147 return Err(format!(
148 "Claude Code requires a slash-free served model alias; start vLLM with --served-model-name and set --model to that alias (received {model})"
149 ));
150 }
151 Ok(())
152}
153
154fn toml_escape(value: &str) -> String {
155 value.replace('\\', "\\\\").replace('\"', "\\\"")
156}
157
158#[cfg(test)]
159mod tests {
160 use std::fs;
161
162 use super::{prepare_claude_env, prepare_codex_home};
163
164 #[test]
165 fn codex_config_is_isolated_and_contains_gateway_provider() {
166 let root = unique_temp_dir("codex");
167 let env = prepare_codex_home(&root, "http://127.0.0.1:3000", "Qwen/test", None).expect("config");
168 let config = fs::read_to_string(root.join("config.toml")).expect("config file");
169
170 assert!(config.contains("[model_providers.agentic-api]"));
171 assert!(config.contains("base_url = \"http://127.0.0.1:3000/v1\""));
172 assert!(config.contains("wire_api = \"responses\""));
173 assert!(config.contains("requires_openai_auth = false"));
174 assert!(config.contains("model = \"Qwen/test\""));
175 assert!(!env.summary.contains("secret"));
176
177 fs::remove_dir_all(root).expect("cleanup");
178 }
179
180 #[test]
181 fn claude_environment_uses_gateway_and_does_not_expose_key() {
182 let env = prepare_claude_env("http://127.0.0.1:3000", "Qwen/test", Some("secret-key"));
183
184 assert_eq!(
185 env.environment.get("ANTHROPIC_BASE_URL"),
186 Some(&"http://127.0.0.1:3000".to_owned())
187 );
188 assert_eq!(env.environment.get("ANTHROPIC_MODEL"), Some(&"Qwen/test".to_owned()));
189 assert_eq!(
190 env.environment.get("ANTHROPIC_DEFAULT_OPUS_MODEL"),
191 Some(&"Qwen/test".to_owned())
192 );
193 assert_eq!(
194 env.environment.get("ANTHROPIC_DEFAULT_SONNET_MODEL"),
195 Some(&"Qwen/test".to_owned())
196 );
197 assert_eq!(
198 env.environment.get("ANTHROPIC_DEFAULT_HAIKU_MODEL"),
199 Some(&"Qwen/test".to_owned())
200 );
201 assert_eq!(env.environment.get("ANTHROPIC_API_KEY"), Some(&"secret-key".to_owned()));
202 assert_eq!(
203 env.environment.get("ANTHROPIC_AUTH_TOKEN"),
204 Some(&"secret-key".to_owned())
205 );
206 assert!(!env.summary.contains("secret-key"));
207 }
208
209 #[test]
210 fn claude_model_requires_a_served_alias() {
211 assert!(super::validate_claude_model("Qwen/test").is_err());
212 assert!(super::validate_claude_model("Qwen-test").is_ok());
213 }
214
215 fn unique_temp_dir(name: &str) -> std::path::PathBuf {
216 let path = std::env::temp_dir().join(format!("agentic-api-{name}-{}", std::process::id()));
217 fs::create_dir_all(&path).expect("temp dir");
218 path
219 }
220}