use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::{Value, json};
use super::{ClientError, ClientKind, ClientManager, SetupResult};
pub(super) struct CodexMarker {
pub(super) previous_provider: Option<String>,
pub(super) managed_chatgpt_base_url: Option<String>,
pub(super) previous_chatgpt_base_url: Option<String>,
pub(super) managed_realtime_base_url: Option<String>,
pub(super) previous_realtime_ws_base_url: Option<String>,
pub(super) previous_realtime_call_base_url: Option<String>,
}
impl ClientManager {
pub(crate) fn write_environment(
&self,
client: ClientKind,
base_url: &str,
token: &str,
) -> Result<PathBuf, ClientError> {
let token_env = client
.token_env()
.ok_or_else(|| ClientError::message("client has no router token environment"))?;
let directory = self.config_home.join("link-assistant-router/clients");
fs::create_dir_all(&directory)?;
let path = self.environment_path(client);
let mut contents = String::new();
if let Some(base_url_env) = client.base_url_env() {
let endpoint = format!(
"{}{}",
base_url.trim_end_matches('/'),
client.integration().endpoint_suffix
);
writeln!(
&mut contents,
"export {base_url_env}={}",
super::util::shell_quote(&endpoint)
)
.expect("writing to a String cannot fail");
}
writeln!(
&mut contents,
"export {token_env}={}",
super::util::shell_quote(token)
)
.expect("writing to a String cannot fail");
if client == ClientKind::Codex {
let alias = crate::token::codex_token_alias(token).ok_or_else(|| {
ClientError::message(
"Codex control-plane setup requires a Router-issued la_sk_ token",
)
})?;
writeln!(
&mut contents,
"export CODEX_ACCESS_TOKEN={}",
super::util::shell_quote(&alias)
)
.expect("writing to a String cannot fail");
writeln!(
&mut contents,
"export CODEX_CONNECTORS_TOKEN={}",
super::util::shell_quote(&alias)
)
.expect("writing to a String cannot fail");
writeln!(
&mut contents,
"export CODEX_AUTHAPI_BASE_URL={}",
super::util::shell_quote(&format!(
"{}/api/services/codex",
base_url.trim_end_matches('/')
))
)
.expect("writing to a String cannot fail");
}
if client == ClientKind::ClaudeCode {
writeln!(
&mut contents,
"export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1"
)
.expect("writing to a String cannot fail");
}
atomic_write(&path, contents.as_bytes())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
}
Ok(path)
}
}
pub(super) type ClaudeEnvOwnership = Vec<(String, Option<String>, Option<String>)>;
pub(super) fn read_or_empty(path: &Path) -> Result<String, ClientError> {
match fs::read_to_string(path) {
Ok(source) => Ok(source),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(error) => Err(ClientError::message(format!(
"could not read {}: {error}",
path.display()
))),
}
}
pub(super) fn read_environment_value(
path: &Path,
name: &str,
) -> Result<Option<String>, ClientError> {
let source = read_or_empty(path)?;
let prefix = format!("export {name}=");
Ok(source.lines().find_map(|line| {
let raw = line.strip_prefix(&prefix)?.trim();
Some(
raw.strip_prefix('\'')
.and_then(|value| value.strip_suffix('\''))
.unwrap_or(raw)
.replace("'\\''", "'"),
)
}))
}
pub(super) fn write_if_changed(
path: &Path,
before: &str,
after: &str,
) -> Result<SetupResult, ClientError> {
if before == after {
return Ok(unchanged(path.to_path_buf()));
}
let parent = path.parent().ok_or_else(|| {
ClientError::message(format!("{} has no parent directory", path.display()))
})?;
fs::create_dir_all(parent)?;
let backup = path.exists().then(|| backup_file(path)).transpose()?;
atomic_write(path, after.as_bytes())?;
Ok(SetupResult {
path: path.to_path_buf(),
backup,
changed: true,
})
}
pub(super) const fn unchanged(path: PathBuf) -> SetupResult {
SetupResult {
path,
backup: None,
changed: false,
}
}
pub(super) fn backup_file(path: &Path) -> Result<PathBuf, ClientError> {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| ClientError::message("config file name is not valid UTF-8"))?;
let backup = path.with_file_name(format!("{file_name}.link-assistant-router.{stamp}.bak"));
atomic_write(&backup, &fs::read(path)?)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(&backup, fs::Permissions::from_mode(0o600))?;
}
Ok(backup)
}
pub(super) fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), ClientError> {
crate::durable_file::atomic_write_owner_only(path, contents).map_err(Into::into)
}
pub(super) fn write_claude_marker(
path: &Path,
base_url: &str,
previous: Option<&str>,
gateway_env: &ClaudeEnvOwnership,
) -> Result<(), ClientError> {
let gateway_env = gateway_env
.iter()
.map(|(key, managed, previous)| {
(
key.clone(),
json!({"managed": managed, "previous": previous}),
)
})
.collect::<serde_json::Map<_, _>>();
let rendered = format!(
"{}\n",
serde_json::to_string_pretty(&json!({
"anthropic_base_url": base_url,
"previous_anthropic_base_url": previous,
"gateway_env": gateway_env,
}))?
);
if read_or_empty(path)? != rendered {
let parent = path
.parent()
.ok_or_else(|| ClientError::message("missing marker parent"))?;
fs::create_dir_all(parent)?;
atomic_write(path, rendered.as_bytes())?;
}
Ok(())
}
pub(super) fn claude_marker(
path: &Path,
) -> Result<Option<(String, Option<String>, ClaudeEnvOwnership)>, ClientError> {
let source = read_or_empty(path)?;
if source.trim().is_empty() {
return Ok(None);
}
let marker: Value = serde_json::from_str(&source)?;
let Some(managed) = marker
.get("anthropic_base_url")
.and_then(Value::as_str)
.map(str::to_string)
else {
return Ok(None);
};
let previous = marker
.get("previous_anthropic_base_url")
.and_then(Value::as_str)
.map(str::to_string);
let gateway_env = marker
.get("gateway_env")
.and_then(Value::as_object)
.into_iter()
.flatten()
.filter_map(|(key, ownership)| {
let managed = match ownership.get("managed") {
Some(Value::String(value)) => Some(value.clone()),
Some(Value::Null) | None => None,
_ => return None,
};
let previous = ownership
.get("previous")
.and_then(Value::as_str)
.map(str::to_string);
Some((key.clone(), managed, previous))
})
.collect();
Ok(Some((managed, previous, gateway_env)))
}
pub(super) fn write_codex_marker(
path: &Path,
previous_provider: Option<&str>,
managed_chatgpt_base_url: &str,
previous_chatgpt_base_url: Option<&str>,
managed_realtime_base_url: &str,
previous_realtime_ws_base_url: Option<&str>,
previous_realtime_call_base_url: Option<&str>,
) -> Result<(), ClientError> {
let rendered = format!(
"{}\n",
serde_json::to_string_pretty(&json!({
"previous_model_provider": previous_provider,
"managed_chatgpt_base_url": managed_chatgpt_base_url,
"previous_chatgpt_base_url": previous_chatgpt_base_url,
"managed_realtime_base_url": managed_realtime_base_url,
"previous_realtime_ws_base_url": previous_realtime_ws_base_url,
"previous_realtime_call_base_url": previous_realtime_call_base_url
}))?
);
let parent = path
.parent()
.ok_or_else(|| ClientError::message("missing marker parent"))?;
fs::create_dir_all(parent)?;
atomic_write(path, rendered.as_bytes())
}
pub(super) fn read_codex_marker(path: &Path) -> Result<CodexMarker, ClientError> {
let source = read_or_empty(path)?;
if source.trim().is_empty() {
return Ok(CodexMarker {
previous_provider: None,
managed_chatgpt_base_url: None,
previous_chatgpt_base_url: None,
managed_realtime_base_url: None,
previous_realtime_ws_base_url: None,
previous_realtime_call_base_url: None,
});
}
let marker: Value = serde_json::from_str(&source)?;
Ok(CodexMarker {
previous_provider: marker
.get("previous_model_provider")
.and_then(Value::as_str)
.map(str::to_string),
managed_chatgpt_base_url: marker
.get("managed_chatgpt_base_url")
.and_then(Value::as_str)
.map(str::to_string),
previous_chatgpt_base_url: marker
.get("previous_chatgpt_base_url")
.and_then(Value::as_str)
.map(str::to_string),
managed_realtime_base_url: marker
.get("managed_realtime_base_url")
.and_then(Value::as_str)
.map(str::to_string),
previous_realtime_ws_base_url: marker
.get("previous_realtime_ws_base_url")
.and_then(Value::as_str)
.map(str::to_string),
previous_realtime_call_base_url: marker
.get("previous_realtime_call_base_url")
.and_then(Value::as_str)
.map(str::to_string),
})
}