Skip to main content

agentic_server/
agentic_harness.rs

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
14/// Write an isolated Codex home for an Agentic API session.
15///
16/// # Errors
17///
18/// Returns an I/O error when the temporary home or generated files cannot be written.
19pub 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            // The gateway accepts parallel_tool_calls=true but serializes tool calls
45            // upstream (#190, #197), so do not advertise parallel execution to Codex.
46            "supports_parallel_tool_calls": false,
47            // apply_patch_tool_type is intentionally omitted: Codex only supports
48            // "freeform", which the gateway cannot normalize while preserving
49            // constrained decoding. Codex falls back to editing via the shell tool.
50            "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
139/// Validates that a Claude Code model name is a slash-free served alias.
140///
141/// # Errors
142///
143/// Returns an error when the model name contains `/`, which Claude Code does not
144/// accept for custom model names.
145pub 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}