okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petauth: return native record for codex access3a0caba

main
10.4 KiB275 linesraw
1// Account pins and 429 handoff depend on OAuth identities and provider responses unavailable in E2E.
2import albedo/harness/extensions/codex/extension as codex
3import albedo/harness/rotation
4import albedo/openai_api
5import albedo/openai_api/types
6import gleam/erlang/process
7import gleam/int
8import gleam/list
9import gleam/option.{None, Some}
10import gleam/string
11import gleeunit/should
12
13const credentials =
14  "{\"accounts\":{\"openai-codex\":[{\"type\":\"oauth\",\"access\":\"h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjb3VudC0xIiwiY2hhdGdwdF9hY2NvdW50X3VzZXJfaWQiOiJzZWF0LTEifX0.s\",\"refresh\":\"refresh-1\",\"expires\":9999999999999,\"accountId\":\"account-1\",\"accountUserId\":\"seat-1\"},{\"type\":\"oauth\",\"access\":\"h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjb3VudC0yIiwiY2hhdGdwdF9hY2NvdW50X3VzZXJfaWQiOiJzZWF0LTIifX0.s\",\"refresh\":\"refresh-2\",\"expires\":9999999999999,\"accountId\":\"account-2\",\"accountUserId\":\"seat-2\"}]}}"
15
16const reversed_credentials =
17  "{\"accounts\":{\"openai-codex\":[{\"type\":\"oauth\",\"access\":\"h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjb3VudC0yIiwiY2hhdGdwdF9hY2NvdW50X3VzZXJfaWQiOiJzZWF0LTIifX0.s\",\"refresh\":\"refresh-2\",\"expires\":9999999999999,\"accountId\":\"account-2\",\"accountUserId\":\"seat-2\"},{\"type\":\"oauth\",\"access\":\"h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjb3VudC0xIiwiY2hhdGdwdF9hY2NvdW50X3VzZXJfaWQiOiJzZWF0LTEifX0.s\",\"refresh\":\"refresh-1\",\"expires\":9999999999999,\"accountId\":\"account-1\",\"accountUserId\":\"seat-1\"}]}}"
18
19pub fn sessions_stick_to_and_distribute_across_codex_accounts_test() -> Nil {
20  let #(root, _, home) = fixture()
21  let _ = write(home, "creds.json", credentials)
22  let first = access(home, "session-1")
23  access(home, "session-1") |> should.equal(first)
24  let accounts =
25    [access(home, "session-1"), access(home, "session-2")]
26    |> list.unique
27  list.length(accounts) |> should.equal(2)
28  cleanup(root)
29}
30
31pub fn session_pin_survives_credential_reordering_test() -> Nil {
32  let #(root, _, home) = fixture()
33  let _ = write(home, "creds.json", credentials)
34  let selected = access(home, "durable-session")
35  let _ = write(home, "creds.json", reversed_credentials)
36  access(home, "durable-session") |> should.equal(selected)
37  cleanup(root)
38}
39
40pub fn revoked_codex_account_is_removed_test() -> Nil {
41  let #(root, _, home) = fixture()
42  let _ = write(home, "creds.json", credentials)
43  let #(revoked, client) = codex_client(home, "revoked-session")
44  let assert Some(message) =
45    codex.account_failure(home, client, types.HttpError(401, "token_revoked"))
46  string.ends_with(message, "run /login") |> should.be_true
47  codex.account_failure(home, client, types.HttpError(500, ""))
48  |> should.equal(None)
49  let remaining = access(home, "revoked-session")
50  { remaining == revoked } |> should.be_false
51  access(home, "another-session") |> should.equal(remaining)
52  let #(_, last) = codex_client(home, "revoked-session")
53  let assert Some(_) =
54    codex.account_failure(home, last, types.HttpError(401, "token_revoked"))
55  let assert Error(_) = native_access(home, "revoked-session")
56  cleanup(root)
57}
58
59pub fn usage_limit_moves_the_session_to_a_sibling_account_test() -> Nil {
60  let #(root, _, home) = fixture()
61  let _ = write(home, "creds.json", credentials)
62  let #(limited, client) = codex_client(home, "busy-session")
63
64  // Ordinary rate limiting is transient and must not move the session.
65  codex.account_failure(home, client, types.HttpError(429, "slow down"))
66  |> should.equal(None)
67  access(home, "busy-session") |> should.equal(limited)
68
69  let body =
70    "{\"error\":{\"type\":\"usage_limit_reached\",\"resets_in_seconds\":3600}}"
71  let assert Some(message) =
72    codex.account_failure(home, client, types.HttpError(429, body))
73  string.contains(message, "usage limit reached") |> should.be_true
74  string.contains(message, "the next turn will use") |> should.be_true
75  let next = access(home, "busy-session")
76  { next == limited } |> should.be_false
77  // Every session avoids the limited account while a sibling has usage.
78  access(home, "fresh-session") |> should.equal(next)
79
80  // With every account limited the message says so instead of naming one.
81  let #(_, sibling) = codex_client(home, "busy-session")
82  let assert Some(exhausted) =
83    codex.account_failure(home, sibling, types.HttpError(429, body))
84  string.contains(exhausted, "no other ChatGPT account") |> should.be_true
85  cleanup(root)
86}
87
88/// Replays a stream where the named tokens answer 429 with `body` and every
89/// other account fails plainly, recording which accounts were tried.
90fn limited_stream(
91  limited: List(String),
92  body: String,
93  tried: process.Subject(String),
94) -> fn(types.Client, a, b) -> Result(c, types.Error) {
95  fn(client: types.Client, _request, _on_event) {
96    process.send(tried, client.api_key)
97    case list.contains(limited, client.api_key) {
98      True -> Error(types.HttpError(429, body))
99      False -> Error(types.HttpError(500, "second account answered"))
100    }
101  }
102}
103
104fn rotate(
105  home: String,
106  session: String,
107  first: types.Client,
108  request: types.Request,
109  stream: fn(types.Client, types.Request, fn(types.Event) -> types.Control) ->
110    Result(types.Turn, types.Error),
111  pause: fn(Int) -> Nil,
112) -> Result(types.Turn, types.Error) {
113  rotation.stream(
114    codex.pool(home, session, stream),
115    first,
116    request,
117    fn(_) { types.Continue },
118    rotation.Budget(8, 0, pause),
119    fn(_) { Nil },
120  )
121}
122
123fn drain(tried: process.Subject(String), found: List(String)) -> List(String) {
124  case process.receive(tried, 0) {
125    Ok(token) -> drain(tried, [token, ..found])
126    Error(_) -> list.reverse(found)
127  }
128}
129
130pub fn a_limited_account_hands_the_same_request_to_the_next_test() -> Nil {
131  let #(root, _, home) = fixture()
132  let _ = write(home, "creds.json", credentials)
133  let #(_, first) = codex_client(home, "swarm-session")
134  let tried = process.new_subject()
135  let body =
136    "{\"error\":{\"type\":\"usage_limit_reached\",\"resets_in_seconds\":3600}}"
137  let request = openai_api.request("gpt-5.5", [types.User("hi")])
138  rotate(
139    home,
140    "swarm-session",
141    first,
142    request,
143    limited_stream([first.api_key], body, tried),
144    fn(_) { panic as "a sibling had room; nothing should wait" },
145  )
146  |> should.equal(Error(types.HttpError(500, "second account answered")))
147  let attempts = drain(tried, [])
148  list.length(attempts) |> should.equal(2)
149  list.first(attempts) |> should.equal(Ok(first.api_key))
150  cleanup(root)
151}
152
153pub fn a_burst_rate_limit_body_rotates_test() -> Nil {
154  let #(root, _, home) = fixture()
155  let _ = write(home, "creds.json", credentials)
156  let #(_, first) = codex_client(home, "burst-session")
157  let tried = process.new_subject()
158  let request = openai_api.request("gpt-6-luna", [types.User("hi")])
159  rotate(
160    home,
161    "burst-session",
162    first,
163    request,
164    limited_stream(
165      [first.api_key],
166      "{\"detail\":\"Rate limit exceeded\"}",
167      tried,
168    ),
169    fn(_) { panic as "a sibling had room; nothing should wait" },
170  )
171  |> should.equal(Error(types.HttpError(500, "second account answered")))
172  list.length(drain(tried, [])) |> should.equal(2)
173  cleanup(root)
174}
175
176pub fn a_rate_limit_rotates_too_and_stops_when_all_are_busy_test() -> Nil {
177  let #(root, _, home) = fixture()
178  let _ = write(home, "creds.json", credentials)
179  let #(_, first) = codex_client(home, "busy-swarm")
180  let tried = process.new_subject()
181  let body =
182    "{\"error\":{\"type\":\"rate_limit_exceeded\",\"resets_in_seconds\":20}}"
183  let #(_, second) = codex_client(home, "other-busy-swarm")
184  let both = list.unique([first.api_key, second.api_key])
185  let request = openai_api.request("gpt-5.5", [types.User("hi")])
186  let paused = process.new_subject()
187  rotate(
188    home,
189    "busy-swarm",
190    first,
191    request,
192    limited_stream(both, body, tried),
193    fn(delay) { process.send(paused, int.to_string(delay)) },
194  )
195  |> should.equal(Error(types.HttpError(429, body)))
196  // Each account once, then four waits with a retry after each, then it stops.
197  list.length(drain(tried, [])) |> should.equal(6)
198  drain(paused, []) |> should.equal(["4000", "8000", "15000", "30000"])
199  cleanup(root)
200}
201
202pub fn a_usage_limit_on_every_account_does_not_wait_test() -> Nil {
203  let #(root, _, home) = fixture()
204  let _ = write(home, "creds.json", credentials)
205  let #(_, first) = codex_client(home, "spent-swarm")
206  let #(_, second) = codex_client(home, "other-spent-swarm")
207  let tried = process.new_subject()
208  let body =
209    "{\"error\":{\"type\":\"usage_limit_reached\",\"resets_in_seconds\":3600}}"
210  let request = openai_api.request("gpt-6-luna", [types.User("hi")])
211  rotate(
212    home,
213    "spent-swarm",
214    first,
215    request,
216    limited_stream(list.unique([first.api_key, second.api_key]), body, tried),
217    fn(_) { panic as "a usage limit lasts hours; it must not be waited on" },
218  )
219  |> should.equal(Error(types.HttpError(429, body)))
220  list.length(drain(tried, [])) |> should.equal(2)
221  cleanup(root)
222}
223
224pub fn selected_account_overrides_the_session_pin_test() -> Nil {
225  let #(root, _, home) = fixture()
226  let _ = write(home, "creds.json", credentials)
227  let pinned = access(home, "pinned-session")
228  let other = case pinned {
229    "account-1" -> "account-2"
230    _ -> "account-1"
231  }
232  let _ =
233    write(
234      home,
235      "creds.json",
236      string.replace(
237        credentials,
238        "\"accountId\":\"" <> other <> "\"",
239        "\"accountId\":\"" <> other <> "\",\"selected\":true",
240      ),
241    )
242  access(home, "pinned-session") |> should.equal(other)
243  access(home, "any-session") |> should.equal(other)
244  cleanup(root)
245}
246
247fn codex_client(home: String, session: String) -> #(String, types.Client) {
248  let assert Ok(access) = native_access(home, session)
249  #(
250    access.account_id,
251    openai_api.codex_client(
252      "http://127.0.0.1",
253      access.token,
254      access.account_id,
255      session,
256    ),
257  )
258}
259
260fn access(home: String, session: String) -> String {
261  let assert Ok(access) = native_access(home, session)
262  access.account_id
263}
264
265@external(erlang, "albedo_openai_auth", "codex_access")
266fn native_access(home: String, session: String) -> Result(codex.Access, String)
267
268@external(erlang, "albedo_skills_test_support", "fixture")
269fn fixture() -> #(String, String, String)
270
271@external(erlang, "albedo_skills_test_support", "write")
272fn write(base: String, relative: String, content: String) -> String
273
274@external(erlang, "albedo_skills_test_support", "cleanup")
275fn cleanup(root: String) -> Nil