Skip to content

Commit 526f92b

Browse files
committed
feat: add 'mode' command for one-click proxy/direct switching (v2.1.0)
- CodexConfigManager: reads/writes ~/.codex/config.toml to toggle model_provider - 'codex-rate mode' shows current mode (proxy/direct) - 'codex-rate mode proxy' switches Codex to use rate-watcher proxy - 'codex-rate mode direct' switches back to account-based auth - Auto-backup config.toml before every modification - 16 new tests for CodexConfigManager (133 total, 0 failures)
1 parent 2fbef9c commit 526f92b

3 files changed

Lines changed: 589 additions & 1 deletion

File tree

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import Foundation
2+
3+
// MARK: - CodexConfigManager
4+
5+
/// Manages Codex CLI config.toml to toggle between proxy and direct modes.
6+
public struct CodexConfigManager {
7+
8+
public enum Mode: String, CustomStringConvertible {
9+
case proxy = "proxy"
10+
case direct = "direct"
11+
public var description: String { rawValue }
12+
}
13+
14+
public enum ConfigError: Error, LocalizedError {
15+
case configNotFound(String)
16+
case readFailed(String)
17+
case writeFailed(String)
18+
19+
public var errorDescription: String? {
20+
switch self {
21+
case .configNotFound(let p): return "Codex config not found: \(p)"
22+
case .readFailed(let m): return "Failed to read config: \(m)"
23+
case .writeFailed(let m): return "Failed to write config: \(m)"
24+
}
25+
}
26+
}
27+
28+
private let configPath: String
29+
private static let providerKey = "rate_watcher"
30+
31+
// MARK: Init
32+
33+
public init(configPath: String? = nil) {
34+
if let p = configPath {
35+
self.configPath = p
36+
} else {
37+
let home = FileManager.default.homeDirectoryForCurrentUser.path
38+
self.configPath = "\(home)/.codex/config.toml"
39+
}
40+
}
41+
42+
// MARK: Public – read
43+
44+
/// The resolved config file path.
45+
public var path: String { configPath }
46+
47+
/// Detect current mode by scanning for an active `model_provider = "rate_watcher"`.
48+
public func currentMode() -> Mode {
49+
guard let content = try? String(contentsOfFile: configPath, encoding: .utf8) else {
50+
return .direct
51+
}
52+
return Self.detectMode(in: content)
53+
}
54+
55+
// MARK: Public – write
56+
57+
/// Switch Codex to proxy mode (route through rate-watcher proxy).
58+
@discardableResult
59+
public func switchTo(proxy port: UInt16 = 19876) throws -> String {
60+
let content = try readConfig()
61+
try backup(content)
62+
var lines = content.components(separatedBy: "\n")
63+
64+
// 1. Comment out every active model_provider line that isn't ours
65+
for i in 0..<lines.count {
66+
let t = lines[i].trimmingCharacters(in: .whitespaces)
67+
guard !t.hasPrefix("#"), t.hasPrefix("model_provider"), t.contains("=") else { continue }
68+
if !t.contains(Self.providerKey) {
69+
lines[i] = "# \(lines[i])"
70+
}
71+
}
72+
73+
// 2. Ensure our provider line is present and uncommented
74+
if let idx = indexOfLine(containing: Self.providerKey, withPrefix: "model_provider", in: lines) {
75+
lines[idx] = "model_provider = \"\(Self.providerKey)\""
76+
} else {
77+
let insertAt = insertionPointForProvider(in: lines)
78+
lines.insert("model_provider = \"\(Self.providerKey)\"", at: insertAt)
79+
}
80+
81+
// 3. Ensure [model_providers.rate_watcher] section exists
82+
let joined = lines.joined(separator: "\n")
83+
if !joined.contains("[model_providers.\(Self.providerKey)]") {
84+
let section = [
85+
"",
86+
"[model_providers.\(Self.providerKey)]",
87+
"name = \"Rate Watcher Proxy\"",
88+
"base_url = \"http://localhost:\(port)\"",
89+
"wire_api = \"responses\"",
90+
]
91+
let at = insertionPointForSection(in: lines)
92+
lines.insert(contentsOf: section, at: at)
93+
} else {
94+
// Update port in existing section
95+
updatePort(port, in: &lines)
96+
}
97+
98+
try writeConfig(lines.joined(separator: "\n"))
99+
return configPath
100+
}
101+
102+
/// Switch back to direct / account mode.
103+
@discardableResult
104+
public func switchToDirect() throws -> String {
105+
let content = try readConfig()
106+
try backup(content)
107+
var lines = content.components(separatedBy: "\n")
108+
109+
for i in 0..<lines.count {
110+
let t = lines[i].trimmingCharacters(in: .whitespaces)
111+
if !t.hasPrefix("#"), t.contains("model_provider"), t.contains(Self.providerKey) {
112+
lines[i] = "# \(lines[i])"
113+
break
114+
}
115+
}
116+
117+
try writeConfig(lines.joined(separator: "\n"))
118+
return configPath
119+
}
120+
121+
// MARK: Internal helpers (visible to tests)
122+
123+
static func detectMode(in content: String) -> Mode {
124+
for line in content.components(separatedBy: "\n") {
125+
let t = line.trimmingCharacters(in: .whitespaces)
126+
if !t.hasPrefix("#"), t.hasPrefix("model_provider"), t.contains(providerKey) {
127+
return .proxy
128+
}
129+
}
130+
return .direct
131+
}
132+
133+
// MARK: Private
134+
135+
private func readConfig() throws -> String {
136+
guard FileManager.default.fileExists(atPath: configPath) else {
137+
throw ConfigError.configNotFound(configPath)
138+
}
139+
do { return try String(contentsOfFile: configPath, encoding: .utf8) }
140+
catch { throw ConfigError.readFailed(error.localizedDescription) }
141+
}
142+
143+
private func writeConfig(_ content: String) throws {
144+
do { try content.write(toFile: configPath, atomically: true, encoding: .utf8) }
145+
catch { throw ConfigError.writeFailed(error.localizedDescription) }
146+
}
147+
148+
private func backup(_ content: String) throws {
149+
let fmt = DateFormatter()
150+
fmt.dateFormat = "yyyy-MM-dd'T'HH-mm-ss"
151+
try content.write(
152+
toFile: configPath + ".rw-backup.\(fmt.string(from: Date()))",
153+
atomically: true, encoding: .utf8
154+
)
155+
}
156+
157+
/// Find an existing line that contains `keyword` and starts with `prefix`.
158+
private func indexOfLine(containing keyword: String, withPrefix prefix: String, in lines: [String]) -> Int? {
159+
for i in 0..<lines.count {
160+
let t = lines[i].trimmingCharacters(in: .whitespaces)
161+
.replacingOccurrences(of: "# ", with: "")
162+
.replacingOccurrences(of: "#", with: "")
163+
if t.hasPrefix(prefix), t.contains(keyword) { return i }
164+
}
165+
return nil
166+
}
167+
168+
/// Best line to insert `model_provider = ...` (right after the `model = ...` line).
169+
private func insertionPointForProvider(in lines: [String]) -> Int {
170+
for i in 0..<lines.count {
171+
let t = lines[i].trimmingCharacters(in: .whitespaces)
172+
if !t.hasPrefix("#"), t.hasPrefix("model "), t.contains("=") { return i + 1 }
173+
}
174+
return min(1, lines.count)
175+
}
176+
177+
/// Best line to insert the `[model_providers.rate_watcher]` section.
178+
private func insertionPointForSection(in lines: [String]) -> Int {
179+
var lastProviderEnd = -1
180+
for i in 0..<lines.count {
181+
if lines[i].hasPrefix("[model_providers.") {
182+
var j = i + 1
183+
while j < lines.count, !lines[j].hasPrefix("[") { j += 1 }
184+
lastProviderEnd = j
185+
}
186+
}
187+
if lastProviderEnd > 0 { return lastProviderEnd }
188+
189+
// Fallback: insert before first non-model top-level section
190+
for i in 0..<lines.count {
191+
let l = lines[i]
192+
if l.hasPrefix("[projects.") || l.hasPrefix("[notice") || l.hasPrefix("[memories")
193+
|| l.hasPrefix("[mcp_servers") || l.hasPrefix("[sandbox") || l.hasPrefix("[features")
194+
{
195+
return i
196+
}
197+
}
198+
return lines.count
199+
}
200+
201+
/// Update `base_url` inside the `[model_providers.rate_watcher]` section.
202+
private func updatePort(_ port: UInt16, in lines: inout [String]) {
203+
var inSection = false
204+
for i in 0..<lines.count {
205+
if lines[i].contains("[model_providers.\(Self.providerKey)]") {
206+
inSection = true; continue
207+
}
208+
if inSection, lines[i].hasPrefix("[") { break }
209+
if inSection, lines[i].contains("base_url") {
210+
lines[i] = "base_url = \"http://localhost:\(port)\""
211+
break
212+
}
213+
}
214+
}
215+
}

Sources/codex-rate/main.swift

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ private struct CLIOptions {
190190
case relay
191191
case cost
192192
case proxy
193+
case mode
193194
case help
194195
}
195196
var command: Command = .status
@@ -201,6 +202,7 @@ private struct CLIOptions {
201202
var proxyUpstream: String = "https://api.openai.com"
202203
var noAutoRelay: Bool = false
203204
var proxyVerbose: Bool = false
205+
var modeTarget: String = ""
204206
}
205207

206208
private func parseArguments() -> CLIOptions {
@@ -226,21 +228,29 @@ private func parseArguments() -> CLIOptions {
226228
opts.command = .cost; idx = 1
227229
case "proxy":
228230
opts.command = .proxy; idx = 1
231+
case "mode":
232+
opts.command = .mode; idx = 1
229233
case "relay":
230234
opts.command = .relay; idx = 1
231235
case "help", "-h", "--help":
232236
opts.command = .help; return opts
233237
case "--json":
234238
opts.command = .status; opts.jsonOutput = true; idx = 1
235239
case "--version", "-v":
236-
print("codex-rate 2.0.0")
240+
print("codex-rate 2.1.0")
237241
exit(0)
238242
default:
239243
fputs(ANSI.c(ANSI.red, "Unknown command: \(first)") + "\n", stderr)
240244
fputs("Run 'codex-rate help' for usage information.\n", stderr)
241245
exit(1)
242246
}
243247

248+
// Parse mode subcommand (proxy/direct)
249+
if opts.command == .mode && idx < args.count && !args[idx].hasPrefix("-") {
250+
opts.modeTarget = args[idx]
251+
idx += 1
252+
}
253+
244254
// Parse remaining flags
245255
while idx < args.count {
246256
let arg = args[idx]
@@ -934,6 +944,7 @@ private func printHelp() {
934944
relay Show relay plan across accounts
935945
cost Show cost dashboard & 7-day spending
936946
proxy Start local HTTP proxy for Codex API
947+
mode Switch Codex between proxy and direct modes
937948
help Show this help message
938949
939950
\(ANSI.c(ANSI.bold, "OPTIONS"))
@@ -961,11 +972,91 @@ private func printHelp() {
961972
codex-rate proxy Start proxy on port 19876
962973
codex-rate proxy --port 8080 Start proxy on custom port
963974
codex-rate proxy --verbose Proxy with request logging
975+
codex-rate mode Show current Codex mode
976+
codex-rate mode proxy Switch to proxy mode
977+
codex-rate mode direct Switch to direct/account mode
964978
\(ANSI.c(ANSI.dim, "Part of Codex Rate Watcher \u{00B7} https://github.com/patchwork-body/shakeflow"))
965979
"""
966980
print(help)
967981
}
968982

983+
984+
// MARK: - Subcommand: Mode
985+
986+
private func runMode(target: String, port: UInt16, json: Bool) {
987+
let mgr = CodexConfigManager()
988+
let current = mgr.currentMode()
989+
990+
// No target = show current mode
991+
if target.isEmpty {
992+
if json {
993+
let obj: [String: String] = [
994+
"mode": current.rawValue,
995+
"config": mgr.path,
996+
]
997+
if let data = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]),
998+
let str = String(data: data, encoding: .utf8) {
999+
print(str)
1000+
}
1001+
} else {
1002+
let icon = current == .proxy ? "PROXY" : "DIRECT"
1003+
let color = current == .proxy ? ANSI.magenta : ANSI.green
1004+
print(ANSI.c(ANSI.bold, "Codex Mode: ") + ANSI.c(ANSI.bold + color, icon))
1005+
print(ANSI.c(ANSI.dim, " Config: \(mgr.path)"))
1006+
if current == .proxy {
1007+
print(ANSI.c(ANSI.dim, " Proxy: http://localhost:\(port)"))
1008+
}
1009+
print()
1010+
print("Switch with: codex-rate mode proxy | codex-rate mode direct")
1011+
}
1012+
return
1013+
}
1014+
1015+
// Switch mode
1016+
switch target.lowercased() {
1017+
case "proxy":
1018+
if current == .proxy {
1019+
print(ANSI.c(ANSI.yellow, "Already in proxy mode."))
1020+
return
1021+
}
1022+
do {
1023+
try mgr.switchTo(proxy: port)
1024+
if json {
1025+
print("{\"mode\":\"proxy\",\"port\":\(port)}")
1026+
} else {
1027+
print(ANSI.c(ANSI.green, "Switched to PROXY mode."))
1028+
print(" Codex will route through: http://localhost:\(port)")
1029+
print()
1030+
print(ANSI.c(ANSI.dim, "Make sure the proxy is running:"))
1031+
print(ANSI.c(ANSI.cyan, " codex-rate proxy --port \(port)"))
1032+
}
1033+
} catch {
1034+
fputs(ANSI.c(ANSI.red, "Failed: \(error.localizedDescription)") + "\n", stderr)
1035+
}
1036+
1037+
case "direct", "normal":
1038+
if current == .direct {
1039+
print(ANSI.c(ANSI.yellow, "Already in direct mode."))
1040+
return
1041+
}
1042+
do {
1043+
try mgr.switchToDirect()
1044+
if json {
1045+
print("{\"mode\":\"direct\"}")
1046+
} else {
1047+
print(ANSI.c(ANSI.green, "Switched to DIRECT mode."))
1048+
print(" Codex will use account-based auth directly.")
1049+
}
1050+
} catch {
1051+
fputs(ANSI.c(ANSI.red, "Failed: \(error.localizedDescription)") + "\n", stderr)
1052+
}
1053+
1054+
default:
1055+
fputs(ANSI.c(ANSI.red, "Unknown mode: \(target)") + "\n", stderr)
1056+
fputs("Usage: codex-rate mode [proxy|direct]\n", stderr)
1057+
}
1058+
}
1059+
9691060
// MARK: - Main Entry Point
9701061

9711062
private let opts = parseArguments()
@@ -983,6 +1074,8 @@ case .cost:
9831074
await runCost(json: opts.jsonOutput)
9841075
case .proxy:
9851076
await runProxy(opts: opts)
1077+
case .mode:
1078+
runMode(target: opts.modeTarget, port: opts.proxyPort, json: opts.jsonOutput)
9861079
case .relay:
9871080
await runRelay(strategyName: opts.relayStrategy, json: opts.jsonOutput)
9881081
case .help:

0 commit comments

Comments
 (0)