|
4 | 4 | Supports context overflow, rate limiting, authentication, and overload detection |
5 | 5 | across multiple providers (Anthropic, OpenAI, Google, Ollama, etc.)." |
6 | 6 | (:require |
| 7 | + [cheshire.core :as json] |
7 | 8 | [clojure.string :as string])) |
8 | 9 |
|
9 | 10 | (set! *warn-on-reflection* true) |
|
227 | 228 | (reduce +) |
228 | 229 | long))) |
229 | 230 |
|
230 | | -(defn ^:private codex-window-reset-ms |
231 | | - "ChatGPT Codex quota headers (`x-codex-<window>-*`): latest reset epoch-ms |
232 | | - among exhausted windows (used-percent >= 100), nil otherwise. Windows with |
233 | | - headroom are ignored on purpose: these headers come on every response with |
234 | | - far-future resets, so only a clearly exhausted window is a wait signal." |
235 | | - [headers now-ms] |
236 | | - (some->> ["primary" "secondary"] |
237 | | - (keep (fn [w] |
238 | | - (let [used (some-> (header-str headers (str "x-codex-" w "-used-percent")) |
239 | | - parse-double) |
240 | | - reset-at (epoch-str->ms (header-str headers (str "x-codex-" w "-reset-at"))) |
241 | | - reset-after-s (parse-long-str (header-str headers (str "x-codex-" w "-reset-after-seconds")))] |
242 | | - (when (and used (>= used 100.0)) |
243 | | - (or reset-at |
244 | | - (some-> reset-after-s (* 1000) (+ (long now-ms)))))))) |
245 | | - seq |
246 | | - (apply max))) |
| 231 | +(defn ^:private response-field [m field] |
| 232 | + (when (map? m) |
| 233 | + (or (get m field) |
| 234 | + (get m (keyword field))))) |
| 235 | + |
| 236 | +(defn ^:private codex-usage-limit-reset-ms |
| 237 | + "ChatGPT Codex OAuth usage-limit reset from the 429 JSON response body." |
| 238 | + [body] |
| 239 | + (let [body (cond |
| 240 | + (map? body) body |
| 241 | + (string? body) (try |
| 242 | + (json/parse-string body) |
| 243 | + (catch Exception _ nil)) |
| 244 | + :else nil) |
| 245 | + error (response-field body "error") |
| 246 | + error-type (response-field error "type") |
| 247 | + reset-at (response-field error "resets_at")] |
| 248 | + (when (and (= "usage_limit_reached" error-type) |
| 249 | + (integer? reset-at)) |
| 250 | + (* (long reset-at) 1000)))) |
| 251 | + |
| 252 | +(defn ^:private future-reset-ms [reset-ms now-ms] |
| 253 | + (when (and reset-ms (> (long reset-ms) (long now-ms))) |
| 254 | + (long reset-ms))) |
247 | 255 |
|
248 | 256 | (defn ^:private bucket-resets |
249 | 257 | "All rate-limit reset headers (`*ratelimit*-reset` like Anthropic's or |
|
264 | 272 | headers)) |
265 | 273 |
|
266 | 274 | (defn rate-limit-wait |
267 | | - "Computes when a rate-limited request can be retried, from HTTP response |
268 | | - headers (lowercase string keys, as returned by the http client). |
| 275 | + "Computes when a rate-limited request can be retried. |
| 276 | +
|
| 277 | + The two-argument arity reads HTTP response headers. The three-argument arity |
| 278 | + additionally reads the response body used by ChatGPT Codex OAuth errors. |
269 | 279 |
|
270 | 280 | Precedence: |
271 | 281 | 1. `retry-after` (delta seconds or HTTP-date) |
272 | | - 2. `anthropic-ratelimit-unified-reset` (epoch seconds; subscription session limits) |
273 | | - 3. `x-codex-<window>-reset-at/-reset-after-seconds` (ChatGPT subscription), |
274 | | - only for exhausted windows (used-percent >= 100) |
| 282 | + 2. ChatGPT Codex `usage_limit_reached` body `error.resets_at` (epoch seconds) |
| 283 | + 3. `anthropic-ratelimit-unified-reset` (epoch seconds; subscription session limits) |
275 | 284 | 4. rate-limit reset buckets (Anthropic `*ratelimit*-reset` RFC 3339/epoch, |
276 | 285 | OpenAI `*ratelimit*-reset-<bucket>` Go-style durations): latest exhausted |
277 | 286 | bucket (remaining = 0), else earliest future reset. |
278 | 287 |
|
279 | | - Returns {:delay-ms N :resets-at epoch-ms} or nil when headers give no usable |
280 | | - future reset (callers should fall back to exponential backoff)." |
281 | | - [headers now-ms] |
282 | | - (when (map? headers) |
283 | | - (let [resets-at (or (retry-after-ms headers now-ms) |
284 | | - (epoch-str->ms (header-str headers "anthropic-ratelimit-unified-reset")) |
285 | | - (codex-window-reset-ms headers now-ms) |
286 | | - (let [buckets (filter #(> (long (:reset-ms %)) (long now-ms)) (bucket-resets headers now-ms)) |
287 | | - exhausted (filter #(some-> (:remaining %) (<= 0)) buckets)] |
288 | | - (or (some->> (seq exhausted) (map :reset-ms) (apply max)) |
289 | | - (some->> (seq buckets) (map :reset-ms) (apply min)))))] |
290 | | - (when (and resets-at (> (long resets-at) (long now-ms))) |
291 | | - {:delay-ms (- (long resets-at) (long now-ms)) |
292 | | - :resets-at (long resets-at)})))) |
| 288 | + Codex quota-window headers are status snapshots, not retry instructions. |
| 289 | +
|
| 290 | + Returns {:delay-ms N :resets-at epoch-ms} or nil when no usable future reset |
| 291 | + is present (callers should fall back to exponential backoff)." |
| 292 | + ([headers now-ms] |
| 293 | + (rate-limit-wait headers nil now-ms)) |
| 294 | + ([headers body now-ms] |
| 295 | + (let [headers (if (map? headers) headers {}) |
| 296 | + buckets (filter #(> (long (:reset-ms %)) (long now-ms)) (bucket-resets headers now-ms)) |
| 297 | + exhausted (filter #(some-> (:remaining %) (<= 0)) buckets) |
| 298 | + bucket-reset (or (some->> (seq exhausted) (map :reset-ms) (apply max)) |
| 299 | + (some->> (seq buckets) (map :reset-ms) (apply min))) |
| 300 | + resets-at (some #(future-reset-ms % now-ms) |
| 301 | + [(retry-after-ms headers now-ms) |
| 302 | + (codex-usage-limit-reset-ms body) |
| 303 | + (epoch-str->ms (header-str headers "anthropic-ratelimit-unified-reset")) |
| 304 | + bucket-reset])] |
| 305 | + (when resets-at |
| 306 | + {:delay-ms (- resets-at (long now-ms)) |
| 307 | + :resets-at resets-at})))) |
0 commit comments