447 lines
19 KiB
Rust
447 lines
19 KiB
Rust
use super::support::agent_test_home;
|
|
use super::*;
|
|
use std::process::{Child, Stdio};
|
|
|
|
struct TestCore {
|
|
child: Option<Child>,
|
|
directory: PathBuf,
|
|
config: PathBuf,
|
|
executable: PathBuf,
|
|
origin: String,
|
|
client: reqwest::Client,
|
|
}
|
|
|
|
impl Drop for TestCore {
|
|
fn drop(&mut self) {
|
|
self.stop();
|
|
if std::thread::panicking() {
|
|
eprintln!(
|
|
"{}",
|
|
fs::read_to_string(self.directory.join("core.log")).unwrap_or_default()
|
|
);
|
|
}
|
|
if self.directory.parent() == Some(std::env::temp_dir().as_path())
|
|
&& self
|
|
.directory
|
|
.file_name()
|
|
.unwrap()
|
|
.to_string_lossy()
|
|
.starts_with("cpa-gui-agent-v8-contract-")
|
|
{
|
|
let _ = fs::remove_dir_all(&self.directory);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TestCore {
|
|
fn stop(&mut self) {
|
|
if let Some(mut child) = self.child.take() {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
}
|
|
}
|
|
|
|
async fn start(&mut self) {
|
|
let mut command = Command::new(&self.executable);
|
|
command
|
|
.args(["-config"])
|
|
.arg(&self.config)
|
|
.arg("-local-model")
|
|
.current_dir(&self.directory)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::from(
|
|
File::create(self.directory.join("core.log")).unwrap(),
|
|
))
|
|
.stderr(Stdio::null());
|
|
configure_background_command(&mut command);
|
|
self.child = Some(command.spawn().unwrap());
|
|
for _ in 0..100 {
|
|
assert!(
|
|
self.child.as_mut().unwrap().try_wait().unwrap().is_none(),
|
|
"test kernel exited during startup"
|
|
);
|
|
if self
|
|
.client
|
|
.get(format!("{}/v8/management/config", self.origin))
|
|
.bearer_auth("isolated-test-secret")
|
|
.send()
|
|
.await
|
|
.is_ok_and(|r| r.status().is_success())
|
|
{
|
|
return;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
panic!("test kernel startup timed out");
|
|
}
|
|
|
|
async fn config_view(&self) -> serde_json::Value {
|
|
self.client
|
|
.get(format!("{}/v8/management/config", self.origin))
|
|
.bearer_auth("isolated-test-secret")
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.error_for_status()
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
async fn validate_save(&self, yaml: &str) -> serde_json::Value {
|
|
let response = self
|
|
.client
|
|
.put(format!("{}/v8/management/config.yaml", self.origin))
|
|
.bearer_auth("isolated-test-secret")
|
|
.header("Content-Type", "application/yaml")
|
|
.body(yaml.to_string())
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let status = response.status();
|
|
let body = response.text().await.unwrap();
|
|
assert!(
|
|
status.is_success(),
|
|
"v8 rejected generated settings: {status} {body}"
|
|
);
|
|
self.config_view().await
|
|
}
|
|
|
|
async fn wait_for_client_key(&self, key: &str, expected: u16) {
|
|
for _ in 0..100 {
|
|
let status = self
|
|
.client
|
|
.get(format!("{}/v1/models", self.origin))
|
|
.bearer_auth(key)
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status()
|
|
.as_u16();
|
|
if status == expected {
|
|
return;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
panic!("client key did not reach HTTP {expected}");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires CPA_V8_TEST_CORE pointing to a v8 executable"]
|
|
async fn v8_alias_save_preserves_multi_key_groups_and_restores_inheritance_after_failure() {
|
|
let executable = fs::canonicalize(std::env::var_os("CPA_V8_TEST_CORE").expect("set CPA_V8_TEST_CORE")).unwrap();
|
|
let directory = agent_test_home("v8-contract-groups");
|
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
drop(listener);
|
|
let mut core = TestCore {
|
|
child: None, config: directory.join("config.yaml"), directory, executable,
|
|
origin: format!("http://127.0.0.1:{port}"),
|
|
client: reqwest::Client::builder().no_proxy().timeout(Duration::from_secs(3)).build().unwrap(),
|
|
};
|
|
let gui = GuiConfigFile {
|
|
host: "127.0.0.1".into(), port, auth_dir: path_to_string(&core.directory.join("auth")),
|
|
management_secret_key: "isolated-test-secret".into(),
|
|
api_keys: vec![GuiApiKeyEntry { key: "isolated-client-key".into(), remark: String::new() }],
|
|
..GuiConfigFile::default()
|
|
};
|
|
let initial = r#"
|
|
config-version: 8
|
|
management: {disable-control-panel: true, disable-auto-update-panel: true}
|
|
api-keys:
|
|
codex:
|
|
- name: shared-upstream
|
|
base-url: http://127.0.0.1:1/v1
|
|
models: [{name: gpt-test}]
|
|
keys:
|
|
- {api-key: test-first, weight: 2, priority: null}
|
|
- {api-key: test-second, weight: 5, models: null, headers: {}, excluded-models: [], disable-cooling: false}
|
|
- api-key: test-third
|
|
models: [{name: private-model}]
|
|
"#;
|
|
fs::write(&core.config, apply_gui_managed_settings(initial, &gui).unwrap()).unwrap();
|
|
core.start().await;
|
|
core.validate_save(&fs::read_to_string(&core.config).unwrap()).await;
|
|
let original = core.config_view().await;
|
|
let current = fetch_management_config_yaml(&gui).await.unwrap();
|
|
let sources = resolved_thinking_alias_sources(¤t, &[], &super::support::test_agent_models(&["gpt-test"])).unwrap();
|
|
let updated = add_model_alias_to_yaml(¤t, &sources[0], "gpt-test-fast", "", false).unwrap();
|
|
let error = commit_management_alias_config_changes(&gui, ¤t, &updated, || Err::<(), _>("local save failed".into())).await.unwrap_err();
|
|
assert!(error.contains("Original configuration was restored"), "{error}");
|
|
assert_eq!(core.config_view().await["api-keys"], original["api-keys"]);
|
|
|
|
let current = fetch_management_config_yaml(&gui).await.unwrap();
|
|
let sources = resolved_thinking_alias_sources(¤t, &[], &super::support::test_agent_models(&["gpt-test"])).unwrap();
|
|
let updated = add_model_alias_to_yaml(¤t, &sources[0], "gpt-test-fast", "", false).unwrap();
|
|
put_management_alias_config_changes(&gui, ¤t, &updated).await.unwrap();
|
|
let saved = core.config_view().await;
|
|
let group = &saved["api-keys"]["codex"][0];
|
|
assert_eq!(saved["api-keys"]["codex"].as_array().unwrap().len(), 1);
|
|
assert_eq!(group["name"], "shared-upstream");
|
|
assert_eq!(group["keys"].as_array().unwrap().len(), 3);
|
|
assert_eq!(group["models"], original["api-keys"]["codex"][0]["models"]);
|
|
assert_eq!(group["keys"][0]["models"][1]["alias"], "gpt-test-fast");
|
|
assert_eq!(group["keys"][1], original["api-keys"]["codex"][0]["keys"][1]);
|
|
assert_eq!(group["keys"][2], original["api-keys"]["codex"][0]["keys"][2]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires CPA_V8_TEST_CORE pointing to a v8 executable"]
|
|
async fn v8_accepts_gui_settings_and_reloads_client_keys() {
|
|
let executable =
|
|
fs::canonicalize(std::env::var_os("CPA_V8_TEST_CORE").expect("set CPA_V8_TEST_CORE"))
|
|
.unwrap();
|
|
let directory = agent_test_home("v8-contract");
|
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
drop(listener);
|
|
let mut core = TestCore {
|
|
child: None,
|
|
config: directory.join("config.yaml"),
|
|
directory,
|
|
executable,
|
|
origin: format!("http://127.0.0.1:{port}"),
|
|
client: reqwest::Client::builder()
|
|
.no_proxy()
|
|
.timeout(Duration::from_secs(2))
|
|
.build()
|
|
.unwrap(),
|
|
};
|
|
let mut gui = GuiConfigFile {
|
|
host: "127.0.0.1".into(),
|
|
port,
|
|
auth_dir: path_to_string(&core.directory.join("auth")),
|
|
management_secret_key: "isolated-test-secret".into(),
|
|
api_keys: vec![GuiApiKeyEntry {
|
|
key: "client-one".into(),
|
|
remark: String::new(),
|
|
}],
|
|
proxy_url: "direct".into(),
|
|
routing_strategy: "round-robin".into(),
|
|
routing_session_affinity: true,
|
|
routing_session_affinity_ttl: "2h30m".into(),
|
|
request_retry: 2,
|
|
max_retry_credentials: 3,
|
|
max_retry_interval: 5,
|
|
streaming_bootstrap_retries: 2,
|
|
disable_cooling: true,
|
|
debug: true,
|
|
commercial_mode: true,
|
|
logging_to_file: true,
|
|
logs_max_total_size_mb: 5,
|
|
error_logs_max_files: 4,
|
|
usage_statistics_enabled: true,
|
|
redis_usage_queue_retention_seconds: 90,
|
|
request_log: true,
|
|
..GuiConfigFile::default()
|
|
};
|
|
let initial = "config-version: 8\nmanagement: {disable-control-panel: true, disable-auto-update-panel: true}\nrequests: null\nobservability: null\noauth: {providers: {codex: {header-defaults: {user-agent: preserve-test}}}}\napi-keys: {openai-compatibility: [{name: preserved, base-url: 'http://127.0.0.1:1/v1', models: [{name: test-model}], keys: [{api-key: upstream-test}]}]}\n";
|
|
let content = apply_gui_managed_settings(initial, &gui).unwrap();
|
|
let content = patch_core_sensitive_words_yaml(
|
|
&content,
|
|
&CoreSensitiveWordsSettings {
|
|
antigravity_sensitive_words: vec!["sample".into()],
|
|
devin_sensitive_words: vec!["sample-devin".into()],
|
|
},
|
|
)
|
|
.unwrap()
|
|
.unwrap();
|
|
fs::write(&core.config, &content).unwrap();
|
|
core.start().await;
|
|
let view = core.validate_save(&content).await;
|
|
assert_eq!(view["routing"]["strategy"], "round-robin");
|
|
assert_eq!(view["routing"]["retry"]["request-retry"], 2);
|
|
assert_eq!(view["routing"]["retry"]["max-retry-credentials"], 3);
|
|
assert_eq!(view["routing"]["retry"]["max-retry-interval"], 5);
|
|
assert_eq!(view["requests"]["streaming"]["bootstrap-retries"], 2);
|
|
assert_eq!(view["routing"]["cooldown"]["disable-cooling"], true);
|
|
assert_eq!(
|
|
view["observability"]["usage"]["redis-usage-queue-retention-seconds"],
|
|
90
|
|
);
|
|
assert_eq!(
|
|
view["oauth"]["providers"]["devin"]["sensitive-words"][0],
|
|
"sample-devin"
|
|
);
|
|
|
|
gui.request_retry = 0;
|
|
gui.max_retry_credentials = 0;
|
|
gui.max_retry_interval = 0;
|
|
gui.streaming_bootstrap_retries = 0;
|
|
gui.disable_cooling = false;
|
|
gui.routing_session_affinity = false;
|
|
gui.routing_session_affinity_ttl.clear();
|
|
gui.proxy_url.clear();
|
|
let current = fs::read_to_string(&core.config).unwrap();
|
|
let content = patch_core_retry_yaml(¤t, &gui).unwrap().unwrap();
|
|
let content = patch_core_session_routing_yaml(&content, &gui)
|
|
.unwrap()
|
|
.unwrap();
|
|
let content = patch_core_network_endpoint_yaml(&content, &gui)
|
|
.unwrap()
|
|
.unwrap();
|
|
let mut logging =
|
|
core_config_settings_from_value(&serde_norway::from_str(&content).unwrap()).unwrap();
|
|
logging.debug = false;
|
|
logging.commercial_mode = false;
|
|
logging.logging_to_file = false;
|
|
logging.logs_max_total_size_mb = 0;
|
|
logging.error_logs_max_files = 0;
|
|
logging.usage_statistics_enabled = false;
|
|
logging.redis_usage_queue_retention_seconds = 1;
|
|
let content =
|
|
patch_core_yaml_document(&content, |doc| apply_core_logging_settings(doc, &logging))
|
|
.unwrap()
|
|
.unwrap();
|
|
let content = patch_core_sensitive_words_yaml(&content, &CoreSensitiveWordsSettings::default())
|
|
.unwrap()
|
|
.unwrap();
|
|
let content = patch_core_tls_settings_yaml(
|
|
&content,
|
|
&CoreTlsSettings {
|
|
enabled: false,
|
|
cert: "test-cert.pem".into(),
|
|
key: "test-key.pem".into(),
|
|
},
|
|
)
|
|
.unwrap()
|
|
.unwrap();
|
|
let view = core.validate_save(&content).await;
|
|
assert_eq!(view["routing"]["retry"]["request-retry"], 0);
|
|
assert_eq!(view["routing"]["session-affinity"], false);
|
|
assert_eq!(view["routing"]["session-affinity-ttl"], "");
|
|
assert_eq!(view["routing"]["cooldown"]["disable-cooling"], false);
|
|
assert_eq!(
|
|
view["observability"]["usage"]["usage-statistics-enabled"],
|
|
false
|
|
);
|
|
assert_eq!(view["observability"]["logs"]["logging-to-file"], false);
|
|
assert_eq!(view["observability"]["logs"]["request-log"], true);
|
|
assert_eq!(view["server"]["tls"]["cert"], "test-cert.pem");
|
|
assert_eq!(view["server"]["tls"]["enable"], false);
|
|
assert_eq!(
|
|
view["api-keys"]["openai-compatibility"][0]["name"],
|
|
"preserved"
|
|
);
|
|
assert_eq!(
|
|
view["oauth"]["providers"]["codex"]["header-defaults"]["user-agent"],
|
|
"preserve-test"
|
|
);
|
|
assert_eq!(
|
|
view["oauth"]["providers"]["antigravity"]["sensitive-words"],
|
|
serde_json::json!([])
|
|
);
|
|
let current = fs::read_to_string(&core.config).unwrap();
|
|
let content = patch_core_api_keys_yaml(¤t, &["client-two".into()]).unwrap();
|
|
write_core_config_if_changed(&core.config, &content).unwrap();
|
|
core.wait_for_client_key("client-two", 200).await;
|
|
core.wait_for_client_key("client-one", 401).await;
|
|
core.stop();
|
|
core.start().await;
|
|
core.wait_for_client_key("client-two", 200).await;
|
|
core.wait_for_client_key("client-one", 401).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires CPA_V8_TEST_CORE pointing to a v8 executable"]
|
|
async fn v8_reloads_usage_after_repeated_gui_saves_in_both_config_layouts() {
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
|
for initial in [
|
|
"remote-management: {disable-control-panel: true, disable-auto-update-panel: true}\nusage-statistics-enabled: false\n",
|
|
"config-version: 8\nmanagement: {disable-control-panel: true, disable-auto-update-panel: true}\nobservability: {usage: {usage-statistics-enabled: false}}\nusage-statistics-enabled: true\n",
|
|
] {
|
|
let executable = fs::canonicalize(std::env::var_os("CPA_V8_TEST_CORE").expect("set CPA_V8_TEST_CORE")).unwrap();
|
|
let directory = agent_test_home("v8-contract-usage");
|
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
drop(listener);
|
|
let mut core = TestCore {
|
|
child: None,
|
|
config: directory.join("config.yaml"),
|
|
directory,
|
|
executable,
|
|
origin: format!("http://127.0.0.1:{port}"),
|
|
client: reqwest::Client::builder().no_proxy().timeout(Duration::from_secs(2)).build().unwrap(),
|
|
};
|
|
let gui = GuiConfigFile {
|
|
host: "127.0.0.1".into(),
|
|
port,
|
|
auth_dir: path_to_string(&core.directory.join("existing-credentials")),
|
|
management_secret_key: "isolated-test-secret".into(),
|
|
usage_statistics_enabled: false,
|
|
..GuiConfigFile::default()
|
|
};
|
|
let upstream = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let upstream_port = upstream.local_addr().unwrap().port();
|
|
let upstream_task = tokio::spawn(async move {
|
|
for _ in 0..3 {
|
|
let (mut stream, _) = upstream.accept().await.unwrap();
|
|
let mut request = Vec::new();
|
|
let mut buffer = [0_u8; 4096];
|
|
loop {
|
|
let read = stream.read(&mut buffer).await.unwrap();
|
|
assert!(read > 0);
|
|
request.extend_from_slice(&buffer[..read]);
|
|
if let Some(end) = request.windows(4).position(|window| window == b"\r\n\r\n") {
|
|
let headers = String::from_utf8_lossy(&request[..end]);
|
|
let length = headers.lines().find_map(|line| {
|
|
let (name, value) = line.split_once(':')?;
|
|
name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse::<usize>().unwrap())
|
|
}).unwrap_or(0);
|
|
if request.len() >= end + 4 + length {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
let body = r#"{"id":"usage-test","object":"chat.completion","model":"usage-probe","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}"#;
|
|
let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
|
|
stream.write_all(response.as_bytes()).await.unwrap();
|
|
}
|
|
});
|
|
fs::create_dir_all(&gui.auth_dir).unwrap();
|
|
let initial = format!("{initial}openai-compatibility:\n - name: isolated-usage-test\n base-url: http://127.0.0.1:{upstream_port}/v1\n api-key-entries: [{{api-key: isolated-upstream-key}}]\n models: [{{name: usage-probe, alias: usage-probe}}]\n");
|
|
let content = apply_gui_managed_settings(&initial, &gui).unwrap();
|
|
fs::write(&core.config, &content).unwrap();
|
|
core.start().await;
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
assert_eq!(core.config_view().await["observability"]["usage"]["usage-statistics-enabled"], false);
|
|
|
|
for enabled in [true, false, true] {
|
|
let content = fs::read_to_string(&core.config).unwrap();
|
|
let mut settings = core_config_settings_from_value(&serde_norway::from_str(&content).unwrap()).unwrap();
|
|
assert_eq!(settings.auth_dir, gui.auth_dir);
|
|
settings.usage_statistics_enabled = enabled;
|
|
let updated = patch_core_yaml_document(&content, |document| apply_core_logging_settings(document, &settings)).unwrap().unwrap();
|
|
write_core_config_if_changed(&core.config, &updated).unwrap();
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
let saved = fs::read_to_string(&core.config).unwrap();
|
|
let settings = core_config_settings_from_value(&serde_norway::from_str(&saved).unwrap()).unwrap();
|
|
assert_eq!(settings.auth_dir, gui.auth_dir);
|
|
assert_eq!(settings.usage_statistics_enabled, enabled);
|
|
core.client.post(format!("{}/v1/chat/completions", core.origin))
|
|
.bearer_auth(&gui.api_keys[0].key)
|
|
.json(&serde_json::json!({"model": "usage-probe", "messages": [{"role": "user", "content": "test"}]}))
|
|
.send().await.unwrap().error_for_status().unwrap()
|
|
.bytes().await.unwrap();
|
|
let mut records = Vec::new();
|
|
for _ in 0..10 {
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
let batch: Vec<serde_json::Value> = core.client
|
|
.get(format!("{}/v8/management/observability/usage/queue?count=100", core.origin))
|
|
.bearer_auth("isolated-test-secret")
|
|
.send().await.unwrap().error_for_status().unwrap()
|
|
.json().await.unwrap();
|
|
records.extend(batch);
|
|
if !records.is_empty() {
|
|
break;
|
|
}
|
|
}
|
|
assert_eq!(records.len(), usize::from(enabled), "usage output did not follow the saved setting");
|
|
}
|
|
upstream_task.await.unwrap();
|
|
}
|
|
}
|