Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions packages/fold-codex/src/CodexModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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<void> = () => Effect.void,
): OpenAiClient.Service => {
// `min` caps the infinite exponential delay; `max` intersects it with the finite retry counter.
const retryDelaySchedule: Schedule.Schedule<Duration.Duration, AiError.AiError> = Schedule.min([
Schedule.exponential(Duration.millis(options.firstEventRetryBaseDelayMs)),
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -234,6 +245,8 @@ export type CodexModelOptions = {
readonly hardening?: Partial<CodexHardeningOptions>
/** Observes stream retries (the future AgentEvents `stream-retry` seam). */
readonly onStreamRetry?: (info: StreamRetryInfo) => Effect.Effect<void>
/** Observes subscription usage-limit snapshots parsed from each response's Codex rate-limit headers. */
readonly onUsageLimits?: (limits: UsageLimits) => Effect.Effect<void>
}

/**
Expand Down Expand Up @@ -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, {
Expand Down
93 changes: 93 additions & 0 deletions packages/fold-codex/src/RateLimitHeaders.ts
Original file line number Diff line number Diff line change
@@ -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-<position>-`
* `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<UsageLimits> => {
const windows: Record<string, UsageLimitWindow> = {}
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<string, unknown> = { limitId: CODEX_LIMIT_ID, windows }
if (limitName !== undefined) candidate.limitName = limitName
if (credits !== undefined) candidate.credits = credits

return decodeUsageLimits(candidate)
}
1 change: 1 addition & 0 deletions packages/fold-codex/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './CodexAuth'
export * from './CodexModel'
export * from './Hardening'
export * from './OAuthFlows'
export * from './RateLimitHeaders'
82 changes: 82 additions & 0 deletions packages/fold-codex/test/RateLimitHeaders.vi.test.ts
Original file line number Diff line number Diff line change
@@ -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 } },
})
})
})
47 changes: 47 additions & 0 deletions packages/fold-core/src/EventLog/UsageLimits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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; 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),
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 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),
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)
1 change: 1 addition & 0 deletions packages/fold-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
30 changes: 30 additions & 0 deletions packages/fold-core/test/EventLog/UsageLimits.vi.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading