oh-my-codex 0.17.3 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/Cargo.lock +13 -5
  2. package/Cargo.toml +2 -1
  3. package/README.md +1 -0
  4. package/crates/omx-api/Cargo.toml +19 -0
  5. package/crates/omx-api/src/lib.rs +2940 -0
  6. package/crates/omx-api/src/main.rs +10 -0
  7. package/crates/omx-api/tests/cli.rs +558 -0
  8. package/crates/omx-explore/src/main.rs +4 -0
  9. package/crates/omx-sparkshell/src/codex_bridge.rs +437 -123
  10. package/crates/omx-sparkshell/src/exec.rs +4 -0
  11. package/crates/omx-sparkshell/src/main.rs +738 -29
  12. package/crates/omx-sparkshell/src/prompt.rs +25 -3
  13. package/crates/omx-sparkshell/src/redaction.rs +241 -0
  14. package/crates/omx-sparkshell/tests/execution.rs +479 -238
  15. package/dist/cli/__tests__/api.test.d.ts +2 -0
  16. package/dist/cli/__tests__/api.test.d.ts.map +1 -0
  17. package/dist/cli/__tests__/api.test.js +175 -0
  18. package/dist/cli/__tests__/api.test.js.map +1 -0
  19. package/dist/cli/__tests__/ask.test.js +72 -5
  20. package/dist/cli/__tests__/ask.test.js.map +1 -1
  21. package/dist/cli/__tests__/autoresearch-goal.test.js +14 -1
  22. package/dist/cli/__tests__/autoresearch-goal.test.js.map +1 -1
  23. package/dist/cli/__tests__/explore.test.js +23 -0
  24. package/dist/cli/__tests__/explore.test.js.map +1 -1
  25. package/dist/cli/__tests__/index.test.js +123 -5
  26. package/dist/cli/__tests__/index.test.js.map +1 -1
  27. package/dist/cli/__tests__/launch-fallback.test.js +76 -0
  28. package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
  29. package/dist/cli/__tests__/package-bin-contract.test.js +4 -3
  30. package/dist/cli/__tests__/package-bin-contract.test.js.map +1 -1
  31. package/dist/cli/__tests__/setup-install-mode.test.js +138 -0
  32. package/dist/cli/__tests__/setup-install-mode.test.js.map +1 -1
  33. package/dist/cli/__tests__/sparkshell-cli.test.js +5 -0
  34. package/dist/cli/__tests__/sparkshell-cli.test.js.map +1 -1
  35. package/dist/cli/__tests__/version-sync-contract.test.js +4 -0
  36. package/dist/cli/__tests__/version-sync-contract.test.js.map +1 -1
  37. package/dist/cli/__tests__/windows-popup-loop-contract.test.js +1 -1
  38. package/dist/cli/__tests__/windows-popup-loop-contract.test.js.map +1 -1
  39. package/dist/cli/api.d.ts +26 -0
  40. package/dist/cli/api.d.ts.map +1 -0
  41. package/dist/cli/api.js +153 -0
  42. package/dist/cli/api.js.map +1 -0
  43. package/dist/cli/explore.d.ts +2 -0
  44. package/dist/cli/explore.d.ts.map +1 -1
  45. package/dist/cli/explore.js +43 -1
  46. package/dist/cli/explore.js.map +1 -1
  47. package/dist/cli/index.d.ts +10 -4
  48. package/dist/cli/index.d.ts.map +1 -1
  49. package/dist/cli/index.js +128 -10
  50. package/dist/cli/index.js.map +1 -1
  51. package/dist/cli/native-assets.d.ts +2 -1
  52. package/dist/cli/native-assets.d.ts.map +1 -1
  53. package/dist/cli/native-assets.js +1 -0
  54. package/dist/cli/native-assets.js.map +1 -1
  55. package/dist/cli/sparkshell.d.ts.map +1 -1
  56. package/dist/cli/sparkshell.js +20 -3
  57. package/dist/cli/sparkshell.js.map +1 -1
  58. package/dist/config/generator.d.ts.map +1 -1
  59. package/dist/config/generator.js +90 -0
  60. package/dist/config/generator.js.map +1 -1
  61. package/dist/hooks/__tests__/best-practice-research-skill.test.d.ts +2 -0
  62. package/dist/hooks/__tests__/best-practice-research-skill.test.d.ts.map +1 -0
  63. package/dist/hooks/__tests__/best-practice-research-skill.test.js +27 -0
  64. package/dist/hooks/__tests__/best-practice-research-skill.test.js.map +1 -0
  65. package/dist/hooks/__tests__/keyword-detector.test.js +11 -0
  66. package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
  67. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +6 -0
  68. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
  69. package/dist/hooks/__tests__/prompt-guidance-wave-two.test.js +4 -0
  70. package/dist/hooks/__tests__/prompt-guidance-wave-two.test.js.map +1 -1
  71. package/dist/hooks/keyword-registry.d.ts.map +1 -1
  72. package/dist/hooks/keyword-registry.js +1 -0
  73. package/dist/hooks/keyword-registry.js.map +1 -1
  74. package/dist/hud/__tests__/reconcile.test.js +2 -2
  75. package/dist/hud/__tests__/reconcile.test.js.map +1 -1
  76. package/dist/hud/__tests__/tmux.test.js +23 -18
  77. package/dist/hud/__tests__/tmux.test.js.map +1 -1
  78. package/dist/hud/tmux.d.ts.map +1 -1
  79. package/dist/hud/tmux.js +7 -6
  80. package/dist/hud/tmux.js.map +1 -1
  81. package/dist/mcp/__tests__/bootstrap.test.js +75 -1
  82. package/dist/mcp/__tests__/bootstrap.test.js.map +1 -1
  83. package/dist/mcp/bootstrap.d.ts +3 -1
  84. package/dist/mcp/bootstrap.d.ts.map +1 -1
  85. package/dist/mcp/bootstrap.js +71 -2
  86. package/dist/mcp/bootstrap.js.map +1 -1
  87. package/dist/scripts/__tests__/codex-native-hook.test.js +737 -26
  88. package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
  89. package/dist/scripts/__tests__/notify-dispatcher.test.js +183 -1
  90. package/dist/scripts/__tests__/notify-dispatcher.test.js.map +1 -1
  91. package/dist/scripts/__tests__/smoke-packed-install.test.js +4 -1
  92. package/dist/scripts/__tests__/smoke-packed-install.test.js.map +1 -1
  93. package/dist/scripts/build-api.d.ts +2 -0
  94. package/dist/scripts/build-api.d.ts.map +1 -0
  95. package/dist/scripts/build-api.js +44 -0
  96. package/dist/scripts/build-api.js.map +1 -0
  97. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  98. package/dist/scripts/codex-native-hook.js +208 -8
  99. package/dist/scripts/codex-native-hook.js.map +1 -1
  100. package/dist/scripts/codex-native-pre-post.d.ts.map +1 -1
  101. package/dist/scripts/codex-native-pre-post.js +89 -24
  102. package/dist/scripts/codex-native-pre-post.js.map +1 -1
  103. package/dist/scripts/notify-dispatcher.js +88 -0
  104. package/dist/scripts/notify-dispatcher.js.map +1 -1
  105. package/dist/scripts/notify-hook/team-dispatch.d.ts.map +1 -1
  106. package/dist/scripts/notify-hook/team-dispatch.js +27 -9
  107. package/dist/scripts/notify-hook/team-dispatch.js.map +1 -1
  108. package/dist/scripts/notify-hook/team-leader-nudge.d.ts.map +1 -1
  109. package/dist/scripts/notify-hook/team-leader-nudge.js +26 -11
  110. package/dist/scripts/notify-hook/team-leader-nudge.js.map +1 -1
  111. package/dist/scripts/notify-hook/team-tmux-guard.d.ts +1 -0
  112. package/dist/scripts/notify-hook/team-tmux-guard.d.ts.map +1 -1
  113. package/dist/scripts/notify-hook/team-tmux-guard.js +38 -0
  114. package/dist/scripts/notify-hook/team-tmux-guard.js.map +1 -1
  115. package/dist/scripts/notify-hook/team-worker-stop.d.ts.map +1 -1
  116. package/dist/scripts/notify-hook/team-worker-stop.js +27 -14
  117. package/dist/scripts/notify-hook/team-worker-stop.js.map +1 -1
  118. package/dist/scripts/run-provider-advisor.js +9 -3
  119. package/dist/scripts/run-provider-advisor.js.map +1 -1
  120. package/dist/scripts/smoke-packed-install.d.ts +1 -1
  121. package/dist/scripts/smoke-packed-install.d.ts.map +1 -1
  122. package/dist/scripts/smoke-packed-install.js +2 -0
  123. package/dist/scripts/smoke-packed-install.js.map +1 -1
  124. package/dist/team/__tests__/runtime.test.js +2 -2
  125. package/dist/team/__tests__/runtime.test.js.map +1 -1
  126. package/dist/team/__tests__/tmux-session.test.js +96 -19
  127. package/dist/team/__tests__/tmux-session.test.js.map +1 -1
  128. package/dist/team/tmux-session.d.ts +1 -0
  129. package/dist/team/tmux-session.d.ts.map +1 -1
  130. package/dist/team/tmux-session.js +34 -10
  131. package/dist/team/tmux-session.js.map +1 -1
  132. package/dist/verification/__tests__/ci-rust-gates.test.js +85 -10
  133. package/dist/verification/__tests__/ci-rust-gates.test.js.map +1 -1
  134. package/dist/verification/__tests__/explore-harness-release-workflow.test.js +1 -0
  135. package/dist/verification/__tests__/explore-harness-release-workflow.test.js.map +1 -1
  136. package/package.json +4 -3
  137. package/plugins/oh-my-codex/.codex-plugin/plugin.json +1 -1
  138. package/plugins/oh-my-codex/skills/best-practice-research/SKILL.md +83 -0
  139. package/plugins/oh-my-codex/skills/deep-interview/SKILL.md +1 -0
  140. package/plugins/oh-my-codex/skills/ralplan/SKILL.md +1 -1
  141. package/prompts/researcher.md +15 -10
  142. package/skills/best-practice-research/SKILL.md +83 -0
  143. package/skills/deep-interview/SKILL.md +1 -0
  144. package/skills/ralplan/SKILL.md +1 -1
  145. package/src/scripts/__tests__/codex-native-hook.test.ts +810 -4
  146. package/src/scripts/__tests__/notify-dispatcher.test.ts +223 -1
  147. package/src/scripts/__tests__/smoke-packed-install.test.ts +8 -2
  148. package/src/scripts/build-api.ts +48 -0
  149. package/src/scripts/codex-native-hook.ts +262 -10
  150. package/src/scripts/codex-native-pre-post.ts +103 -24
  151. package/src/scripts/notify-dispatcher.ts +97 -0
  152. package/src/scripts/notify-hook/team-dispatch.ts +27 -8
  153. package/src/scripts/notify-hook/team-leader-nudge.ts +25 -11
  154. package/src/scripts/notify-hook/team-tmux-guard.ts +42 -0
  155. package/src/scripts/notify-hook/team-worker-stop.ts +24 -13
  156. package/src/scripts/run-provider-advisor.ts +11 -3
  157. package/src/scripts/smoke-packed-install.ts +2 -0
  158. package/templates/catalog-manifest.json +7 -0
@@ -0,0 +1,2940 @@
1
+ use serde::{Deserialize, Serialize};
2
+ use serde_json::{json, Value};
3
+ use std::collections::BTreeMap;
4
+ use std::env;
5
+ use std::fs;
6
+ use std::io::{self, BufRead, BufReader, Read, Write};
7
+ use std::net::{IpAddr, Shutdown, TcpListener, TcpStream};
8
+ use std::path::{Path, PathBuf};
9
+ use std::process::{Command, Stdio};
10
+ use std::sync::atomic::{AtomicBool, Ordering};
11
+ use std::sync::{Arc, Mutex};
12
+ use std::time::{Duration, SystemTime, UNIX_EPOCH};
13
+
14
+ pub type Result<T> = std::result::Result<T, OmxApiError>;
15
+
16
+ pub const DEFAULT_API_PORT: u16 = 14510;
17
+ pub const MAX_HTTP_HEADER_BYTES: usize = 64 * 1024;
18
+ pub const MAX_HTTP_BODY_BYTES: usize = 4 * 1024 * 1024;
19
+ const CODEX_RESPONSES_PATH: &str = "/responses";
20
+ const CODEX_IMAGES_GENERATIONS_PATH: &str = "/images/generations";
21
+ const CODEX_DEFAULT_ORIGINATOR: &str = "codex_cli_rs";
22
+ const CODEX_DEFAULT_BACKEND_BASE_PATH: &str = "/backend-api/codex";
23
+ const CODEX_INSTALLATION_ID_HEADER: &str = "x-codex-installation-id";
24
+ const CODEX_WINDOW_ID_HEADER: &str = "x-codex-window-id";
25
+
26
+ #[derive(Debug)]
27
+ pub enum OmxApiError {
28
+ Io(io::Error),
29
+ Json(serde_json::Error),
30
+ Message(String),
31
+ }
32
+
33
+ impl std::fmt::Display for OmxApiError {
34
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35
+ match self {
36
+ Self::Io(error) => write!(f, "{error}"),
37
+ Self::Json(error) => write!(f, "{error}"),
38
+ Self::Message(message) => f.write_str(message),
39
+ }
40
+ }
41
+ }
42
+
43
+ impl std::error::Error for OmxApiError {}
44
+
45
+ impl From<io::Error> for OmxApiError {
46
+ fn from(error: io::Error) -> Self {
47
+ Self::Io(error)
48
+ }
49
+ }
50
+
51
+ impl From<serde_json::Error> for OmxApiError {
52
+ fn from(error: serde_json::Error) -> Self {
53
+ Self::Json(error)
54
+ }
55
+ }
56
+
57
+ #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
58
+ #[serde(rename_all = "kebab-case")]
59
+ pub enum BackendMode {
60
+ Mock,
61
+ RealPrivate,
62
+ }
63
+
64
+ impl BackendMode {
65
+ fn parse(value: &str) -> Result<Self> {
66
+ match value {
67
+ "mock" => Ok(Self::Mock),
68
+ "real-private" => Ok(Self::RealPrivate),
69
+ other => Err(OmxApiError::Message(format!(
70
+ "unsupported backend mode '{other}'; expected mock or real-private"
71
+ ))),
72
+ }
73
+ }
74
+
75
+ fn as_str(&self) -> &'static str {
76
+ match self {
77
+ Self::Mock => "mock",
78
+ Self::RealPrivate => "real-private",
79
+ }
80
+ }
81
+ }
82
+
83
+ #[derive(Clone, Debug, Serialize, Deserialize)]
84
+ pub struct ServerConfig {
85
+ pub host: String,
86
+ pub port: u16,
87
+ pub backend: BackendMode,
88
+ pub state_file: PathBuf,
89
+ pub once: bool,
90
+ pub daemon: bool,
91
+ pub local_bearer_token: Option<String>,
92
+ }
93
+
94
+ impl Default for ServerConfig {
95
+ fn default() -> Self {
96
+ Self {
97
+ host: "127.0.0.1".to_string(),
98
+ port: DEFAULT_API_PORT,
99
+ backend: BackendMode::Mock,
100
+ state_file: default_state_file(),
101
+ once: false,
102
+ daemon: false,
103
+ local_bearer_token: None,
104
+ }
105
+ }
106
+ }
107
+
108
+ #[derive(Clone, Debug, Serialize, Deserialize)]
109
+ pub struct DaemonState {
110
+ pub pid: u32,
111
+ pub host: String,
112
+ pub port: u16,
113
+ pub backend: BackendMode,
114
+ pub started_at_unix: u64,
115
+ #[serde(skip)]
116
+ pub local_bearer_token: Option<String>,
117
+ #[serde(skip_serializing_if = "Option::is_none")]
118
+ pub local_bearer_token_file: Option<PathBuf>,
119
+ }
120
+
121
+ impl DaemonState {
122
+ pub fn base_url(&self) -> String {
123
+ format!("http://{}:{}", self.host, self.port)
124
+ }
125
+ }
126
+
127
+ #[derive(Clone, Debug, Default, Serialize, Deserialize)]
128
+ pub struct TelemetrySnapshot {
129
+ pub requests_total: u64,
130
+ pub by_route: BTreeMap<String, u64>,
131
+ }
132
+
133
+ #[derive(Debug, Default)]
134
+ pub struct Telemetry {
135
+ inner: Mutex<TelemetrySnapshot>,
136
+ }
137
+
138
+ impl Telemetry {
139
+ pub fn record(&self, route: &str) {
140
+ let mut inner = self.inner.lock().expect("telemetry lock poisoned");
141
+ inner.requests_total += 1;
142
+ *inner.by_route.entry(route.to_string()).or_insert(0) += 1;
143
+ }
144
+
145
+ pub fn snapshot(&self) -> TelemetrySnapshot {
146
+ self.inner.lock().expect("telemetry lock poisoned").clone()
147
+ }
148
+ }
149
+
150
+ #[derive(Clone, Debug)]
151
+ pub struct Request {
152
+ pub method: String,
153
+ pub path: String,
154
+ pub headers: BTreeMap<String, String>,
155
+ pub body: Vec<u8>,
156
+ }
157
+
158
+ #[derive(Clone, Debug)]
159
+ pub struct Response {
160
+ pub status: u16,
161
+ pub content_type: String,
162
+ pub body: Vec<u8>,
163
+ pub extra_headers: Vec<(String, String)>,
164
+ }
165
+
166
+ impl Response {
167
+ pub fn json(status: u16, value: Value) -> Self {
168
+ let body = serde_json::to_vec(&value).expect("JSON serialization should not fail");
169
+ Self {
170
+ status,
171
+ content_type: "application/json".to_string(),
172
+ body,
173
+ extra_headers: Vec::new(),
174
+ }
175
+ }
176
+
177
+ pub fn text(status: u16, content_type: &str, body: impl Into<Vec<u8>>) -> Self {
178
+ Self {
179
+ status,
180
+ content_type: content_type.to_string(),
181
+ body: body.into(),
182
+ extra_headers: Vec::new(),
183
+ }
184
+ }
185
+ }
186
+
187
+ pub fn default_state_file() -> PathBuf {
188
+ env::temp_dir().join("omx-api-daemon.json")
189
+ }
190
+
191
+ pub fn write_daemon_state(path: impl AsRef<Path>, state: &DaemonState) -> Result<()> {
192
+ let path = path.as_ref();
193
+ if let Some(parent) = path.parent() {
194
+ fs::create_dir_all(parent)?;
195
+ }
196
+ let bytes = serde_json::to_vec_pretty(state)?;
197
+ fs::write(path, bytes)?;
198
+ Ok(())
199
+ }
200
+
201
+ pub fn read_daemon_state(path: impl AsRef<Path>) -> Result<Option<DaemonState>> {
202
+ let path = path.as_ref();
203
+ if !path.exists() {
204
+ return Ok(None);
205
+ }
206
+ let bytes = fs::read(path)?;
207
+ Ok(Some(serde_json::from_slice(&bytes)?))
208
+ }
209
+
210
+ pub fn remove_daemon_state(path: impl AsRef<Path>) -> Result<()> {
211
+ let path = path.as_ref();
212
+ match fs::remove_file(path) {
213
+ Ok(()) => Ok(()),
214
+ Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
215
+ Err(error) => Err(error.into()),
216
+ }
217
+ }
218
+
219
+ fn token_file_for_state(path: impl AsRef<Path>) -> PathBuf {
220
+ let mut path = path.as_ref().to_path_buf();
221
+ path.set_extension("token");
222
+ path
223
+ }
224
+
225
+ fn write_local_bearer_token(path: impl AsRef<Path>, token: &str) -> Result<()> {
226
+ let path = path.as_ref();
227
+ if let Some(parent) = path.parent() {
228
+ fs::create_dir_all(parent)?;
229
+ }
230
+ #[cfg(unix)]
231
+ {
232
+ use std::os::unix::fs::OpenOptionsExt;
233
+ let mut file = fs::OpenOptions::new()
234
+ .create(true)
235
+ .truncate(true)
236
+ .write(true)
237
+ .mode(0o600)
238
+ .open(path)?;
239
+ file.write_all(token.as_bytes())?;
240
+ Ok(())
241
+ }
242
+ #[cfg(not(unix))]
243
+ {
244
+ fs::write(path, token)?;
245
+ Ok(())
246
+ }
247
+ }
248
+
249
+ fn read_local_bearer_token(path: impl AsRef<Path>) -> Result<Option<String>> {
250
+ let path = path.as_ref();
251
+ if !path.exists() {
252
+ return Ok(None);
253
+ }
254
+ Ok(Some(fs::read_to_string(path)?.trim().to_string()).filter(|token| !token.is_empty()))
255
+ }
256
+
257
+ fn remove_local_bearer_token(path: impl AsRef<Path>) -> Result<()> {
258
+ match fs::remove_file(path) {
259
+ Ok(()) => Ok(()),
260
+ Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
261
+ Err(error) => Err(error.into()),
262
+ }
263
+ }
264
+
265
+ pub fn redact_secrets(input: &str) -> String {
266
+ let mut out = String::with_capacity(input.len());
267
+ let mut redact_next = false;
268
+ for (index, token) in input.split_whitespace().enumerate() {
269
+ if index > 0 {
270
+ out.push(' ');
271
+ }
272
+ let lower = token.to_ascii_lowercase();
273
+ if redact_next
274
+ || lower.starts_with("sk-")
275
+ || lower.starts_with("sess-")
276
+ || lower.starts_with("bearer")
277
+ || lower.contains("api_key=")
278
+ || lower.contains("apikey=")
279
+ || lower.contains("authorization:")
280
+ {
281
+ out.push_str("[REDACTED]");
282
+ redact_next =
283
+ lower == "bearer" || lower.ends_with("bearer") || lower.contains("authorization:");
284
+ } else {
285
+ out.push_str(token);
286
+ redact_next = false;
287
+ }
288
+ }
289
+ redact_secret_markers(&redact_key_value_secret_fragments(
290
+ &redact_json_secret_values(&out),
291
+ ))
292
+ }
293
+
294
+ fn redact_json_secret_values(input: &str) -> String {
295
+ let mut value: Value = match serde_json::from_str(input) {
296
+ Ok(value) => value,
297
+ Err(_) => return input.to_string(),
298
+ };
299
+ redact_value(&mut value);
300
+ serde_json::to_string(&value).unwrap_or_else(|_| input.to_string())
301
+ }
302
+
303
+ fn redact_key_value_secret_fragments(input: &str) -> String {
304
+ let mut output = input.to_string();
305
+ let secret_keys = [
306
+ "access_token",
307
+ "api_key",
308
+ "apikey",
309
+ "auth_token",
310
+ "authorization",
311
+ "password",
312
+ "secret",
313
+ "token",
314
+ ];
315
+ let mut search_start = 0;
316
+ loop {
317
+ if search_start >= output.len() {
318
+ break;
319
+ }
320
+ let lower = output[search_start..].to_ascii_lowercase();
321
+ let Some((relative_key_start, key)) = secret_keys
322
+ .iter()
323
+ .filter_map(|key| lower.find(key).map(|index| (index, *key)))
324
+ .min_by_key(|(index, _)| *index)
325
+ else {
326
+ break;
327
+ };
328
+ let key_start = search_start + relative_key_start;
329
+ let key_end = key_start + key.len();
330
+ let after_key = &output[key_end..];
331
+ let Some(delimiter_offset) = after_key.char_indices().find_map(|(offset, ch)| match ch {
332
+ '=' | ':' => Some(offset),
333
+ '"' | '\'' | ' ' | '\t' => None,
334
+ _ => Some(usize::MAX),
335
+ }) else {
336
+ break;
337
+ };
338
+ if delimiter_offset == usize::MAX {
339
+ search_start = key_end;
340
+ continue;
341
+ }
342
+ let delimiter = key_end + delimiter_offset;
343
+ let mut value_start = delimiter + 1;
344
+ while output
345
+ .as_bytes()
346
+ .get(value_start)
347
+ .is_some_and(u8::is_ascii_whitespace)
348
+ {
349
+ value_start += 1;
350
+ }
351
+ let quote = output
352
+ .as_bytes()
353
+ .get(value_start)
354
+ .copied()
355
+ .filter(|byte| *byte == b'"' || *byte == b'\'');
356
+ if quote.is_some() {
357
+ value_start += 1;
358
+ }
359
+ let mut value_end = if let Some(quote) = quote {
360
+ output
361
+ .as_bytes()
362
+ .get(value_start..)
363
+ .and_then(|tail| tail.iter().position(|byte| *byte == quote))
364
+ .map(|offset| value_start + offset)
365
+ .unwrap_or_else(|| find_secret_end(&output, value_start))
366
+ } else {
367
+ find_secret_end(&output, value_start)
368
+ };
369
+ if value_end <= value_start {
370
+ search_start = key_end;
371
+ continue;
372
+ }
373
+ if quote.is_none() {
374
+ value_end = value_end
375
+ .min(
376
+ output[value_start..]
377
+ .find(',')
378
+ .map(|offset| value_start + offset)
379
+ .unwrap_or(output.len()),
380
+ )
381
+ .min(
382
+ output[value_start..]
383
+ .find('}')
384
+ .map(|offset| value_start + offset)
385
+ .unwrap_or(output.len()),
386
+ );
387
+ }
388
+ output.replace_range(value_start..value_end, "[REDACTED]");
389
+ search_start = value_start + "[REDACTED]".len();
390
+ }
391
+ output
392
+ }
393
+
394
+ fn redact_secret_markers(input: &str) -> String {
395
+ let mut output = input.to_string();
396
+ for marker in ["sk-", "ghp_", "xoxb-"] {
397
+ while let Some(start) = output.find(marker) {
398
+ let end = find_secret_end(&output, start);
399
+ output.replace_range(start..end, "[REDACTED]");
400
+ }
401
+ }
402
+ output
403
+ }
404
+
405
+ fn find_secret_end(text: &str, start: usize) -> usize {
406
+ text[start..]
407
+ .char_indices()
408
+ .find_map(|(offset, ch)| {
409
+ if ch.is_whitespace() || matches!(ch, ',' | ';' | ')' | ']' | '}') {
410
+ Some(start + offset)
411
+ } else {
412
+ None
413
+ }
414
+ })
415
+ .unwrap_or(text.len())
416
+ }
417
+
418
+ fn redact_value(value: &mut Value) {
419
+ match value {
420
+ Value::Object(map) => {
421
+ for (key, nested) in map {
422
+ let key = key.to_ascii_lowercase();
423
+ if key.contains("key")
424
+ || key.contains("token")
425
+ || key.contains("secret")
426
+ || key == "authorization"
427
+ {
428
+ *nested = Value::String("[REDACTED]".to_string());
429
+ } else {
430
+ redact_value(nested);
431
+ }
432
+ }
433
+ }
434
+ Value::Array(items) => {
435
+ for item in items {
436
+ redact_value(item);
437
+ }
438
+ }
439
+ Value::String(text) if text.starts_with("sk-") || text.starts_with("Bearer ") => {
440
+ *text = "[REDACTED]".to_string();
441
+ }
442
+ _ => {}
443
+ }
444
+ }
445
+
446
+ pub fn sse_event(event: Option<&str>, data: &Value) -> String {
447
+ let mut output = String::new();
448
+ if let Some(event) = event {
449
+ output.push_str("event: ");
450
+ output.push_str(event);
451
+ output.push('\n');
452
+ }
453
+ let json = serde_json::to_string(data).expect("JSON serialization should not fail");
454
+ for line in json.lines() {
455
+ output.push_str("data: ");
456
+ output.push_str(line);
457
+ output.push('\n');
458
+ }
459
+ output.push('\n');
460
+ output
461
+ }
462
+
463
+ pub fn sse_done() -> String {
464
+ "data: [DONE]\n\n".to_string()
465
+ }
466
+
467
+ pub fn route_request(
468
+ request: &Request,
469
+ backend: &BackendMode,
470
+ telemetry: &Telemetry,
471
+ shutdown: Option<&AtomicBool>,
472
+ expected_bearer: Option<&str>,
473
+ ) -> Response {
474
+ let route = canonical_route(&request.path);
475
+ telemetry.record(route);
476
+
477
+ if let Some(expected) = expected_bearer.filter(|token| !token.is_empty()) {
478
+ if !has_matching_local_bearer(request, expected) {
479
+ return Response::json(
480
+ 401,
481
+ json!({
482
+ "error": {
483
+ "message": "matching local bearer token required",
484
+ "type": "unauthorized"
485
+ }
486
+ }),
487
+ );
488
+ }
489
+ } else if local_bearer_required() && !has_local_bearer(request) {
490
+ return Response::json(
491
+ 401,
492
+ json!({
493
+ "error": {
494
+ "message": "local bearer token required by OMX_API_REQUIRE_LOCAL_BEARER",
495
+ "type": "unauthorized"
496
+ }
497
+ }),
498
+ );
499
+ }
500
+
501
+ match (request.method.as_str(), request.path.as_str()) {
502
+ ("GET", "/health") => Response::json(
503
+ 200,
504
+ json!({
505
+ "status": "ok",
506
+ "backend": backend.as_str(),
507
+ }),
508
+ ),
509
+ ("GET", "/v1/models") => Response::json(
510
+ 200,
511
+ json!({
512
+ "object": "list",
513
+ "data": [
514
+ {"id": "omx-mock", "object": "model", "owned_by": "omx"},
515
+ {"id": "omx-private", "object": "model", "owned_by": "local"}
516
+ ]
517
+ }),
518
+ ),
519
+ ("POST", "/v1/responses") => responses_response(request, backend),
520
+ ("POST", "/v1/chat/completions") => chat_response(request, backend),
521
+ ("POST", "/v1/images/generations") => image_response(request, backend),
522
+ ("GET", "/__admin/telemetry") => Response::json(200, json!(telemetry.snapshot())),
523
+ ("POST", "/__admin/stop") => {
524
+ if let Some(flag) = shutdown {
525
+ flag.store(true, Ordering::SeqCst);
526
+ }
527
+ Response::json(200, json!({"status": "stopping"}))
528
+ }
529
+ _ => Response::json(
530
+ 404,
531
+ json!({
532
+ "error": {
533
+ "message": format!("no route for {} {}", request.method, request.path),
534
+ "type": "not_found"
535
+ }
536
+ }),
537
+ ),
538
+ }
539
+ }
540
+
541
+ fn local_bearer_required() -> bool {
542
+ env::var("OMX_API_REQUIRE_LOCAL_BEARER")
543
+ .ok()
544
+ .map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "on"))
545
+ .unwrap_or(false)
546
+ }
547
+
548
+ fn has_matching_local_bearer(request: &Request, expected: &str) -> bool {
549
+ request
550
+ .headers
551
+ .get("authorization")
552
+ .and_then(|value| value.strip_prefix("Bearer "))
553
+ .map(str::trim)
554
+ .is_some_and(|token| token == expected)
555
+ }
556
+
557
+ fn has_local_bearer(request: &Request) -> bool {
558
+ request
559
+ .headers
560
+ .get("authorization")
561
+ .and_then(|value| value.strip_prefix("Bearer "))
562
+ .map(str::trim)
563
+ .filter(|token| !token.is_empty())
564
+ .is_some()
565
+ }
566
+
567
+ fn canonical_route(path: &str) -> &'static str {
568
+ match path {
569
+ "/health" => "/health",
570
+ "/v1/models" => "/v1/models",
571
+ "/v1/responses" => "/v1/responses",
572
+ "/v1/chat/completions" => "/v1/chat/completions",
573
+ "/v1/images/generations" => "/v1/images/generations",
574
+ "/__admin/telemetry" => "/__admin/telemetry",
575
+ "/__admin/stop" => "/__admin/stop",
576
+ _ => "unknown",
577
+ }
578
+ }
579
+
580
+ fn responses_response(request: &Request, backend: &BackendMode) -> Response {
581
+ let body = match parse_json_body(request) {
582
+ Ok(body) => body,
583
+ Err(response) => return response,
584
+ };
585
+ if body.get("stream").and_then(Value::as_bool).unwrap_or(false) {
586
+ if *backend == BackendMode::RealPrivate {
587
+ return real_private_text_response(&body, "response", true);
588
+ }
589
+ let payload = json!({"type": "message", "delta": "omx mock response"});
590
+ let mut stream = sse_event(Some("response.output_text.delta"), &payload);
591
+ stream.push_str(&sse_done());
592
+ return Response::text(200, "text/event-stream", stream.into_bytes());
593
+ }
594
+
595
+ if *backend == BackendMode::RealPrivate {
596
+ return real_private_text_response(&body, "response", false);
597
+ }
598
+
599
+ let input = extract_prompt(&body);
600
+ Response::json(
601
+ 200,
602
+ json!({
603
+ "id": format!("omx-{}", now_unix()),
604
+ "object": "response",
605
+ "model": body.get("model").and_then(Value::as_str).unwrap_or("omx-mock"),
606
+ "backend": backend.as_str(),
607
+ "output_text": format!("omx mock response to: {}", redact_secrets(&input)),
608
+ "choices": [{
609
+ "index": 0,
610
+ "message": {"role": "assistant", "content": "omx mock response"},
611
+ "finish_reason": "stop"
612
+ }]
613
+ }),
614
+ )
615
+ }
616
+
617
+ fn chat_response(request: &Request, backend: &BackendMode) -> Response {
618
+ let body = match parse_json_body(request) {
619
+ Ok(body) => body,
620
+ Err(response) => return response,
621
+ };
622
+ if body.get("stream").and_then(Value::as_bool).unwrap_or(false) {
623
+ let text = if *backend == BackendMode::RealPrivate {
624
+ match real_private_text(&body) {
625
+ Ok(text) => text,
626
+ Err((status, kind, message)) => {
627
+ return Response::json(
628
+ status,
629
+ json!({"error": {"message": message, "type": kind}}),
630
+ );
631
+ }
632
+ }
633
+ } else {
634
+ "omx mock response".to_string()
635
+ };
636
+ let chunk = json!({
637
+ "id": format!("chatcmpl-omx-{}", now_unix()),
638
+ "object": "chat.completion.chunk",
639
+ "choices": [{"index": 0, "delta": {"content": text}, "finish_reason": null}]
640
+ });
641
+ let mut stream = sse_event(None, &chunk);
642
+ stream.push_str(&sse_done());
643
+ return Response::text(200, "text/event-stream", stream.into_bytes());
644
+ }
645
+ if *backend == BackendMode::RealPrivate {
646
+ return real_private_text_response(&body, "chat.completion", false);
647
+ }
648
+ Response::json(
649
+ 200,
650
+ json!({
651
+ "id": format!("chatcmpl-omx-{}", now_unix()),
652
+ "object": "chat.completion",
653
+ "model": body.get("model").and_then(Value::as_str).unwrap_or("omx-mock"),
654
+ "backend": backend.as_str(),
655
+ "choices": [{
656
+ "index": 0,
657
+ "message": {"role": "assistant", "content": "omx mock response"},
658
+ "finish_reason": "stop"
659
+ }]
660
+ }),
661
+ )
662
+ }
663
+
664
+ fn real_private_text_response(body: &Value, object: &str, stream: bool) -> Response {
665
+ match real_private_text(body) {
666
+ Ok(text) => text_response_json(body, object, "real-private", &text, stream),
667
+ Err((status, kind, message)) => Response::json(
668
+ status,
669
+ json!({
670
+ "error": {
671
+ "message": message,
672
+ "type": kind
673
+ }
674
+ }),
675
+ ),
676
+ }
677
+ }
678
+
679
+ fn real_private_text(body: &Value) -> std::result::Result<String, (u16, &'static str, String)> {
680
+ if let Some(fixture) = env::var("OMX_API_REAL_PRIVATE_RESPONSE_TEXT")
681
+ .ok()
682
+ .filter(|value| !value.trim().is_empty())
683
+ {
684
+ return Ok(fixture);
685
+ }
686
+
687
+ let Some(auth) = discover_codex_oauth() else {
688
+ return Err((
689
+ 503,
690
+ "missing_auth",
691
+ "missing Codex OAuth token; run Codex login or set OMX_API_REAL_PRIVATE_RESPONSE_TEXT for fixture smoke tests".to_string(),
692
+ ));
693
+ };
694
+
695
+ let Some(upstream) = env::var("OMX_API_PRIVATE_BACKEND_URL")
696
+ .ok()
697
+ .filter(|value| !value.trim().is_empty())
698
+ else {
699
+ return Err((
700
+ 501,
701
+ "private_backend_unconfigured",
702
+ "real-private OAuth was found, but OMX_API_PRIVATE_BACKEND_URL is not configured for this private backend build".to_string(),
703
+ ));
704
+ };
705
+
706
+ match post_codex_responses_to_url(&upstream, body, &auth) {
707
+ Ok(text) => Ok(text),
708
+ Err(error) => Err((
709
+ 502,
710
+ "private_backend_error",
711
+ redact_secrets(&error.to_string()),
712
+ )),
713
+ }
714
+ }
715
+
716
+ fn text_response_json(
717
+ body: &Value,
718
+ object: &str,
719
+ backend: &str,
720
+ text: &str,
721
+ stream: bool,
722
+ ) -> Response {
723
+ if stream {
724
+ let mut output = sse_event(
725
+ Some("response.created"),
726
+ &json!({"type": "response.created", "response": {"backend": backend}}),
727
+ );
728
+ output.push_str(&sse_event(
729
+ Some("response.output_text.delta"),
730
+ &json!({"type": "response.output_text.delta", "delta": text}),
731
+ ));
732
+ output.push_str(&sse_event(
733
+ Some("response.completed"),
734
+ &json!({"type": "response.completed"}),
735
+ ));
736
+ output.push_str(&sse_done());
737
+ return Response::text(200, "text/event-stream", output.into_bytes());
738
+ }
739
+
740
+ Response::json(
741
+ 200,
742
+ json!({
743
+ "id": format!("omx-{}", now_unix()),
744
+ "object": object,
745
+ "model": body.get("model").and_then(Value::as_str).unwrap_or("omx-private"),
746
+ "backend": backend,
747
+ "output_text": text,
748
+ "choices": [{
749
+ "index": 0,
750
+ "message": {"role": "assistant", "content": text},
751
+ "finish_reason": "stop"
752
+ }]
753
+ }),
754
+ )
755
+ }
756
+
757
+ fn image_response(request: &Request, backend: &BackendMode) -> Response {
758
+ let body = match parse_json_body(request) {
759
+ Ok(body) => body,
760
+ Err(response) => return response,
761
+ };
762
+ if *backend == BackendMode::RealPrivate {
763
+ return real_private_image_response(&body);
764
+ }
765
+ let prompt = body.get("prompt").and_then(Value::as_str).unwrap_or("");
766
+ if body.get("stream").and_then(Value::as_bool).unwrap_or(false) {
767
+ let mut stream = sse_event(
768
+ Some("image_generation.partial_image"),
769
+ &json!({"type": "image_generation.partial_image", "b64_json": "omx-mock-image-fragment"}),
770
+ );
771
+ stream.push_str(&sse_event(
772
+ Some("image_generation.completed"),
773
+ &json!({"type": "image_generation.completed"}),
774
+ ));
775
+ stream.push_str(&sse_done());
776
+ return Response::text(200, "text/event-stream", stream.into_bytes());
777
+ }
778
+ Response::json(
779
+ 200,
780
+ json!({
781
+ "created": now_unix(),
782
+ "backend": backend.as_str(),
783
+ "data": [{
784
+ "url": "https://localhost.omx.invalid/mock-image.png",
785
+ "revised_prompt": redact_secrets(prompt)
786
+ }]
787
+ }),
788
+ )
789
+ }
790
+
791
+ fn real_private_image_response(body: &Value) -> Response {
792
+ match real_private_image(body) {
793
+ Ok(image) => image_response_json(body, "real-private", image),
794
+ Err((status, kind, message)) => Response::json(
795
+ status,
796
+ json!({
797
+ "error": {
798
+ "message": message,
799
+ "type": kind
800
+ }
801
+ }),
802
+ ),
803
+ }
804
+ }
805
+
806
+ #[derive(Clone, Debug, Default)]
807
+ struct GeneratedImage {
808
+ url: Option<String>,
809
+ b64_json: Option<String>,
810
+ revised_prompt: Option<String>,
811
+ }
812
+
813
+ fn real_private_image(
814
+ body: &Value,
815
+ ) -> std::result::Result<GeneratedImage, (u16, &'static str, String)> {
816
+ if let Some(b64_json) = env::var("OMX_API_REAL_PRIVATE_IMAGE_B64_JSON")
817
+ .ok()
818
+ .filter(|value| !value.trim().is_empty())
819
+ {
820
+ return Ok(GeneratedImage {
821
+ b64_json: Some(b64_json),
822
+ revised_prompt: body
823
+ .get("prompt")
824
+ .and_then(Value::as_str)
825
+ .map(redact_secrets),
826
+ ..GeneratedImage::default()
827
+ });
828
+ }
829
+ if let Some(url) = env::var("OMX_API_REAL_PRIVATE_IMAGE_URL")
830
+ .ok()
831
+ .filter(|value| !value.trim().is_empty())
832
+ {
833
+ return Ok(GeneratedImage {
834
+ url: Some(url),
835
+ revised_prompt: body
836
+ .get("prompt")
837
+ .and_then(Value::as_str)
838
+ .map(redact_secrets),
839
+ ..GeneratedImage::default()
840
+ });
841
+ }
842
+
843
+ let Some(auth) = discover_codex_oauth() else {
844
+ return Err((
845
+ 503,
846
+ "missing_auth",
847
+ "missing Codex OAuth token; run Codex login or set OMX_API_REAL_PRIVATE_IMAGE_B64_JSON/OMX_API_REAL_PRIVATE_IMAGE_URL for fixture smoke tests".to_string(),
848
+ ));
849
+ };
850
+
851
+ if let Some(upstream) = env::var("OMX_API_PRIVATE_IMAGE_BACKEND_URL")
852
+ .ok()
853
+ .filter(|value| !value.trim().is_empty())
854
+ {
855
+ return post_codex_image_to_url(&upstream, body, &auth).map_err(|error| {
856
+ (
857
+ 502,
858
+ "private_backend_error",
859
+ redact_secrets(&error.to_string()),
860
+ )
861
+ });
862
+ }
863
+
864
+ if let Some(upstream) = env::var("OMX_API_PRIVATE_BACKEND_URL")
865
+ .ok()
866
+ .filter(|value| !value.trim().is_empty())
867
+ {
868
+ return post_codex_image_via_responses_to_url(&upstream, body, &auth).map_err(|error| {
869
+ (
870
+ 502,
871
+ "private_backend_error",
872
+ redact_secrets(&error.to_string()),
873
+ )
874
+ });
875
+ }
876
+
877
+ Err((
878
+ 501,
879
+ "private_image_backend_unconfigured",
880
+ "real-private OAuth was found, but neither OMX_API_PRIVATE_IMAGE_BACKEND_URL nor OMX_API_PRIVATE_BACKEND_URL is configured for image generation".to_string(),
881
+ ))
882
+ }
883
+
884
+ fn image_response_json(body: &Value, backend: &str, image: GeneratedImage) -> Response {
885
+ let mut item = serde_json::Map::new();
886
+ if let Some(url) = image.url {
887
+ item.insert("url".to_string(), Value::String(url));
888
+ }
889
+ if let Some(b64_json) = image.b64_json {
890
+ item.insert("b64_json".to_string(), Value::String(b64_json));
891
+ }
892
+ if let Some(revised_prompt) = image.revised_prompt.or_else(|| {
893
+ body.get("prompt")
894
+ .and_then(Value::as_str)
895
+ .map(redact_secrets)
896
+ }) {
897
+ item.insert("revised_prompt".to_string(), Value::String(revised_prompt));
898
+ }
899
+ if item.is_empty() {
900
+ item.insert(
901
+ "url".to_string(),
902
+ Value::String("https://localhost.omx.invalid/private-image.png".to_string()),
903
+ );
904
+ }
905
+
906
+ if body.get("stream").and_then(Value::as_bool).unwrap_or(false) {
907
+ let mut stream = sse_event(
908
+ Some("image_generation.partial_image"),
909
+ &json!({"type": "image_generation.partial_image", "backend": backend, "image": item}),
910
+ );
911
+ stream.push_str(&sse_event(
912
+ Some("image_generation.completed"),
913
+ &json!({"type": "image_generation.completed", "backend": backend}),
914
+ ));
915
+ stream.push_str(&sse_done());
916
+ return Response::text(200, "text/event-stream", stream.into_bytes());
917
+ }
918
+
919
+ Response::json(
920
+ 200,
921
+ json!({
922
+ "created": now_unix(),
923
+ "backend": backend,
924
+ "data": [Value::Object(item)]
925
+ }),
926
+ )
927
+ }
928
+
929
+ fn parse_json_body(request: &Request) -> std::result::Result<Value, Response> {
930
+ if request.body.is_empty() {
931
+ return Ok(json!({}));
932
+ }
933
+ serde_json::from_slice(&request.body).map_err(|error| {
934
+ Response::json(
935
+ 400,
936
+ json!({
937
+ "error": {
938
+ "message": format!("invalid JSON request body: {error}"),
939
+ "type": "invalid_request_error"
940
+ }
941
+ }),
942
+ )
943
+ })
944
+ }
945
+
946
+ fn extract_prompt(body: &Value) -> String {
947
+ if let Some(input) = body.get("input") {
948
+ if let Some(text) = input.as_str() {
949
+ return text.to_string();
950
+ }
951
+ if let Some(items) = input.as_array() {
952
+ let text = items
953
+ .iter()
954
+ .filter_map(|item| item.get("content"))
955
+ .flat_map(|content| match content {
956
+ Value::String(text) => vec![text.clone()],
957
+ Value::Array(parts) => parts
958
+ .iter()
959
+ .filter_map(|part| {
960
+ part.get("text").and_then(Value::as_str).map(str::to_string)
961
+ })
962
+ .collect::<Vec<_>>(),
963
+ other => vec![other.to_string()],
964
+ })
965
+ .collect::<Vec<_>>()
966
+ .join("\n");
967
+ if !text.is_empty() {
968
+ return text;
969
+ }
970
+ }
971
+ return input.to_string();
972
+ }
973
+ if let Some(messages) = body.get("messages").and_then(Value::as_array) {
974
+ return messages
975
+ .iter()
976
+ .filter_map(|message| message.get("content"))
977
+ .map(|content| {
978
+ content
979
+ .as_str()
980
+ .map(str::to_string)
981
+ .unwrap_or_else(|| content.to_string())
982
+ })
983
+ .collect::<Vec<_>>()
984
+ .join("\n");
985
+ }
986
+ if let Some(prompt) = body.get("prompt").and_then(Value::as_str) {
987
+ return prompt.to_string();
988
+ }
989
+ String::new()
990
+ }
991
+
992
+ #[derive(Clone, Debug, Default)]
993
+ struct CodexOAuth {
994
+ token: String,
995
+ account_id: Option<String>,
996
+ }
997
+
998
+ fn discover_codex_oauth() -> Option<CodexOAuth> {
999
+ if let Some(token) = env::var("OMX_API_CODEX_OAUTH_TOKEN")
1000
+ .ok()
1001
+ .filter(|value| !value.trim().is_empty())
1002
+ {
1003
+ return Some(CodexOAuth {
1004
+ token,
1005
+ account_id: env::var("OMX_API_CODEX_ACCOUNT_ID")
1006
+ .ok()
1007
+ .filter(|value| !value.trim().is_empty()),
1008
+ });
1009
+ }
1010
+
1011
+ let auth_path = env::var("CODEX_HOME")
1012
+ .ok()
1013
+ .filter(|value| !value.trim().is_empty())
1014
+ .map(PathBuf::from)
1015
+ .or_else(|| {
1016
+ env::var("HOME")
1017
+ .ok()
1018
+ .map(|home| PathBuf::from(home).join(".codex"))
1019
+ })
1020
+ .map(|home| home.join("auth.json"))?;
1021
+ let bytes = fs::read(auth_path).ok()?;
1022
+ let value: Value = serde_json::from_slice(&bytes).ok()?;
1023
+ Some(CodexOAuth {
1024
+ token: find_oauth_token(&value)?,
1025
+ account_id: find_codex_account_id(&value),
1026
+ })
1027
+ }
1028
+
1029
+ fn find_oauth_token(value: &Value) -> Option<String> {
1030
+ match value {
1031
+ Value::Object(map) => {
1032
+ for preferred in ["access_token", "id_token", "oauth_token", "token"] {
1033
+ if let Some(token) = map
1034
+ .get(preferred)
1035
+ .and_then(Value::as_str)
1036
+ .filter(|token| token.len() > 20)
1037
+ {
1038
+ return Some(token.to_string());
1039
+ }
1040
+ }
1041
+ map.values().find_map(find_oauth_token)
1042
+ }
1043
+ Value::Array(items) => items.iter().find_map(find_oauth_token),
1044
+ _ => None,
1045
+ }
1046
+ }
1047
+
1048
+ fn find_codex_account_id(value: &Value) -> Option<String> {
1049
+ match value {
1050
+ Value::Object(map) => {
1051
+ for preferred in ["account_id", "chatgpt_account_id"] {
1052
+ if let Some(account_id) = map
1053
+ .get(preferred)
1054
+ .and_then(Value::as_str)
1055
+ .filter(|account_id| !account_id.trim().is_empty())
1056
+ {
1057
+ return Some(account_id.to_string());
1058
+ }
1059
+ }
1060
+ map.values().find_map(find_codex_account_id)
1061
+ }
1062
+ Value::Array(items) => items.iter().find_map(find_codex_account_id),
1063
+ _ => None,
1064
+ }
1065
+ }
1066
+
1067
+ #[derive(Debug)]
1068
+ struct CodexNativeRequest {
1069
+ path: String,
1070
+ headers: Vec<(String, String)>,
1071
+ body: Value,
1072
+ }
1073
+
1074
+ fn build_codex_native_request(
1075
+ upstream_path: &str,
1076
+ body: &Value,
1077
+ auth: &CodexOAuth,
1078
+ ) -> CodexNativeRequest {
1079
+ let session_id = env::var("OMX_API_CODEX_SESSION_ID")
1080
+ .ok()
1081
+ .filter(|value| !value.trim().is_empty())
1082
+ .unwrap_or_else(|| "omx-api-local-session".to_string());
1083
+ let thread_id = env::var("OMX_API_CODEX_THREAD_ID")
1084
+ .ok()
1085
+ .filter(|value| !value.trim().is_empty())
1086
+ .unwrap_or_else(|| "omx-api-local-thread".to_string());
1087
+ let installation_id = env::var("OMX_API_CODEX_INSTALLATION_ID")
1088
+ .ok()
1089
+ .filter(|value| !value.trim().is_empty())
1090
+ .unwrap_or_else(|| "omx-api-local-installation".to_string());
1091
+ let window_id = env::var("OMX_API_CODEX_WINDOW_ID")
1092
+ .ok()
1093
+ .filter(|value| !value.trim().is_empty())
1094
+ .unwrap_or_else(|| "omx-api-local-window".to_string());
1095
+ let model = body
1096
+ .get("model")
1097
+ .and_then(Value::as_str)
1098
+ .filter(|value| !value.trim().is_empty())
1099
+ .map(str::to_string)
1100
+ .or_else(|| env::var("OMX_API_GENERATE_MODEL").ok())
1101
+ .unwrap_or_else(|| "omx-private".to_string());
1102
+ let prompt = extract_prompt(body);
1103
+ let tools = body.get("tools").cloned().unwrap_or_else(|| json!([]));
1104
+ let tool_choice = body
1105
+ .get("tool_choice")
1106
+ .cloned()
1107
+ .unwrap_or_else(|| json!("auto"));
1108
+ let path = normalize_codex_responses_path(upstream_path);
1109
+ let mut request_body = json!({
1110
+ "model": model,
1111
+ "input": [{
1112
+ "type": "message",
1113
+ "role": "user",
1114
+ "content": [{"type": "input_text", "text": prompt}]
1115
+ }],
1116
+ "tools": tools,
1117
+ "tool_choice": tool_choice,
1118
+ "parallel_tool_calls": false,
1119
+ "reasoning": body.get("reasoning").cloned().unwrap_or(Value::Null),
1120
+ "store": false,
1121
+ "stream": true,
1122
+ "include": [],
1123
+ "prompt_cache_key": thread_id,
1124
+ "client_metadata": {
1125
+ CODEX_INSTALLATION_ID_HEADER: installation_id.clone()
1126
+ }
1127
+ });
1128
+ if let Some(instructions) = body.get("instructions").cloned() {
1129
+ request_body["instructions"] = instructions;
1130
+ }
1131
+
1132
+ let mut headers = vec![
1133
+ ("Content-Type".to_string(), "application/json".to_string()),
1134
+ ("Accept".to_string(), "text/event-stream".to_string()),
1135
+ (
1136
+ "Authorization".to_string(),
1137
+ format!("Bearer {}", auth.token),
1138
+ ),
1139
+ (
1140
+ "originator".to_string(),
1141
+ CODEX_DEFAULT_ORIGINATOR.to_string(),
1142
+ ),
1143
+ ("User-Agent".to_string(), codex_user_agent()),
1144
+ ("x-client-request-id".to_string(), thread_id.clone()),
1145
+ ("session_id".to_string(), session_id.clone()),
1146
+ ("session-id".to_string(), session_id),
1147
+ ("thread_id".to_string(), thread_id.clone()),
1148
+ ("thread-id".to_string(), thread_id),
1149
+ (CODEX_WINDOW_ID_HEADER.to_string(), window_id),
1150
+ ];
1151
+ if let Some(account_id) = auth.account_id.as_ref() {
1152
+ headers.push(("ChatGPT-Account-ID".to_string(), account_id.clone()));
1153
+ }
1154
+
1155
+ CodexNativeRequest {
1156
+ path,
1157
+ headers,
1158
+ body: request_body,
1159
+ }
1160
+ }
1161
+
1162
+ fn codex_user_agent() -> String {
1163
+ env::var("OMX_API_CODEX_USER_AGENT")
1164
+ .ok()
1165
+ .filter(|value| !value.trim().is_empty())
1166
+ .unwrap_or_else(|| "codex_cli_rs/0.130.0 (omx-api)".to_string())
1167
+ }
1168
+
1169
+ fn normalize_codex_responses_path(path: &str) -> String {
1170
+ let path = if path == "/" || path.is_empty() {
1171
+ CODEX_DEFAULT_BACKEND_BASE_PATH
1172
+ } else {
1173
+ path.trim_end_matches('/')
1174
+ };
1175
+ if path.ends_with(CODEX_RESPONSES_PATH) {
1176
+ path.to_string()
1177
+ } else {
1178
+ format!("{path}{CODEX_RESPONSES_PATH}")
1179
+ }
1180
+ }
1181
+
1182
+ fn normalize_codex_images_generations_path(path: &str) -> String {
1183
+ let path = if path == "/" || path.is_empty() {
1184
+ CODEX_DEFAULT_BACKEND_BASE_PATH
1185
+ } else {
1186
+ path.trim_end_matches('/')
1187
+ };
1188
+ if path.ends_with(CODEX_IMAGES_GENERATIONS_PATH) {
1189
+ path.to_string()
1190
+ } else {
1191
+ format!("{path}{CODEX_IMAGES_GENERATIONS_PATH}")
1192
+ }
1193
+ }
1194
+
1195
+ fn build_codex_image_request(
1196
+ upstream_path: &str,
1197
+ body: &Value,
1198
+ auth: &CodexOAuth,
1199
+ ) -> CodexNativeRequest {
1200
+ let model = body
1201
+ .get("model")
1202
+ .and_then(Value::as_str)
1203
+ .filter(|value| !value.trim().is_empty())
1204
+ .map(str::to_string)
1205
+ .or_else(|| env::var("OMX_API_IMAGE_MODEL").ok())
1206
+ .unwrap_or_else(|| "omx-private-image".to_string());
1207
+ let prompt = body
1208
+ .get("prompt")
1209
+ .and_then(Value::as_str)
1210
+ .unwrap_or_default();
1211
+ let mut request_body = json!({
1212
+ "model": model,
1213
+ "prompt": prompt,
1214
+ "n": body.get("n").cloned().unwrap_or_else(|| json!(1)),
1215
+ "size": body.get("size").cloned().unwrap_or_else(|| json!("1024x1024")),
1216
+ });
1217
+ for key in ["quality", "response_format", "style", "user"] {
1218
+ if let Some(value) = body.get(key).cloned() {
1219
+ request_body[key] = value;
1220
+ }
1221
+ }
1222
+
1223
+ let mut headers = vec![
1224
+ ("Content-Type".to_string(), "application/json".to_string()),
1225
+ (
1226
+ "Accept".to_string(),
1227
+ "application/json, text/event-stream".to_string(),
1228
+ ),
1229
+ (
1230
+ "Authorization".to_string(),
1231
+ format!("Bearer {}", auth.token),
1232
+ ),
1233
+ (
1234
+ "originator".to_string(),
1235
+ CODEX_DEFAULT_ORIGINATOR.to_string(),
1236
+ ),
1237
+ ("User-Agent".to_string(), codex_user_agent()),
1238
+ ];
1239
+ if let Some(account_id) = auth.account_id.as_ref() {
1240
+ headers.push(("ChatGPT-Account-ID".to_string(), account_id.clone()));
1241
+ }
1242
+
1243
+ CodexNativeRequest {
1244
+ path: normalize_codex_images_generations_path(upstream_path),
1245
+ headers,
1246
+ body: request_body,
1247
+ }
1248
+ }
1249
+
1250
+ fn post_codex_responses_to_url(url: &str, body: &Value, auth: &CodexOAuth) -> Result<String> {
1251
+ let parsed = parse_http_backend_url(url)?;
1252
+ let codex_request = build_codex_native_request(&parsed.path, body, auth);
1253
+ let payload = serde_json::to_vec(&codex_request.body)?;
1254
+ let mut stream = TcpStream::connect((parsed.host.as_str(), parsed.port))?;
1255
+ stream.set_read_timeout(Some(Duration::from_secs(120)))?;
1256
+ stream.set_write_timeout(Some(Duration::from_secs(30)))?;
1257
+ write!(
1258
+ stream,
1259
+ "POST {} HTTP/1.1\r\nHost: {}:{}\r\n",
1260
+ codex_request.path, parsed.host, parsed.port
1261
+ )?;
1262
+ for (name, value) in codex_request.headers {
1263
+ write!(stream, "{name}: {value}\r\n")?;
1264
+ }
1265
+ write!(
1266
+ stream,
1267
+ "Content-Length: {}\r\nConnection: close\r\n\r\n",
1268
+ payload.len()
1269
+ )?;
1270
+ stream.write_all(&payload)?;
1271
+ let mut raw = String::new();
1272
+ stream.read_to_string(&mut raw)?;
1273
+ let (head, response_body) = split_http_response(&raw)?;
1274
+ let status = head
1275
+ .lines()
1276
+ .next()
1277
+ .and_then(|line| line.split_whitespace().nth(1))
1278
+ .and_then(|value| value.parse::<u16>().ok())
1279
+ .unwrap_or(502);
1280
+ if !(200..300).contains(&status) {
1281
+ return Err(OmxApiError::Message(format!(
1282
+ "private backend returned HTTP {status}: {response_body}"
1283
+ )));
1284
+ }
1285
+
1286
+ Ok(extract_backend_text_response(&response_body))
1287
+ }
1288
+
1289
+ fn post_codex_image_to_url(url: &str, body: &Value, auth: &CodexOAuth) -> Result<GeneratedImage> {
1290
+ let parsed = parse_http_backend_url(url)?;
1291
+ let codex_request = build_codex_image_request(&parsed.path, body, auth);
1292
+ let payload = serde_json::to_vec(&codex_request.body)?;
1293
+ let mut stream = TcpStream::connect((parsed.host.as_str(), parsed.port))?;
1294
+ stream.set_read_timeout(Some(Duration::from_secs(120)))?;
1295
+ stream.set_write_timeout(Some(Duration::from_secs(30)))?;
1296
+ write!(
1297
+ stream,
1298
+ "POST {} HTTP/1.1\r\nHost: {}:{}\r\n",
1299
+ codex_request.path, parsed.host, parsed.port
1300
+ )?;
1301
+ for (name, value) in codex_request.headers {
1302
+ write!(stream, "{name}: {value}\r\n")?;
1303
+ }
1304
+ write!(
1305
+ stream,
1306
+ "Content-Length: {}\r\nConnection: close\r\n\r\n",
1307
+ payload.len()
1308
+ )?;
1309
+ stream.write_all(&payload)?;
1310
+ let mut raw = String::new();
1311
+ stream.read_to_string(&mut raw)?;
1312
+ let (head, response_body) = split_http_response(&raw)?;
1313
+ let status = head
1314
+ .lines()
1315
+ .next()
1316
+ .and_then(|line| line.split_whitespace().nth(1))
1317
+ .and_then(|value| value.parse::<u16>().ok())
1318
+ .unwrap_or(502);
1319
+ if !(200..300).contains(&status) {
1320
+ return Err(OmxApiError::Message(format!(
1321
+ "private backend returned HTTP {status}: {response_body}"
1322
+ )));
1323
+ }
1324
+
1325
+ Ok(extract_backend_image_response(&response_body))
1326
+ }
1327
+
1328
+ fn post_codex_image_via_responses_to_url(
1329
+ url: &str,
1330
+ body: &Value,
1331
+ auth: &CodexOAuth,
1332
+ ) -> Result<GeneratedImage> {
1333
+ let mut request = body.clone();
1334
+ let prompt = body
1335
+ .get("prompt")
1336
+ .and_then(Value::as_str)
1337
+ .unwrap_or_default();
1338
+ request["input"] = json!([{
1339
+ "type": "message",
1340
+ "role": "user",
1341
+ "content": [{"type": "input_text", "text": prompt}]
1342
+ }]);
1343
+ request["tools"] = json!([{"type": "image_generation"}]);
1344
+ request["tool_choice"] = json!({"type": "image_generation"});
1345
+ request["stream"] = json!(true);
1346
+
1347
+ let parsed = parse_http_backend_url(url)?;
1348
+ let codex_request = build_codex_native_request(&parsed.path, &request, auth);
1349
+ let payload = serde_json::to_vec(&codex_request.body)?;
1350
+ let mut stream = TcpStream::connect((parsed.host.as_str(), parsed.port))?;
1351
+ stream.set_read_timeout(Some(Duration::from_secs(120)))?;
1352
+ stream.set_write_timeout(Some(Duration::from_secs(30)))?;
1353
+ write!(
1354
+ stream,
1355
+ "POST {} HTTP/1.1\r\nHost: {}:{}\r\n",
1356
+ codex_request.path, parsed.host, parsed.port
1357
+ )?;
1358
+ for (name, value) in codex_request.headers {
1359
+ write!(stream, "{name}: {value}\r\n")?;
1360
+ }
1361
+ write!(
1362
+ stream,
1363
+ "Content-Length: {}\r\nConnection: close\r\n\r\n",
1364
+ payload.len()
1365
+ )?;
1366
+ stream.write_all(&payload)?;
1367
+ let mut raw = String::new();
1368
+ stream.read_to_string(&mut raw)?;
1369
+ let (head, response_body) = split_http_response(&raw)?;
1370
+ let status = head
1371
+ .lines()
1372
+ .next()
1373
+ .and_then(|line| line.split_whitespace().nth(1))
1374
+ .and_then(|value| value.parse::<u16>().ok())
1375
+ .unwrap_or(502);
1376
+ if !(200..300).contains(&status) {
1377
+ return Err(OmxApiError::Message(format!(
1378
+ "private backend returned HTTP {status}: {response_body}"
1379
+ )));
1380
+ }
1381
+
1382
+ Ok(extract_backend_image_response(&response_body))
1383
+ }
1384
+
1385
+ fn split_http_response(raw: &str) -> Result<(String, String)> {
1386
+ let (head, response_body) = raw.split_once("\r\n\r\n").ok_or_else(|| {
1387
+ OmxApiError::Message("private backend returned malformed HTTP".to_string())
1388
+ })?;
1389
+ let body = if head.lines().any(|line| {
1390
+ let lower = line.to_ascii_lowercase();
1391
+ lower.starts_with("transfer-encoding:") && lower.contains("chunked")
1392
+ }) {
1393
+ decode_chunked_body(response_body)?
1394
+ } else {
1395
+ response_body.to_string()
1396
+ };
1397
+ Ok((head.to_string(), body))
1398
+ }
1399
+
1400
+ fn decode_chunked_body(input: &str) -> Result<String> {
1401
+ let bytes = input.as_bytes();
1402
+ let mut index = 0;
1403
+ let mut output = Vec::new();
1404
+ loop {
1405
+ let Some(line_end) = find_crlf(bytes, index) else {
1406
+ return Err(OmxApiError::Message(
1407
+ "chunked response missing chunk size".to_string(),
1408
+ ));
1409
+ };
1410
+ let size_line = std::str::from_utf8(&bytes[index..line_end])
1411
+ .map_err(|_| OmxApiError::Message("chunked response size is not UTF-8".to_string()))?;
1412
+ let size_hex = size_line.split(';').next().unwrap_or_default().trim();
1413
+ let size = usize::from_str_radix(size_hex, 16).map_err(|_| {
1414
+ OmxApiError::Message(format!("invalid chunk size `{}`", redact_secrets(size_hex)))
1415
+ })?;
1416
+ index = line_end + 2;
1417
+ if size == 0 {
1418
+ break;
1419
+ }
1420
+ if bytes.len() < index + size + 2 {
1421
+ return Err(OmxApiError::Message(
1422
+ "chunked response ended mid-chunk".to_string(),
1423
+ ));
1424
+ }
1425
+ output.extend_from_slice(&bytes[index..index + size]);
1426
+ index += size;
1427
+ if bytes.get(index..index + 2) != Some(b"\r\n") {
1428
+ return Err(OmxApiError::Message(
1429
+ "chunked response missing chunk terminator".to_string(),
1430
+ ));
1431
+ }
1432
+ index += 2;
1433
+ }
1434
+ String::from_utf8(output)
1435
+ .map_err(|_| OmxApiError::Message("chunked response body is not UTF-8".to_string()))
1436
+ }
1437
+
1438
+ fn find_crlf(bytes: &[u8], start: usize) -> Option<usize> {
1439
+ bytes
1440
+ .get(start..)?
1441
+ .windows(2)
1442
+ .position(|chunk| chunk == b"\r\n")
1443
+ .map(|offset| start + offset)
1444
+ }
1445
+
1446
+ fn extract_backend_image_response(response_body: &str) -> GeneratedImage {
1447
+ if response_body
1448
+ .lines()
1449
+ .any(|line| line.starts_with("event:") || line.starts_with("data:"))
1450
+ {
1451
+ for line in response_body.lines() {
1452
+ let Some(data) = line.strip_prefix("data:").map(str::trim) else {
1453
+ continue;
1454
+ };
1455
+ if data == "[DONE]" || data.is_empty() {
1456
+ continue;
1457
+ }
1458
+ if let Ok(value) = serde_json::from_str::<Value>(data) {
1459
+ let image = image_from_value(&value);
1460
+ if image.url.is_some() || image.b64_json.is_some() {
1461
+ return image;
1462
+ }
1463
+ }
1464
+ }
1465
+ }
1466
+
1467
+ let value: Value = serde_json::from_str(response_body).unwrap_or_else(|_| json!({}));
1468
+ image_from_value(&value)
1469
+ }
1470
+
1471
+ fn image_from_value(value: &Value) -> GeneratedImage {
1472
+ let candidate = value
1473
+ .pointer("/data/0")
1474
+ .or_else(|| value.pointer("/image"))
1475
+ .or_else(|| value.pointer("/output/0"))
1476
+ .unwrap_or(value);
1477
+ GeneratedImage {
1478
+ url: candidate
1479
+ .get("url")
1480
+ .and_then(Value::as_str)
1481
+ .or_else(|| value.pointer("/data/0/url").and_then(Value::as_str))
1482
+ .map(str::to_string),
1483
+ b64_json: candidate
1484
+ .get("b64_json")
1485
+ .and_then(Value::as_str)
1486
+ .or_else(|| candidate.get("b64").and_then(Value::as_str))
1487
+ .or_else(|| value.pointer("/data/0/b64_json").and_then(Value::as_str))
1488
+ .or_else(|| {
1489
+ value
1490
+ .pointer("/partial_image")
1491
+ .and_then(Value::as_str)
1492
+ .or_else(|| value.pointer("/b64_json").and_then(Value::as_str))
1493
+ })
1494
+ .map(str::to_string),
1495
+ revised_prompt: candidate
1496
+ .get("revised_prompt")
1497
+ .and_then(Value::as_str)
1498
+ .map(redact_secrets),
1499
+ }
1500
+ }
1501
+
1502
+ fn extract_backend_text_response(response_body: &str) -> String {
1503
+ if response_body
1504
+ .lines()
1505
+ .any(|line| line.starts_with("event:") || line.starts_with("data:"))
1506
+ {
1507
+ let mut text = String::new();
1508
+ for line in response_body.lines() {
1509
+ let Some(data) = line.strip_prefix("data:").map(str::trim) else {
1510
+ continue;
1511
+ };
1512
+ if data == "[DONE]" || data.is_empty() {
1513
+ continue;
1514
+ }
1515
+ if let Ok(value) = serde_json::from_str::<Value>(data) {
1516
+ if let Some(delta) = value.get("delta").and_then(Value::as_str) {
1517
+ text.push_str(delta);
1518
+ } else if let Some(delta) = value
1519
+ .pointer("/item/content/0/text")
1520
+ .and_then(Value::as_str)
1521
+ {
1522
+ text.push_str(delta);
1523
+ } else if let Some(delta) = value
1524
+ .pointer("/response/output_text")
1525
+ .and_then(Value::as_str)
1526
+ {
1527
+ text.push_str(delta);
1528
+ }
1529
+ }
1530
+ }
1531
+ if !text.is_empty() {
1532
+ return text;
1533
+ }
1534
+ }
1535
+
1536
+ let value: Value = serde_json::from_str(response_body)
1537
+ .unwrap_or_else(|_| json!({"output_text": response_body}));
1538
+ value
1539
+ .get("output_text")
1540
+ .and_then(Value::as_str)
1541
+ .or_else(|| {
1542
+ value
1543
+ .pointer("/choices/0/message/content")
1544
+ .and_then(Value::as_str)
1545
+ })
1546
+ .or_else(|| {
1547
+ value
1548
+ .pointer("/output/0/content/0/text")
1549
+ .and_then(Value::as_str)
1550
+ })
1551
+ .unwrap_or(response_body)
1552
+ .to_string()
1553
+ }
1554
+
1555
+ #[derive(Debug)]
1556
+ struct BackendUrl {
1557
+ host: String,
1558
+ port: u16,
1559
+ path: String,
1560
+ }
1561
+
1562
+ fn parse_http_backend_url(url: &str) -> Result<BackendUrl> {
1563
+ let rest = url.strip_prefix("http://").ok_or_else(|| {
1564
+ OmxApiError::Message(
1565
+ "OMX_API_PRIVATE_BACKEND_URL must use http:// in V1A; use a localhost TLS terminator for HTTPS backends".to_string(),
1566
+ )
1567
+ })?;
1568
+ let (authority, path) = rest
1569
+ .split_once('/')
1570
+ .map(|(authority, path)| (authority, format!("/{path}")))
1571
+ .unwrap_or((rest, "/".to_string()));
1572
+ let (host, port) = if let Some((host, port)) = authority.rsplit_once(':') {
1573
+ let parsed = port
1574
+ .parse::<u16>()
1575
+ .map_err(|_| OmxApiError::Message(format!("invalid private backend port in {url}")))?;
1576
+ (host.to_string(), parsed)
1577
+ } else {
1578
+ (authority.to_string(), 80)
1579
+ };
1580
+ if host.is_empty() {
1581
+ return Err(OmxApiError::Message(format!(
1582
+ "empty private backend host in {url}"
1583
+ )));
1584
+ }
1585
+ if !is_loopback_host(&host) && env::var_os("OMX_API_ALLOW_UNSAFE_PRIVATE_BACKEND").is_none() {
1586
+ return Err(OmxApiError::Message(format!(
1587
+ "private backend host `{host}` is not loopback; set OMX_API_ALLOW_UNSAFE_PRIVATE_BACKEND=1 only for trusted development"
1588
+ )));
1589
+ }
1590
+ Ok(BackendUrl { host, port, path })
1591
+ }
1592
+
1593
+ fn is_loopback_host(host: &str) -> bool {
1594
+ if host == "localhost" {
1595
+ return true;
1596
+ }
1597
+ let trimmed = host.trim_matches(['[', ']']);
1598
+ trimmed
1599
+ .parse::<IpAddr>()
1600
+ .map(|addr| addr.is_loopback())
1601
+ .unwrap_or(false)
1602
+ }
1603
+
1604
+ pub fn serve(config: ServerConfig) -> Result<DaemonState> {
1605
+ validate_loopback_host(&config.host)?;
1606
+ let listener = TcpListener::bind((config.host.as_str(), config.port))?;
1607
+ let port = listener.local_addr()?.port();
1608
+ let state = DaemonState {
1609
+ pid: std::process::id(),
1610
+ host: config.host.clone(),
1611
+ port,
1612
+ backend: config.backend.clone(),
1613
+ started_at_unix: now_unix(),
1614
+ local_bearer_token: config.local_bearer_token.clone(),
1615
+ local_bearer_token_file: config
1616
+ .local_bearer_token
1617
+ .as_ref()
1618
+ .map(|_| token_file_for_state(&config.state_file)),
1619
+ };
1620
+ if let Some(token) = config.local_bearer_token.as_deref() {
1621
+ write_local_bearer_token(token_file_for_state(&config.state_file), token)?;
1622
+ }
1623
+ write_daemon_state(&config.state_file, &state)?;
1624
+
1625
+ let telemetry = Arc::new(Telemetry::default());
1626
+ let shutdown = Arc::new(AtomicBool::new(false));
1627
+
1628
+ for stream in listener.incoming() {
1629
+ let stream = stream?;
1630
+ handle_connection(
1631
+ stream,
1632
+ &config.backend,
1633
+ &telemetry,
1634
+ &shutdown,
1635
+ config.local_bearer_token.as_deref(),
1636
+ )?;
1637
+ if config.once || shutdown.load(Ordering::SeqCst) {
1638
+ break;
1639
+ }
1640
+ }
1641
+ remove_local_bearer_token(token_file_for_state(&config.state_file))?;
1642
+ remove_daemon_state(&config.state_file)?;
1643
+ Ok(state)
1644
+ }
1645
+
1646
+ fn handle_connection(
1647
+ mut stream: TcpStream,
1648
+ backend: &BackendMode,
1649
+ telemetry: &Telemetry,
1650
+ shutdown: &AtomicBool,
1651
+ expected_bearer: Option<&str>,
1652
+ ) -> Result<()> {
1653
+ stream.set_read_timeout(Some(Duration::from_secs(10)))?;
1654
+ stream.set_write_timeout(Some(Duration::from_secs(10)))?;
1655
+ let request = match read_http_request(&mut stream) {
1656
+ Ok(request) => request,
1657
+ Err(OmxApiError::Message(message)) if message.contains("too large") => {
1658
+ write_http_response(
1659
+ &mut stream,
1660
+ Response::json(
1661
+ 413,
1662
+ json!({
1663
+ "error": {
1664
+ "message": message,
1665
+ "type": "request_too_large"
1666
+ }
1667
+ }),
1668
+ ),
1669
+ )?;
1670
+ let _ = stream.shutdown(Shutdown::Both);
1671
+ return Ok(());
1672
+ }
1673
+ Err(error) => return Err(error),
1674
+ };
1675
+ let response = route_request(
1676
+ &request,
1677
+ backend,
1678
+ telemetry,
1679
+ Some(shutdown),
1680
+ expected_bearer,
1681
+ );
1682
+ write_http_response(&mut stream, response)?;
1683
+ let _ = stream.shutdown(Shutdown::Both);
1684
+ Ok(())
1685
+ }
1686
+
1687
+ pub fn read_http_request(stream: &mut TcpStream) -> Result<Request> {
1688
+ let mut reader = BufReader::new(stream.try_clone()?);
1689
+ let mut request_line = String::new();
1690
+ reader.read_line(&mut request_line)?;
1691
+ let mut header_bytes = request_line.len();
1692
+ if header_bytes > MAX_HTTP_HEADER_BYTES {
1693
+ return Err(OmxApiError::Message(
1694
+ "HTTP request headers too large".to_string(),
1695
+ ));
1696
+ }
1697
+ let mut parts = request_line.split_whitespace();
1698
+ let method = parts.next().unwrap_or_default().to_string();
1699
+ let path = parts
1700
+ .next()
1701
+ .unwrap_or("/")
1702
+ .split('?')
1703
+ .next()
1704
+ .unwrap_or("/")
1705
+ .to_string();
1706
+
1707
+ let mut headers = BTreeMap::new();
1708
+ loop {
1709
+ let mut line = String::new();
1710
+ reader.read_line(&mut line)?;
1711
+ header_bytes += line.len();
1712
+ if header_bytes > MAX_HTTP_HEADER_BYTES {
1713
+ return Err(OmxApiError::Message(
1714
+ "HTTP request headers too large".to_string(),
1715
+ ));
1716
+ }
1717
+ let trimmed = line.trim_end_matches(['\r', '\n']);
1718
+ if trimmed.is_empty() {
1719
+ break;
1720
+ }
1721
+ if let Some((key, value)) = trimmed.split_once(':') {
1722
+ headers.insert(key.trim().to_ascii_lowercase(), value.trim().to_string());
1723
+ }
1724
+ }
1725
+
1726
+ let length = headers
1727
+ .get("content-length")
1728
+ .and_then(|value| value.parse::<usize>().ok())
1729
+ .unwrap_or(0);
1730
+ if length > MAX_HTTP_BODY_BYTES {
1731
+ return Err(OmxApiError::Message(format!(
1732
+ "HTTP request body too large: {length} bytes exceeds {MAX_HTTP_BODY_BYTES}"
1733
+ )));
1734
+ }
1735
+ let mut body = vec![0; length];
1736
+ reader.read_exact(&mut body)?;
1737
+
1738
+ Ok(Request {
1739
+ method,
1740
+ path,
1741
+ headers,
1742
+ body,
1743
+ })
1744
+ }
1745
+
1746
+ pub fn write_http_response(stream: &mut TcpStream, response: Response) -> Result<()> {
1747
+ let reason = reason_phrase(response.status);
1748
+ write!(
1749
+ stream,
1750
+ "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n",
1751
+ response.status,
1752
+ reason,
1753
+ response.content_type,
1754
+ response.body.len()
1755
+ )?;
1756
+ for (key, value) in response.extra_headers {
1757
+ write!(stream, "{}: {}\r\n", key, value)?;
1758
+ }
1759
+ write!(stream, "\r\n")?;
1760
+ stream.write_all(&response.body)?;
1761
+ stream.flush()?;
1762
+ Ok(())
1763
+ }
1764
+
1765
+ fn reason_phrase(status: u16) -> &'static str {
1766
+ match status {
1767
+ 200 => "OK",
1768
+ 400 => "Bad Request",
1769
+ 401 => "Unauthorized",
1770
+ 404 => "Not Found",
1771
+ 413 => "Payload Too Large",
1772
+ 503 => "Service Unavailable",
1773
+ _ => "OK",
1774
+ }
1775
+ }
1776
+
1777
+ pub fn stop_daemon(state_file: impl AsRef<Path>) -> Result<Value> {
1778
+ let Some(state) = read_daemon_state(&state_file)? else {
1779
+ return Ok(json!({"status": "not-running"}));
1780
+ };
1781
+ let response = http_request_with_bearer(
1782
+ &state.host,
1783
+ state.port,
1784
+ "POST",
1785
+ "/__admin/stop",
1786
+ Some(b"{}"),
1787
+ read_local_bearer_token(
1788
+ state
1789
+ .local_bearer_token_file
1790
+ .as_ref()
1791
+ .unwrap_or(&token_file_for_state(state_file.as_ref())),
1792
+ )?
1793
+ .as_deref(),
1794
+ )?;
1795
+ remove_local_bearer_token(
1796
+ state
1797
+ .local_bearer_token_file
1798
+ .as_ref()
1799
+ .unwrap_or(&token_file_for_state(state_file.as_ref())),
1800
+ )?;
1801
+ remove_daemon_state(state_file)?;
1802
+ Ok(json!({"status": "stopped", "response": response}))
1803
+ }
1804
+
1805
+ pub fn status(state_file: impl AsRef<Path>) -> Result<Value> {
1806
+ Ok(match read_daemon_state(state_file)? {
1807
+ Some(state) => json!({"status": "running", "daemon": state, "base_url": state.base_url()}),
1808
+ None => json!({"status": "not-running"}),
1809
+ })
1810
+ }
1811
+
1812
+ pub fn http_request(
1813
+ host: &str,
1814
+ port: u16,
1815
+ method: &str,
1816
+ path: &str,
1817
+ body: Option<&[u8]>,
1818
+ ) -> Result<String> {
1819
+ http_request_with_bearer(host, port, method, path, body, None)
1820
+ }
1821
+
1822
+ pub fn http_request_with_bearer(
1823
+ host: &str,
1824
+ port: u16,
1825
+ method: &str,
1826
+ path: &str,
1827
+ body: Option<&[u8]>,
1828
+ bearer: Option<&str>,
1829
+ ) -> Result<String> {
1830
+ let mut stream = TcpStream::connect((host, port))?;
1831
+ let body = body.unwrap_or_default();
1832
+ write!(
1833
+ stream,
1834
+ "{} {} HTTP/1.1\r\nHost: {}:{}\r\nContent-Length: {}\r\n",
1835
+ method,
1836
+ path,
1837
+ host,
1838
+ port,
1839
+ body.len()
1840
+ )?;
1841
+ if let Some(token) = bearer.filter(|token| !token.is_empty()) {
1842
+ write!(stream, "Authorization: Bearer {token}\r\n")?;
1843
+ }
1844
+ write!(stream, "Connection: close\r\n\r\n")?;
1845
+ stream.write_all(body)?;
1846
+ let mut response = String::new();
1847
+ stream.read_to_string(&mut response)?;
1848
+ Ok(response)
1849
+ }
1850
+
1851
+ pub fn http_json_request(
1852
+ host: &str,
1853
+ port: u16,
1854
+ method: &str,
1855
+ path: &str,
1856
+ body: &Value,
1857
+ bearer: Option<&str>,
1858
+ ) -> Result<String> {
1859
+ let payload = serde_json::to_vec(body)?;
1860
+ let raw = http_request_with_bearer(host, port, method, path, Some(&payload), bearer)?;
1861
+ let (head, response_body) = raw
1862
+ .split_once("\r\n\r\n")
1863
+ .ok_or_else(|| OmxApiError::Message("local API returned malformed HTTP".to_string()))?;
1864
+ let status = head
1865
+ .lines()
1866
+ .next()
1867
+ .and_then(|line| line.split_whitespace().nth(1))
1868
+ .and_then(|value| value.parse::<u16>().ok())
1869
+ .unwrap_or(502);
1870
+ if !(200..300).contains(&status) {
1871
+ return Err(OmxApiError::Message(format!(
1872
+ "local API returned HTTP {status}: {response_body}"
1873
+ )));
1874
+ }
1875
+ Ok(response_body.to_string())
1876
+ }
1877
+
1878
+ fn resolve_client_target(state_file: PathBuf) -> Result<(String, u16, Option<String>)> {
1879
+ if let Some(url) = env::var("OMX_API_BASE_URL")
1880
+ .ok()
1881
+ .filter(|value| !value.trim().is_empty())
1882
+ {
1883
+ let parsed = parse_http_backend_url(url.trim_end_matches('/'))?;
1884
+ return Ok((
1885
+ parsed.host,
1886
+ parsed.port,
1887
+ env::var("OMX_API_LOCAL_BEARER").ok(),
1888
+ ));
1889
+ }
1890
+ if let Some(state) = read_daemon_state(&state_file)? {
1891
+ let token = read_local_bearer_token(
1892
+ state
1893
+ .local_bearer_token_file
1894
+ .as_ref()
1895
+ .unwrap_or(&token_file_for_state(&state_file)),
1896
+ )?;
1897
+ return Ok((state.host, state.port, token));
1898
+ }
1899
+ let port = env::var("OMX_API_PORT")
1900
+ .ok()
1901
+ .and_then(|value| value.parse::<u16>().ok())
1902
+ .unwrap_or(DEFAULT_API_PORT);
1903
+ Ok((
1904
+ "127.0.0.1".to_string(),
1905
+ port,
1906
+ env::var("OMX_API_LOCAL_BEARER").ok(),
1907
+ ))
1908
+ }
1909
+
1910
+ fn spawn_daemon(config: &ServerConfig) -> Result<DaemonState> {
1911
+ let exe = env::current_exe()?;
1912
+ let token = config
1913
+ .local_bearer_token
1914
+ .clone()
1915
+ .unwrap_or_else(generate_local_bearer_token);
1916
+ let mut args = vec![
1917
+ "serve".to_string(),
1918
+ "--host".to_string(),
1919
+ config.host.clone(),
1920
+ "--port".to_string(),
1921
+ config.port.to_string(),
1922
+ "--backend".to_string(),
1923
+ config.backend.as_str().to_string(),
1924
+ "--state-file".to_string(),
1925
+ config.state_file.display().to_string(),
1926
+ ];
1927
+ if config.once {
1928
+ args.push("--once".to_string());
1929
+ }
1930
+ let mut child = Command::new(exe)
1931
+ .args(args)
1932
+ .env("OMX_API_LOCAL_BEARER", &token)
1933
+ .stdin(Stdio::null())
1934
+ .stdout(Stdio::null())
1935
+ .stderr(Stdio::null())
1936
+ .spawn()?;
1937
+ for _ in 0..100 {
1938
+ if let Some(state) = read_daemon_state(&config.state_file)? {
1939
+ return Ok(state);
1940
+ }
1941
+ if let Some(status) = child.try_wait()? {
1942
+ return Err(OmxApiError::Message(format!(
1943
+ "daemon exited before writing state: {status}"
1944
+ )));
1945
+ }
1946
+ std::thread::sleep(Duration::from_millis(20));
1947
+ }
1948
+ Err(OmxApiError::Message(
1949
+ "daemon did not write state within timeout".to_string(),
1950
+ ))
1951
+ }
1952
+
1953
+ pub fn run_cli<I, W, E>(args: I, mut stdout: W, _stderr: E) -> Result<()>
1954
+ where
1955
+ I: IntoIterator,
1956
+ I::Item: Into<String>,
1957
+ W: Write,
1958
+ E: Write,
1959
+ {
1960
+ let args: Vec<String> = args.into_iter().map(Into::into).collect();
1961
+ match args.first().map(String::as_str) {
1962
+ Some("serve") => {
1963
+ if args[1..].iter().any(|arg| arg == "--system") {
1964
+ if args[1..].iter().any(|arg| arg == "--dry-run") {
1965
+ run_system_plan(&args[1..], stdout)?;
1966
+ return Ok(());
1967
+ }
1968
+ return Err(OmxApiError::Message(
1969
+ "serve --system is dry-run only in V1A; pass --dry-run to inspect the service plan"
1970
+ .to_string(),
1971
+ ));
1972
+ }
1973
+ let config = parse_server_config(&args[1..])?;
1974
+ if config.daemon {
1975
+ let state = spawn_daemon(&config)?;
1976
+ writeln!(
1977
+ stdout,
1978
+ "{}",
1979
+ serde_json::to_string_pretty(&json!({
1980
+ "status": "started",
1981
+ "daemon": state,
1982
+ "base_url": state.base_url()
1983
+ }))?
1984
+ )?;
1985
+ } else if config.once {
1986
+ let state = serve(config)?;
1987
+ writeln!(
1988
+ stdout,
1989
+ "{}",
1990
+ serde_json::to_string(&json!({"served": state}))?
1991
+ )?;
1992
+ } else {
1993
+ serve(config)?;
1994
+ }
1995
+ }
1996
+ Some("generate") => run_generate(&args[1..], stdout)?,
1997
+ Some("smoke") => run_smoke(&args[1..], stdout)?,
1998
+ Some("status") => {
1999
+ let state_file = parse_state_file(&args[1..])?;
2000
+ writeln!(
2001
+ stdout,
2002
+ "{}",
2003
+ serde_json::to_string_pretty(&status(state_file)?)?
2004
+ )?;
2005
+ }
2006
+ Some("stop") => {
2007
+ let state_file = parse_state_file(&args[1..])?;
2008
+ writeln!(
2009
+ stdout,
2010
+ "{}",
2011
+ serde_json::to_string_pretty(&stop_daemon(state_file)?)?
2012
+ )?;
2013
+ }
2014
+ Some("system") => run_system(&args[1..], stdout)?,
2015
+ Some("help") | Some("--help") | Some("-h") | None => {
2016
+ writeln!(stdout, "{}", help_text())?;
2017
+ }
2018
+ Some(other) => {
2019
+ return Err(OmxApiError::Message(format!(
2020
+ "unknown subcommand '{other}'"
2021
+ )))
2022
+ }
2023
+ }
2024
+ Ok(())
2025
+ }
2026
+
2027
+ fn run_system<W: Write>(args: &[String], mut stdout: W) -> Result<()> {
2028
+ match args.first().map(String::as_str) {
2029
+ Some("dry-run") => writeln!(
2030
+ stdout,
2031
+ "{}",
2032
+ serde_json::to_string_pretty(&json!({
2033
+ "ok": true,
2034
+ "action": "system.dry-run",
2035
+ "would_start": "omx-api serve --backend mock"
2036
+ }))?
2037
+ )?,
2038
+ Some("generate") => writeln!(
2039
+ stdout,
2040
+ "{}",
2041
+ serde_json::to_string_pretty(&json!({
2042
+ "ok": true,
2043
+ "action": "system.generate",
2044
+ "files": ["state-file", "localhost-http-server"]
2045
+ }))?
2046
+ )?,
2047
+ Some(other) => {
2048
+ return Err(OmxApiError::Message(format!(
2049
+ "unknown system action '{other}'"
2050
+ )))
2051
+ }
2052
+ None => {
2053
+ return Err(OmxApiError::Message(
2054
+ "system requires dry-run or generate".to_string(),
2055
+ ))
2056
+ }
2057
+ }
2058
+ Ok(())
2059
+ }
2060
+
2061
+ fn run_system_plan<W: Write>(args: &[String], mut stdout: W) -> Result<()> {
2062
+ let mut service_args = vec!["serve".to_string()];
2063
+ for arg in args {
2064
+ if arg != "--system" && arg != "--dry-run" {
2065
+ service_args.push(arg.clone());
2066
+ }
2067
+ }
2068
+ writeln!(
2069
+ stdout,
2070
+ "{}",
2071
+ serde_json::to_string_pretty(&json!({
2072
+ "ok": true,
2073
+ "action": "serve.system.dry-run",
2074
+ "platform": env::consts::OS,
2075
+ "install_supported": false,
2076
+ "reason": "V1A emits a platform-aware service plan but refuses persistent service installation",
2077
+ "argv": service_args
2078
+ }))?
2079
+ )?;
2080
+ Ok(())
2081
+ }
2082
+
2083
+ fn run_generate<W: Write>(args: &[String], mut stdout: W) -> Result<()> {
2084
+ let Some(kind) = args.first().map(String::as_str) else {
2085
+ return Err(OmxApiError::Message(
2086
+ "generate requires text or image".to_string(),
2087
+ ));
2088
+ };
2089
+ let mut state_file = default_state_file();
2090
+ let mut prompt_parts = Vec::new();
2091
+ let mut index = 1;
2092
+ while index < args.len() {
2093
+ match args[index].as_str() {
2094
+ "--state-file" => {
2095
+ index += 1;
2096
+ state_file = PathBuf::from(required_value(args, index, "--state-file")?);
2097
+ }
2098
+ value => prompt_parts.push(value.to_string()),
2099
+ }
2100
+ index += 1;
2101
+ }
2102
+ let prompt = prompt_parts.join(" ");
2103
+ if prompt.trim().is_empty() {
2104
+ return Err(OmxApiError::Message(format!(
2105
+ "generate {kind} requires a prompt"
2106
+ )));
2107
+ }
2108
+ let (host, port, bearer) = resolve_client_target(state_file)?;
2109
+ let (path, body) = match kind {
2110
+ "text" => (
2111
+ "/v1/responses",
2112
+ json!({"model": env::var("OMX_API_GENERATE_MODEL").unwrap_or_else(|_| "omx-private".to_string()), "input": prompt}),
2113
+ ),
2114
+ "image" => ("/v1/images/generations", json!({"prompt": prompt})),
2115
+ other => {
2116
+ return Err(OmxApiError::Message(format!(
2117
+ "unknown generate kind '{other}'; expected text or image"
2118
+ )))
2119
+ }
2120
+ };
2121
+ let response = http_json_request(&host, port, "POST", path, &body, bearer.as_deref())?;
2122
+ writeln!(stdout, "{response}")?;
2123
+ Ok(())
2124
+ }
2125
+
2126
+ fn run_smoke<W: Write>(args: &[String], mut stdout: W) -> Result<()> {
2127
+ match args.first().map(String::as_str) {
2128
+ Some("text") => {
2129
+ if env::var_os("OMX_API_LIVE_SMOKE").is_none() {
2130
+ return Err(OmxApiError::Message(
2131
+ "set OMX_API_LIVE_SMOKE=1 to run the real-private text smoke".to_string(),
2132
+ ));
2133
+ }
2134
+ let body = json!({"model": "omx-private", "input": "Say OMX API smoke OK."});
2135
+ let response = real_private_text_response(&body, "response", false);
2136
+ if !(200..300).contains(&response.status) {
2137
+ return Err(OmxApiError::Message(
2138
+ String::from_utf8_lossy(&response.body).trim().to_string(),
2139
+ ));
2140
+ }
2141
+ writeln!(stdout, "{}", String::from_utf8_lossy(&response.body).trim())?;
2142
+ Ok(())
2143
+ }
2144
+ _ => Err(OmxApiError::Message(
2145
+ "smoke requires text (example: OMX_API_LIVE_SMOKE=1 omx-api smoke text)".to_string(),
2146
+ )),
2147
+ }
2148
+ }
2149
+
2150
+ fn parse_server_config(args: &[String]) -> Result<ServerConfig> {
2151
+ let mut config = ServerConfig {
2152
+ local_bearer_token: env::var("OMX_API_LOCAL_BEARER")
2153
+ .ok()
2154
+ .filter(|value| !value.trim().is_empty()),
2155
+ ..ServerConfig::default()
2156
+ };
2157
+ if let Some(backend) = env::var("OMX_API_BACKEND")
2158
+ .ok()
2159
+ .filter(|value| !value.trim().is_empty())
2160
+ {
2161
+ config.backend = BackendMode::parse(&backend)?;
2162
+ }
2163
+ let mut index = 0;
2164
+ while index < args.len() {
2165
+ match args[index].as_str() {
2166
+ "--host" => {
2167
+ index += 1;
2168
+ config.host = required_value(args, index, "--host")?.to_string();
2169
+ }
2170
+ "--port" => {
2171
+ index += 1;
2172
+ config.port = required_value(args, index, "--port")?
2173
+ .parse()
2174
+ .map_err(|_| OmxApiError::Message("--port must be an integer".to_string()))?;
2175
+ }
2176
+ "--backend" => {
2177
+ index += 1;
2178
+ config.backend = BackendMode::parse(required_value(args, index, "--backend")?)?;
2179
+ }
2180
+ "--state-file" => {
2181
+ index += 1;
2182
+ config.state_file = PathBuf::from(required_value(args, index, "--state-file")?);
2183
+ }
2184
+ "--once" => config.once = true,
2185
+ "--daemon" => config.daemon = true,
2186
+ "--dry-run" => {}
2187
+ "--system" => {}
2188
+ other => {
2189
+ return Err(OmxApiError::Message(format!(
2190
+ "unknown serve flag '{other}'"
2191
+ )))
2192
+ }
2193
+ }
2194
+ index += 1;
2195
+ }
2196
+ validate_loopback_host(&config.host)?;
2197
+ if config.backend == BackendMode::RealPrivate && config.local_bearer_token.is_none() {
2198
+ config.local_bearer_token = Some(generate_local_bearer_token());
2199
+ }
2200
+ Ok(config)
2201
+ }
2202
+
2203
+ fn validate_loopback_host(host: &str) -> Result<()> {
2204
+ if is_loopback_host(host) {
2205
+ Ok(())
2206
+ } else {
2207
+ Err(OmxApiError::Message(format!(
2208
+ "omx-api is localhost-only; refusing non-loopback host `{host}`"
2209
+ )))
2210
+ }
2211
+ }
2212
+
2213
+ fn generate_local_bearer_token() -> String {
2214
+ let mut bytes = [0_u8; 32];
2215
+ if fs::File::open("/dev/urandom")
2216
+ .and_then(|mut file| file.read_exact(&mut bytes))
2217
+ .is_err()
2218
+ {
2219
+ let seed = format!("{}-{}", std::process::id(), now_unix());
2220
+ for (index, byte) in seed.as_bytes().iter().enumerate() {
2221
+ bytes[index % bytes.len()] ^= *byte;
2222
+ }
2223
+ }
2224
+ bytes.iter().map(|byte| format!("{byte:02x}")).collect()
2225
+ }
2226
+
2227
+ fn parse_state_file(args: &[String]) -> Result<PathBuf> {
2228
+ let mut state_file = default_state_file();
2229
+ let mut index = 0;
2230
+ while index < args.len() {
2231
+ match args[index].as_str() {
2232
+ "--state-file" => {
2233
+ index += 1;
2234
+ state_file = PathBuf::from(required_value(args, index, "--state-file")?);
2235
+ }
2236
+ other => return Err(OmxApiError::Message(format!("unknown flag '{other}'"))),
2237
+ }
2238
+ index += 1;
2239
+ }
2240
+ Ok(state_file)
2241
+ }
2242
+
2243
+ fn required_value<'a>(args: &'a [String], index: usize, flag: &str) -> Result<&'a str> {
2244
+ args.get(index)
2245
+ .map(String::as_str)
2246
+ .ok_or_else(|| OmxApiError::Message(format!("{flag} requires a value")))
2247
+ }
2248
+
2249
+ fn help_text() -> &'static str {
2250
+ "omx-api serve [--daemon] [--system --dry-run]|status|stop|generate text|generate image|smoke text\nNote: real-private backend mode is experimental and requires local bearer auth."
2251
+ }
2252
+
2253
+ fn now_unix() -> u64 {
2254
+ SystemTime::now()
2255
+ .duration_since(UNIX_EPOCH)
2256
+ .unwrap_or_default()
2257
+ .as_secs()
2258
+ }
2259
+
2260
+ #[cfg(test)]
2261
+ mod tests {
2262
+ use super::*;
2263
+ use std::sync::{Mutex, OnceLock};
2264
+
2265
+ fn env_lock() -> std::sync::MutexGuard<'static, ()> {
2266
+ static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2267
+ LOCK.get_or_init(|| Mutex::new(()))
2268
+ .lock()
2269
+ .expect("env lock")
2270
+ }
2271
+
2272
+ fn request(method: &str, path: &str, body: Value) -> Request {
2273
+ Request {
2274
+ method: method.to_string(),
2275
+ path: path.to_string(),
2276
+ headers: BTreeMap::new(),
2277
+ body: serde_json::to_vec(&body).unwrap(),
2278
+ }
2279
+ }
2280
+
2281
+ #[test]
2282
+ fn routes_health_and_models() {
2283
+ let telemetry = Telemetry::default();
2284
+ let health = route_request(
2285
+ &request("GET", "/health", json!({})),
2286
+ &BackendMode::Mock,
2287
+ &telemetry,
2288
+ None,
2289
+ None,
2290
+ );
2291
+ assert_eq!(health.status, 200);
2292
+ assert!(String::from_utf8(health.body).unwrap().contains("mock"));
2293
+
2294
+ let models = route_request(
2295
+ &request("GET", "/v1/models", json!({})),
2296
+ &BackendMode::Mock,
2297
+ &telemetry,
2298
+ None,
2299
+ None,
2300
+ );
2301
+ assert_eq!(models.status, 200);
2302
+ assert!(String::from_utf8(models.body).unwrap().contains("omx-mock"));
2303
+ assert_eq!(telemetry.snapshot().requests_total, 2);
2304
+ }
2305
+
2306
+ #[test]
2307
+ fn response_endpoint_redacts_prompt_secrets() {
2308
+ let telemetry = Telemetry::default();
2309
+ let response = route_request(
2310
+ &request(
2311
+ "POST",
2312
+ "/v1/responses",
2313
+ json!({"input": "use sk-secret123 please"}),
2314
+ ),
2315
+ &BackendMode::Mock,
2316
+ &telemetry,
2317
+ None,
2318
+ None,
2319
+ );
2320
+ let body = String::from_utf8(response.body).unwrap();
2321
+ assert!(body.contains("[REDACTED]"));
2322
+ assert!(!body.contains("sk-secret123"));
2323
+ }
2324
+
2325
+ #[test]
2326
+ fn post_endpoints_reject_malformed_json() {
2327
+ let telemetry = Telemetry::default();
2328
+ for path in [
2329
+ "/v1/responses",
2330
+ "/v1/chat/completions",
2331
+ "/v1/images/generations",
2332
+ ] {
2333
+ let malformed = Request {
2334
+ method: "POST".to_string(),
2335
+ path: path.to_string(),
2336
+ headers: BTreeMap::new(),
2337
+ body: b"{bad json".to_vec(),
2338
+ };
2339
+ let response = route_request(&malformed, &BackendMode::Mock, &telemetry, None, None);
2340
+ assert_eq!(response.status, 400, "{path} should reject malformed JSON");
2341
+ let body = String::from_utf8(response.body).unwrap();
2342
+ assert!(body.contains("invalid_request_error"));
2343
+ }
2344
+ }
2345
+
2346
+ #[test]
2347
+ fn redacts_bearer_credentials_as_pairs() {
2348
+ let redacted = redact_secrets("Authorization: Bearer abc123 and Bearer def456");
2349
+ assert!(!redacted.contains("abc123"));
2350
+ assert!(!redacted.contains("def456"));
2351
+ assert!(redacted.contains("[REDACTED] [REDACTED]"));
2352
+ }
2353
+
2354
+ #[test]
2355
+ fn redact_secrets_handles_embedded_json_and_key_value_forms() {
2356
+ let raw = r#"error body {"access_token":"tok-1","api_key":"sk-json"} password: hunter2 sk-one sk-two"#;
2357
+ let redacted = redact_secrets(raw);
2358
+ for secret in ["tok-1", "sk-json", "hunter2", "sk-one", "sk-two"] {
2359
+ assert!(!redacted.contains(secret), "leaked {secret}: {redacted}");
2360
+ }
2361
+ assert!(redacted.contains("[REDACTED]"));
2362
+ }
2363
+
2364
+ #[test]
2365
+ fn read_http_request_rejects_oversized_body_before_allocation() {
2366
+ let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind listener");
2367
+ let port = listener.local_addr().expect("addr").port();
2368
+ let handle = std::thread::spawn(move || {
2369
+ let (mut server, _) = listener.accept().expect("accept");
2370
+ read_http_request(&mut server).expect_err("oversized request should fail")
2371
+ });
2372
+ let mut client = TcpStream::connect(("127.0.0.1", port)).expect("connect");
2373
+ write!(
2374
+ client,
2375
+ "POST /v1/responses HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: {}\r\n\r\n",
2376
+ MAX_HTTP_BODY_BYTES + 1
2377
+ )
2378
+ .expect("write request");
2379
+ let error = handle.join().expect("reader thread");
2380
+ assert!(error.to_string().contains("body too large"), "{error}");
2381
+ }
2382
+
2383
+ #[test]
2384
+ fn route_requires_matching_local_bearer_when_configured() {
2385
+ let telemetry = Telemetry::default();
2386
+ let response = route_request(
2387
+ &request("GET", "/v1/models", json!({})),
2388
+ &BackendMode::Mock,
2389
+ &telemetry,
2390
+ None,
2391
+ Some("secret-token"),
2392
+ );
2393
+ assert_eq!(response.status, 401);
2394
+
2395
+ let mut authed = request("GET", "/v1/models", json!({}));
2396
+ authed.headers.insert(
2397
+ "authorization".to_string(),
2398
+ "Bearer secret-token".to_string(),
2399
+ );
2400
+ let response = route_request(
2401
+ &authed,
2402
+ &BackendMode::Mock,
2403
+ &telemetry,
2404
+ None,
2405
+ Some("secret-token"),
2406
+ );
2407
+ assert_eq!(response.status, 200);
2408
+ }
2409
+
2410
+ #[test]
2411
+ fn private_backend_url_rejects_non_loopback_plain_http() {
2412
+ let error = parse_http_backend_url("http://example.com/v1/responses")
2413
+ .expect_err("non-loopback private backend must be rejected");
2414
+ assert!(error.to_string().contains("not loopback"));
2415
+ let error = parse_http_backend_url("http://127.0.0.1.evil.test/v1/responses")
2416
+ .expect_err("hostname prefix must not bypass loopback check");
2417
+ assert!(error.to_string().contains("not loopback"));
2418
+ }
2419
+
2420
+ #[test]
2421
+ fn codex_native_request_matches_installed_codex_responses_wire_shape() {
2422
+ let _guard = env_lock();
2423
+ env::set_var("OMX_API_CODEX_SESSION_ID", "session-1");
2424
+ env::set_var("OMX_API_CODEX_THREAD_ID", "thread-1");
2425
+ env::set_var("OMX_API_CODEX_INSTALLATION_ID", "install-1");
2426
+ env::set_var("OMX_API_CODEX_WINDOW_ID", "window-1");
2427
+ env::set_var("OMX_API_CODEX_USER_AGENT", "codex_cli_rs/test");
2428
+
2429
+ let auth = CodexOAuth {
2430
+ token: "oauth-token".to_string(),
2431
+ account_id: Some("account-1".to_string()),
2432
+ };
2433
+ let request = build_codex_native_request(
2434
+ "/backend-api/codex",
2435
+ &json!({
2436
+ "model": "gpt-5.3-codex",
2437
+ "input": "summarize this",
2438
+ "reasoning": {"effort": "low"},
2439
+ "instructions": "Follow the sparkshell summary contract."
2440
+ }),
2441
+ &auth,
2442
+ );
2443
+
2444
+ assert_eq!(request.path, "/backend-api/codex/responses");
2445
+ assert!(request
2446
+ .headers
2447
+ .contains(&("Accept".to_string(), "text/event-stream".to_string())));
2448
+ assert!(request.headers.contains(&(
2449
+ "Authorization".to_string(),
2450
+ "Bearer oauth-token".to_string()
2451
+ )));
2452
+ assert!(request
2453
+ .headers
2454
+ .contains(&("ChatGPT-Account-ID".to_string(), "account-1".to_string())));
2455
+ assert!(request
2456
+ .headers
2457
+ .contains(&("originator".to_string(), "codex_cli_rs".to_string())));
2458
+ assert!(request
2459
+ .headers
2460
+ .contains(&("x-client-request-id".to_string(), "thread-1".to_string())));
2461
+ assert!(request
2462
+ .headers
2463
+ .contains(&("session_id".to_string(), "session-1".to_string())));
2464
+ assert!(request
2465
+ .headers
2466
+ .contains(&("session-id".to_string(), "session-1".to_string())));
2467
+ assert!(request
2468
+ .headers
2469
+ .contains(&("thread_id".to_string(), "thread-1".to_string())));
2470
+ assert!(request
2471
+ .headers
2472
+ .contains(&("thread-id".to_string(), "thread-1".to_string())));
2473
+ assert!(!request
2474
+ .headers
2475
+ .iter()
2476
+ .any(|(name, _)| name == CODEX_INSTALLATION_ID_HEADER));
2477
+
2478
+ assert_eq!(request.body["model"], "gpt-5.3-codex");
2479
+ assert_eq!(request.body["tools"], json!([]));
2480
+ assert_eq!(request.body["tool_choice"], "auto");
2481
+ assert_eq!(request.body["parallel_tool_calls"], false);
2482
+ assert_eq!(request.body["reasoning"], json!({"effort": "low"}));
2483
+ assert_eq!(
2484
+ request.body["instructions"],
2485
+ "Follow the sparkshell summary contract."
2486
+ );
2487
+ assert_eq!(request.body["store"], false);
2488
+ assert_eq!(request.body["stream"], true);
2489
+ assert_eq!(request.body["include"], json!([]));
2490
+ assert_eq!(request.body["prompt_cache_key"], "thread-1");
2491
+ assert_eq!(
2492
+ request.body["client_metadata"][CODEX_INSTALLATION_ID_HEADER],
2493
+ "install-1"
2494
+ );
2495
+ assert_eq!(
2496
+ request.body["input"],
2497
+ json!([{
2498
+ "type": "message",
2499
+ "role": "user",
2500
+ "content": [{"type": "input_text", "text": "summarize this"}]
2501
+ }])
2502
+ );
2503
+
2504
+ env::remove_var("OMX_API_CODEX_SESSION_ID");
2505
+ env::remove_var("OMX_API_CODEX_THREAD_ID");
2506
+ env::remove_var("OMX_API_CODEX_INSTALLATION_ID");
2507
+ env::remove_var("OMX_API_CODEX_WINDOW_ID");
2508
+ env::remove_var("OMX_API_CODEX_USER_AGENT");
2509
+ }
2510
+
2511
+ #[test]
2512
+ fn real_private_posts_codex_native_request_and_parses_sse_text() {
2513
+ let _guard = env_lock();
2514
+ env::remove_var("OMX_API_REAL_PRIVATE_RESPONSE_TEXT");
2515
+ env::set_var("OMX_API_CODEX_OAUTH_TOKEN", "oauth-token");
2516
+ env::set_var("OMX_API_CODEX_ACCOUNT_ID", "account-1");
2517
+ env::set_var("OMX_API_CODEX_THREAD_ID", "thread-1");
2518
+ env::set_var("OMX_API_CODEX_SESSION_ID", "session-1");
2519
+
2520
+ let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind private backend");
2521
+ let addr = listener.local_addr().expect("local addr");
2522
+ let seen = Arc::new(Mutex::new(String::new()));
2523
+ let seen_thread = Arc::clone(&seen);
2524
+ let handle = std::thread::spawn(move || {
2525
+ let (mut stream, _) = listener.accept().expect("accept request");
2526
+ let mut raw_bytes = Vec::new();
2527
+ let mut buffer = [0_u8; 1024];
2528
+ let header_end = loop {
2529
+ let read = stream.read(&mut buffer).expect("read request");
2530
+ assert!(read > 0, "request closed before headers");
2531
+ raw_bytes.extend_from_slice(&buffer[..read]);
2532
+ if let Some(index) = raw_bytes.windows(4).position(|chunk| chunk == b"\r\n\r\n") {
2533
+ break index + 4;
2534
+ }
2535
+ };
2536
+ let head = String::from_utf8_lossy(&raw_bytes[..header_end]).to_string();
2537
+ let content_length = head
2538
+ .lines()
2539
+ .find_map(|line| line.strip_prefix("Content-Length: "))
2540
+ .and_then(|value| value.trim().parse::<usize>().ok())
2541
+ .expect("content length");
2542
+ while raw_bytes.len() < header_end + content_length {
2543
+ let read = stream.read(&mut buffer).expect("read body");
2544
+ assert!(read > 0, "request closed before body");
2545
+ raw_bytes.extend_from_slice(&buffer[..read]);
2546
+ }
2547
+ let raw = String::from_utf8_lossy(&raw_bytes).to_string();
2548
+ *seen_thread.lock().expect("seen lock") = raw;
2549
+ let body = concat!(
2550
+ "event: response.output_text.delta\n",
2551
+ "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n",
2552
+ "data: [DONE]\n\n"
2553
+ );
2554
+ write!(
2555
+ stream,
2556
+ "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2557
+ body.len(),
2558
+ body
2559
+ )
2560
+ .expect("write response");
2561
+ });
2562
+
2563
+ env::set_var(
2564
+ "OMX_API_PRIVATE_BACKEND_URL",
2565
+ format!("http://127.0.0.1:{}/backend-api/codex", addr.port()),
2566
+ );
2567
+ let text = real_private_text(&json!({
2568
+ "model": "gpt-5.3-codex",
2569
+ "input": "ping",
2570
+ "reasoning": {"effort": "low"},
2571
+ "instructions": "Summarize via sparkshell."
2572
+ }))
2573
+ .expect("private text response");
2574
+ handle.join().expect("server thread");
2575
+
2576
+ assert_eq!(text, "hello");
2577
+ let raw = seen.lock().expect("seen lock").clone();
2578
+ assert!(raw.starts_with("POST /backend-api/codex/responses HTTP/1.1"));
2579
+ assert!(raw.contains("Accept: text/event-stream\r\n"));
2580
+ assert!(raw.contains("Authorization: Bearer oauth-token\r\n"));
2581
+ assert!(raw.contains("ChatGPT-Account-ID: account-1\r\n"));
2582
+ assert!(raw.contains("originator: codex_cli_rs\r\n"));
2583
+ assert!(raw.contains("\"stream\":true") || raw.contains("\"stream\": true"));
2584
+ assert!(
2585
+ raw.contains("\"prompt_cache_key\":\"thread-1\"")
2586
+ || raw.contains("\"prompt_cache_key\": \"thread-1\"")
2587
+ );
2588
+ assert!(
2589
+ raw.contains("\"type\":\"input_text\"") || raw.contains("\"type\": \"input_text\"")
2590
+ );
2591
+ assert!(raw.contains("\"text\":\"ping\"") || raw.contains("\"text\": \"ping\""));
2592
+ let forwarded_body = raw.split("\r\n\r\n").nth(1).expect("forwarded body");
2593
+ let forwarded_json: Value = serde_json::from_str(forwarded_body).expect("forwarded JSON");
2594
+ assert_eq!(forwarded_json["reasoning"], json!({"effort": "low"}));
2595
+ assert_eq!(forwarded_json["instructions"], "Summarize via sparkshell.");
2596
+
2597
+ env::remove_var("OMX_API_CODEX_OAUTH_TOKEN");
2598
+ env::remove_var("OMX_API_CODEX_ACCOUNT_ID");
2599
+ env::remove_var("OMX_API_CODEX_THREAD_ID");
2600
+ env::remove_var("OMX_API_CODEX_SESSION_ID");
2601
+ env::remove_var("OMX_API_PRIVATE_BACKEND_URL");
2602
+ }
2603
+
2604
+ #[test]
2605
+ fn real_private_responses_stream_uses_upstream_sse() {
2606
+ let _guard = env_lock();
2607
+ env::remove_var("OMX_API_REAL_PRIVATE_RESPONSE_TEXT");
2608
+ env::set_var("OMX_API_CODEX_OAUTH_TOKEN", "oauth-token");
2609
+
2610
+ let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind private backend");
2611
+ let addr = listener.local_addr().expect("local addr");
2612
+
2613
+ let handle = std::thread::spawn(move || {
2614
+ let (mut stream, _) = listener.accept().expect("accept request");
2615
+ let mut raw = Vec::new();
2616
+ let mut buffer = [0_u8; 1024];
2617
+ let header_end = loop {
2618
+ let read = stream.read(&mut buffer).expect("read request");
2619
+ assert!(read > 0, "request closed before headers");
2620
+ raw.extend_from_slice(&buffer[..read]);
2621
+ if let Some(index) = raw.windows(4).position(|chunk| chunk == b"\r\n\r\n") {
2622
+ break index + 4;
2623
+ }
2624
+ };
2625
+ let head = String::from_utf8_lossy(&raw[..header_end]).to_string();
2626
+ let content_length = head
2627
+ .lines()
2628
+ .find_map(|line| line.strip_prefix("Content-Length: "))
2629
+ .and_then(|value| value.trim().parse::<usize>().ok())
2630
+ .expect("content length");
2631
+ while raw.len() < header_end + content_length {
2632
+ let read = stream.read(&mut buffer).expect("read request body");
2633
+ assert!(read > 0, "request closed before body");
2634
+ raw.extend_from_slice(&buffer[..read]);
2635
+ }
2636
+
2637
+ let body = concat!(
2638
+ "event: response.output_text.delta\n",
2639
+ "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n",
2640
+ "data: [DONE]\n\n"
2641
+ );
2642
+ write!(
2643
+ stream,
2644
+ "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2645
+ body.len(),
2646
+ body
2647
+ )
2648
+ .expect("write response");
2649
+ });
2650
+
2651
+ env::set_var(
2652
+ "OMX_API_PRIVATE_BACKEND_URL",
2653
+ format!("http://127.0.0.1:{}/backend-api/codex", addr.port()),
2654
+ );
2655
+
2656
+ let response = route_request(
2657
+ &request("POST", "/v1/responses", json!({"stream": true})),
2658
+ &BackendMode::RealPrivate,
2659
+ &Telemetry::default(),
2660
+ None,
2661
+ None,
2662
+ );
2663
+
2664
+ handle.join().expect("server thread");
2665
+ assert_eq!(response.status, 200);
2666
+ assert_eq!(response.content_type, "text/event-stream");
2667
+ let body = String::from_utf8(response.body).unwrap();
2668
+ assert!(body.contains("response.output_text.delta"));
2669
+ assert!(body.contains("data: [DONE]"));
2670
+
2671
+ env::remove_var("OMX_API_CODEX_OAUTH_TOKEN");
2672
+ env::remove_var("OMX_API_PRIVATE_BACKEND_URL");
2673
+ }
2674
+
2675
+ #[test]
2676
+ fn real_private_image_falls_back_to_responses_image_tool() {
2677
+ let _guard = env_lock();
2678
+ env::remove_var("OMX_API_REAL_PRIVATE_IMAGE_B64_JSON");
2679
+ env::remove_var("OMX_API_REAL_PRIVATE_IMAGE_URL");
2680
+ env::remove_var("OMX_API_PRIVATE_IMAGE_BACKEND_URL");
2681
+ env::set_var("OMX_API_CODEX_OAUTH_TOKEN", "oauth-token");
2682
+
2683
+ let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind private backend");
2684
+ let addr = listener.local_addr().expect("local addr");
2685
+ let seen = Arc::new(Mutex::new(String::new()));
2686
+ let seen_thread = Arc::clone(&seen);
2687
+ let handle = std::thread::spawn(move || {
2688
+ let (mut stream, _) = listener.accept().expect("accept request");
2689
+ let mut raw_bytes = Vec::new();
2690
+ let mut buffer = [0_u8; 1024];
2691
+ let header_end = loop {
2692
+ let read = stream.read(&mut buffer).expect("read request");
2693
+ assert!(read > 0, "request closed before headers");
2694
+ raw_bytes.extend_from_slice(&buffer[..read]);
2695
+ if let Some(index) = raw_bytes.windows(4).position(|chunk| chunk == b"\r\n\r\n") {
2696
+ break index + 4;
2697
+ }
2698
+ };
2699
+ let head = String::from_utf8_lossy(&raw_bytes[..header_end]).to_string();
2700
+ let content_length = head
2701
+ .lines()
2702
+ .find_map(|line| line.strip_prefix("Content-Length: "))
2703
+ .and_then(|value| value.trim().parse::<usize>().ok())
2704
+ .expect("content length");
2705
+ while raw_bytes.len() < header_end + content_length {
2706
+ let read = stream.read(&mut buffer).expect("read request body");
2707
+ assert!(read > 0, "request closed before body");
2708
+ raw_bytes.extend_from_slice(&buffer[..read]);
2709
+ }
2710
+ *seen_thread.lock().expect("seen lock") =
2711
+ String::from_utf8_lossy(&raw_bytes).to_string();
2712
+ let body = concat!(
2713
+ "event: response.image_generation_call.partial_image\n",
2714
+ "data: {\"type\":\"response.image_generation_call.partial_image\",\"partial_image\":\"ZmFrZS1pbWFnZS10b29s\"}\n\n",
2715
+ "data: [DONE]\n\n"
2716
+ );
2717
+ write!(
2718
+ stream,
2719
+ "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2720
+ body.len(),
2721
+ body
2722
+ )
2723
+ .expect("write response");
2724
+ });
2725
+
2726
+ env::set_var(
2727
+ "OMX_API_PRIVATE_BACKEND_URL",
2728
+ format!("http://127.0.0.1:{}/backend-api/codex", addr.port()),
2729
+ );
2730
+ let response = route_request(
2731
+ &request(
2732
+ "POST",
2733
+ "/v1/images/generations",
2734
+ json!({"prompt": "tool image", "stream": true}),
2735
+ ),
2736
+ &BackendMode::RealPrivate,
2737
+ &Telemetry::default(),
2738
+ None,
2739
+ None,
2740
+ );
2741
+
2742
+ handle.join().expect("server thread");
2743
+ assert_eq!(response.status, 200);
2744
+ assert_eq!(response.content_type, "text/event-stream");
2745
+ let body = String::from_utf8(response.body).unwrap();
2746
+ assert!(body.contains("image_generation.partial_image"));
2747
+ assert!(body.contains("ZmFrZS1pbWFnZS10b29s"));
2748
+ let raw = seen.lock().expect("seen lock").clone();
2749
+ let forwarded_body = raw.split("\r\n\r\n").nth(1).expect("forwarded body");
2750
+ let forwarded_json: Value = serde_json::from_str(forwarded_body).expect("forwarded JSON");
2751
+ assert_eq!(
2752
+ forwarded_json["tools"],
2753
+ json!([{"type": "image_generation"}])
2754
+ );
2755
+ assert_eq!(
2756
+ forwarded_json["tool_choice"],
2757
+ json!({"type": "image_generation"})
2758
+ );
2759
+ assert_eq!(
2760
+ forwarded_json["input"][0]["content"][0]["text"],
2761
+ "tool image"
2762
+ );
2763
+
2764
+ env::remove_var("OMX_API_CODEX_OAUTH_TOKEN");
2765
+ env::remove_var("OMX_API_PRIVATE_BACKEND_URL");
2766
+ }
2767
+
2768
+ #[test]
2769
+ fn redact_secrets_handles_chunk_like_sse_frames() {
2770
+ let chunk = "data: {\"message\":\"Bearer sk-mock-token\"}\n";
2771
+ let redacted = redact_secrets(chunk);
2772
+ assert!(!redacted.contains("sk-mock-token"));
2773
+ assert!(redacted.contains("[REDACTED]"));
2774
+ }
2775
+
2776
+ #[test]
2777
+ fn chat_stream_response_uses_chat_chunk_shape() {
2778
+ let telemetry = Telemetry::default();
2779
+ let response = route_request(
2780
+ &request(
2781
+ "POST",
2782
+ "/v1/chat/completions",
2783
+ json!({"stream": true, "messages": []}),
2784
+ ),
2785
+ &BackendMode::Mock,
2786
+ &telemetry,
2787
+ None,
2788
+ None,
2789
+ );
2790
+ assert_eq!(response.content_type, "text/event-stream");
2791
+ let body = String::from_utf8(response.body).unwrap();
2792
+ assert!(body.contains("\"object\":\"chat.completion.chunk\""));
2793
+ assert!(body.contains("\"delta\":{\"content\":\"omx mock response\"}"));
2794
+ assert!(body.contains("data: [DONE]"));
2795
+ }
2796
+
2797
+ #[test]
2798
+ fn image_stream_response_uses_image_events() {
2799
+ let telemetry = Telemetry::default();
2800
+ let response = route_request(
2801
+ &request(
2802
+ "POST",
2803
+ "/v1/images/generations",
2804
+ json!({"stream": true, "prompt": "x"}),
2805
+ ),
2806
+ &BackendMode::Mock,
2807
+ &telemetry,
2808
+ None,
2809
+ None,
2810
+ );
2811
+ assert_eq!(response.content_type, "text/event-stream");
2812
+ let body = String::from_utf8(response.body).unwrap();
2813
+ assert!(body.contains("event: image_generation.partial_image"));
2814
+ assert!(body.contains("data: [DONE]"));
2815
+ }
2816
+
2817
+ #[test]
2818
+ fn real_private_image_reports_unconfigured_without_image_backend() {
2819
+ let _guard = env_lock();
2820
+ env::set_var("OMX_API_CODEX_OAUTH_TOKEN", "oauth-token");
2821
+ env::remove_var("OMX_API_REAL_PRIVATE_IMAGE_B64_JSON");
2822
+ env::remove_var("OMX_API_REAL_PRIVATE_IMAGE_URL");
2823
+ env::remove_var("OMX_API_PRIVATE_IMAGE_BACKEND_URL");
2824
+ env::remove_var("OMX_API_PRIVATE_BACKEND_URL");
2825
+ let telemetry = Telemetry::default();
2826
+ let response = route_request(
2827
+ &request("POST", "/v1/images/generations", json!({"prompt": "x"})),
2828
+ &BackendMode::RealPrivate,
2829
+ &telemetry,
2830
+ None,
2831
+ None,
2832
+ );
2833
+ assert_eq!(response.status, 501);
2834
+ assert!(String::from_utf8(response.body)
2835
+ .unwrap()
2836
+ .contains("private_image_backend_unconfigured"));
2837
+ env::remove_var("OMX_API_CODEX_OAUTH_TOKEN");
2838
+ }
2839
+
2840
+ #[test]
2841
+ fn real_private_chat_stream_preserves_backend_errors() {
2842
+ let _guard = env_lock();
2843
+ env::remove_var("OMX_API_REAL_PRIVATE_RESPONSE_TEXT");
2844
+ env::remove_var("OMX_API_CODEX_OAUTH_TOKEN");
2845
+ env::remove_var("OMX_API_PRIVATE_BACKEND_URL");
2846
+ let previous_codex_home = env::var("CODEX_HOME").ok();
2847
+ env::set_var(
2848
+ "CODEX_HOME",
2849
+ env::temp_dir().join("omx-api-test-missing-auth"),
2850
+ );
2851
+ let telemetry = Telemetry::default();
2852
+ let response = route_request(
2853
+ &request(
2854
+ "POST",
2855
+ "/v1/chat/completions",
2856
+ json!({"stream": true, "messages": []}),
2857
+ ),
2858
+ &BackendMode::RealPrivate,
2859
+ &telemetry,
2860
+ None,
2861
+ None,
2862
+ );
2863
+ let status = response.status;
2864
+ let content_type = response.content_type.clone();
2865
+ let body = String::from_utf8(response.body).unwrap();
2866
+ if let Some(codex_home) = previous_codex_home {
2867
+ env::set_var("CODEX_HOME", codex_home);
2868
+ } else {
2869
+ env::remove_var("CODEX_HOME");
2870
+ }
2871
+
2872
+ assert_eq!(status, 503);
2873
+ assert_eq!(content_type, "application/json");
2874
+ assert!(body.contains("missing_auth"));
2875
+ }
2876
+
2877
+ #[test]
2878
+ fn live_text_smoke_fails_when_private_backend_returns_error() {
2879
+ let _guard = env_lock();
2880
+ env::set_var("OMX_API_LIVE_SMOKE", "1");
2881
+ env::remove_var("OMX_API_REAL_PRIVATE_RESPONSE_TEXT");
2882
+ env::set_var("OMX_API_CODEX_OAUTH_TOKEN", "oauth-token");
2883
+ env::remove_var("OMX_API_PRIVATE_BACKEND_URL");
2884
+
2885
+ let mut stdout = Vec::new();
2886
+ let error = run_cli(["smoke", "text"], &mut stdout, Vec::new())
2887
+ .expect_err("smoke must fail on real-private error JSON");
2888
+
2889
+ assert!(stdout.is_empty(), "error smoke should not write stdout");
2890
+ assert!(error.to_string().contains("private_backend_unconfigured"));
2891
+
2892
+ env::remove_var("OMX_API_LIVE_SMOKE");
2893
+ env::remove_var("OMX_API_CODEX_OAUTH_TOKEN");
2894
+ }
2895
+
2896
+ #[test]
2897
+ fn live_text_smoke_succeeds_when_response_fixture_set() {
2898
+ let _guard = env_lock();
2899
+ let mut stdout = Vec::new();
2900
+ env::set_var("OMX_API_LIVE_SMOKE", "1");
2901
+ env::set_var("OMX_API_REAL_PRIVATE_RESPONSE_TEXT", "ok-fixture-smoke");
2902
+
2903
+ run_cli(["smoke", "text"], &mut stdout, Vec::new()).unwrap();
2904
+
2905
+ env::remove_var("OMX_API_LIVE_SMOKE");
2906
+ env::remove_var("OMX_API_REAL_PRIVATE_RESPONSE_TEXT");
2907
+ assert!(String::from_utf8(stdout)
2908
+ .unwrap()
2909
+ .contains("ok-fixture-smoke"));
2910
+ }
2911
+
2912
+ #[test]
2913
+ fn daemon_state_round_trips() {
2914
+ let path = env::temp_dir().join(format!("omx-api-test-{}.json", now_unix()));
2915
+ let state = DaemonState {
2916
+ pid: 42,
2917
+ host: "127.0.0.1".to_string(),
2918
+ port: 9999,
2919
+ backend: BackendMode::Mock,
2920
+ started_at_unix: 1,
2921
+ local_bearer_token: None,
2922
+ local_bearer_token_file: None,
2923
+ };
2924
+ write_daemon_state(&path, &state).unwrap();
2925
+ let raw = fs::read_to_string(&path).unwrap();
2926
+ assert!(!raw.contains("local_bearer_token"));
2927
+ let read = read_daemon_state(&path).unwrap().unwrap();
2928
+ assert_eq!(read.pid, 42);
2929
+ remove_daemon_state(&path).unwrap();
2930
+ assert!(read_daemon_state(&path).unwrap().is_none());
2931
+ }
2932
+
2933
+ #[test]
2934
+ fn cli_system_dry_run_is_json() {
2935
+ let mut out = Vec::new();
2936
+ run_cli(["system", "dry-run"], &mut out, io::sink()).unwrap();
2937
+ let value: Value = serde_json::from_slice(&out).unwrap();
2938
+ assert_eq!(value["action"], "system.dry-run");
2939
+ }
2940
+ }