From 466eed9d66b676a30dde8acb1591ce20bf694821 Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Wed, 20 Aug 2025 19:40:50 -0400 Subject: [PATCH 1/3] fix(openai): improve reliability for local OpenAI-compatible servers Previous behavior: Local OpenAI-compatible servers experienced intermittent connection failures and streaming issues due to missing HTTP/1.1 keep-alive configuration and inadequate timeout handling. What changed: - packages/core/src/core/openaiContentGenerator.ts: Add HTTP agent configuration with proper keep-alive settings, implement retry logic with exponential backoff, add constants for timeout and connection pooling - packages/cli/src/config/config.ts: Consolidate OpenAI configuration functions, add setupOpenAIFromCliArgs and getEffectiveAuthType for proper config hierarchy (CLI > env > settings) - packages/cli/src/validateNonInterActiveAuth.ts: Consolidate debug logging into multi-line statements, use getEffectiveAuthType for consistent auth resolution - packages/cli/src/gemini.tsx: Fix import order, add error logging in debug mode, update imports after config consolidation - packages/core/src/core/contentGenerator.ts: Add getBackendName function for human-readable API names in error messages - packages/core/src/core/geminiChat.ts: Add getConfig() method to expose configuration for error reporting - packages/core/src/core/turn.ts: Use getBackendName for better error messages - packages/cli/src/nonInteractiveCli.ts: Apply OpenAI CLI arguments before auth initialization - docs/cli/openai-auth.md: Document local server configuration options Why: Local OpenAI-compatible servers often have different connection characteristics than cloud services, requiring specific HTTP agent configuration to maintain stable connections. The changes ensure reliable streaming and prevent premature connection closures. Testable: Run with local OpenAI-compatible server using OPENAI_BASE_URL=http://localhost:1234/v1 and verify stable streaming without connection drops. --- docs/cli/openai-auth.md | 19 +- packages/cli/src/config/config.ts | 58 ++++- packages/cli/src/gemini.tsx | 64 ++++-- packages/cli/src/nonInteractiveCli.ts | 4 + .../cli/src/validateNonInterActiveAuth.ts | 45 ++-- packages/core/src/core/contentGenerator.ts | 56 +++++ packages/core/src/core/geminiChat.ts | 4 + .../core/src/core/openaiContentGenerator.ts | 202 +++++++++++++----- packages/core/src/core/turn.ts | 8 +- 9 files changed, 361 insertions(+), 99 deletions(-) diff --git a/docs/cli/openai-auth.md b/docs/cli/openai-auth.md index 9dd8c0caa2c..1fe42dcc237 100644 --- a/docs/cli/openai-auth.md +++ b/docs/cli/openai-auth.md @@ -62,7 +62,24 @@ You can use custom endpoints by setting the `OPENAI_BASE_URL` environment variab - Using Azure OpenAI - Using other OpenAI-compatible APIs -- Using local OpenAI-compatible servers +- Using local OpenAI-compatible servers (e.g., LM Studio, Ollama, llama.cpp) + +### Local OpenAI-Compatible Servers + +Run Qwen Code with local OpenAI-compatible servers for offline development: + +```bash +# For local OpenAI-compatible servers +export OPENAI_API_KEY="any-value" # Required but can be any non-empty value +export OPENAI_BASE_URL="http://localhost:1234/v1" # Your local server URL +export OPENAI_MODEL="your-model-name" # Model name from your server +export OPENAI_STREAMING="false" # Optional: disable streaming for better reliability + +qwen +``` + +**Notes:** +- Some local servers may have streaming reliability issues. Set `OPENAI_STREAMING=false` for improved stability ## Switching Authentication Methods diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index af879a995a7..277221a44fe 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -22,6 +22,7 @@ import { FileDiscoveryService, TelemetryTarget, FileFilteringOptions, + AuthType, } from '@qwen-code/qwen-code-core'; import { Settings } from './settings.js'; @@ -329,15 +330,8 @@ export async function loadCliConfig( const activeExtensions = extensions.filter( (_, i) => allExtensions[i].isActive, ); - // Handle OpenAI API key from command line - if (argv.openaiApiKey) { - process.env.OPENAI_API_KEY = argv.openaiApiKey; - } - - // Handle OpenAI base URL from command line - if (argv.openaiBaseUrl) { - process.env.OPENAI_BASE_URL = argv.openaiBaseUrl; - } + // Set up OpenAI configuration from CLI arguments + setupOpenAIFromCliArgs(argv); // Handle Tavily API key from command line if (argv.tavilyApiKey) { @@ -561,3 +555,49 @@ function mergeExcludeTools( } return [...allExcludeTools]; } + +/** + * Apply OpenAI configuration from CLI arguments to environment. + * Following the existing pattern for environment setup. + * @param argv - Command line arguments + */ +function setupOpenAIFromCliArgs(argv: CliArgs): void { + // Following existing pattern for environment variable setup + if (argv.openaiApiKey) { + process.env.OPENAI_API_KEY = argv.openaiApiKey; + } + + if (argv.openaiBaseUrl) { + process.env.OPENAI_BASE_URL = argv.openaiBaseUrl; + } + + // Set model if using OpenAI + if (argv.model && process.env.OPENAI_API_KEY) { + process.env.OPENAI_MODEL = argv.model; + } +} + +/** + * Get the effective auth type based on configuration hierarchy. + * Environment variables take precedence over settings. + * @param settings - Loaded settings + * @returns The auth type to use + */ +export function getEffectiveAuthType(settings: Settings): AuthType | undefined { + // Check environment variables first (highest precedence) + if (process.env.OPENAI_API_KEY) { + return AuthType.USE_OPENAI; + } + if (process.env.GEMINI_API_KEY) { + return AuthType.USE_GEMINI; + } + if (process.env.GOOGLE_GENAI_USE_VERTEXAI === 'true') { + return AuthType.USE_VERTEX_AI; + } + if (process.env.GOOGLE_GENAI_USE_GCA === 'true') { + return AuthType.LOGIN_WITH_GOOGLE; + } + + // Fall back to settings + return settings.selectedAuthType; +} diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index e9e0420b5fd..56f951c1831 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -40,12 +40,49 @@ import { getOauthClient, } from '@qwen-code/qwen-code-core'; import { validateAuthMethod } from './config/auth.js'; +import { getEffectiveAuthType } from './config/config.js'; import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js'; import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; import { checkForUpdates } from './ui/utils/updateCheck.js'; import { handleAutoUpdate } from './utils/handleAutoUpdate.js'; import { appEvents, AppEvent } from './utils/events.js'; +/** + * Initialize authentication for the config based on settings and environment. + * Centralizes auth initialization to follow DRY principle. + * @param config - The Config instance to initialize auth for + * @param settings - The loaded settings containing selectedAuthType + * @returns Promise + */ +async function initializeAuth( + config: Config, + settings: LoadedSettings, +): Promise { + // Skip if using external auth + if (settings.merged.useExternalAuth) { + return; + } + + // Get the effective auth type based on configuration hierarchy + const effectiveAuthType = getEffectiveAuthType(settings.merged); + + if (!effectiveAuthType) { + return; + } + + try { + const err = validateAuthMethod(effectiveAuthType); + if (!err) { + await config.refreshAuth(effectiveAuthType); + } + } catch (err) { + // Log auth errors but don't exit - let the appropriate handler deal with auth errors + if (config.getDebugMode()) { + console.error('Auth initialization error:', err); + } + } +} + export function validateDnsResolutionOrder( order: string | undefined, ): DnsResolutionOrder { @@ -191,6 +228,12 @@ export async function main() { await config.initialize(); + // Initialize auth after config.initialize() to ensure correct backend is used + // In sandbox mode, auth is handled separately below to avoid OAuth redirect issues + if (!process.env.SANDBOX) { + await initializeAuth(config, settings); + } + // Load custom themes from settings themeManager.loadCustomThemes(settings.merged.customThemes); @@ -209,21 +252,12 @@ export async function main() { : []; const sandboxConfig = config.getSandbox(); if (sandboxConfig) { - if ( - settings.merged.selectedAuthType && - !settings.merged.useExternalAuth - ) { - // Validate authentication here because the sandbox will interfere with the Oauth2 web redirect. - try { - const err = validateAuthMethod(settings.merged.selectedAuthType); - if (err) { - throw new Error(err); - } - await config.refreshAuth(settings.merged.selectedAuthType); - } catch (err) { - console.error('Error authenticating:', err); - process.exit(1); - } + // Initialize auth before entering sandbox to avoid OAuth redirect issues + try { + await initializeAuth(config, settings); + } catch (err) { + console.error('Error authenticating:', err); + process.exit(1); } await start_sandbox(sandboxConfig, memoryArgs, config); process.exit(0); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 44bba0092e8..8b5d6f58f38 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -41,6 +41,10 @@ export async function runNonInteractive( }); const geminiClient = config.getGeminiClient(); + if (config.getDebugMode()) { + const contentGen = config.getContentGeneratorConfig(); + console.debug(`NonInteractive using content generator: ${JSON.stringify(contentGen)}`); + } const toolRegistry: ToolRegistry = await config.getToolRegistry(); const abortController = new AbortController(); diff --git a/packages/cli/src/validateNonInterActiveAuth.ts b/packages/cli/src/validateNonInterActiveAuth.ts index c1e7c586b43..6a7475101bd 100644 --- a/packages/cli/src/validateNonInterActiveAuth.ts +++ b/packages/cli/src/validateNonInterActiveAuth.ts @@ -7,32 +7,25 @@ import { AuthType, Config } from '@qwen-code/qwen-code-core'; import { USER_SETTINGS_PATH } from './config/settings.js'; import { validateAuthMethod } from './config/auth.js'; - -function getAuthTypeFromEnv(): AuthType | undefined { - if (process.env.GOOGLE_GENAI_USE_GCA === 'true') { - return AuthType.LOGIN_WITH_GOOGLE; - } - if (process.env.GOOGLE_GENAI_USE_VERTEXAI === 'true') { - return AuthType.USE_VERTEX_AI; - } - if (process.env.GEMINI_API_KEY) { - return AuthType.USE_GEMINI; - } - if (process.env.OPENAI_API_KEY) { - return AuthType.USE_OPENAI; - } - if (process.env.QWEN_OAUTH_TOKEN) { - return AuthType.QWEN_OAUTH; - } - return undefined; -} +import { getEffectiveAuthType } from './config/config.js'; export async function validateNonInteractiveAuth( configuredAuthType: AuthType | undefined, useExternalAuth: boolean | undefined, nonInteractiveConfig: Config, ) { - const effectiveAuthType = configuredAuthType || getAuthTypeFromEnv(); + const debug = nonInteractiveConfig.getDebugMode() || process.env.DEBUG === 'true' || process.env.DEBUG === '1'; + + // Use the configuration hierarchy from config module + // This ensures CLI args > env vars > settings.json precedence + const effectiveAuthType = getEffectiveAuthType({ selectedAuthType: configuredAuthType }); + + if (debug) { + console.debug('[DEBUG:validateNonInteractiveAuth] Called\n' + + ` configuredAuthType: ${configuredAuthType}\n` + + ` useExternalAuth: ${useExternalAuth}\n` + + ` effectiveAuthType: ${effectiveAuthType}`); + } if (!effectiveAuthType) { console.error( @@ -49,6 +42,18 @@ export async function validateNonInteractiveAuth( } } + if (debug) { + console.debug('[DEBUG:validateNonInteractiveAuth] Before refreshAuth:', effectiveAuthType); + } + await nonInteractiveConfig.refreshAuth(effectiveAuthType); + + if (debug) { + const contentGen = nonInteractiveConfig.getContentGeneratorConfig(); + console.debug('[DEBUG:validateNonInteractiveAuth] Auth refreshed\n' + + ` effectiveAuthType: ${effectiveAuthType}\n` + + ` contentGenerator: ${JSON.stringify(contentGen)}`); + } + return nonInteractiveConfig; } diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 76c09ad321a..40eb7ed7e39 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -49,6 +49,36 @@ export enum AuthType { QWEN_OAUTH = 'qwen-oauth', } +/** + * Get a human-readable backend name based on the auth type. + * @param authType - The authentication type being used + * @returns A human-readable backend name for error messages + */ +export function getBackendName(authType?: AuthType | string): string { + switch (authType) { + case AuthType.USE_OPENAI: + case 'openai': + return 'OpenAI API'; + case AuthType.QWEN_OAUTH: + case 'qwen-oauth': + return 'Qwen API'; + case AuthType.USE_VERTEX_AI: + case 'vertex-ai': + return 'Vertex AI'; + case AuthType.USE_GEMINI: + case 'gemini-api-key': + return 'Gemini API'; + case AuthType.CLOUD_SHELL: + case 'cloud-shell': + return 'Cloud Shell'; + case AuthType.LOGIN_WITH_GOOGLE: + case 'oauth-personal': + return 'Google OAuth'; + default: + return 'AI API'; + } +} + export type ContentGeneratorConfig = { model: string; apiKey?: string; @@ -75,6 +105,15 @@ export function createContentGeneratorConfig( config: Config, authType: AuthType | undefined, ): ContentGeneratorConfig { + const debug = config.getDebugMode() || process.env.DEBUG === 'true' || process.env.DEBUG === '1'; + if (debug) { + console.debug( + '[DEBUG:createContentGeneratorConfig] Called\n' + + ` authType: ${authType}\n` + + ` Caller stack: ${new Error().stack?.split('\n').slice(2, 4).join('\n')}` + ); + } + const geminiApiKey = process.env.GEMINI_API_KEY || undefined; const googleApiKey = process.env.GOOGLE_API_KEY || undefined; const googleCloudProject = process.env.GOOGLE_CLOUD_PROJECT || undefined; @@ -83,6 +122,15 @@ export function createContentGeneratorConfig( // Use runtime model from config if available; otherwise, fall back to parameter or default const effectiveModel = config.getModel() || DEFAULT_GEMINI_MODEL; + + if (debug) { + console.debug( + '[DEBUG:createContentGeneratorConfig] Environment:\n' + + ` OPENAI_API_KEY: ${openaiApiKey ? 'SET' : 'NOT SET'}\n` + + ` GEMINI_API_KEY: ${geminiApiKey ? 'SET' : 'NOT SET'}\n` + + ` Effective model: ${effectiveModel}` + ); + } const contentGeneratorConfig: ContentGeneratorConfig = { model: effectiveModel, @@ -128,6 +176,14 @@ export function createContentGeneratorConfig( contentGeneratorConfig.apiKey = openaiApiKey; contentGeneratorConfig.model = process.env.OPENAI_MODEL || DEFAULT_GEMINI_MODEL; + + if (debug) { + console.debug( + '[DEBUG:createContentGeneratorConfig] Configured for OpenAI\n' + + ` Final model: ${contentGeneratorConfig.model}\n` + + ` Returning config with authType: ${contentGeneratorConfig.authType}` + ); + } return contentGeneratorConfig; } diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index c66237c4f2b..e2aa8509e54 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -137,6 +137,10 @@ export class GeminiChat { validateHistory(history); } + getConfig(): Config { + return this.config; + } + private _getRequestTextFromContents(contents: Content[]): string { return JSON.stringify(contents); } diff --git a/packages/core/src/core/openaiContentGenerator.ts b/packages/core/src/core/openaiContentGenerator.ts index eeba5db7f81..68ee28163d6 100644 --- a/packages/core/src/core/openaiContentGenerator.ts +++ b/packages/core/src/core/openaiContentGenerator.ts @@ -22,11 +22,39 @@ import { } from '@google/genai'; import { AuthType, ContentGenerator } from './contentGenerator.js'; import OpenAI from 'openai'; +// HTTP Agent configuration constants +const HTTP_AGENT_DEFAULTS = { + CONNECTION_TIMEOUT: 30000, // 30s connection timeout + BODY_TIMEOUT_DEFAULT: 600000, // 10min for non-streaming + BODY_TIMEOUT_STREAMING: 300000,// 5min for streaming + HEADERS_TIMEOUT: 30000, // 30s for headers + KEEP_ALIVE_TIMEOUT: 4000, // 4s (under LM Studio's 5s limit) + KEEP_ALIVE_MAX_TIMEOUT: 60000, // 1min max + MAX_CONNECTIONS: 2, // Conservative pooling + PIPELINING: 0, // Disabled for compatibility +}; + +// Retry configuration +const RETRY_CONFIG = { + MAX_ATTEMPTS: 3, + INITIAL_DELAY_MS: 1000, + MAX_DELAY_MS: 5000, +}; + +// Dynamic import of undici to handle missing dependency gracefully +let Agent: typeof import('undici').Agent | undefined; +try { + const undici = await import('undici'); + Agent = undici.Agent; +} catch (error) { + // undici not available, will use default fetch without custom agent +} import { logApiError, logApiResponse } from '../telemetry/loggers.js'; import { ApiErrorEvent, ApiResponseEvent } from '../telemetry/types.js'; import { Config } from '../config/config.js'; import { openaiLogger } from '../utils/openaiLogger.js'; import { safeJsonParse } from '../utils/safeJsonParse.js'; +import { retryWithBackoff } from '../utils/retry.js'; // OpenAI API type definitions for logging interface OpenAIToolCall { @@ -95,25 +123,26 @@ export class OpenAIContentGenerator implements ContentGenerator { this.model = model; this.config = config; const baseURL = process.env.OPENAI_BASE_URL || ''; - - // Configure timeout settings - using progressive timeouts - const timeoutConfig = { - // Base timeout for most requests (2 minutes) - timeout: 120000, - // Maximum retries for failed requests - maxRetries: 3, - // HTTP client options - httpAgent: undefined, // Let the client use default agent - }; - - // Allow config to override timeout settings const contentGeneratorConfig = this.config.getContentGeneratorConfig(); - if (contentGeneratorConfig?.timeout) { - timeoutConfig.timeout = contentGeneratorConfig.timeout; - } - if (contentGeneratorConfig?.maxRetries !== undefined) { - timeoutConfig.maxRetries = contentGeneratorConfig.maxRetries; - } + + // Check if streaming is disabled via environment + const streamingDisabled = process.env.OPENAI_STREAMING === 'false'; + + // Use config values if provided, otherwise use sensible defaults + const timeout = contentGeneratorConfig?.timeout || 600000; // 10 minutes default + const maxRetries = contentGeneratorConfig?.maxRetries ?? 2; // 2 retries default + + const fetchOptions = Agent ? { + dispatcher: new Agent({ + connect: { timeout: HTTP_AGENT_DEFAULTS.CONNECTION_TIMEOUT }, + bodyTimeout: streamingDisabled ? timeout : HTTP_AGENT_DEFAULTS.BODY_TIMEOUT_STREAMING, + headersTimeout: HTTP_AGENT_DEFAULTS.HEADERS_TIMEOUT, + keepAliveTimeout: HTTP_AGENT_DEFAULTS.KEEP_ALIVE_TIMEOUT, + keepAliveMaxTimeout: HTTP_AGENT_DEFAULTS.KEEP_ALIVE_MAX_TIMEOUT, + connections: HTTP_AGENT_DEFAULTS.MAX_CONNECTIONS, + pipelining: HTTP_AGENT_DEFAULTS.PIPELINING, + }) + } : {}; const version = config.getCliVersion() || 'unknown'; const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; @@ -133,9 +162,10 @@ export class OpenAIContentGenerator implements ContentGenerator { this.client = new OpenAI({ apiKey, baseURL, - timeout: timeoutConfig.timeout, - maxRetries: timeoutConfig.maxRetries, + timeout, + maxRetries, defaultHeaders, + fetchOptions, }); } @@ -152,6 +182,52 @@ export class OpenAIContentGenerator implements ContentGenerator { return false; // Default behavior: never suppress error logging } + /** + * Determine if an error should be retried + * Handles connection failures, timeouts, and transient errors + */ + private shouldRetryError(error: unknown): boolean { + if (!error) return false; + + const errorMessage = error instanceof Error + ? error.message.toLowerCase() + : String(error).toLowerCase(); + + // Connection failures that should be retried + const connectionErrors = [ + 'econnreset', + 'econnrefused', + 'epipe', + 'socket hang up', + 'socket closed', + 'terminated', + 'network error', + 'fetch failed', + ]; + + for (const connError of connectionErrors) { + if (errorMessage.includes(connError)) { + return true; + } + } + + // Timeout errors should be retried with backoff + if (this.isTimeoutError(error)) { + return true; + } + + // Check for HTTP status codes that indicate retry + const errorCode = (error as { code?: string })?.code; + const errorStatus = (error as { status?: number })?.status; + + // Retry on 429 (rate limit) and 5xx (server errors) + if (errorStatus && (errorStatus === 429 || (errorStatus >= 500 && errorStatus < 600))) { + return true; + } + + return false; + } + /** * Check if an error is a timeout error */ @@ -162,10 +238,8 @@ export class OpenAIContentGenerator implements ContentGenerator { error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const errorCode = (error as any)?.code; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const errorType = (error as any)?.type; + const errorCode = (error as { code?: string })?.code; + const errorType = (error as { type?: string })?.type; // Check for common timeout indicators return ( @@ -253,10 +327,21 @@ export class OpenAIContentGenerator implements ContentGenerator { request.config.tools, ); } - // console.log('createParams', createParams); - const completion = (await this.client.chat.completions.create( - createParams, - )) as OpenAI.Chat.ChatCompletion; + // Wrap API call with retry logic for connection failures + const completion = await retryWithBackoff( + async () => { + return (await this.client.chat.completions.create( + createParams, + )) as OpenAI.Chat.ChatCompletion; + }, + { + maxAttempts: RETRY_CONFIG.MAX_ATTEMPTS, + initialDelayMs: RETRY_CONFIG.INITIAL_DELAY_MS, + maxDelayMs: RETRY_CONFIG.MAX_DELAY_MS, + shouldRetry: (error) => this.shouldRetryError(error), + authType: this.config.getContentGeneratorConfig()?.authType, + } + ); const response = this.convertToGeminiFormat(completion); const durationMs = Date.now() - startTime; @@ -294,17 +379,14 @@ export class OpenAIContentGenerator implements ContentGenerator { // Log API error event for UI telemetry const errorEvent = new ApiErrorEvent( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any).requestID || 'unknown', + (error as { requestID?: string }).requestID || 'unknown', this.model, errorMessage, durationMs, userPromptId, this.config.getContentGeneratorConfig()?.authType, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any).type, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any).code, + (error as { type?: string }).type, + (error as { code?: string }).code, ); logApiError(this.config, errorEvent); @@ -345,6 +427,18 @@ export class OpenAIContentGenerator implements ContentGenerator { const startTime = Date.now(); const messages = this.convertToOpenAIFormat(request); + // Check if streaming is disabled via environment variable + const streamingDisabled = process.env.OPENAI_STREAMING === 'false'; + + if (streamingDisabled) { + // Use non-streaming generateContent and convert to a single-yield generator + const response = await this.generateContent(request, userPromptId); + const singleYieldGenerator = async function* () { + yield response; + }; + return singleYieldGenerator(); + } + try { // Build sampling parameters with clear priority const samplingParams = this.buildSamplingParameters(request); @@ -366,9 +460,21 @@ export class OpenAIContentGenerator implements ContentGenerator { ); } - const stream = (await this.client.chat.completions.create( - createParams, - )) as AsyncIterable; + // Wrap streaming API call with retry logic for initial connection + const stream = await retryWithBackoff( + async () => { + return (await this.client.chat.completions.create( + createParams, + )) as AsyncIterable; + }, + { + maxAttempts: RETRY_CONFIG.MAX_ATTEMPTS, + initialDelayMs: RETRY_CONFIG.INITIAL_DELAY_MS, + maxDelayMs: RETRY_CONFIG.MAX_DELAY_MS, + shouldRetry: (error) => this.shouldRetryError(error), + authType: this.config.getContentGeneratorConfig()?.authType, + } + ); const originalStream = this.streamGenerator(stream); @@ -427,17 +533,14 @@ export class OpenAIContentGenerator implements ContentGenerator { // Log API error event for UI telemetry const errorEvent = new ApiErrorEvent( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any).requestID || 'unknown', + (error as { requestID?: string }).requestID || 'unknown', this.model, errorMessage, durationMs, userPromptId, this.config.getContentGeneratorConfig()?.authType, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any).type, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any).code, + (error as { type?: string }).type, + (error as { code?: string }).code, ); logApiError(this.config, errorEvent); @@ -481,17 +584,14 @@ export class OpenAIContentGenerator implements ContentGenerator { // Log API error event for UI telemetry const errorEvent = new ApiErrorEvent( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any).requestID || 'unknown', + (error as { requestID?: string }).requestID || 'unknown', this.model, errorMessage, durationMs, userPromptId, this.config.getContentGeneratorConfig()?.authType, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any).type, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any).code, + (error as { type?: string }).type, + (error as { code?: string }).code, ); logApiError(this.config, errorEvent); @@ -802,10 +902,6 @@ export class OpenAIContentGenerator implements ContentGenerator { } } - // console.log( - // 'OpenAI Tools Parameters:', - // JSON.stringify(openAITools, null, 2), - // ); return openAITools; } diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index c726bc7372f..b4e98d01fc8 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -25,6 +25,7 @@ import { toFriendlyError, } from '../utils/errors.js'; import { GeminiChat } from './geminiChat.js'; +import { getBackendName } from './contentGenerator.js'; // Define a structure for tools passed to the server export interface ServerTool { @@ -270,10 +271,15 @@ export class Turn { return; } + // Get the backend name for better error messages + const authType = this.chat.getConfig()?.getContentGeneratorConfig()?.authType; + const backendName = getBackendName(authType); + const contextForReport = [...this.chat.getHistory(/*curated*/ true), req]; + await reportError( error, - 'Error when talking to Gemini API', + `Error when talking to ${backendName}`, contextForReport, 'Turn.run-sendMessageStream', ); From 088675336dfacf4c21d25aa0865a0bbfdf74dfb2 Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Wed, 20 Aug 2025 19:57:13 -0400 Subject: [PATCH 2/3] fix: add missing telemetry imports after merge --- packages/core/src/core/geminiChat.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 7b51f900c0a..bf0609b1cb9 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -15,6 +15,7 @@ import { createUserContent, Part, Tool, + GenerateContentResponseUsageMetadata, } from '@google/genai'; import { retryWithBackoff } from '../utils/retry.js'; import { isFunctionResponse } from '../utils/messageInspectors.js'; @@ -23,6 +24,16 @@ import { Config } from '../config/config.js'; import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; import { hasCycleInSchema } from '../tools/tools.js'; import { StructuredError } from './turn.js'; +import { + logApiRequest, + logApiResponse, + logApiError +} from '../telemetry/loggers.js'; +import { + ApiRequestEvent, + ApiResponseEvent, + ApiErrorEvent +} from '../telemetry/types.js'; /** * Returns true if the response is valid, false otherwise. From 89cd276a60bcdd9337faa66cea9cfedce787f448 Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Wed, 20 Aug 2025 20:05:05 -0400 Subject: [PATCH 3/3] fix: move undici dynamic import to async context with debug logging --- .../core/src/core/openaiContentGenerator.ts | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/packages/core/src/core/openaiContentGenerator.ts b/packages/core/src/core/openaiContentGenerator.ts index c22f2ab2b57..dd9038c2cbb 100644 --- a/packages/core/src/core/openaiContentGenerator.ts +++ b/packages/core/src/core/openaiContentGenerator.ts @@ -43,12 +43,6 @@ const RETRY_CONFIG = { // Dynamic import of undici to handle missing dependency gracefully let Agent: typeof import('undici').Agent | undefined; -try { - const undici = await import('undici'); - Agent = undici.Agent; -} catch (error) { - // undici not available, will use default fetch without custom agent -} import { logApiError, logApiResponse } from '../telemetry/loggers.js'; import { ApiErrorEvent, ApiResponseEvent } from '../telemetry/types.js'; import { Config } from '../config/config.js'; @@ -303,6 +297,21 @@ export class OpenAIContentGenerator implements ContentGenerator { request: GenerateContentParameters, userPromptId: string, ): Promise { + // Initialize undici Agent if not already done + if (!Agent) { + try { + const undici = await import('undici'); + Agent = undici.Agent; + if (this.config?.getDebugMode()) { + console.debug('[OpenAI] Successfully loaded undici for HTTP agent optimization'); + } + } catch (error) { + if (this.config?.getDebugMode()) { + console.debug('[OpenAI] undici not available, using default fetch:', error instanceof Error ? error.message : error); + } + } + } + const startTime = Date.now(); const messages = this.convertToOpenAIFormat(request); @@ -424,6 +433,21 @@ export class OpenAIContentGenerator implements ContentGenerator { request: GenerateContentParameters, userPromptId: string, ): Promise> { + // Initialize undici Agent if not already done + if (!Agent) { + try { + const undici = await import('undici'); + Agent = undici.Agent; + if (this.config?.getDebugMode()) { + console.debug('[OpenAI] Successfully loaded undici for HTTP agent optimization'); + } + } catch (error) { + if (this.config?.getDebugMode()) { + console.debug('[OpenAI] undici not available, using default fetch:', error instanceof Error ? error.message : error); + } + } + } + const startTime = Date.now(); const messages = this.convertToOpenAIFormat(request);