Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6f99ccb
fix(api): enforce API_TIMEOUT_MS for OpenAI-compatible headers
chioarub Jul 11, 2026
8d80665
test(api): cover Copilot responses fallback deadlines
chioarub Jul 11, 2026
d458dcb
fix(api): redact secrets in timeout URL paths
chioarub Jul 12, 2026
d294683
fix(api): harden Copilot response deadlines
chioarub Jul 13, 2026
8fa2e76
fix(api): prevent header-timeout request replay
chioarub Jul 13, 2026
18adf7d
fix(api): harden timeout cleanup and redaction
chioarub Jul 14, 2026
7755133
Merge remote-tracking branch 'upstream/main' into fix/openai-compat-t…
chioarub Jul 14, 2026
03546d2
fix(api): redact encoded transport credentials
chioarub Jul 14, 2026
60166e2
fix(api): harden deadline retries and URL redaction
chioarub Jul 15, 2026
97b71d5
fix(api): preserve aborted fetch reasons
chioarub Jul 15, 2026
0431635
fix(api): preserve caller abort reasons
chioarub Jul 15, 2026
021e461
Merge remote-tracking branch 'upstream/main' into fix/openai-compat-t…
chioarub Jul 15, 2026
34f056c
test(api): clear caller abort timer
chioarub Jul 15, 2026
25de516
Merge remote-tracking branch 'upstream/main' into fix/openai-compat-t…
chioarub Jul 15, 2026
554dac7
Merge remote-tracking branch 'upstream/main' into fix/openai-compat-t…
chioarub Jul 15, 2026
e93e175
Merge remote-tracking branch 'upstream/main' into fix/openai-compat-t…
chioarub Jul 16, 2026
1eccccc
docs(api): clarify API_TIMEOUT_MS transport scope
chioarub Jul 16, 2026
fdcec3e
docs(api): explain timeout env loading
chioarub Jul 16, 2026
82e1a10
Merge remote-tracking branch 'upstream/main' into fix/openai-compat-t…
chioarub Jul 17, 2026
068b2c9
fix(api): reset deadline for proxy retries
jatmn Jul 17, 2026
04d8f2e
fix(api): type deadline fetch adapter
jatmn Jul 18, 2026
f791528
fix(api): honor abort cleanup and request signals
jatmn Jul 18, 2026
ea0dcdd
fix(api): do not block proxy retries on body cancellation
jatmn Jul 18, 2026
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
11 changes: 9 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -423,8 +423,15 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here
# Turn on whichever audience you're debugging; both can run together.
# OPENCLAUDE_LOG_TOKEN_USAGE=verbose

# Custom timeout for API requests in milliseconds (default: varies)
# API_TIMEOUT_MS=60000
# Time-to-response-headers deadline for OpenAI-compatible API requests
# in milliseconds (default: 600000, or 10 minutes). Use a safe positive
# integer; invalid values use the default and values above 2147483647 are capped.
# This runtime setting must be exported from your shell or launcher; the
# provider env-file loader intentionally ignores runtime/debug knobs.
# This covers generic OpenAI-compatible requests, direct GitHub Copilot
# Responses, and Copilot chat-to-Responses fallback requests. First-party
# Codex OAuth Responses and the Anthropic SDK retain their existing handling.
# API_TIMEOUT_MS=600000

# Enable debug logging
# CLAUDE_DEBUG=1
Expand Down
1 change: 1 addition & 0 deletions docs/advanced-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,7 @@ token; do not set both credentials.
| `OPENAI_MODEL` | OpenAI-compatible only | Model name such as `gpt-4o`, `deepseek-v4-flash`, or `llama3.3:70b` |
| `OPENAI_BASE_URL` | No | API endpoint, defaulting to `https://api.openai.com/v1` |
| `OPENAI_API_BASE` | No | Compatibility alias for `OPENAI_BASE_URL` |
| `API_TIMEOUT_MS` | No | Time-to-response-headers deadline for generic OpenAI-compatible requests, direct GitHub Copilot Responses, and Copilot chat-to-Responses fallback requests, in milliseconds (default: `600000`, or 10 minutes). The value must be a safe positive integer; invalid, zero, negative, or fractional values use the default, and values above `2147483647` are capped. The deadline is disarmed after headers arrive, so it does not limit response streaming. Export this runtime setting from your shell or launcher; the provider env-file loader ignores runtime/debug settings, so a value configured only there leaves the default in effect. First-party Codex OAuth Responses and the Anthropic SDK retain their existing timeout handling. |
| `OPENCLAUDE_OLLAMA_NUM_CTX` | Ollama only | Request-level Ollama context window. Defaults to `32768`; set a larger value for longer same-session history if your model and hardware can handle it. |
| `CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` | No | JSON map of OpenAI-compatible model names to context windows, such as `{"custom-model":1000000}`. Use this when a custom provider does not expose context metadata from `/v1/models`. |
| `CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS` | No | JSON map of OpenAI-compatible model names to max output tokens, such as `{"custom-model":32768}`. Use this when a custom provider does not expose output-limit metadata from `/v1/models`. |
Expand Down
3 changes: 2 additions & 1 deletion src/services/api/codexShim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,7 @@ export async function performCodexRequest(options: {
params: ShimCreateParams
defaultHeaders: Record<string, string>
signal?: AbortSignal
fetcher?: typeof fetchWithProxyRetry
}): Promise<Response> {
const compressedMessages = compressToolHistory(
options.params.messages as Array<{
Expand Down Expand Up @@ -678,7 +679,7 @@ export async function performCodexRequest(options: {
}
headers.originator ??= 'openclaude'

const response = await fetchWithProxyRetry(
const response = await (options.fetcher ?? fetchWithProxyRetry)(
`${options.request.baseUrl}/responses`,
{
method: 'POST',
Expand Down
117 changes: 117 additions & 0 deletions src/services/api/fetchWithProxyRetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,120 @@ test('fetchWithProxyRetry retries and disables keepalive after receiving a 504 r
expect((calls[0] as RequestInit).keepalive).toBeUndefined()
expect((calls[1] as RequestInit).keepalive).toBe(false)
})

test('fetchWithProxyRetry retries when cancelling a discarded 504 body stalls', async () => {
let attempts = 0

globalThis.fetch = (async () => {
attempts++
if (attempts === 1) {
return new Response(new ReadableStream({
cancel() {
return new Promise(() => {})
},
}), { status: 504 })
}
return new Response('ok')
}) as unknown as FetchType

const response = await fetchWithProxyRetry('https://example.com/search')

expect(response.status).toBe(200)
expect(attempts).toBe(2)
})

test('fetchWithProxyRetry does not retry a 504 after the request is aborted', async () => {
const controller = new AbortController()
const abortReason = new DOMException('Deadline exceeded', 'TimeoutError')
let attempts = 0
let bodyCancelled = false

globalThis.fetch = (async () => {
attempts++
controller.abort(abortReason)
return new Response(new ReadableStream({
cancel() {
bodyCancelled = true
},
}), { status: 504 })
}) as unknown as FetchType

await expect(
fetchWithProxyRetry('https://example.com/generate', {
method: 'POST',
signal: controller.signal,
}),
).rejects.toBe(abortReason)

expect(attempts).toBe(1)
await Promise.resolve()
expect(bodyCancelled).toBe(true)
})

test('fetchWithProxyRetry honors an aborted Request signal without replaying', async () => {
const controller = new AbortController()
const abortReason = new DOMException('Deadline exceeded', 'TimeoutError')
let attempts = 0

globalThis.fetch = (async () => {
attempts++
controller.abort(abortReason)
return new Response('Gateway Timeout', { status: 504 })
}) as unknown as FetchType

const request = new Request('https://example.com/generate', {
method: 'POST',
signal: controller.signal,
})

await expect(fetchWithProxyRetry(request)).rejects.toBe(abortReason)
expect(attempts).toBe(1)
})

test('fetchWithProxyRetry preserves the abort reason for a generic fetch failure', async () => {
for (const message of ['fetch failed', 'invalid_argument']) {
const controller = new AbortController()
const abortReason = new DOMException('Deadline exceeded', 'TimeoutError')
let attempts = 0

globalThis.fetch = (async () => {
attempts++
controller.abort(abortReason)
throw new TypeError(message)
}) as unknown as FetchType

await expect(
fetchWithProxyRetry('https://example.com/generate', {
method: 'POST',
signal: controller.signal,
}),
).rejects.toBe(abortReason)

expect(attempts).toBe(1)
}
})

test('fetchWithProxyRetry preserves an explicit AbortError from fetch', async () => {
const controller = new AbortController()
const abortReason = new DOMException('Caller cancelled', 'AbortError')
const fetchAbortError = new DOMException(
'The operation was aborted.',
'AbortError',
)
let attempts = 0

globalThis.fetch = (async () => {
attempts++
controller.abort(abortReason)
throw fetchAbortError
}) as unknown as FetchType

await expect(
fetchWithProxyRetry('https://example.com/generate', {
method: 'POST',
signal: controller.signal,
}),
).rejects.toBe(fetchAbortError)

expect(attempts).toBe(1)
})
33 changes: 30 additions & 3 deletions src/services/api/fetchWithProxyRetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { disableKeepAlive, getProxyFetchOptions } from '../../utils/proxy.js'
const RETRYABLE_FETCH_ERROR_PATTERN =
/socket connection was closed unexpectedly|ECONNRESET|EPIPE|socket hang up|Connection reset by peer|fetch failed/i

export type ProxyRetryFetcher = (
input: string | URL | Request,
init?: RequestInit,
) => Promise<Response>

export function isRetryableFetchError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false
Expand All @@ -16,19 +21,32 @@ export function isRetryableFetchError(error: unknown): boolean {
export async function fetchWithProxyRetry(
input: string | URL | Request,
init?: RequestInit,
options?: { forAnthropicAPI?: boolean; maxAttempts?: number },
options?: {
forAnthropicAPI?: boolean
maxAttempts?: number
fetcher?: ProxyRetryFetcher
},
): Promise<Response> {
const maxAttempts = Math.max(1, options?.maxAttempts ?? 2)
const fetcher = options?.fetcher ?? fetch
const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined)
let lastError: unknown

for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const response = await fetch(input, {
const response = await fetcher(input, {
...init,
...getProxyFetchOptions({
forAnthropicAPI: options?.forAnthropicAPI,
}),
})
if (signal?.aborted) {
void response.body?.cancel().catch(() => {})
throw (
signal.reason ??
new DOMException('The operation was aborted.', 'AbortError')
)
}

// If an upstream proxy or local NAT silently dropped the keep-alive socket,
// it might result in a 502/504 response instead of a hard network exception.
Expand All @@ -37,14 +55,23 @@ export async function fetchWithProxyRetry(
(response.status === 502 || response.status === 504) &&
attempt < maxAttempts
) {
void response.body?.cancel().catch(() => {})
disableKeepAlive()
continue
}

return response
} catch (error) {
lastError = error
if (attempt >= maxAttempts || !isRetryableFetchError(error)) {
if (signal?.aborted) {
throw error instanceof Error && error.name === 'AbortError'
? error
: (signal.reason ?? error)
}
if (
attempt >= maxAttempts ||
!isRetryableFetchError(error)
) {
throw error
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
disableKeepAlive()
Expand Down
22 changes: 22 additions & 0 deletions src/services/api/openaiErrorClassification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,28 @@ export type OpenAICompatibilityFailure = {
requestUrl?: string
}

const NON_REPLAYABLE_OPENAI_REQUEST = Symbol.for(
'openclaude.openai.nonReplayableRequest',
)

export function markOpenAIRequestNonReplayable<T extends object>(error: T): T {
Object.defineProperty(error, NON_REPLAYABLE_OPENAI_REQUEST, {
value: true,
configurable: false,
enumerable: false,
writable: false,
})
return error
}

export function isOpenAIRequestNonReplayable(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
Reflect.get(error, NON_REPLAYABLE_OPENAI_REQUEST) === true
)
}

const OPENAI_CATEGORY_MARKER_PREFIX = '[openai_category='

const LOCALHOST_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1'])
Expand Down
Loading