From acdb628ebd0a1ddb1b7f89d262877fb7fc96b435 Mon Sep 17 00:00:00 2001 From: Agent <_@pepijn.ai> Date: Wed, 23 Sep 2026 19:27:00 +0000 Subject: [PATCH 1/2] Expose Codex subscription usage limits over the fold-codex transport Add a provider-neutral usage-limits snapshot to fold-core and populate it from the Codex rate-limit response headers the ChatGPT backend attaches to every /responses reply. Co-Authored-By: Claude Opus 4.8 HumanLayer-Session: https://app.humanlayer.com/sessions/01a0bf0d-c4bd-787c-b350-a30c562a74d6 --- packages/fold-codex/src/CodexModel.ts | 19 +++- packages/fold-codex/src/RateLimitHeaders.ts | 93 +++++++++++++++++++ packages/fold-codex/src/index.ts | 1 + .../test/RateLimitHeaders.vi.test.ts | 82 ++++++++++++++++ .../fold-core/src/EventLog/UsageLimits.ts | 57 ++++++++++++ packages/fold-core/src/index.ts | 1 + .../test/EventLog/UsageLimits.vi.test.ts | 30 ++++++ 7 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 packages/fold-codex/src/RateLimitHeaders.ts create mode 100644 packages/fold-codex/test/RateLimitHeaders.vi.test.ts create mode 100644 packages/fold-core/src/EventLog/UsageLimits.ts create mode 100644 packages/fold-core/test/EventLog/UsageLimits.vi.test.ts diff --git a/packages/fold-codex/src/CodexModel.ts b/packages/fold-codex/src/CodexModel.ts index dd714b2..27f0775 100644 --- a/packages/fold-codex/src/CodexModel.ts +++ b/packages/fold-codex/src/CodexModel.ts @@ -17,7 +17,7 @@ import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { OpenAiClient, OpenAiLanguageModel } from '@humanlayer/effect-ai-openai' import type * as OpenAiSchema from '@humanlayer/effect-ai-openai/OpenAiSchema' import { customModel, resolveCodexReasoning } from '@humanlayer/fold-core' -import type { ReasoningLevel, FoldModel } from '@humanlayer/fold-core' +import type { ReasoningLevel, FoldModel, UsageLimits } from '@humanlayer/fold-core' import { Match, Context, Duration, Effect, Layer, Option, Schedule, Schema, Stream } from 'effect' import type { Scope } from 'effect' import { AiError } from 'effect/unstable/ai' @@ -37,6 +37,7 @@ import { withFirstEventRetry, withStallTimeouts, } from './Hardening' +import { usageLimitsFromCodexHeaders } from './RateLimitHeaders' /** The ChatGPT Codex backend the provider talks to (the client appends `/responses`). */ export const CODEX_API_URL = 'https://chatgpt.com/backend-api/codex' @@ -111,7 +112,11 @@ type ResponseFold = { * non-streaming requests with "Stream must be set to true") and returns the terminal event's * response, which also carries `generateText`/`generateObject` on the stock provider for free. */ -export const decorateCodexClient = (inner: OpenAiClient.Service, options: CodexRetryOptions): OpenAiClient.Service => { +export const decorateCodexClient = ( + inner: OpenAiClient.Service, + options: CodexRetryOptions, + onUsageLimits: (limits: UsageLimits) => Effect.Effect = () => Effect.void, +): OpenAiClient.Service => { // `min` caps the infinite exponential delay; `max` intersects it with the finite retry counter. const retryDelaySchedule: Schedule.Schedule = Schedule.min([ Schedule.exponential(Duration.millis(options.firstEventRetryBaseDelayMs)), @@ -143,6 +148,12 @@ export const decorateCodexClient = (inner: OpenAiClient.Service, options: CodexR // failures retry in-effect - nothing has streamed yet, so a re-send cannot duplicate anything. return acquireOnce(transformed).pipe( Effect.retry({ while: isCodexRetryableBeforeFirstEvent, schedule: retrySchedule }), + Effect.tap(([response]) => + Option.match(usageLimitsFromCodexHeaders(response.headers), { + onNone: () => Effect.void, + onSome: onUsageLimits, + }), + ), Effect.map(([response, firstStream]) => { let pending: EventStream | null = firstStream @@ -234,6 +245,8 @@ export type CodexModelOptions = { readonly hardening?: Partial /** Observes stream retries (the future AgentEvents `stream-retry` seam). */ readonly onStreamRetry?: (info: StreamRetryInfo) => Effect.Effect + /** Observes subscription usage-limit snapshots parsed from each response's Codex rate-limit headers. */ + readonly onUsageLimits?: (limits: UsageLimits) => Effect.Effect } /** @@ -273,7 +286,7 @@ export const makeCodexLanguageModel = ( const hardening: MutableCodexRetryOptions = { ...defaultCodexHardening, ...options.hardening } if (options.onStreamRetry !== undefined) hardening.onStreamRetry = options.onStreamRetry - const codexClient = decorateCodexClient(stockClient, hardening) + const codexClient = decorateCodexClient(stockClient, hardening, options.onUsageLimits) const reasoning = resolveCodexReasoning(options.reasoning ?? 'off') const reasoningConfig = Match.valueTags(reasoning, { diff --git a/packages/fold-codex/src/RateLimitHeaders.ts b/packages/fold-codex/src/RateLimitHeaders.ts new file mode 100644 index 0000000..a505445 --- /dev/null +++ b/packages/fold-codex/src/RateLimitHeaders.ts @@ -0,0 +1,93 @@ +/** + * Parse the Codex subscription usage-limit headers the ChatGPT backend attaches to every + * `/responses` reply into fold-core's provider-neutral {@link UsageLimits} snapshot. + * + * The backend reports the default `codex` limit family as a flat header set: `x-codex--` + * `used-percent`/`window-minutes`/`reset-at` for the `primary` and `secondary` positions, plus a + * `x-codex-credits-*` group and a `x-codex-limit-name` label. `reset-at` is absolute epoch seconds; + * fold stores epoch milliseconds, so it is scaled here. Every field is best-effort: a header that is + * absent, blank, or unparsable is dropped, and a response that carries no window and no credit data + * yields `None` rather than an empty snapshot. + */ +import { decodeUsageLimits } from '@humanlayer/fold-core' +import type { UsageLimitCredits, UsageLimits, UsageLimitWindow } from '@humanlayer/fold-core' +import { Option } from 'effect' +import { Headers } from 'effect/unstable/http' + +const CODEX_LIMIT_ID = 'codex' +const HEADER_PREFIX = `x-${CODEX_LIMIT_ID}` +const WINDOW_POSITIONS = ['primary', 'secondary'] as const + +type WindowPosition = (typeof WINDOW_POSITIONS)[number] + +const headerString = (headers: Headers.Headers, name: string): string | undefined => { + const value = Headers.get(headers, name) + if (Option.isNone(value)) return undefined + const trimmed = value.value.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +const headerNumber = (headers: Headers.Headers, name: string): number | undefined => { + const raw = headerString(headers, name) + if (raw === undefined) return undefined + const parsed = Number(raw) + return Number.isFinite(parsed) ? parsed : undefined +} + +const headerBoolean = (headers: Headers.Headers, name: string): boolean | undefined => { + const raw = headerString(headers, name) + if (raw === undefined) return undefined + if (raw.toLowerCase() === 'true') return true + if (raw.toLowerCase() === 'false') return false + return undefined +} + +const parseWindow = (headers: Headers.Headers, position: WindowPosition): UsageLimitWindow | undefined => { + const usedPercent = headerNumber(headers, `${HEADER_PREFIX}-${position}-used-percent`) + const windowMinutes = headerNumber(headers, `${HEADER_PREFIX}-${position}-window-minutes`) + const resetAtSeconds = headerNumber(headers, `${HEADER_PREFIX}-${position}-reset-at`) + + const window: { -readonly [Key in keyof UsageLimitWindow]: UsageLimitWindow[Key] } = {} + if (usedPercent !== undefined && usedPercent >= 0 && usedPercent <= 100) window.usedPercent = usedPercent + if (windowMinutes !== undefined && Number.isInteger(windowMinutes) && windowMinutes > 0) + window.windowMinutes = windowMinutes + if (resetAtSeconds !== undefined && Number.isInteger(resetAtSeconds) && resetAtSeconds >= 0) + window.resetsAt = resetAtSeconds * 1000 + + return Object.keys(window).length > 0 ? window : undefined +} + +const parseCredits = (headers: Headers.Headers): UsageLimitCredits | undefined => { + const hasCredits = headerBoolean(headers, `${HEADER_PREFIX}-credits-has-credits`) + const unlimited = headerBoolean(headers, `${HEADER_PREFIX}-credits-unlimited`) + const balance = headerString(headers, `${HEADER_PREFIX}-credits-balance`) + + const credits: { -readonly [Key in keyof UsageLimitCredits]: UsageLimitCredits[Key] } = {} + if (hasCredits !== undefined) credits.hasCredits = hasCredits + if (unlimited !== undefined) credits.unlimited = unlimited + if (balance !== undefined) credits.balance = balance + + return Object.keys(credits).length > 0 ? credits : undefined +} + +/** + * Read the default Codex usage-limit family from one response's headers. Returns `None` when the + * response carries neither a window nor credit data, so callers only surface real snapshots. + */ +export const usageLimitsFromCodexHeaders = (headers: Headers.Headers): Option.Option => { + const windows: Record = {} + for (const position of WINDOW_POSITIONS) { + const window = parseWindow(headers, position) + if (window !== undefined) windows[position] = window + } + + const credits = parseCredits(headers) + if (Object.keys(windows).length === 0 && credits === undefined) return Option.none() + + const limitName = headerString(headers, `${HEADER_PREFIX}-limit-name`) + const candidate: Record = { limitId: CODEX_LIMIT_ID, windows } + if (limitName !== undefined) candidate.limitName = limitName + if (credits !== undefined) candidate.credits = credits + + return decodeUsageLimits(candidate) +} diff --git a/packages/fold-codex/src/index.ts b/packages/fold-codex/src/index.ts index a7bd5b5..6186977 100644 --- a/packages/fold-codex/src/index.ts +++ b/packages/fold-codex/src/index.ts @@ -3,3 +3,4 @@ export * from './CodexAuth' export * from './CodexModel' export * from './Hardening' export * from './OAuthFlows' +export * from './RateLimitHeaders' diff --git a/packages/fold-codex/test/RateLimitHeaders.vi.test.ts b/packages/fold-codex/test/RateLimitHeaders.vi.test.ts new file mode 100644 index 0000000..b9369d8 --- /dev/null +++ b/packages/fold-codex/test/RateLimitHeaders.vi.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from '@effect/vitest' +import { Option } from 'effect' +import { Headers } from 'effect/unstable/http' + +import { usageLimitsFromCodexHeaders } from '../src/index' + +describe('usageLimitsFromCodexHeaders', () => { + it('parses the default codex primary and secondary windows', () => { + const headers = Headers.fromInput({ + 'x-codex-primary-used-percent': '12.5', + 'x-codex-primary-window-minutes': '300', + 'x-codex-primary-reset-at': '1704069000', + 'x-codex-secondary-used-percent': '80', + 'x-codex-secondary-window-minutes': '10080', + 'x-codex-secondary-reset-at': '1704074400', + }) + + const limits = usageLimitsFromCodexHeaders(headers) + expect(Option.isSome(limits)).toBe(true) + expect(Option.getOrThrow(limits)).toEqual({ + limitId: 'codex', + windows: { + primary: { usedPercent: 12.5, windowMinutes: 300, resetsAt: 1704069000000 }, + secondary: { usedPercent: 80, windowMinutes: 10080, resetsAt: 1704074400000 }, + }, + }) + }) + + it('scales the reset-at header from epoch seconds to epoch milliseconds', () => { + const headers = Headers.fromInput({ + 'x-codex-primary-used-percent': '5', + 'x-codex-primary-reset-at': '1704069000', + }) + + const limits = Option.getOrThrow(usageLimitsFromCodexHeaders(headers)) + expect(limits.windows.primary?.resetsAt).toBe(1704069000000) + }) + + it('keeps a window when only used-percent is present', () => { + const headers = Headers.fromInput({ 'x-codex-primary-used-percent': '42' }) + + expect(Option.getOrThrow(usageLimitsFromCodexHeaders(headers)).windows).toEqual({ + primary: { usedPercent: 42 }, + }) + }) + + it('carries the limit name and credit snapshot when present', () => { + const headers = Headers.fromInput({ + 'x-codex-primary-used-percent': '10', + 'x-codex-limit-name': 'gpt-6-astra', + 'x-codex-credits-has-credits': 'true', + 'x-codex-credits-unlimited': 'false', + 'x-codex-credits-balance': '12.34', + }) + + expect(Option.getOrThrow(usageLimitsFromCodexHeaders(headers))).toEqual({ + limitId: 'codex', + limitName: 'gpt-6-astra', + windows: { primary: { usedPercent: 10 } }, + credits: { hasCredits: true, unlimited: false, balance: '12.34' }, + }) + }) + + it('returns None when no rate-limit headers are present', () => { + const headers = Headers.fromInput({ 'content-type': 'text/event-stream' }) + expect(Option.isNone(usageLimitsFromCodexHeaders(headers))).toBe(true) + }) + + it('drops unparsable and out-of-range values instead of failing', () => { + const headers = Headers.fromInput({ + 'x-codex-primary-used-percent': 'not-a-number', + 'x-codex-primary-window-minutes': '-3', + 'x-codex-primary-reset-at': '1704069000', + 'x-codex-secondary-used-percent': '150', + }) + + expect(Option.getOrThrow(usageLimitsFromCodexHeaders(headers))).toEqual({ + limitId: 'codex', + windows: { primary: { resetsAt: 1704069000000 } }, + }) + }) +}) diff --git a/packages/fold-core/src/EventLog/UsageLimits.ts b/packages/fold-core/src/EventLog/UsageLimits.ts new file mode 100644 index 0000000..2a35f96 --- /dev/null +++ b/packages/fold-core/src/EventLog/UsageLimits.ts @@ -0,0 +1,57 @@ +import { Schema } from 'effect' + +/** Percentage (0-100) of a subscription usage-limit window consumed. Best-effort; providers may omit it. */ +export const UsageLimitPercent = Schema.Finite.check(Schema.isBetween({ minimum: 0, maximum: 100 })).annotate({ + identifier: 'UsageLimitPercent', +}) +export type UsageLimitPercent = typeof UsageLimitPercent.Type + +/** Nominal length of a usage-limit window in minutes (300 for a 5-hour window, 10080 for a 7-day window). */ +export const UsageLimitWindowMinutes = Schema.Int.check(Schema.isGreaterThan(0)).annotate({ + identifier: 'UsageLimitWindowMinutes', +}) +export type UsageLimitWindowMinutes = typeof UsageLimitWindowMinutes.Type + +/** Epoch milliseconds at which a usage-limit window resets. */ +export const UsageLimitResetsAt = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)).annotate({ + identifier: 'UsageLimitResetsAt', +}) +export type UsageLimitResetsAt = typeof UsageLimitResetsAt.Type + +/** + * One usage-limit window. The window's position is the map key in {@link UsageLimits.windows}; its + * duration is carried by `windowMinutes`, because a provider's window positions are not fixed + * durations. Every field is optional so a reader tolerates a provider that reports only a subset. + */ +export const UsageLimitWindow = Schema.Struct({ + usedPercent: Schema.optional(UsageLimitPercent), + windowMinutes: Schema.optional(UsageLimitWindowMinutes), + resetsAt: Schema.optional(UsageLimitResetsAt), +}).annotate({ identifier: 'UsageLimitWindow' }) +export type UsageLimitWindow = typeof UsageLimitWindow.Type + +/** Prepaid credit / overage state that can offset a reached usage limit. */ +export const UsageLimitCredits = Schema.Struct({ + hasCredits: Schema.optional(Schema.Boolean), + unlimited: Schema.optional(Schema.Boolean), + balance: Schema.optional(Schema.String), +}).annotate({ identifier: 'UsageLimitCredits' }) +export type UsageLimitCredits = typeof UsageLimitCredits.Type + +/** + * Durable, provider-neutral subscription usage-limit snapshot. Deliberately fold-owned and fully + * best-effort like {@link UsageEncoded}: every provider reports a different subset, so each field is + * optional and a reader must tolerate an empty snapshot. Windows are keyed by a provider-neutral id + * (for example `primary`/`secondary`) so a consumer reads a window's duration from `windowMinutes` + * rather than inferring it from the key. + */ +export const UsageLimits = Schema.Struct({ + limitId: Schema.optional(Schema.String), + limitName: Schema.optional(Schema.String), + windows: Schema.Record(Schema.String, UsageLimitWindow), + credits: Schema.optional(UsageLimitCredits), +}).annotate({ identifier: 'UsageLimits' }) +export type UsageLimits = typeof UsageLimits.Type + +/** Decode an unknown value into a {@link UsageLimits}, yielding `None` when it does not conform. */ +export const decodeUsageLimits = Schema.decodeUnknownOption(UsageLimits) diff --git a/packages/fold-core/src/index.ts b/packages/fold-core/src/index.ts index daf235c..0c3cc42 100644 --- a/packages/fold-core/src/index.ts +++ b/packages/fold-core/src/index.ts @@ -30,6 +30,7 @@ export * from './EventLog/LogEntryFactory' export * from './EventLog/Schemas' export * from './EventLog/StoredLogEntryDecoder' export * from './EventLog/Usage' +export * from './EventLog/UsageLimits' export * from './HookRunner/HookRunnerLayer' export * from './HookRunner/Errors' export * from './HookRunner/HookRunnerService' diff --git a/packages/fold-core/test/EventLog/UsageLimits.vi.test.ts b/packages/fold-core/test/EventLog/UsageLimits.vi.test.ts new file mode 100644 index 0000000..82bea69 --- /dev/null +++ b/packages/fold-core/test/EventLog/UsageLimits.vi.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from '@effect/vitest' +import { Option } from 'effect' + +import { decodeUsageLimits } from '../../src/EventLog/UsageLimits' + +describe('decodeUsageLimits', () => { + it('accepts a best-effort snapshot with sparse windows', () => { + const decoded = decodeUsageLimits({ + limitId: 'codex', + windows: { + primary: { usedPercent: 12.5, windowMinutes: 300, resetsAt: 1704069000000 }, + secondary: { resetsAt: 1704074400000 }, + }, + }) + + expect(Option.getOrThrow(decoded).windows.secondary).toEqual({ resetsAt: 1704074400000 }) + }) + + it('accepts an empty windows map', () => { + expect(Option.isSome(decodeUsageLimits({ windows: {} }))).toBe(true) + }) + + it('rejects a percentage outside 0-100', () => { + expect(Option.isNone(decodeUsageLimits({ windows: { primary: { usedPercent: 150 } } }))).toBe(true) + }) + + it('rejects a non-positive window duration', () => { + expect(Option.isNone(decodeUsageLimits({ windows: { primary: { windowMinutes: 0 } } }))).toBe(true) + }) +}) From c0fd09e8629e428b8ee2cd8575b63444a95efec8 Mon Sep 17 00:00:00 2001 From: Agent <_@pepijn.ai> Date: Wed, 23 Sep 2026 19:37:00 +0000 Subject: [PATCH 2/2] Trim UsageLimits doc comments to the fold-core one-line convention Match the comment density of the sibling Usage.ts: the window and snapshot blocks read as one line each, since the names already carry the intent. Co-Authored-By: Claude Fable 5.1 HumanLayer-Session: https://app.humanlayer.com/sessions/01a0bf0d-c4bd-787c-b350-a30c562a74d6 --- packages/fold-core/src/EventLog/UsageLimits.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/fold-core/src/EventLog/UsageLimits.ts b/packages/fold-core/src/EventLog/UsageLimits.ts index 2a35f96..b2fc133 100644 --- a/packages/fold-core/src/EventLog/UsageLimits.ts +++ b/packages/fold-core/src/EventLog/UsageLimits.ts @@ -18,11 +18,7 @@ export const UsageLimitResetsAt = Schema.Finite.check(Schema.isGreaterThanOrEqua }) export type UsageLimitResetsAt = typeof UsageLimitResetsAt.Type -/** - * One usage-limit window. The window's position is the map key in {@link UsageLimits.windows}; its - * duration is carried by `windowMinutes`, because a provider's window positions are not fixed - * durations. Every field is optional so a reader tolerates a provider that reports only a subset. - */ +/** One usage-limit window; read its duration from `windowMinutes`, not the map key, since window positions are not fixed durations. */ export const UsageLimitWindow = Schema.Struct({ usedPercent: Schema.optional(UsageLimitPercent), windowMinutes: Schema.optional(UsageLimitWindowMinutes), @@ -38,13 +34,7 @@ export const UsageLimitCredits = Schema.Struct({ }).annotate({ identifier: 'UsageLimitCredits' }) export type UsageLimitCredits = typeof UsageLimitCredits.Type -/** - * Durable, provider-neutral subscription usage-limit snapshot. Deliberately fold-owned and fully - * best-effort like {@link UsageEncoded}: every provider reports a different subset, so each field is - * optional and a reader must tolerate an empty snapshot. Windows are keyed by a provider-neutral id - * (for example `primary`/`secondary`) so a consumer reads a window's duration from `windowMinutes` - * rather than inferring it from the key. - */ +/** Durable, provider-neutral usage-limit snapshot. Best-effort like {@link UsageEncoded}: every field is optional and a reader tolerates an empty snapshot. */ export const UsageLimits = Schema.Struct({ limitId: Schema.optional(Schema.String), limitName: Schema.optional(Schema.String),