From c06750410a590fb53f01056c4f7f145d8a65c59c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 24 Apr 2026 13:32:44 +0200 Subject: [PATCH] fix(ai,coding-agent): expose provider timeout/retry controls closes #3627 --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/providers/anthropic.ts | 9 +++- .../src/providers/azure-openai-responses.ts | 6 ++- .../ai/src/providers/openai-completions.ts | 6 ++- packages/ai/src/providers/openai-responses.ts | 6 ++- packages/ai/src/providers/simple-options.ts | 2 + packages/ai/src/types.ts | 10 +++++ packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/settings.md | 18 +++++--- packages/coding-agent/src/core/sdk.ts | 6 ++- .../coding-agent/src/core/settings-manager.ts | 43 +++++++++++++++++-- 11 files changed, 94 insertions(+), 14 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index d5a5bb8b..9bdf3d01 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -10,6 +10,7 @@ - Fixed DeepSeek V4 session replay 400 errors by adding `thinkingFormat: "deepseek"` (sends `thinking: { type }` + `reasoning_effort`), a `reasoningEffortMap`, and `requiresReasoningContentOnAssistantMessages` compat that injects empty `reasoning_content` on all replayed assistant messages when reasoning is enabled ([#3636](https://github.com/badlogic/pi-mono/issues/3636)) - Fixed GPT-5.5 generated context window metadata to use the observed 272k limit. +- Fixed provider request controls to expose `timeoutMs` and `maxRetries` in stream options and forward them through OpenAI/Azure/Anthropic request options, preventing unconfigurable SDK timeout/retry defaults on long-running local inference requests ([#3627](https://github.com/badlogic/pi-mono/issues/3627)) ## [0.70.0] - 2026-04-23 diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 053538f5..78f552f9 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -457,7 +457,14 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti params = nextParams as MessageCreateParamsStreaming; } const response = await client.messages - .create({ ...params, stream: true }, { signal: options?.signal }) + .create( + { ...params, stream: true }, + { + signal: options?.signal, + timeout: options?.timeoutMs, + maxRetries: options?.maxRetries, + }, + ) .asResponse(); await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); stream.push({ type: "start", partial: output }); diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts index b9981935..10d86ae6 100644 --- a/packages/ai/src/providers/azure-openai-responses.ts +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -92,7 +92,11 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses" params = nextParams as ResponseCreateParamsStreaming; } const { data: openaiStream, response } = await client.responses - .create(params, options?.signal ? { signal: options.signal } : undefined) + .create(params, { + signal: options?.signal, + timeout: options?.timeoutMs, + maxRetries: options?.maxRetries, + }) .withResponse(); await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); stream.push({ type: "start", partial: output }); diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index e2e479e2..aecdad1d 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -145,7 +145,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA params = nextParams as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming; } const { data: openaiStream, response } = await client.chat.completions - .create(params, { signal: options?.signal }) + .create(params, { + signal: options?.signal, + timeout: options?.timeoutMs, + maxRetries: options?.maxRetries, + }) .withResponse(); await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); stream.push({ type: "start", partial: output }); diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 6ce7eada..2e11d292 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -99,7 +99,11 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses", OpenAIRes params = nextParams as ResponseCreateParamsStreaming; } const { data: openaiStream, response } = await client.responses - .create(params, options?.signal ? { signal: options.signal } : undefined) + .create(params, { + signal: options?.signal, + timeout: options?.timeoutMs, + maxRetries: options?.maxRetries, + }) .withResponse(); await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); stream.push({ type: "start", partial: output }); diff --git a/packages/ai/src/providers/simple-options.ts b/packages/ai/src/providers/simple-options.ts index 35524aa8..8e99ad37 100644 --- a/packages/ai/src/providers/simple-options.ts +++ b/packages/ai/src/providers/simple-options.ts @@ -11,6 +11,8 @@ export function buildBaseOptions(model: Model, options?: SimpleStreamOption headers: options?.headers, onPayload: options?.onPayload, onResponse: options?.onResponse, + timeoutMs: options?.timeoutMs, + maxRetries: options?.maxRetries, maxRetryDelayMs: options?.maxRetryDelayMs, metadata: options?.metadata, }; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index ce131f75..817f389a 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -101,6 +101,16 @@ export interface StreamOptions { * Not supported by all providers (e.g., AWS Bedrock uses SDK auth). */ headers?: Record; + /** + * HTTP request timeout in milliseconds for providers/SDKs that support it. + * For example, OpenAI and Anthropic SDK clients default to 10 minutes. + */ + timeoutMs?: number; + /** + * Maximum retry attempts for providers/SDKs that support client-side retries. + * For example, OpenAI and Anthropic SDK clients default to 2. + */ + maxRetries?: number; /** * Maximum delay in milliseconds to wait for a retry when the server requests a long wait. * If the server's requested delay exceeds this value, the request fails immediately diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 74ba6673..7386bf49 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,7 @@ - Fixed `/copy` to avoid unbounded OSC 52 writes and clipboard races that could break terminal rendering or panic the native clipboard addon ([#3639](https://github.com/badlogic/pi-mono/issues/3639)) - Fixed extension flag docs to show `pi.getFlag()` using registered flag names without the CLI `--` prefix ([#3614](https://github.com/badlogic/pi-mono/issues/3614)) +- Fixed provider retry/timeout settings wiring by adding `retry.provider.{timeoutMs,maxRetries,maxRetryDelayMs}`, migrating legacy `retry.maxDelayMs`, and forwarding provider controls into `streamSimple` request options ([#3627](https://github.com/badlogic/pi-mono/issues/3627)) ## [0.70.0] - 2026-04-23 diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 609a89ba..e7d94d88 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -77,12 +77,14 @@ Edit directly or use `/settings` for common options. | Setting | Type | Default | Description | |---------|------|---------|-------------| -| `retry.enabled` | boolean | `true` | Enable automatic retry on transient errors | -| `retry.maxRetries` | number | `3` | Maximum retry attempts | -| `retry.baseDelayMs` | number | `2000` | Base delay for exponential backoff (2s, 4s, 8s) | -| `retry.maxDelayMs` | number | `60000` | Max server-requested delay before failing (60s) | +| `retry.enabled` | boolean | `true` | Enable automatic agent-level retry on transient errors | +| `retry.maxRetries` | number | `3` | Maximum agent-level retry attempts | +| `retry.baseDelayMs` | number | `2000` | Base delay for agent-level exponential backoff (2s, 4s, 8s) | +| `retry.provider.timeoutMs` | number | SDK default | Provider/SDK request timeout in milliseconds | +| `retry.provider.maxRetries` | number | SDK default | Provider/SDK retry attempts | +| `retry.provider.maxRetryDelayMs` | number | `60000` | Max server-requested delay before failing (60s) | -When a provider requests a retry delay longer than `maxDelayMs` (e.g., Google's "quota will reset after 5h"), the request fails immediately with an informative error instead of waiting silently. Set to `0` to disable the cap. +When a provider requests a retry delay longer than `retry.provider.maxRetryDelayMs` (e.g., Google's "quota will reset after 5h"), the request fails immediately with an informative error instead of waiting silently. Set to `0` to disable the cap. ```json { @@ -90,7 +92,11 @@ When a provider requests a retry delay longer than `maxDelayMs` (e.g., Google's "enabled": true, "maxRetries": 3, "baseDelayMs": 2000, - "maxDelayMs": 60000 + "provider": { + "timeoutMs": 3600000, + "maxRetries": 0, + "maxRetryDelayMs": 60000 + } } } ``` diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 47ca4a2a..5d31ab38 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -315,10 +315,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} if (!auth.ok) { throw new Error(auth.error); } + const providerRetrySettings = settingsManager.getProviderRetrySettings(); const openRouterAttributionHeaders = getOpenRouterAttributionHeaders(model, settingsManager); return streamSimple(model, context, { ...options, apiKey: auth.apiKey, + timeoutMs: options?.timeoutMs ?? providerRetrySettings.timeoutMs, + maxRetries: options?.maxRetries ?? providerRetrySettings.maxRetries, + maxRetryDelayMs: options?.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs, headers: openRouterAttributionHeaders || auth.headers || options?.headers ? { ...openRouterAttributionHeaders, ...auth.headers, ...options?.headers } @@ -353,7 +357,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} followUpMode: settingsManager.getFollowUpMode(), transport: settingsManager.getTransport(), thinkingBudgets: settingsManager.getThinkingBudgets(), - maxRetryDelayMs: settingsManager.getRetrySettings().maxDelayMs, + maxRetryDelayMs: settingsManager.getProviderRetrySettings().maxRetryDelayMs, }); // Restore messages if session has existing data diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index d54b7753..da351d2f 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -16,11 +16,17 @@ export interface BranchSummarySettings { skipPrompt?: boolean; // default: false - when true, skips "Summarize branch?" prompt and defaults to no summary } +export interface ProviderRetrySettings { + timeoutMs?: number; // SDK/provider request timeout in milliseconds + maxRetries?: number; // SDK/provider retry attempts + maxRetryDelayMs?: number; // default: 60000 (max server-requested delay before failing) +} + export interface RetrySettings { enabled?: boolean; // default: true maxRetries?: number; // default: 3 baseDelayMs?: number; // default: 2000 (exponential backoff: 2s, 4s, 8s) - maxDelayMs?: number; // default: 60000 (max server-requested delay before failing) + provider?: ProviderRetrySettings; } export interface TerminalSettings { @@ -354,6 +360,30 @@ export class SettingsManager { } } + // Migrate retry.maxDelayMs -> retry.provider.maxRetryDelayMs + if ( + "retry" in settings && + typeof settings.retry === "object" && + settings.retry !== null && + !Array.isArray(settings.retry) + ) { + const retrySettings = settings.retry as Record; + const providerSettings = + typeof retrySettings.provider === "object" && retrySettings.provider !== null + ? (retrySettings.provider as Record) + : undefined; + if ( + typeof retrySettings.maxDelayMs === "number" && + (providerSettings?.maxRetryDelayMs === undefined || providerSettings?.maxRetryDelayMs === null) + ) { + retrySettings.provider = { + ...(providerSettings ?? {}), + maxRetryDelayMs: retrySettings.maxDelayMs, + }; + } + delete retrySettings.maxDelayMs; + } + return settings as Settings; } @@ -683,12 +713,19 @@ export class SettingsManager { this.save(); } - getRetrySettings(): { enabled: boolean; maxRetries: number; baseDelayMs: number; maxDelayMs: number } { + getRetrySettings(): { enabled: boolean; maxRetries: number; baseDelayMs: number } { return { enabled: this.getRetryEnabled(), maxRetries: this.settings.retry?.maxRetries ?? 3, baseDelayMs: this.settings.retry?.baseDelayMs ?? 2000, - maxDelayMs: this.settings.retry?.maxDelayMs ?? 60000, + }; + } + + getProviderRetrySettings(): { timeoutMs?: number; maxRetries?: number; maxRetryDelayMs: number } { + return { + timeoutMs: this.settings.retry?.provider?.timeoutMs, + maxRetries: this.settings.retry?.provider?.maxRetries, + maxRetryDelayMs: this.settings.retry?.provider?.maxRetryDelayMs ?? 60000, }; }