|
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) |
|
203 | 204 | (reduce +) |
204 | 205 | long))) |
205 | 206 |
|
206 | | -(defn ^:private codex-window-reset-ms |
207 | | - "ChatGPT Codex quota headers (`x-codex-<window>-*`): latest reset epoch-ms |
208 | | - among exhausted windows (used-percent >= 100), nil otherwise. Windows with |
209 | | - headroom are ignored on purpose: these headers come on every response with |
210 | | - far-future resets, so only a clearly exhausted window is a wait signal." |
211 | | - [headers now-ms] |
212 | | - (some->> ["primary" "secondary"] |
213 | | - (keep (fn [w] |
214 | | - (let [used (some-> (header-str headers (str "x-codex-" w "-used-percent")) |
215 | | - parse-double) |
216 | | - reset-at (epoch-str->ms (header-str headers (str "x-codex-" w "-reset-at"))) |
217 | | - reset-after-s (parse-long-str (header-str headers (str "x-codex-" w "-reset-after-seconds")))] |
218 | | - (when (and used (>= used 100.0)) |
219 | | - (or reset-at |
220 | | - (some-> reset-after-s (* 1000) (+ (long now-ms)))))))) |
221 | | - seq |
222 | | - (apply max))) |
| 207 | +(defn ^:private response-field [m field] |
| 208 | + (when (map? m) |
| 209 | + (or (get m field) |
| 210 | + (get m (keyword field))))) |
| 211 | + |
| 212 | +(defn ^:private codex-usage-limit-reset-ms |
| 213 | + "ChatGPT Codex OAuth usage-limit reset from the 429 JSON response body." |
| 214 | + [body] |
| 215 | + (let [body (cond |
| 216 | + (map? body) body |
| 217 | + (string? body) (try |
| 218 | + (json/parse-string body) |
| 219 | + (catch Exception _ nil)) |
| 220 | + :else nil) |
| 221 | + error (response-field body "error") |
| 222 | + error-type (response-field error "type") |
| 223 | + reset-at (response-field error "resets_at")] |
| 224 | + (when (and (= "usage_limit_reached" error-type) |
| 225 | + (integer? reset-at)) |
| 226 | + (* (long reset-at) 1000)))) |
| 227 | + |
| 228 | +(defn ^:private future-reset-ms [reset-ms now-ms] |
| 229 | + (when (and reset-ms (> (long reset-ms) (long now-ms))) |
| 230 | + (long reset-ms))) |
223 | 231 |
|
224 | 232 | (defn ^:private bucket-resets |
225 | 233 | "All rate-limit reset headers (`*ratelimit*-reset` like Anthropic's or |
|
240 | 248 | headers)) |
241 | 249 |
|
242 | 250 | (defn rate-limit-wait |
243 | | - "Computes when a rate-limited request can be retried, from HTTP response |
244 | | - headers (lowercase string keys, as returned by the http client). |
| 251 | + "Computes when a rate-limited request can be retried. |
| 252 | +
|
| 253 | + The two-argument arity reads HTTP response headers. The three-argument arity |
| 254 | + additionally reads the response body used by ChatGPT Codex OAuth errors. |
245 | 255 |
|
246 | 256 | Precedence: |
247 | 257 | 1. `retry-after` (delta seconds or HTTP-date) |
248 | | - 2. `anthropic-ratelimit-unified-reset` (epoch seconds; subscription session limits) |
249 | | - 3. `x-codex-<window>-reset-at/-reset-after-seconds` (ChatGPT subscription), |
250 | | - only for exhausted windows (used-percent >= 100) |
| 258 | + 2. ChatGPT Codex `usage_limit_reached` body `error.resets_at` (epoch seconds) |
| 259 | + 3. `anthropic-ratelimit-unified-reset` (epoch seconds; subscription session limits) |
251 | 260 | 4. rate-limit reset buckets (Anthropic `*ratelimit*-reset` RFC 3339/epoch, |
252 | 261 | OpenAI `*ratelimit*-reset-<bucket>` Go-style durations): latest exhausted |
253 | 262 | bucket (remaining = 0), else earliest future reset. |
254 | 263 |
|
255 | | - Returns {:delay-ms N :resets-at epoch-ms} or nil when headers give no usable |
256 | | - future reset (callers should fall back to exponential backoff)." |
257 | | - [headers now-ms] |
258 | | - (when (map? headers) |
259 | | - (let [resets-at (or (retry-after-ms headers now-ms) |
260 | | - (epoch-str->ms (header-str headers "anthropic-ratelimit-unified-reset")) |
261 | | - (codex-window-reset-ms headers now-ms) |
262 | | - (let [buckets (filter #(> (long (:reset-ms %)) (long now-ms)) (bucket-resets headers now-ms)) |
263 | | - exhausted (filter #(some-> (:remaining %) (<= 0)) buckets)] |
264 | | - (or (some->> (seq exhausted) (map :reset-ms) (apply max)) |
265 | | - (some->> (seq buckets) (map :reset-ms) (apply min)))))] |
266 | | - (when (and resets-at (> (long resets-at) (long now-ms))) |
267 | | - {:delay-ms (- (long resets-at) (long now-ms)) |
268 | | - :resets-at (long resets-at)})))) |
| 264 | + Codex quota-window headers are status snapshots, not retry instructions. |
| 265 | +
|
| 266 | + Returns {:delay-ms N :resets-at epoch-ms} or nil when no usable future reset |
| 267 | + is present (callers should fall back to exponential backoff)." |
| 268 | + ([headers now-ms] |
| 269 | + (rate-limit-wait headers nil now-ms)) |
| 270 | + ([headers body now-ms] |
| 271 | + (let [headers (if (map? headers) headers {}) |
| 272 | + buckets (filter #(> (long (:reset-ms %)) (long now-ms)) (bucket-resets headers now-ms)) |
| 273 | + exhausted (filter #(some-> (:remaining %) (<= 0)) buckets) |
| 274 | + bucket-reset (or (some->> (seq exhausted) (map :reset-ms) (apply max)) |
| 275 | + (some->> (seq buckets) (map :reset-ms) (apply min))) |
| 276 | + resets-at (some #(future-reset-ms % now-ms) |
| 277 | + [(retry-after-ms headers now-ms) |
| 278 | + (codex-usage-limit-reset-ms body) |
| 279 | + (epoch-str->ms (header-str headers "anthropic-ratelimit-unified-reset")) |
| 280 | + bucket-reset])] |
| 281 | + (when resets-at |
| 282 | + {:delay-ms (- resets-at (long now-ms)) |
| 283 | + :resets-at resets-at})))) |
0 commit comments