Skip to content
Merged
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
13 changes: 13 additions & 0 deletions packages/opencode/src/config/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,19 @@ export class Info extends Schema.Class<Info>("ProviderConfig")({
description:
"Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.",
}),
rateLimit: Schema.optional(
Schema.Struct({
perMinute: Schema.optional(PositiveInt).annotate({
description: "Learned or user-set request limit per 60 seconds.",
}),
perDay: Schema.optional(PositiveInt).annotate({
description: "Learned or user-set request limit per 24 hours.",
}),
}).annotate({
description:
"Request-rate limits for this provider. Populated automatically the first time a 429 response is received, or can be set manually.",
}),
),
}),
[Schema.Record(Schema.String, Schema.Any)],
),
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/provider/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { APICallError } from "ai"
import { STATUS_CODES } from "http"
import { iife } from "@/util/iife"
import type { ProviderID } from "./schema"
import { RateLimit } from "./rate-limit"

// Adapted from overflow detection patterns in:
// https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/overflow.ts
Expand Down Expand Up @@ -181,6 +182,9 @@ export function parseAPICallError(input: { providerID: ProviderID; error: APICal
}

const metadata = input.error.url ? { url: input.error.url } : undefined
if (input.error.statusCode === 429) {
RateLimit.onRateLimitError(input.providerID)
}
return {
type: "api_error",
message: m,
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { withStatics } from "@/util/schema"

import * as ProviderTransform from "./transform"
import { ModelID, ProviderID } from "./schema"
import { RateLimit } from "./rate-limit"

const log = Log.create({ service: "provider" })

Expand Down Expand Up @@ -1471,12 +1472,15 @@ const layer: Layer.Layer<
}
}

RateLimit.tick(model.providerID)
const res = await fetchFn(input, {
...opts,
// @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
timeout: false,
})

RateLimit.recordResponse(model.providerID, res.headers)

if (!chunkAbortCtl) return res
return wrapSSE(res, chunkTimeout, chunkAbortCtl)
}
Expand Down
173 changes: 173 additions & 0 deletions packages/opencode/src/provider/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import fs from "fs"
import path from "path"
import { Global } from "../global"
import { Log } from "../util"
import type { ProviderID } from "./schema"

const log = Log.create({ service: "provider.rate-limit" })

type HeaderSnapshot = {
limit?: number
remaining?: number
resetAt?: number
}

type State = {
minute: number[]
day: number[]
learned: { perMinute?: number; perDay?: number }
headers?: { requests?: HeaderSnapshot; tokens?: HeaderSnapshot }
loggedHeaders: boolean
}

const state = new Map<ProviderID, State>()

function ensure(providerID: ProviderID): State {
const existing = state.get(providerID)
if (existing) return existing
const next: State = { minute: [], day: [], learned: {}, loggedHeaders: false }
state.set(providerID, next)
return next
}

function prune(s: State) {
const now = Date.now()
s.minute = s.minute.filter((t) => t > now - 60_000)
s.day = s.day.filter((t) => t > now - 86_400_000)
}

export function tick(providerID: ProviderID) {
const s = ensure(providerID)
const now = Date.now()
s.minute.push(now)
s.day.push(now)
prune(s)
}

const REQUEST_HEADER_FAMILIES: Array<[string, string, string]> = [
["x-ratelimit-limit-requests", "x-ratelimit-remaining-requests", "x-ratelimit-reset-requests"],
["anthropic-ratelimit-requests-limit", "anthropic-ratelimit-requests-remaining", "anthropic-ratelimit-requests-reset"],
["ratelimit-limit", "ratelimit-remaining", "ratelimit-reset"],
]

const TOKEN_HEADER_FAMILIES: Array<[string, string, string]> = [
["x-ratelimit-limit-tokens", "x-ratelimit-remaining-tokens", "x-ratelimit-reset-tokens"],
["anthropic-ratelimit-tokens-limit", "anthropic-ratelimit-tokens-remaining", "anthropic-ratelimit-tokens-reset"],
]

function parseFamily(headers: Headers, family: Array<[string, string, string]>): HeaderSnapshot | undefined {
for (const [limitKey, remainingKey, resetKey] of family) {
const limit = headers.get(limitKey)
const remaining = headers.get(remainingKey)
if (!limit && !remaining) continue
const reset = headers.get(resetKey)
const resetAt = parseReset(reset)
return {
limit: limit ? Number.parseInt(limit, 10) : undefined,
remaining: remaining ? Number.parseInt(remaining, 10) : undefined,
resetAt,
}
}
return undefined
}

function parseReset(value: string | null): number | undefined {
if (!value) return undefined
const asNumber = Number.parseFloat(value)
if (!Number.isNaN(asNumber)) {
if (asNumber > 1_000_000_000_000) return Math.round(asNumber)
if (asNumber > 1_000_000_000) return Math.round(asNumber * 1000)
return Date.now() + Math.round(asNumber * 1000)
}
const asDate = Date.parse(value)
if (!Number.isNaN(asDate)) return asDate
return undefined
}

export function recordResponse(providerID: ProviderID, headers: Headers) {
const s = ensure(providerID)
const requests = parseFamily(headers, REQUEST_HEADER_FAMILIES)
const tokens = parseFamily(headers, TOKEN_HEADER_FAMILIES)
const parsed = requests || tokens ? { requests, tokens } : undefined
if (parsed) s.headers = parsed
if (!s.loggedHeaders) {
s.loggedHeaders = true
log.info("provider first response headers", {
providerID,
rateLimitHeaders: parsed ?? "none",
keys: Array.from(headers.keys()).filter((k) => k.includes("ratelimit") || k === "retry-after"),
})
}
}

export function onRateLimitError(providerID: ProviderID) {
const s = ensure(providerID)
prune(s)
const perMinute = s.minute.length
const perDay = s.day.length
if (perMinute === 0 && perDay === 0) return
s.learned.perMinute = Math.max(s.learned.perMinute ?? 0, perMinute)
s.learned.perDay = Math.max(s.learned.perDay ?? 0, perDay)
try {
persistLearnedLimits(providerID, s.learned.perMinute, s.learned.perDay)
log.info("learned rate limit from 429", { providerID, perMinute, perDay })
} catch (e) {
log.warn("failed to persist learned rate limit", { providerID, error: String(e) })
}
}

function persistLearnedLimits(providerID: ProviderID, perMinute: number, perDay: number) {
const jsoncPath = path.join(Global.Path.config, "opencode.jsonc")
if (fs.existsSync(jsoncPath)) {
log.warn("opencode.jsonc detected; skipping learned-limit write to preserve comments", {
providerID,
path: jsoncPath,
})
return
}
const jsonPath = path.join(Global.Path.config, "opencode.json")
const data = readJsonSafe(jsonPath)
data.provider ??= {}
data.provider[providerID] ??= {}
data.provider[providerID].options ??= {}
const rateLimit = (data.provider[providerID].options.rateLimit ??= {})
rateLimit.perMinute = Math.max(rateLimit.perMinute ?? 0, perMinute)
rateLimit.perDay = Math.max(rateLimit.perDay ?? 0, perDay)
fs.writeFileSync(jsonPath, JSON.stringify(data, null, 2) + "\n")
}

function readJsonSafe(p: string): Record<string, any> {
let raw = ""
try {
raw = fs.readFileSync(p, "utf8")
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e
}
return raw.trim() === "" ? {} : JSON.parse(raw)
}

export type Snapshot = {
minute: { count: number; limit?: number }
day: { count: number; limit?: number }
headers?: { requests?: HeaderSnapshot; tokens?: HeaderSnapshot }
}

export function snapshot(providerID: ProviderID): Snapshot {
const s = ensure(providerID)
prune(s)
return {
minute: { count: s.minute.length, limit: s.learned.perMinute },
day: { count: s.day.length, limit: s.learned.perDay },
headers: s.headers,
}
}

export function reset(providerID?: ProviderID) {
if (providerID) {
state.delete(providerID)
return
}
state.clear()
}

export * as RateLimit from "./rate-limit"
79 changes: 79 additions & 0 deletions packages/opencode/test/rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, test, beforeEach, afterAll } from "bun:test"
import fs from "fs"
import path from "path"
import { Global } from "../src/global"
import { RateLimit } from "../src/provider/rate-limit"
import { ProviderID } from "../src/provider/schema"

const provider = ProviderID.make("test-provider")
const configPath = path.join(Global.Path.config, "opencode.json")

beforeEach(() => {
RateLimit.reset()
if (fs.existsSync(configPath)) fs.rmSync(configPath)
})

afterAll(() => {
if (fs.existsSync(configPath)) fs.rmSync(configPath)
})

describe("RateLimit", () => {
test("tick counts requests in both windows", () => {
RateLimit.tick(provider)
RateLimit.tick(provider)
RateLimit.tick(provider)
const snap = RateLimit.snapshot(provider)
expect(snap.minute.count).toBe(3)
expect(snap.day.count).toBe(3)
})

test("recordResponse parses x-ratelimit-* headers", () => {
const headers = new Headers({
"x-ratelimit-limit-requests": "1000",
"x-ratelimit-remaining-requests": "997",
"x-ratelimit-reset-requests": "30",
})
RateLimit.recordResponse(provider, headers)
const snap = RateLimit.snapshot(provider)
expect(snap.headers?.requests?.limit).toBe(1000)
expect(snap.headers?.requests?.remaining).toBe(997)
expect(snap.headers?.requests?.resetAt).toBeGreaterThan(Date.now())
})

test("onRateLimitError persists the current counter to opencode.json", () => {
for (let i = 0; i < 7; i++) RateLimit.tick(provider)
RateLimit.onRateLimitError(provider)
expect(fs.existsSync(configPath)).toBe(true)
const written = JSON.parse(fs.readFileSync(configPath, "utf8"))
expect(written.provider[provider].options.rateLimit.perMinute).toBe(7)
expect(written.provider[provider].options.rateLimit.perDay).toBe(7)
})

test("onRateLimitError only bumps learned limits upward", () => {
fs.mkdirSync(Global.Path.config, { recursive: true })
fs.writeFileSync(
configPath,
JSON.stringify({
provider: { [provider]: { options: { rateLimit: { perMinute: 50, perDay: 500 } } } },
}),
)
for (let i = 0; i < 3; i++) RateLimit.tick(provider)
RateLimit.onRateLimitError(provider)
const written = JSON.parse(fs.readFileSync(configPath, "utf8"))
expect(written.provider[provider].options.rateLimit.perMinute).toBe(50)
expect(written.provider[provider].options.rateLimit.perDay).toBe(500)
})

test("onRateLimitError skips write when opencode.jsonc exists", () => {
const jsoncPath = path.join(Global.Path.config, "opencode.jsonc")
fs.mkdirSync(Global.Path.config, { recursive: true })
fs.writeFileSync(jsoncPath, "// comments preserved\n{}\n")
try {
for (let i = 0; i < 4; i++) RateLimit.tick(provider)
RateLimit.onRateLimitError(provider)
expect(fs.existsSync(configPath)).toBe(false)
} finally {
if (fs.existsSync(jsoncPath)) fs.rmSync(jsoncPath)
}
})
})