/**
 * AIService - Manages communication with Azure backend API, model routing, rate limiting
 * 
 * Purpose:
 * - Backend API communication
 * - Model selection and routing
 * - Rate limiting enforcement
 * - Conversation compactization
 * - Token usage tracking
 * - Request/response transformation
 */

import {
  MODELS,
  MODEL_PROVIDERS,
  HTTP_STATUS,
  ERROR_TYPES,
  SYSTEM_DEFAULTS,
  COMPACTION_CONFIG
} from '../utilities/constants.js';
import { getOllamaService, OLLAMA_MODEL_PREFIX } from './ollamaService.js';

/**
 * Pull the reasoning-token count out of a usage object, checking every
 * provider shape we've seen in the fleet:
 *   - OpenAI:      usage.completion_tokens_details.reasoning_tokens
 *   - Some Azure:  usage.reasoning_tokens (flattened)
 *   - Claude:      usage.output_tokens_details.reasoning_tokens
 * Returns null when no reasoning tokens are reported (non-reasoning model,
 * or provider hasn't populated the field).
 *
 * Surfaced even for models that don't expose reasoning content text
 * (OpenAI o-series, gpt-5-*-reasoning variants): the count alone is useful
 * — it tells the operator "the model spent N tokens thinking" which
 * explains slow turns and signal "depth of deliberation" at a glance.
 *
 * @param {Object|null|undefined} usage
 * @returns {number|null}
 * @private
 */
/**
 * Parse the SSE stream emitted by autopilot-backend's `/llm/chat` route
 * into a final response object. This is the CLI half of the backend ↔
 * CLI wire contract (backend emission is tested in autopilot-backend's
 * `routes/__tests__/llm.chat.reasoning.test.js`).
 *
 * Accepts anything that returns chunks matching the ReadableStream
 * default-reader protocol: an object with `.read()` returning
 * `{ done, value: Uint8Array }`. Production callers pass
 * `response.body.getReader()`; tests pass a fake reader constructed over
 * a fixed set of byte chunks.
 *
 * Event shapes understood (must match backend's emission):
 *   { type: 'start',           ...metadata }              → ignored
 *   { type: 'chunk',           content: string }          → accumulated into `content`
 *   { type: 'reasoning_chunk', content: string }          → accumulated into `reasoning`
 *   { type: 'done',            content, reasoning?, usage, model, finishReason } → finalizes
 *   { type: 'error',           error, code }              → throws
 *
 * Callbacks (all optional) fire progressively:
 *   onChunk(content)           — on each text chunk
 *   onReasoningChunk(content)  — on each reasoning chunk
 *
 * @param {{ read(): Promise<{done: boolean, value: Uint8Array|undefined}> }} reader
 * @param {Object} [opts]
 * @param {Function} [opts.onChunk]
 * @param {Function} [opts.onReasoningChunk]
 * @param {string}   [opts.fallbackModel]  Used when the final `done` event lacks `model`.
 * @returns {Promise<{
 *   content: string, reasoning: string, reasoningTokens: number|null,
 *   usage: Object|null, model: string, finishReason: string
 * }>}
 */
export async function _parseSSEResponseStream(reader, opts = {}) {
  const { onChunk, onReasoningChunk, fallbackModel = null } = opts;
  const decoder = new TextDecoder();
  let fullContent = '';
  let fullReasoning = '';
  let finalData = null;
  let lineBuffer = '';

  const processLine = (line) => {
    const trimmedLine = line.trim();
    if (!trimmedLine.startsWith('data: ')) return;
    const data = trimmedLine.slice(6).trim();
    if (data === '[DONE]' || data.length === 0) return;

    let parsed;
    try { parsed = JSON.parse(data); }
    catch { return; /* malformed SSE chunk — skip defensively */ }

    if (parsed.type === 'chunk' && parsed.content) {
      fullContent += parsed.content;
      if (onChunk) onChunk(parsed.content);
    } else if (parsed.type === 'reasoning_chunk' && parsed.content) {
      fullReasoning += parsed.content;
      if (onReasoningChunk) onReasoningChunk(parsed.content);
    } else if (parsed.type === 'done') {
      finalData = {
        content: parsed.content || fullContent,
        // Prefer the streamed accumulation; fall back to a batched `reasoning`
        // field on the done event (rare but supported).
        reasoning: fullReasoning || parsed.reasoning || '',
        reasoningTokens: _extractReasoningTokens(parsed.usage),
        usage: parsed.usage,
        model: parsed.model || fallbackModel,
        finishReason: parsed.finishReason || 'stop',
      };
    } else if (parsed.type === 'error') {
      const error = new Error(parsed.error);
      error.code = parsed.code;
      throw error;
    }
    // Unknown event types (e.g. 'start') are silently ignored — forward
    // compat for future backend event additions.
  };

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    lineBuffer += decoder.decode(value, { stream: true });
    const parts = lineBuffer.split('\n');
    lineBuffer = parts.pop() || ''; // last fragment may be incomplete
    for (const line of parts) processLine(line);
  }
  // Flush any trailing unterminated line (streams that end without \n).
  if (lineBuffer.trim().length > 0) processLine(lineBuffer);

  if (finalData) return finalData;
  // Stream ended without a `done` event — return what we accumulated.
  return {
    content: fullContent,
    reasoning: fullReasoning,
    reasoningTokens: null,
    usage: null,
    model: fallbackModel,
    finishReason: 'stop',
  };
}

export function _extractReasoningTokens(usage) {
  if (!usage || typeof usage !== 'object') return null;
  const candidates = [
    usage.reasoning_tokens,
    usage.completion_tokens_details?.reasoning_tokens,
    usage.output_tokens_details?.reasoning_tokens,
    usage.completionTokensDetails?.reasoningTokens,
  ];
  for (const c of candidates) {
    if (typeof c === 'number' && Number.isFinite(c)) return c;
  }
  return null;
}

class AIService {
  constructor(config, logger, budgetService, errorHandler) {
    this.config = config;
    this.logger = logger;
    this.budgetService = budgetService;
    this.errorHandler = errorHandler;
    
    this.baseUrl = config.backend?.baseUrl || 'https://loxia-api-g7hrb8bxdae8a2h7.z02.azurefd.net';
    this.timeout = config.backend?.timeout || 270000; // 4.5 minutes for LLM responses (no auto-retry)
    
    // Rate limiting
    this.rateLimiters = new Map();
    this.requestQueue = new Map();
    
    // Circuit breaker
    this.circuitBreaker = {
      failures: 0,
      lastFailureTime: null,
      isOpen: false,
      threshold: 5,
      timeout: 30000 // 30 seconds
    };
    
    // Model specifications
    this.modelSpecs = this._initializeModelSpecs();
    
    // Conversation managers for multi-model support
    this.conversationManagers = new Map();

    // API Key Manager reference (will be set by LoxiaSystem)
    this.apiKeyManager = null;

    // Agent Pool reference (will be set by LoxiaSystem)
    this.agentPool = null;

    // Active requests tracking for abort support
    // Maps agentId -> { controller: AbortController, requestId: string, startTime: Date }
    this.activeRequests = new Map();

    // Ollama service for local/offline models (lazy initialized)
    this._ollamaService = null;

    // Track user-initiated aborts (separate from timeout aborts)
    // Set contains agentIds that were aborted by user action
    this.userAbortedRequests = new Set();

    // Model reliability tracking (in-memory, global across agents)
    // Maps modelId -> { successCount: number, failureCount: number, lastSuccess: Date|null, lastFailure: Date|null }
    this.modelReliability = new Map();
  }

  /**
   * Get Ollama service (lazy init)
   * @returns {import('./ollamaService.js').OllamaService}
   */
  _getOllamaService() {
    if (!this._ollamaService) {
      this._ollamaService = getOllamaService(this.config, this.logger);
    }
    return this._ollamaService;
  }

  /**
   * Check if a model is an Ollama local model
   */
  _isOllamaModel(model) {
    return model && model.startsWith(OLLAMA_MODEL_PREFIX);
  }

  /**
   * Record a successful model response
   * @param {string} model - Model name
   */
  recordModelSuccess(model) {
    const existing = this.modelReliability.get(model) || { successCount: 0, failureCount: 0, lastSuccess: null, lastFailure: null };
    existing.successCount++;
    existing.lastSuccess = new Date();
    this.modelReliability.set(model, existing);
    this.logger?.debug(`Model success recorded: ${model}`, { successCount: existing.successCount });
  }

  /**
   * Record a model failure
   * @param {string} model - Model name
   * @param {string} errorType - Type of error that occurred
   */
  recordModelFailure(model, errorType = 'unknown') {
    const existing = this.modelReliability.get(model) || { successCount: 0, failureCount: 0, lastSuccess: null, lastFailure: null };
    existing.failureCount++;
    existing.lastFailure = new Date();
    existing.lastErrorType = errorType;
    this.modelReliability.set(model, existing);
    this.logger?.debug(`Model failure recorded: ${model}`, { failureCount: existing.failureCount, errorType });
  }

  /**
   * Get reliability info for a model
   * @param {string} model - Model name
   * @returns {Object} Reliability info with verified status
   */
  getModelReliability(model) {
    const info = this.modelReliability.get(model);
    if (!info) {
      return { verified: false, successCount: 0, failureCount: 0 };
    }
    return {
      verified: info.successCount > 0,
      successCount: info.successCount,
      failureCount: info.failureCount,
      lastSuccess: info.lastSuccess,
      lastFailure: info.lastFailure,
      lastErrorType: info.lastErrorType
    };
  }

  /**
   * Get all model reliability data
   * @returns {Object} Map of model -> reliability info
   */
  getAllModelReliability() {
    const result = {};
    for (const [model, info] of this.modelReliability.entries()) {
      result[model] = {
        verified: info.successCount > 0,
        successCount: info.successCount,
        failureCount: info.failureCount
      };
    }
    return result;
  }

  /**
   * Set models service reference (for model suggestions)
   * @param {ModelsService} modelsService - Models service instance
   */
  setModelsService(modelsService) {
    this.modelsService = modelsService;
  }

  /**
   * Check if an error is model-related (should trigger model suggestion modal)
   * @param {Error} error - The error to check
   * @returns {boolean} True if error is model-related
   */
  isModelRelatedError(error) {
    const modelErrorPatterns = [
      /model.*not found/i,
      /model.*unavailable/i,
      /model.*does not exist/i,
      /invalid.*model/i,
      /unsupported.*model/i,
      /quota.*exceeded/i,
      /rate.*limit/i,
      /context.*length.*exceeded/i,
      /token.*limit/i,
      /maximum.*context/i,
      /capacity/i,
      /overloaded/i,
      /503/,
      /429/
    ];

    const errorMessage = error.message || '';
    const errorCode = error.code || error.status;

    // Check status codes
    if ([429, 503, 404].includes(errorCode)) {
      return true;
    }

    // Check error message patterns
    return modelErrorPatterns.some(pattern => pattern.test(errorMessage));
  }

  /**
   * Classify the type of model error
   * @param {Error} error - The error to classify
   * @returns {string} Error type
   */
  classifyModelError(error) {
    const message = (error.message || '').toLowerCase();
    const code = error.code || error.status;

    if (code === 404 || message.includes('not found') || message.includes('does not exist')) {
      return 'model_not_found';
    }
    if (code === 429 || message.includes('rate limit') || message.includes('quota')) {
      return 'rate_limit';
    }
    if (message.includes('context') || message.includes('token limit') || message.includes('maximum')) {
      return 'context_exceeded';
    }
    if (code === 503 || message.includes('overload') || message.includes('capacity')) {
      return 'model_overloaded';
    }
    if (code === 401 || code === 403) {
      return 'auth_error';
    }
    return 'unknown';
  }

  /**
   * Get model suggestions with reliability info
   * @param {string} failedModel - The model that failed
   * @param {Error} error - The error that occurred
   * @returns {Object} Suggestions object with models and reliability info
   */
  getModelSuggestions(failedModel, error) {
    const suggestions = [];
    const allModels = this.modelsService?.getModels() || [];

    for (const model of allModels) {
      if (model.name === failedModel) continue; // Skip the failed model

      const reliability = this.getModelReliability(model.name);
      suggestions.push({
        name: model.name,
        displayName: model.displayName || model.name,
        provider: model.provider,
        contextWindow: model.contextWindow,
        verified: reliability.verified,
        successCount: reliability.successCount,
        failureCount: reliability.failureCount
      });
    }

    // Sort: verified models first, then by success count
    suggestions.sort((a, b) => {
      if (a.verified !== b.verified) return b.verified ? 1 : -1;
      return b.successCount - a.successCount;
    });

    return {
      failedModel,
      errorType: this.classifyModelError(error),
      errorMessage: error.message,
      suggestions // All available models (sorted by reliability)
    };
  }

  /**
   * Send message to backend API
   * @param {string} model - Target model name
   * @param {string|Array} messages - Message content or conversation history
   * @param {Object} options - Additional options (agentId, systemPrompt, etc.)
   * @returns {Promise<Object>} API response with content and metadata
   */
  async sendMessage(model, messages, options = {}) {
    const requestId = `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;

    // Route Ollama models directly to local inference (bypass Azure backend)
    if (this._isOllamaModel(model)) {
      return this._sendOllamaMessage(model, messages, options, requestId);
    }

    try {
      // Check circuit breaker (compaction calls pass skipCircuitBreaker: true to avoid tripping it)
      if (this._isCircuitBreakerOpen() && !options.skipCircuitBreaker) {
        throw new Error('Service temporarily unavailable - circuit breaker is open');
      }

      // Skip local model validation - let Azure backend handle it

      // Check rate limits
      await this._checkRateLimit(model);
      
      // Format messages for specific model
      const formattedMessages = this._formatMessagesForModel(messages, model, options);
      
      // Prepare request payload
      const payload = {
        model, // Use original model name - let Azure backend handle it
        messages: formattedMessages,
        options: {
          // Respect options.maxTokens if provided (e.g., from compaction service)
          max_tokens: Math.min(options.maxTokens || this.modelSpecs[model]?.maxTokens || COMPACTION_CONFIG.MAX_OUTPUT_TOKENS, COMPACTION_CONFIG.MAX_OUTPUT_TOKENS),
          temperature: options.temperature || 0.7,
          stream: options.stream || false,
          // Native function-call tools — same convention as the streaming path.
          ...(Array.isArray(options.tools) && options.tools.length > 0 && { tools: options.tools }),
        },
        metadata: {
          requestId,
          agentId: options.agentId,
          timestamp: new Date().toISOString()
        }
      };
      
      // Add system prompt if provided
      if (options.systemPrompt) {
        payload.system = options.systemPrompt;
      }
      
      this.logger.info(`Sending message to model: ${model}`, {
        requestId,
        agentId: options.agentId,
        messageCount: Array.isArray(messages) ? messages.length : 1,
        maxTokens: payload.max_tokens
      });
      
      // Make API request
      const response = await this._makeAPIRequest('/chat/completions', payload, requestId, {
        ...options,
        sessionId: options.sessionId, // Pass session ID for API key retrieval
        platformProvided: options.platformProvided !== false
      });
      
      // Track usage
      if (response.usage) {
        await this.trackUsage(options.agentId, model, {
          prompt_tokens: response.usage.prompt_tokens || 0,
          completion_tokens: response.usage.completion_tokens || 0,
          total_tokens: response.usage.total_tokens || 0
        });
      }
      
      // Reset circuit breaker on success
      this._resetCircuitBreaker();

      // Track model reliability - mark as verified
      this.recordModelSuccess(model);

      const msg = response.choices[0]?.message || {};
      // Non-streaming reasoning: providers that surface content text put it
      // on the message as `reasoning_content` (DeepSeek-R1, xAI reasoning)
      // or `reasoning` (some OpenAI-compatible gateways) or `thinking`
      // (Anthropic when surfaced through a compatible shape). Token count
      // comes from usage regardless of content presence.
      const reasoning = (typeof msg.reasoning_content === 'string' && msg.reasoning_content)
        || (typeof msg.reasoning === 'string' && msg.reasoning)
        || (typeof msg.thinking === 'string' && msg.thinking)
        || '';
      return {
        content: msg.content || '',
        reasoning,
        reasoningTokens: _extractReasoningTokens(response.usage),
        model: response.model,
        tokenUsage: response.usage,
        requestId,
        finishReason: response.choices[0]?.finish_reason
      };

    } catch (error) {
      // Handle circuit breaker (skip for compaction calls to avoid contaminating main circuit)
      if (!options.skipCircuitBreaker) {
        this._recordFailure();
      }

      // Track model failure
      this.recordModelFailure(model, error.code || error.status || 'unknown');

      this.logger.error(`AI service request failed: ${error.message}`, {
        requestId,
        model,
        agentId: options.agentId,
        error: error.stack
      });

      // Handle specific error types
      await this.handleHttpError(error, { requestId, model, agentId: options.agentId });

      throw error;
    }
  }

  /**
   * Send message to backend API with streaming response
   * @param {string} model - Target model name
   * @param {string|Array} messages - Message content or conversation history
   * @param {Object} options - Additional options (agentId, systemPrompt, etc.)
   * @param {Function} options.onChunk - Callback for each text chunk
   * @param {Function} options.onDone - Callback when stream completes
   * @param {Function} options.onError - Callback for errors
   * @returns {Promise<Object>} Final response with content and metadata
   */
  async sendMessageStream(model, messages, options = {}) {
    const requestId = `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
    const { onChunk, onDone, onError } = options;

    // Route Ollama models directly to local streaming inference
    if (this._isOllamaModel(model)) {
      return this._sendOllamaStream(model, messages, options, requestId);
    }

    try {
      // Check circuit breaker
      if (this._isCircuitBreakerOpen()) {
        throw new Error('Service temporarily unavailable - circuit breaker is open');
      }

      // Check rate limits
      await this._checkRateLimit(model);

      // Format messages for specific model
      const formattedMessages = this._formatMessagesForModel(messages, model, options);

      // Prepare request payload
      const payload = {
        model,
        messages: formattedMessages,
        options: {
          max_tokens: Math.min(options.maxTokens || this.modelSpecs[model]?.maxTokens || COMPACTION_CONFIG.MAX_OUTPUT_TOKENS, COMPACTION_CONFIG.MAX_OUTPUT_TOKENS),
          temperature: options.temperature || 0.7,
          stream: true, // Force streaming
          // Native function-call tools. CLI owns the tool catalog; backend
          // forwards these verbatim to the Responses API (and ignores for
          // chat-completion models). See src/tools/openaiFunctionSchemas.js.
          ...(Array.isArray(options.tools) && options.tools.length > 0 && { tools: options.tools }),
        },
        metadata: {
          requestId,
          agentId: options.agentId,
          timestamp: new Date().toISOString()
        }
      };

      // Add system prompt if provided
      if (options.systemPrompt) {
        payload.system = options.systemPrompt;
      }

      this.logger.info(`Sending streaming message to model: ${model}`, {
        requestId,
        agentId: options.agentId,
        messageCount: Array.isArray(messages) ? messages.length : 1,
        toolCount: options.tools?.length || 0,
      });

      // Make streaming API request
      const response = await this._makeStreamingAPIRequest(payload, requestId, {
        ...options,
        onChunk,
        onDone,
        onError
      });

      // Track usage
      if (response.usage) {
        await this.trackUsage(options.agentId, model, {
          prompt_tokens: response.usage.prompt_tokens || response.usage.input_tokens || 0,
          completion_tokens: response.usage.completion_tokens || response.usage.output_tokens || 0,
          total_tokens: response.usage.total_tokens || 0
        });
      }

      // Reset circuit breaker on success
      this._resetCircuitBreaker();

      // Track model reliability - mark as verified
      this.recordModelSuccess(model);

      return {
        content: response.content || '',
        model: response.model || model,
        tokenUsage: response.usage,
        requestId,
        finishReason: response.finishReason || 'stop'
      };

    } catch (error) {
      // Handle circuit breaker
      this._recordFailure();

      // Track model failure
      this.recordModelFailure(model, error.code || error.status || 'unknown');

      this.logger.error(`AI streaming service request failed: ${error.message}`, {
        requestId,
        model,
        agentId: options.agentId,
        error: error.stack
      });

      if (onError) {
        onError(error);
      }

      throw error;
    }
  }

  /**
   * Make streaming API request using Server-Sent Events
   * @private
   */
  async _makeStreamingAPIRequest(payload, requestId, options = {}) {
    const { onChunk, onDone, onError, sessionId, platformProvided } = options;

    const backendUrl = `${this.baseUrl}/llm/chat`;

    // Get API key
    let apiKey = null;
    let vendorApiKey = null;

    if (this.apiKeyManager) {
      const keys = this.apiKeyManager.getKeysForRequest(sessionId, {
        platformProvided: platformProvided !== false,
        vendor: this._getVendorFromModel(payload.model)
      });

      apiKey = keys.loxiaApiKey;
      vendorApiKey = keys.vendorApiKey;
    }

    if (!apiKey && options.apiKey) {
      apiKey = options.apiKey;
    }

    if (!apiKey && this.config.apiKey) {
      apiKey = this.config.apiKey;
    }

    if (!apiKey) {
      throw new Error('No API key configured. Please configure your Loxia API key in Settings.');
    }

    // Validate messages before sending
    if (!payload.messages || payload.messages.length === 0) {
      throw new Error('No messages to send - messages array is empty');
    }

    const lastMessage = payload.messages[payload.messages.length - 1];
    const messageContent = lastMessage?.content || '';

    if (!messageContent.trim()) {
      this.logger?.warn('Empty message content detected', {
        messagesCount: payload.messages.length,
        lastMessageRole: lastMessage?.role,
        agentId: options.agentId
      });
      throw new Error('Message content is empty - cannot send empty request');
    }

    // Transform payload for Azure backend
    const azurePayload = {
      conversationId: requestId,
      message: messageContent,
      messages: payload.messages,
      model: payload.model,
      requestId,
      options: payload.options || {},
      stream: true, // Enable streaming
      platformProvided: platformProvided !== false
    };

    if (payload.system) {
      azurePayload.systemPrompt = payload.system;
    }

    if (platformProvided) {
      azurePayload.apiKey = apiKey;
    } else {
      if (vendorApiKey) {
        azurePayload.vendorApiKey = vendorApiKey;
      }
      azurePayload.apiKey = apiKey;
    }

    this.logger.info('Making streaming request to Azure backend', {
      url: backendUrl,
      model: payload.model,
      requestId,
      agentId: options.agentId,
      hasApiKey: !!apiKey
    });

    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), this.timeout);

    // Track active request for abort support
    const agentId = options.agentId;
    if (agentId) {
      this.activeRequests.set(agentId, {
        controller,
        requestId,
        startTime: new Date(),
        type: 'streaming'
      });
      this.logger.debug(`Registered active streaming request for agent: ${agentId}`, { requestId });
    }

    // Track accumulated content for partial return on abort
    let fullContent = '';
    // Reasoning/thinking accumulator hoisted to outer scope so the catch
    // block (user-abort path) can return any reasoning captured before the
    // abort fired. Populated from 'reasoning_chunk' SSE events inside the
    // try block.
    let fullReasoning = '';

    try {
      const response = await fetch(backendUrl, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${apiKey}`,
          'X-Request-ID': requestId,
          'Accept': 'text/event-stream'
        },
        body: JSON.stringify(azurePayload),
        signal: controller.signal
      });

      clearTimeout(timeoutId);

      if (!response.ok) {
        const errorText = await response.text();
        const error = new Error(`HTTP ${response.status}: ${response.statusText}${errorText ? ` - ${errorText}` : ''}`);
        error.status = response.status;
        throw error;
      }

      // Check if we actually got a stream
      const contentType = response.headers.get('content-type');
      if (!contentType?.includes('text/event-stream')) {
        // Not a stream, parse as regular JSON (fallback)
        let data;
        try {
          data = await response.json();
        } catch (jsonError) {
          // Backend returned non-JSON or malformed JSON in non-streaming response
          const rawText = '(body already consumed)';
          this.logger.warn('Non-streaming response has malformed JSON, falling back to text', {
            requestId,
            contentType,
            error: jsonError.message
          });
          // Try to return whatever we can
          const fallbackResult = {
            content: '',
            usage: null,
            model: payload.model,
            finishReason: 'error'
          };
          if (onError) {
            onError(new Error(`Backend returned malformed response: ${jsonError.message}`));
          }
          if (onDone) {
            onDone(fallbackResult);
          }
          return fallbackResult;
        }
        // Check if the non-streaming response is an error from the backend
        if (data.error) {
          const errorMsg = `Model "${payload.model}" returned no stream generator. ${data.error}${data.details ? ` — ${data.details}` : ''}. The model may not support streaming or the deployment may not be configured correctly.`;
          this.logger.warn('Backend returned non-streaming error response', {
            requestId,
            model: payload.model,
            error: data.error,
            details: data.details
          });
          const error = new Error(errorMsg);
          error.isModelError = true;
          if (onError) {
            onError(error);
          }
          throw error;
        }
        if (onChunk && data.content) {
          onChunk(data.content);
        }
        if (onDone) {
          onDone(data);
        }
        return {
          content: data.content || '',
          usage: data.usage,
          model: data.model || payload.model,
          finishReason: data.finishReason || 'stop'
        };
      }

      // Validate response body before attempting to read stream
      if (!response.body) {
        this.logger.warn('Response body is null despite OK status, falling back to text parsing', {
          requestId,
          status: response.status,
          contentType
        });
        // Try to get content as text and return it
        const textContent = await response.text().catch(() => '');
        if (onChunk && textContent) {
          onChunk(textContent);
        }
        const fallbackResult = {
          content: textContent,
          usage: null,
          model: payload.model,
          finishReason: 'stop'
        };
        if (onDone) {
          onDone(fallbackResult);
        }
        return fallbackResult;
      }

      // Parse the SSE stream via the shared helper — see `_parseSSEResponseStream`
      // (exported for testability). The helper encodes the full backend
      // contract: chunk/reasoning_chunk/done/error event shapes, line
      // buffering across TCP frames, accumulation of reasoning, trailing-
      // unterminated-line flush. Changing this contract means updating the
      // helper AND the autopilot-backend tests that lock the emission side.
      fullContent = '';   // Reset (declared outside try for catch block access)
      fullReasoning = ''; // Same reason — hoisted for catch-block reuse.
      let finalData = null;
      try {
        finalData = await _parseSSEResponseStream(response.body.getReader(), {
          onChunk: (chunk) => {
            fullContent += chunk;
            if (onChunk) onChunk(chunk);
          },
          onReasoningChunk: (chunk) => {
            fullReasoning += chunk;
            if (options.onReasoningChunk) options.onReasoningChunk(chunk);
          },
          fallbackModel: payload.model,
        });
      } catch (streamError) {
        // Backend `type: 'error'` events throw out of the helper with a .code.
        // Route them through onError like the previous implementation did.
        if (streamError?.code && onError) onError(streamError);
        throw streamError;
      }

      // Final callback with complete data
      if (onDone) {
        onDone(finalData || { content: fullContent, reasoning: fullReasoning, reasoningTokens: null });
      }

      // Clean up active request tracking on success
      if (agentId) {
        this.activeRequests.delete(agentId);
        this.logger.debug(`Cleaned up active request for agent: ${agentId}`, { requestId });
      }

      return finalData || {
        content: fullContent,
        reasoning: fullReasoning,
        reasoningTokens: null,
        usage: null,
        model: payload.model,
        finishReason: 'stop'
      };

    } catch (error) {
      clearTimeout(timeoutId);

      // Check if this was a user-initiated abort (tracked in userAbortedRequests Set)
      const wasUserAbort = agentId && this.userAbortedRequests.has(agentId);

      // Clean up tracking
      if (agentId) {
        this.activeRequests.delete(agentId);
        this.userAbortedRequests.delete(agentId); // Clean up abort flag
      }

      const isTimeout = error.name === 'AbortError' && !wasUserAbort;
      const isGatewayTimeout = error.status === 504 || error.message?.includes('504');

      // Handle user-initiated abort gracefully - no error, just return partial content
      if (wasUserAbort) {
        this.logger.info('Streaming request aborted by user', {
          requestId,
          agentId
        });

        // Return partial content that was received before abort
        const abortResult = {
          content: fullContent || '', // Use accumulated content
          reasoning: fullReasoning || '', // Preserve any reasoning captured before abort
          reasoningTokens: null,
          usage: null,
          model: payload.model,
          finishReason: 'user_abort',
          aborted: true
        };

        if (onDone) {
          onDone(abortResult);
        }

        return abortResult;
      }

      this.logger.error('Streaming request to Azure backend failed', {
        requestId,
        error: error.message,
        status: error.status,
        isTimeout,
        isGatewayTimeout
      });

      if (isGatewayTimeout || isTimeout) {
        const timeoutError = new Error(
          `The AI service is taking longer than expected to respond. ` +
          `Please try again in a moment. (Error: ${isGatewayTimeout ? '504 Gateway Timeout' : 'Request Timeout'})`
        );
        timeoutError.status = isGatewayTimeout ? 504 : 408;
        timeoutError.isTimeout = true;
        timeoutError.shouldReturnToChat = true;
        throw timeoutError;
      }

      throw error;
    }
  }

  /**
   * Compactize conversation for specific model context window
   * @param {Array} messages - Message history
   * @param {string} targetModel - Target model name
   * @returns {Promise<Array>} Compactized messages
   */
  async compactizeConversation(messages, targetModel) {
    const modelSpec = this.modelSpecs[targetModel];
    if (!modelSpec) {
      throw new Error(`Unknown model: ${targetModel}`);
    }
    
    const maxTokens = modelSpec.contextWindow * 0.8; // Use 80% of context window
    let currentTokens = 0;
    const compactizedMessages = [];
    
    // Estimate tokens for each message
    const messagesWithTokens = await Promise.all(
      messages.map(async (msg) => ({
        ...msg,
        estimatedTokens: await this._estimateTokens(msg.content, targetModel)
      }))
    );
    
    // Start from the most recent messages
    const reversedMessages = [...messagesWithTokens].reverse();
    
    for (const message of reversedMessages) {
      if (currentTokens + message.estimatedTokens > maxTokens) {
        // If we've exceeded the limit, summarize older messages
        if (compactizedMessages.length === 0) {
          // If even the latest message is too long, truncate it
          const truncatedContent = await this._truncateMessage(message.content, maxTokens);
          compactizedMessages.unshift({
            ...message,
            content: truncatedContent,
            estimatedTokens: maxTokens
          });
        }
        break;
      }
      
      compactizedMessages.unshift(message);
      currentTokens += message.estimatedTokens;
    }
    
    // If we have remaining older messages, create a summary
    const remainingMessages = messagesWithTokens.slice(0, -compactizedMessages.length);
    if (remainingMessages.length > 0) {
      const summary = await this._summarizeMessages(remainingMessages, targetModel);
      compactizedMessages.unshift({
        role: 'system',
        content: `Previous conversation summary: ${summary}`,
        timestamp: remainingMessages[0].timestamp,
        type: 'summary',
        estimatedTokens: await this._estimateTokens(summary, targetModel)
      });
    }
    
    this.logger.info(`Conversation compactized for model: ${targetModel}`, {
      originalMessages: messages.length,
      compactizedMessages: compactizedMessages.length,
      estimatedTokens: currentTokens,
      maxTokens
    });
    
    return compactizedMessages;
  }

  /**
   * Track token usage and costs
   * @param {number} tokens - Number of tokens used
   * @param {number} cost - Cost in dollars
   * @returns {Promise<void>}
   */
  async trackUsage(agentId, model, tokenUsage, cost) {
    try {
      if (this.budgetService) {
        this.budgetService.trackUsage(agentId, model, tokenUsage);
      }
    } catch (error) {
      this.logger.error(`Usage tracking failed: ${error.message}`);
    }
  }

  /**
   * Handle HTTP errors with comprehensive error handling
   * @param {Error} error - Error object
   * @param {Object} context - Request context
   * @returns {Promise<void>}
   */
  async handleHttpError(error, context) {
    // If error already has timeout flags, re-throw it directly to preserve them
    if (error.isTimeout || error.shouldReturnToChat) {
      throw error;
    }

    const errorType = this.errorHandler?.classifyError?.(error, context);

    switch (error.status || error.code) {
      case HTTP_STATUS.BAD_REQUEST:
        this.logger.error('Bad request to AI service', { context, error: error.message });
        throw new Error(`Invalid request: ${error.message}`);
        
      case HTTP_STATUS.UNAUTHORIZED:
        this.logger.error('Authentication failed with AI service', { context });
        throw new Error('Authentication failed - check API credentials');
        
      case HTTP_STATUS.FORBIDDEN:
        this.logger.error('Access forbidden to AI service', { context });
        throw new Error('Access forbidden - insufficient permissions');
        
      case HTTP_STATUS.NOT_FOUND:
        this.logger.error('AI service endpoint not found', { context });
        throw new Error('Service endpoint not found');
        
      case HTTP_STATUS.TOO_MANY_REQUESTS:
        this.logger.warn('Rate limit exceeded', { context });
        await this._handleRateLimit(context);
        throw new Error('Rate limit exceeded - request queued for retry');
        
      case HTTP_STATUS.INTERNAL_SERVER_ERROR:
      case HTTP_STATUS.BAD_GATEWAY:
      case HTTP_STATUS.SERVICE_UNAVAILABLE:
      case HTTP_STATUS.GATEWAY_TIMEOUT:
        this.logger.error('AI service unavailable', { context, status: error.status });
        await this._handleServiceUnavailable(context);
        throw new Error('AI service temporarily unavailable');
        
      default:
        this.logger.error('Unknown AI service error', { context, error: error.message });
        throw new Error(`AI service error: ${error.message}`);
    }
  }

  /**
   * Set API key manager instance
   * @param {ApiKeyManager} apiKeyManager - API key manager instance
   */
  setApiKeyManager(apiKeyManager) {
    this.apiKeyManager = apiKeyManager;
    
    this.logger?.info('API key manager set for AI service', {
      hasManager: !!apiKeyManager
    });
  }

  /**
   * Set agent pool reference
   * @param {Object} agentPool - Agent pool instance
   */
  setAgentPool(agentPool) {
    this.agentPool = agentPool;

    this.logger?.info('Agent pool set for AI service', {
      hasAgentPool: !!agentPool
    });
  }

  /**
   * Abort an active request for a specific agent
   * Used when user clicks "Stop" to immediately cancel streaming
   * @param {string} agentId - Agent ID whose request should be aborted
   * @param {string} partialContent - Optional partial content received before abort
   * @returns {boolean} True if request was found and aborted
   */
  abortRequest(agentId, partialContent = '') {
    const activeRequest = this.activeRequests.get(agentId);

    if (!activeRequest) {
      this.logger?.debug(`No active request found for agent: ${agentId}`);
      return false;
    }

    const { controller, requestId, startTime, type } = activeRequest;
    const duration = Date.now() - startTime.getTime();

    this.logger?.info(`Aborting ${type} request for agent: ${agentId}`, {
      requestId,
      durationMs: duration
    });

    // Mark this as a user-initiated abort BEFORE triggering abort
    // This allows catch blocks to distinguish user abort from timeout
    this.userAbortedRequests.add(agentId);

    // Trigger the abort
    controller.abort();

    // Clean up active request tracking (but keep userAbortedRequests for catch block)
    this.activeRequests.delete(agentId);

    this.logger?.info(`Successfully aborted request for agent: ${agentId}`, {
      requestId,
      durationMs: duration
    });

    return true;
  }

  /**
   * Check if an agent has an active request
   * @param {string} agentId - Agent ID to check
   * @returns {Object|null} Active request info or null
   */
  getActiveRequest(agentId) {
    const request = this.activeRequests.get(agentId);
    if (!request) return null;

    return {
      requestId: request.requestId,
      type: request.type,
      startTime: request.startTime,
      durationMs: Date.now() - request.startTime.getTime()
    };
  }

  /**
   * Get count of all active requests
   * @returns {number} Number of active requests
   */
  getActiveRequestCount() {
    return this.activeRequests.size;
  }

  /**
   * Generate image from text prompt using AI models
   * @param {string} prompt - Text description of the image to generate
   * @param {Object} options - Generation options
   * @param {string} options.model - Model to use (resolved from modelsService if omitted)
   * @param {string} options.size - Image size (e.g., '1024x1024', '512x512')
   * @param {string} options.quality - Image quality ('standard' or 'hd')
   * @param {string} options.responseFormat - Response format ('url' or 'b64_json')
   * @param {string} options.sessionId - Session ID for API key retrieval
   * @param {boolean} options.platformProvided - Whether to use platform model
   * @returns {Promise<Object>} Generated image result with URL or base64 data
   */
  async generateImage(prompt, options = {}) {
    const requestId = `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;

    try {
      // Check circuit breaker
      if (this._isCircuitBreakerOpen()) {
        throw new Error('Service temporarily unavailable - circuit breaker is open');
      }

      // Validate prompt
      if (!prompt || typeof prompt !== 'string' || prompt.trim().length === 0) {
        throw new Error('Image generation requires a non-empty text prompt');
      }

      // Resolve model: caller-specified → modelsService lookup → error
      const model = options.model || this._resolveModelByApiType('image_generation');
      if (!model) {
        throw new Error('No image generation model available. Ensure your API has image generation models configured.');
      }
      const size = options.size || '1024x1024';
      const quality = options.quality || 'standard';
      const responseFormat = options.responseFormat || 'url';

      this.logger.info(`Generating image with model: ${model}`, {
        requestId,
        model,
        size,
        quality,
        promptLength: prompt.length
      });

      // Prepare request payload
      const payload = {
        prompt,
        model,
        size,
        quality,
        response_format: responseFormat,
        n: 1, // Generate 1 image
        metadata: {
          requestId,
          timestamp: new Date().toISOString()
        }
      };

      // Make API request to image generation endpoint
      const response = await this._makeImageAPIRequest(payload, requestId, {
        sessionId: options.sessionId,
        platformProvided: options.platformProvided !== false
      });

      // Reset circuit breaker on success
      this._resetCircuitBreaker();

      return {
        url: response.data?.[0]?.url || null,
        b64_json: response.data?.[0]?.b64_json || null,
        model: response.model || model,
        requestId,
        revisedPrompt: response.data?.[0]?.revised_prompt || prompt
      };

    } catch (error) {
      // Handle circuit breaker
      this._recordFailure();

      this.logger.error(`Image generation failed: ${error.message}`, {
        requestId,
        model: options.model,
        error: error.stack
      });

      throw error;
    }
  }

  /**
   * Edit an existing image using GPT-Image models (image-to-image)
   * @param {string} prompt - Editing instruction
   * @param {string} imageBase64 - Base64-encoded source image
   * @param {Object} options - Options (model, maskBase64, sessionId)
   * @returns {Promise<Object>} Edited image result { b64_json, model, requestId }
   */
  async editImage(prompt, imageBase64, options = {}) {
    const requestId = `img-edit-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;

    try {
      if (this._isCircuitBreakerOpen()) {
        throw new Error('Service temporarily unavailable - circuit breaker is open');
      }

      if (!prompt || typeof prompt !== 'string' || prompt.trim().length === 0) {
        throw new Error('Image editing requires a non-empty text prompt');
      }

      if (!imageBase64) {
        throw new Error('Image editing requires a source image');
      }

      const model = options.model || 'gpt-image-1.5';

      this.logger.info(`Editing image with model: ${model}`, {
        requestId,
        model,
        hasMask: !!options.maskBase64,
        promptLength: prompt.length
      });

      const payload = {
        prompt,
        model,
        imageBase64,
        ...(options.maskBase64 && { maskBase64: options.maskBase64 }),
        requestId
      };

      const editUrl = `${this.baseUrl}/llm/edit-image`;

      // Get API key
      let apiKey = null;
      if (this.apiKeyManager) {
        const keys = this.apiKeyManager.getKeysForRequest(options.sessionId, {
          platformProvided: options.platformProvided !== false,
          vendor: this._getVendorFromModel(model)
        });
        apiKey = keys.loxiaApiKey;
      }
      if (!apiKey && options.apiKey) apiKey = options.apiKey;
      if (!apiKey && this.config.apiKey) apiKey = this.config.apiKey;
      if (!apiKey && process.env.LOXIA_API_KEY) apiKey = process.env.LOXIA_API_KEY;
      if (!apiKey) {
        throw new Error('No API key configured. Please configure your Loxia API key in Settings.');
      }

      const imageTimeout = this.timeout * 3; // Editing may take longer
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), imageTimeout);

      try {
        const response = await fetch(editUrl, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${apiKey}`,
            'X-Request-ID': requestId
          },
          body: JSON.stringify(payload),
          signal: controller.signal
        });

        clearTimeout(timeoutId);

        if (!response.ok) {
          const errorText = await response.text();
          const error = new Error(`HTTP ${response.status}: ${response.statusText}${errorText ? ` - ${errorText}` : ''}`);
          error.status = response.status;
          throw error;
        }

        const data = await response.json();

        this._resetCircuitBreaker();

        return {
          url: null,
          b64_json: data.images?.[0]?.b64_json || null,
          model: data.model || model,
          requestId,
          revisedPrompt: prompt
        };

      } finally {
        clearTimeout(timeoutId);
      }

    } catch (error) {
      this._recordFailure();

      this.logger.error(`Image editing failed: ${error.message}`, {
        requestId,
        model: options.model,
        error: error.stack
      });

      if (error.name === 'AbortError' || error.message?.includes('timeout')) {
        const timeoutError = new Error(
          `Image editing is taking longer than expected. Please try again in a moment.`
        );
        timeoutError.status = 408;
        timeoutError.isTimeout = true;
        timeoutError.shouldReturnToChat = true;
        throw timeoutError;
      }

      throw error;
    }
  }

  /**
   * Generate a video using Sora 2 (or dynamically resolved video model)
   * This submits a video generation job and returns a job ID for polling
   * @param {string} prompt - Text description of the video to generate
   * @param {Object} options - Options
   * @param {number} options.width - Video width (default: 1920)
   * @param {number} options.height - Video height (default: 1080)
   * @param {number} options.duration - Duration in seconds 1-25 (default: 10)
   * @param {number} options.variants - Number of variants 1-4 (default: 1)
   * @param {string} options.model - Model to use (resolved from modelsService if omitted)
   * @param {string} options.sessionId - Session ID for API key retrieval
   * @returns {Promise<Object>} Job info with jobId for status polling
   */
  async generateVideo(prompt, options = {}) {
    const requestId = `vid-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;

    try {
      // Check circuit breaker
      if (this._isCircuitBreakerOpen()) {
        throw new Error('Service temporarily unavailable - circuit breaker is open');
      }

      // Validate prompt
      if (!prompt || typeof prompt !== 'string' || prompt.trim().length === 0) {
        throw new Error('Video generation requires a non-empty text prompt');
      }

      // Resolve model: caller-specified → modelsService lookup → error
      const model = options.model || this._resolveModelByApiType('video_generation');
      if (!model) {
        throw new Error('No video generation model available. Ensure your API has video generation models configured.');
      }
      const width = options.width || 1080;
      const height = options.height || 1080;
      const duration = options.duration || 5;
      const variants = options.variants || 1;

      this.logger.info(`Submitting video generation job with model: ${model}`, {
        requestId,
        model,
        width,
        height,
        duration,
        variants,
        promptLength: prompt.length
      });

      // Prepare request payload
      const payload = {
        prompt,
        model,
        width,
        height,
        duration,
        variants,
        requestId
      };

      // Make API request to video generation endpoint
      const response = await this._makeVideoAPIRequest(payload, requestId, {
        sessionId: options.sessionId,
        platformProvided: options.platformProvided !== false
      });

      // Reset circuit breaker on success
      this._resetCircuitBreaker();

      return {
        jobId: response.jobId,
        status: response.status,
        prompt: prompt,
        model: response.model || model,
        width: response.width,
        height: response.height,
        duration: response.duration,
        variants: response.variants,
        requestId,
        createdAt: response.createdAt
      };

    } catch (error) {
      // Handle circuit breaker
      this._recordFailure();

      this.logger.error(`Video generation failed: ${error.message}`, {
        requestId,
        model: options.model,
        error: error.stack
      });

      throw error;
    }
  }

  /**
   * Get video generation job status
   * @param {string} jobId - The Sora job ID to check
   * @param {Object} options - Options
   * @param {string} options.model - Model used (for routing)
   * @param {string} options.sessionId - Session ID for API key retrieval
   * @returns {Promise<Object>} Job status with videoUrl if completed
   */
  async getVideoJobStatus(jobId, options = {}) {
    try {
      const model = options.model || this._resolveModelByApiType('video_generation');

      this.logger.debug(`Checking video job status: ${jobId}`, { model });

      // Make API request to get job status
      const response = await this._makeVideoStatusRequest(jobId, {
        model,
        sessionId: options.sessionId
      });

      return {
        jobId: response.jobId,
        status: response.status,
        videoUrl: response.videoUrl,
        generations: response.generations,
        error: response.error,
        createdAt: response.createdAt,
        completedAt: response.completedAt
      };

    } catch (error) {
      this.logger.error(`Video job status check failed: ${error.message}`, {
        jobId,
        error: error.stack
      });

      throw error;
    }
  }

  /**
   * Foundry web-grounded search (Azure AI Foundry Responses API +
   * Grounding with Bing Search).
   *
   * Sends a single stateless request to the backend's /llm/web-search
   * route. The model decides whether to invoke the web_search_preview
   * tool, Foundry runs the Bing search server-side, and we receive a
   * synthesized answer with citation URLs. From the agent's
   * perspective this is a single tool call — Foundry hides the inner
   * search/read/answer loop.
   *
   * Returns 503 from the backend until FOUNDRY_WEB_SEARCH_ENABLED=true
   * and the Bing Grounding resource is connected to the chat
   * deployment via the Foundry portal — see
   * autopilot-backend/docs/FOUNDRY_WEB_SEARCH_PROVISIONING.md.
   *
   * @param {string} query
   * @param {Object} [options]
   * @param {string} [options.model] — chat model to orchestrate the
   *   search (defaults to 'gpt-4o'; must have Bing Grounding bound).
   * @param {string} [options.instructions] — system-style nudge to
   *   force tool use ("always search before answering").
   * @param {string} [options.idempotencyKey] — per-request unique;
   *   used by the backend's per-search billing dedup.
   * @param {string} [options.sessionId] — for API key resolution.
   * @returns {Promise<{ answer: string, citations: Array, model: string, usage: Object }>}
   */
  async foundryWebSearch(query, options = {}) {
    const requestId = `ws-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;

    try {
      if (this._isCircuitBreakerOpen()) {
        throw new Error('Service temporarily unavailable - circuit breaker is open');
      }
      if (!query || typeof query !== 'string' || query.trim().length === 0) {
        throw new Error('Web search requires a non-empty query string');
      }
      if (query.length > 2000) {
        throw new Error('Web search query exceeds 2000 char limit');
      }

      const model = options.model || 'gpt-4o';

      this.logger?.info(`Foundry web search: ${query.slice(0, 60)}…`, {
        requestId, model, queryLength: query.length,
      });

      const response = await this._makeFoundryWebSearchAPIRequest(
        {
          query,
          model,
          instructions: options.instructions,
          idempotencyKey: options.idempotencyKey,
        },
        requestId,
        { sessionId: options.sessionId, platformProvided: options.platformProvided !== false },
      );

      this._resetCircuitBreaker();

      return {
        answer: response.answer,
        citations: response.citations || [],
        model: response.model,
        usage: response.usage || {},
        requestId,
      };

    } catch (error) {
      this._recordFailure();
      this.logger?.error(`Foundry web search failed: ${error.message}`, {
        requestId, model: options.model, status: error.status, error: error.stack,
      });
      throw error;
    }
  }

  /** @private — POST to /llm/web-search with auth + canonical error mapping. */
  async _makeFoundryWebSearchAPIRequest(payload, requestId, options = {}) {
    const url = `${this.baseUrl}/llm/web-search`;

    // Same API-key resolution chain as the image / video paths.
    let apiKey = null;
    if (this.apiKeyManager) {
      const keys = this.apiKeyManager.getKeysForRequest(options.sessionId, {
        platformProvided: options.platformProvided !== false,
      });
      apiKey = keys.loxiaApiKey;
    }
    if (!apiKey && options.apiKey)      apiKey = options.apiKey;
    if (!apiKey && this.config.apiKey)  apiKey = this.config.apiKey;
    if (!apiKey && process.env.LOXIA_API_KEY) apiKey = process.env.LOXIA_API_KEY;
    if (!apiKey) {
      throw new Error('No API key configured. Please configure your Loxia API key in Settings.');
    }

    // Foundry round-trip is normally 2-5s; allow a generous 60s
    // ceiling for cold deployments + multi-search runs.
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), 60_000);

    try {
      const response = await fetch(url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${apiKey}`,
          'X-Request-ID': requestId,
        },
        body: JSON.stringify(payload),
        signal: controller.signal,
      });

      if (!response.ok) {
        let body = '';
        try { body = await response.text(); } catch { /* ignore */ }
        // 503: feature flag off or no deployment — surface a
        // user-friendly message rather than the raw upstream string.
        if (response.status === 503) {
          const e = new Error('Web search isn\'t enabled on this Loxia deployment. Operators: see FOUNDRY_WEB_SEARCH_PROVISIONING.md.');
          e.status = 503;
          throw e;
        }
        const e = new Error(`HTTP ${response.status}: ${response.statusText}${body ? ` - ${body.slice(0, 200)}` : ''}`);
        e.status = response.status;
        throw e;
      }

      return await response.json();

    } finally {
      clearTimeout(timeoutId);
    }
  }

  /**
   * Check service health for circuit breaker
   * @returns {Promise<boolean>} Service health status
   */
  async checkServiceHealth() {
    try {
      const response = await this._makeAPIRequest('/health', {}, 'health-check');
      return response.status === 'healthy';
    } catch (error) {
      return false;
    }
  }

  /**
   * Switch agent to different model
   * @param {string} agentId - Agent identifier
   * @param {string} newModel - New model name
   * @returns {Promise<Object>} Switch result
   */
  async switchAgentModel(agentId, newModel) {
    try {
      if (!this._isValidModel(newModel)) {
        throw new Error(`Invalid model: ${newModel}`);
      }
      
      // Get conversation manager for agent
      let conversationManager = this.conversationManagers.get(agentId);
      if (!conversationManager) {
        // Create new conversation manager if it doesn't exist
        conversationManager = new ConversationManager(agentId, this.logger);
        this.conversationManagers.set(agentId, conversationManager);
      }
      
      // Switch model and return conversation
      const modelConversation = await conversationManager.switchModel(newModel);
      
      // CRITICAL FIX: Update agent's currentModel field in AgentPool
      const agent = await this.agentPool?.getAgent(agentId);
      if (agent) {
        agent.currentModel = newModel;
        await this.agentPool.persistAgentState(agentId);
      }
      
      this.logger.info(`Agent model switched: ${agentId}`, {
        newModel,
        messageCount: modelConversation.messages.length
      });
      
      return {
        success: true,
        agentId,
        newModel,
        conversation: modelConversation
      };
      
    } catch (error) {
      this.logger.error(`Model switch failed: ${error.message}`, { agentId, newModel });
      throw error;
    }
  }

  /**
   * Initialize model specifications
   * @private
   */
  _initializeModelSpecs() {
    const baseSpecs = {
      // Anthropic Claude models
      [MODELS.ANTHROPIC_SONNET]: {
        provider: MODEL_PROVIDERS.ANTHROPIC,
        contextWindow: 200000,
        maxTokens: 8192,  // Increased from 4096
        costPer1kTokens: 0.015
      },
      [MODELS.ANTHROPIC_HAIKU]: {
        provider: MODEL_PROVIDERS.ANTHROPIC,
        contextWindow: 200000,
        maxTokens: 8192,  // Increased from 4096
        costPer1kTokens: 0.0025
      },

      // OpenAI models
      [MODELS.GPT_4]: {
        provider: MODEL_PROVIDERS.OPENAI,
        contextWindow: 128000,
        maxTokens: 8192,  // Increased from 4096
        costPer1kTokens: 0.03
      },
      [MODELS.GPT_4_MINI]: {
        provider: MODEL_PROVIDERS.OPENAI,
        contextWindow: 128000,
        maxTokens: 16384,
        costPer1kTokens: 0.0015
      },
      'gpt-4o': {
        provider: MODEL_PROVIDERS.OPENAI,
        contextWindow: 128000,
        maxTokens: 8192,
        costPer1kTokens: 0.03
      },
      'gpt-4o-mini': {
        provider: MODEL_PROVIDERS.OPENAI,
        contextWindow: 128000,
        maxTokens: 16384,
        costPer1kTokens: 0.0015
      },
      'gpt-4-turbo': {
        provider: MODEL_PROVIDERS.OPENAI,
        contextWindow: 128000,
        maxTokens: 8192,
        costPer1kTokens: 0.03
      },
      'gpt-3.5-turbo': {
        provider: MODEL_PROVIDERS.OPENAI,
        contextWindow: 16384,
        maxTokens: 4096,
        costPer1kTokens: 0.001
      },
      [MODELS.GPT_5_1_CODEX_MINI]: {
        provider: MODEL_PROVIDERS.OPENAI,
        contextWindow: 400000,
        maxTokens: 8192,
        costPer1kTokens: 0.002
      },

      // DeepSeek models
      [MODELS.DEEPSEEK_R1]: {
        provider: MODEL_PROVIDERS.DEEPSEEK,
        contextWindow: 128000,
        maxTokens: 8192,
        costPer1kTokens: 0.002
      },

      // Phi models
      [MODELS.PHI_4]: {
        provider: MODEL_PROVIDERS.PHI,
        contextWindow: 16384,
        maxTokens: 4096,  // Increased from 2048
        costPer1kTokens: 0.001
      },
      [MODELS.PHI_4_REASONING]: {
        provider: MODEL_PROVIDERS.PHI,
        contextWindow: 32000,
        maxTokens: 4096,
        costPer1kTokens: 0.001
      },

      // Azure AI Foundry models
      'azure-ai-grok3': {
        provider: 'AZURE',
        contextWindow: 128000,
        maxTokens: 8192,  // Increased from 4096
        costPer1kTokens: 0.01
      },
      'azure-ai-deepseek-r1': {
        provider: 'AZURE',
        contextWindow: 128000,
        maxTokens: 8192,
        costPer1kTokens: 0.002
      },
      'azure-openai-gpt-5': {
        provider: 'AZURE',
        contextWindow: 128000,
        maxTokens: 8192,
        costPer1kTokens: 0.03
      },
      'azure-openai-gpt-4': {
        provider: 'AZURE',
        contextWindow: 128000,
        maxTokens: 8192,
        costPer1kTokens: 0.03
      },
      'azure-openai-gpt-4o': {
        provider: 'AZURE',
        contextWindow: 128000,
        maxTokens: 8192,
        costPer1kTokens: 0.03
      },

      // Router model
      'model-router': {
        provider: 'AZURE',
        contextWindow: 16384,
        maxTokens: 2048,
        costPer1kTokens: 0.001
      }
    };

    // No need for prefixed models anymore - just return clean base specs
    return baseSpecs;
  }

  /**
   * Format messages for specific model
   * @private
   */
  _formatMessagesForModel(messages, model, options) {
    // Get model spec or use default
    const modelSpec = this.modelSpecs[model] || { provider: 'AZURE' };
    
    let formattedMessages;
    
    if (typeof messages === 'string') {
      // Single message
      formattedMessages = [{
        role: 'user',
        content: messages
      }];
    } else {
      // Message array
      formattedMessages = messages.map(msg => this._formatSingleMessage(msg, model));
    }
    
    // Apply provider-specific formatting
    switch (modelSpec.provider) {
      case MODEL_PROVIDERS.ANTHROPIC:
        return this._formatForAnthropic(formattedMessages);
      case MODEL_PROVIDERS.OPENAI:
        return this._formatForOpenAI(formattedMessages);
      case MODEL_PROVIDERS.AZURE:
        return this._formatForAzure(formattedMessages);
      default:
        return formattedMessages;
    }
  }

  /**
   * Format single message for model
   * @private
   */
  _formatSingleMessage(message, model) {
    return {
      role: message.role || 'user',
      content: message.content,
      timestamp: message.timestamp
    };
  }

  /**
   * Format messages for Anthropic models
   * @private
   */
  _formatForAnthropic(messages) {
    return messages.map(msg => {
      if (msg.role === 'system') {
        return {
          role: 'user',
          content: `System: ${msg.content}`
        };
      }
      return msg;
    });
  }

  /**
   * Format messages for OpenAI models
   * @private
   */
  _formatForOpenAI(messages) {
    // OpenAI supports system role natively
    return messages;
  }

  /**
   * Format messages for Azure models
   * @private
   */
  _formatForAzure(messages) {
    // Azure may have specific formatting requirements
    return messages.map(msg => ({
      ...msg,
      content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)
    }));
  }

  /**
   * Check if model is valid
   * @private
   */
  _isValidModel(model) {
    this.logger.debug('Validating model', { model, modelType: typeof model });
    
    // Check if model exists in our specs directly
    if (this.modelSpecs[model] !== undefined) {
      return true;
    }
    
    this.logger.warn('Model validation failed', { 
      model,
      availableModels: Object.keys(this.modelSpecs)
    });
    
    return false;
  }

  /**
   * Check model health status
   * @private
   */
  async _checkModelHealth(model) {
    // Implementation would check model-specific health endpoints
    // For now, return true (assuming all models are healthy)
    return true;
  }

  /**
   * Estimate tokens for content
   * @private
   */
  async _estimateTokens(content, model) {
    // Rough estimation: 1 token ≈ 4 characters for most models
    return Math.ceil(content.length / 4);
  }

  /**
   * Truncate message to fit token limit
   * @private
   */
  async _truncateMessage(content, maxTokens) {
    const maxChars = maxTokens * 4; // Rough estimation
    if (content.length <= maxChars) {
      return content;
    }
    
    return content.substring(0, maxChars - 20) + '\n... [message truncated]';
  }

  /**
   * Summarize messages for compactization
   * @private
   */
  async _summarizeMessages(messages, model) {
    const combinedContent = messages
      .map(msg => `${msg.role}: ${msg.content}`)
      .join('\n');
    
    // This would use the AI service to create a summary
    // For now, return a simple truncated version
    const maxLength = 500;
    if (combinedContent.length <= maxLength) {
      return combinedContent;
    }
    
    return combinedContent.substring(0, maxLength) + '... [conversation summary truncated]';
  }

  /**
   * Make API request (single attempt, no retries)
   * @private
   */
  async _makeAPIRequest(endpoint, payload, requestId, options = {}) {
    // Make request to configured backend
    const backendUrl = `${this.baseUrl}/llm/chat`;
    
    // Get API keys from session-based storage
    let apiKey = null;
    let vendorApiKey = null;
    
    // Log the state for debugging
    this.logger?.info('🔑 API Key retrieval state', {
      hasApiKeyManager: !!this.apiKeyManager,
      sessionId: options.sessionId,
      hasSessionId: !!options.sessionId,
      optionsKeys: Object.keys(options),
      model: payload.model
    });
    
    // First try to get from API key manager using session ID
    if (this.apiKeyManager) {
      const keys = this.apiKeyManager.getKeysForRequest(options.sessionId, {
        platformProvided: options.platformProvided !== false,
        vendor: this._getVendorFromModel(payload.model)
      });
      
      apiKey = keys.loxiaApiKey;
      vendorApiKey = keys.vendorApiKey;
      
      this.logger?.debug('Retrieved API keys from session manager', {
        sessionId: options.sessionId,
        hasLoxiaKey: !!apiKey,
        hasVendorKey: !!vendorApiKey,
        vendor: this._getVendorFromModel(payload.model)
      });
    }
    
    // Fallback to options (passed from frontend)
    if (!apiKey && options.apiKey) {
      apiKey = options.apiKey;
    }
    
    // Fallback to config if no API key from session or options
    if (!apiKey && this.config.apiKey) {
      apiKey = this.config.apiKey;
    }
    
    if (!apiKey) {
      throw new Error('No API key configured. Please configure your Loxia API key in Settings.');
    }

    // Validate messages before sending
    if (!payload.messages || payload.messages.length === 0) {
      throw new Error('No messages to send - messages array is empty');
    }

    const lastMessage = payload.messages[payload.messages.length - 1];
    const messageContent = lastMessage?.content || '';

    if (!messageContent.trim()) {
      this.logger?.warn('Empty message content detected (non-streaming)', {
        messagesCount: payload.messages.length,
        lastMessageRole: lastMessage?.role,
        agentId: options.agentId
      });
      throw new Error('Message content is empty - cannot send empty request');
    }

    // Use the model name from payload (already transformed)
    const modelName = payload.model;

    // Transform the payload to match the Azure backend API format
    const azurePayload = {
      conversationId: requestId,
      message: messageContent,
      messages: payload.messages,
      model: modelName,
      requestId,
      options: payload.options || {},
      platformProvided: options.platformProvided !== false // Indicate if this is a platform model
    };
    
    // Add system prompt if provided
    if (payload.system) {
      azurePayload.systemPrompt = payload.system;
    }
    
    // Include appropriate API key based on model type
    if (options.platformProvided) {
      // Platform models use Loxia API key
      azurePayload.apiKey = apiKey;
    } else {
      // Direct access models use vendor-specific keys
      if (vendorApiKey) {
        azurePayload.vendorApiKey = vendorApiKey;
      }
      
      // Also include custom API keys from options for backward compatibility
      if (options.customApiKeys) {
        azurePayload.customApiKeys = options.customApiKeys;
      }
      
      // Include Loxia API key as fallback
      azurePayload.apiKey = apiKey;
    }
    
    // Single attempt - no automatic retries for LLM requests
    // On timeout, return to chat mode and let user retry manually
    const agentId = options.agentId;

    try {
      this.logger.info('Making request to Azure backend', {
        url: backendUrl,
        model: payload.model,
        requestId,
        agentId,
        hasApiKey: !!apiKey,
        timeout: this.timeout
      });

      // Use AbortController for proper timeout handling (fetch timeout option doesn't work in Node.js)
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), this.timeout);

      // Track active request for abort support
      if (agentId) {
        this.activeRequests.set(agentId, {
          controller,
          requestId,
          startTime: new Date(),
          type: 'non-streaming'
        });
      }

      try {
        const response = await fetch(backendUrl, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${apiKey}`,
            'X-Request-ID': requestId
          },
          body: JSON.stringify(azurePayload),
          signal: controller.signal
        });

        clearTimeout(timeoutId);

        if (!response.ok) {
          const errorText = await response.text();
          const error = new Error(`HTTP ${response.status}: ${response.statusText}${errorText ? ` - ${errorText}` : ''}`);
          error.status = response.status;
          throw error;
        }

        const data = await response.json();

        // Clean up active request tracking on success
        if (agentId) {
          this.activeRequests.delete(agentId);
        }

        // Transform Azure backend response to match expected format
        return {
          choices: [{
            message: {
              content: data.content || '',
              role: 'assistant'
            },
            finish_reason: data.finishReason || 'stop'
          }],
          usage: data.usage || {
            prompt_tokens: 0,
            completion_tokens: 0,
            total_tokens: 0
          },
          model: data.model || payload.model,
          id: data.requestId || requestId
        };

      } finally {
        clearTimeout(timeoutId);
      }

    } catch (error) {
      // Check if this was a user-initiated abort (tracked in userAbortedRequests Set)
      const wasUserAbort = agentId && this.userAbortedRequests.has(agentId);

      // Clean up tracking
      if (agentId) {
        this.activeRequests.delete(agentId);
        this.userAbortedRequests.delete(agentId); // Clean up abort flag
      }

      const isTimeout = (error.name === 'AbortError' && !wasUserAbort) ||
                        error.message?.includes('timeout') ||
                        error.message?.includes('aborted');
      const isGatewayTimeout = error.status === 504 ||
                                error.message?.includes('504') ||
                                error.message?.includes('Gateway Timeout');

      // Handle user-initiated abort gracefully - no error thrown
      if (wasUserAbort) {
        this.logger.info('Request aborted by user', { requestId, agentId });
        // Return a minimal valid response instead of throwing
        return {
          choices: [{
            message: {
              content: '',
              role: 'assistant'
            },
            finish_reason: 'user_abort'
          }],
          usage: null,
          model: payload.model,
          id: requestId,
          aborted: true
        };
      }

      this.logger.error('Request to Azure backend failed', {
        requestId,
        error: error.message,
        status: error.status,
        isTimeout,
        isGatewayTimeout
      });

      // For timeout errors, throw immediately to return to chat mode
      // User can manually retry when ready
      if (isGatewayTimeout || isTimeout) {
        const timeoutError = new Error(
          `The AI service is taking longer than expected to respond. ` +
          `Please try again in a moment. (Error: ${isGatewayTimeout ? '504 Gateway Timeout' : 'Request Timeout'})`
        );
        timeoutError.status = isGatewayTimeout ? 504 : 408;
        timeoutError.isTimeout = true;
        timeoutError.shouldReturnToChat = true;
        throw timeoutError;
      }

      // For other errors, also throw immediately
      throw error;
    }
  }


  /**
   * Make API request for image generation
   * @private
   */
  async _makeImageAPIRequest(payload, requestId, options = {}) {
    // Image generation endpoint
    const imageUrl = `${this.baseUrl}/llm/generate-image`;

    // Get API keys from session-based storage
    let apiKey = null;
    let vendorApiKey = null;

    this.logger?.info('🖼️ Image API request state', {
      hasApiKeyManager: !!this.apiKeyManager,
      sessionId: options.sessionId,
      hasSessionId: !!options.sessionId,
      model: payload.model
    });

    // First try to get from API key manager using session ID
    if (this.apiKeyManager) {
      const keys = this.apiKeyManager.getKeysForRequest(options.sessionId, {
        platformProvided: options.platformProvided !== false,
        vendor: this._getVendorFromModel(payload.model)
      });

      apiKey = keys.loxiaApiKey;
      vendorApiKey = keys.vendorApiKey;

      this.logger?.debug('Retrieved API keys from session manager for image', {
        sessionId: options.sessionId,
        hasLoxiaKey: !!apiKey,
        hasVendorKey: !!vendorApiKey
      });
    }

    // Fallback to options (passed from frontend)
    if (!apiKey && options.apiKey) {
      apiKey = options.apiKey;
    }

    // Fallback to config if no API key from session or options
    if (!apiKey && this.config.apiKey) {
      apiKey = this.config.apiKey;
    }

    // Fallback to environment variable (matching webServer.js and modelsService.js pattern)
    if (!apiKey && process.env.LOXIA_API_KEY) {
      apiKey = process.env.LOXIA_API_KEY;
      this.logger?.debug('Using LOXIA_API_KEY from environment for image generation');
    }

    if (!apiKey) {
      throw new Error('No API key configured. Please configure your Loxia API key in Settings.');
    }

    // Transform the payload to match Azure backend format
    // Backend expects: prompt, model, size, quality, style, n, requestId
    const azurePayload = {
      prompt: payload.prompt,
      model: payload.model,
      size: payload.size,
      quality: payload.quality,
      style: payload.style || 'vivid', // Backend default
      n: payload.n || 1,
      requestId
    };

    // API key is sent via Authorization header, not in body

    const imageTimeout = this.timeout * 2; // Image generation may take longer (6 minutes)

    // Single attempt - no automatic retries
    // On timeout, return to chat mode and let user retry manually
    try {
      this.logger.info('Making image request to Azure backend', {
        url: imageUrl,
        model: payload.model,
        requestId,
        hasApiKey: !!apiKey,
        timeout: imageTimeout
      });

      // Use AbortController for proper timeout handling
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), imageTimeout);

      try {
        const response = await fetch(imageUrl, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${apiKey}`,
            'X-Request-ID': requestId
          },
          body: JSON.stringify(azurePayload),
          signal: controller.signal
        });

        clearTimeout(timeoutId);

        if (!response.ok) {
          const errorText = await response.text();
          const error = new Error(`HTTP ${response.status}: ${response.statusText}${errorText ? ` - ${errorText}` : ''}`);
          error.status = response.status;
          throw error;
        }

        const data = await response.json();

        // Backend returns: { images: [...], usage: {...}, model, requestId, created }
        // Transform to match our expected format
        return {
          data: data.images || [],
          model: data.model || payload.model,
          created: data.created || Date.now(),
          usage: data.usage
        };

      } finally {
        clearTimeout(timeoutId);
      }

    } catch (error) {
      const isTimeout = error.name === 'AbortError' || error.message?.includes('timeout');
      const isGatewayTimeout = error.status === 504 || error.message?.includes('504');

      this.logger.error('Image request to Azure backend failed', {
        requestId,
        error: error.message,
        status: error.status,
        isTimeout,
        isGatewayTimeout
      });

      // For timeout errors, throw with user-friendly message
      if (isGatewayTimeout || isTimeout) {
        const timeoutError = new Error(
          `Image generation is taking longer than expected. ` +
          `Please try again in a moment. (Error: ${isGatewayTimeout ? '504 Gateway Timeout' : 'Request Timeout'})`
        );
        timeoutError.status = isGatewayTimeout ? 504 : 408;
        timeoutError.isTimeout = true;
        timeoutError.shouldReturnToChat = true;
        throw timeoutError;
      }

      throw error;
    }
  }

  /**
   * Make API request for video generation (job submission)
   * @private
   */
  async _makeVideoAPIRequest(payload, requestId, options = {}) {
    const videoUrl = `${this.baseUrl}/llm/generate-video`;

    // Get API keys from session-based storage
    let apiKey = null;

    this.logger?.info('🎬 Video API request state', {
      hasApiKeyManager: !!this.apiKeyManager,
      sessionId: options.sessionId,
      hasSessionId: !!options.sessionId,
      model: payload.model
    });

    // First try to get from API key manager using session ID
    if (this.apiKeyManager) {
      const keys = this.apiKeyManager.getKeysForRequest(options.sessionId, {
        platformProvided: options.platformProvided !== false,
        vendor: this._getVendorFromModel(payload.model)
      });
      apiKey = keys.loxiaApiKey;
    }

    // Fallback to options (passed from frontend)
    if (!apiKey && options.apiKey) {
      apiKey = options.apiKey;
    }

    // Fallback to config if no API key from session or options
    if (!apiKey && this.config.apiKey) {
      apiKey = this.config.apiKey;
    }

    // Fallback to environment variable (matching webServer.js and modelsService.js pattern)
    if (!apiKey && process.env.LOXIA_API_KEY) {
      apiKey = process.env.LOXIA_API_KEY;
      this.logger?.debug('Using LOXIA_API_KEY from environment for video generation');
    }

    if (!apiKey) {
      throw new Error('No API key configured. Please configure your Loxia API key in Settings.');
    }

    // Transform the payload to match backend format
    const videoPayload = {
      prompt: payload.prompt,
      model: payload.model,
      width: payload.width || 1080,
      height: payload.height || 1080,
      duration: payload.duration || 5,
      variants: payload.variants || 1,
      requestId
    };

    const videoTimeout = 60000; // 1 minute timeout for job submission

    try {
      this.logger.info('Making video request to Azure backend', {
        url: videoUrl,
        model: payload.model,
        requestId,
        hasApiKey: !!apiKey,
        timeout: videoTimeout
      });

      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), videoTimeout);

      try {
        const response = await fetch(videoUrl, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${apiKey}`,
            'X-Request-ID': requestId
          },
          body: JSON.stringify(videoPayload),
          signal: controller.signal
        });

        clearTimeout(timeoutId);

        if (!response.ok) {
          const errorText = await response.text();
          const error = new Error(`HTTP ${response.status}: ${response.statusText}${errorText ? ` - ${errorText}` : ''}`);
          error.status = response.status;
          throw error;
        }

        return await response.json();

      } finally {
        clearTimeout(timeoutId);
      }

    } catch (error) {
      const isTimeout = error.name === 'AbortError' || error.message?.includes('timeout');
      const isGatewayTimeout = error.status === 504 || error.message?.includes('504');

      this.logger.error('Video request to Azure backend failed', {
        requestId,
        error: error.message,
        status: error.status,
        isTimeout,
        isGatewayTimeout
      });

      if (isGatewayTimeout || isTimeout) {
        const timeoutError = new Error(
          `Video job submission is taking longer than expected. ` +
          `Please try again in a moment. (Error: ${isGatewayTimeout ? '504 Gateway Timeout' : 'Request Timeout'})`
        );
        timeoutError.status = isGatewayTimeout ? 504 : 408;
        timeoutError.isTimeout = true;
        throw timeoutError;
      }

      throw error;
    }
  }

  /**
   * Make API request to get video job status
   * @private
   */
  async _makeVideoStatusRequest(jobId, options = {}) {
    const statusUrl = `${this.baseUrl}/llm/video-job/${encodeURIComponent(jobId)}`;

    // Get API key
    let apiKey = null;

    if (this.apiKeyManager) {
      const keys = this.apiKeyManager.getKeysForRequest(options.sessionId, {
        platformProvided: options.platformProvided !== false,
        vendor: 'openai' // Sora is Azure OpenAI
      });
      apiKey = keys.loxiaApiKey;
    }

    if (!apiKey && options.apiKey) {
      apiKey = options.apiKey;
    }

    if (!apiKey && this.config.apiKey) {
      apiKey = this.config.apiKey;
    }

    // Fallback to environment variable
    if (!apiKey && process.env.LOXIA_API_KEY) {
      apiKey = process.env.LOXIA_API_KEY;
      this.logger?.debug('Using LOXIA_API_KEY from environment for video status check');
    }

    if (!apiKey) {
      throw new Error('No API key configured. Please configure your Loxia API key in Settings.');
    }

    const statusTimeout = 30000; // 30 second timeout for status check

    try {
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), statusTimeout);

      try {
        const url = new URL(statusUrl);
        url.searchParams.set('model', options.model || '');

        const response = await fetch(url.toString(), {
          method: 'GET',
          headers: {
            'Authorization': `Bearer ${apiKey}`
          },
          signal: controller.signal
        });

        clearTimeout(timeoutId);

        if (!response.ok) {
          const errorText = await response.text();
          const error = new Error(`HTTP ${response.status}: ${response.statusText}${errorText ? ` - ${errorText}` : ''}`);
          error.status = response.status;
          throw error;
        }

        return await response.json();

      } finally {
        clearTimeout(timeoutId);
      }

    } catch (error) {
      this.logger.error('Video status request failed', {
        jobId,
        error: error.message,
        status: error.status
      });
      throw error;
    }
  }

  /**
   * Check rate limits
   * @private
   */
  async _checkRateLimit(model) {
    // Implementation would check model-specific rate limits
    // For now, just add a small delay
    await new Promise(resolve => setTimeout(resolve, 100));
  }

  /**
   * Handle rate limit exceeded
   * @private
   */
  async _handleRateLimit(context) {
    const delay = 60000; // 1 minute delay for rate limits
    this.logger.info(`Rate limit exceeded, waiting ${delay}ms`, context);
    await new Promise(resolve => setTimeout(resolve, delay));
  }

  /**
   * Handle service unavailable
   * @private
   */
  async _handleServiceUnavailable(context) {
    this._recordFailure();
    const delay = 30000; // 30 second delay for service issues
    this.logger.info(`Service unavailable, waiting ${delay}ms`, context);
    await new Promise(resolve => setTimeout(resolve, delay));
  }

  /**
   * Check if circuit breaker is open
   * @private
   */
  _isCircuitBreakerOpen() {
    if (!this.circuitBreaker.isOpen) {
      return false;
    }
    
    const timeSinceLastFailure = Date.now() - this.circuitBreaker.lastFailureTime;
    if (timeSinceLastFailure > this.circuitBreaker.timeout) {
      this.circuitBreaker.isOpen = false;
      this.circuitBreaker.failures = 0;
      return false;
    }
    
    return true;
  }

  /**
   * Record failure for circuit breaker
   * @private
   */
  _recordFailure() {
    this.circuitBreaker.failures++;
    this.circuitBreaker.lastFailureTime = Date.now();
    
    if (this.circuitBreaker.failures >= this.circuitBreaker.threshold) {
      this.circuitBreaker.isOpen = true;
      this.logger.warn('Circuit breaker opened due to repeated failures');
    }
  }

  /**
   * Reset circuit breaker on success
   * @private
   */
  _resetCircuitBreaker() {
    if (this.circuitBreaker.failures > 0) {
      this.circuitBreaker.failures = 0;
      this.circuitBreaker.isOpen = false;
      this.logger.info('Circuit breaker reset - service recovered');
    }
  }

  /**
   * Resolve a model name by its API type from modelsService.
   * Queries the cached model catalog for models whose api_type array
   * contains the given type (e.g., 'image_generation', 'video_generation').
   * @param {string} apiType - The API type to match (e.g., 'image_generation')
   * @returns {string|null} Model name from the catalog, or null if not found
   * @private
   */
  _resolveModelByApiType(apiType) {
    if (!this.modelsService) return null;
    try {
      const models = this.modelsService.getModels();
      const match = models.find(m =>
        (Array.isArray(m.api_type) && m.api_type.includes(apiType)) ||
        m.modality === apiType
      );
      if (match) {
        // Use the catalog key (match.name) — the backend resolves deployment internally
        this.logger?.debug(`Resolved ${apiType} model from catalog`, { model: match.name });
        return match.name;
      }
    } catch (e) {
      this.logger?.debug(`Failed to resolve ${apiType} model from modelsService`, { error: e.message });
    }
    return null;
  }

  /**
   * Extract vendor name from model name
   * @param {string} model - Model name
   * @returns {string|null} Vendor name
   * @private
   */
  _getVendorFromModel(model) {
    if (!model) return null;
    
    const modelName = model.toLowerCase();
    
    if (modelName.includes('anthropic') || modelName.includes('claude')) {
      return 'anthropic';
    } else if (modelName.includes('openai') || modelName.includes('gpt')) {
      return 'openai';
    } else if (modelName.includes('deepseek')) {
      return 'deepseek';
    } else if (modelName.includes('phi')) {
      return 'microsoft';
    } else if (modelName.startsWith('ollama-')) {
      return 'ollama';
    }

    return null;
  }

  // === Ollama Local Model Methods ===

  /**
   * Send non-streaming message via Ollama (local inference)
   * @private
   */
  async _sendOllamaMessage(model, messages, options, requestId) {
    try {
      const ollama = this._getOllamaService();
      const available = await ollama.isAvailable();
      if (!available) {
        throw new Error('Ollama is not running. Start it with "ollama serve" and try again.');
      }

      const formattedMessages = this._formatMessagesForModel(messages, model, options);

      const response = await ollama.sendMessage(model, formattedMessages, {
        systemPrompt: options.systemPrompt,
        temperature: options.temperature,
        maxTokens: options.maxTokens
      });

      // Track usage (with zero cost — local models are free)
      if (response.usage && this.budgetService) {
        await this.budgetService.trackUsage(options.agentId, model, response.usage);
      }

      this.recordModelSuccess(model);

      return {
        content: response.choices[0]?.message?.content || '',
        model: response.model,
        tokenUsage: response.usage,
        requestId,
        finishReason: response.choices[0]?.finish_reason || 'stop'
      };
    } catch (error) {
      this.recordModelFailure(model, 'ollama_error');
      this.logger?.error(`[Ollama] sendMessage failed: ${error.message}`, {
        requestId, model, agentId: options.agentId
      });
      throw error;
    }
  }

  /**
   * Send streaming message via Ollama (local inference)
   * @private
   */
  async _sendOllamaStream(model, messages, options, requestId) {
    try {
      const ollama = this._getOllamaService();
      const available = await ollama.isAvailable();
      if (!available) {
        throw new Error('Ollama is not running. Start it with "ollama serve" and try again.');
      }

      const formattedMessages = this._formatMessagesForModel(messages, model, options);

      const result = await ollama.sendMessageStream(model, formattedMessages, {
        systemPrompt: options.systemPrompt,
        temperature: options.temperature,
        maxTokens: options.maxTokens,
        onChunk: options.onChunk,
        onDone: options.onDone,
        onError: options.onError
      });

      // Track usage (zero cost)
      if (result.tokenUsage && this.budgetService) {
        await this.budgetService.trackUsage(options.agentId, model, result.tokenUsage);
      }

      this.recordModelSuccess(model);

      return {
        content: result.content,
        model: result.model,
        tokenUsage: result.tokenUsage,
        requestId,
        finishReason: result.finishReason || 'stop'
      };
    } catch (error) {
      this.recordModelFailure(model, 'ollama_error');
      this.logger?.error(`[Ollama] sendMessageStream failed: ${error.message}`, {
        requestId, model, agentId: options.agentId
      });
      if (options.onError) options.onError(error);
      throw error;
    }
  }

  /**
   * Get available Ollama models (for model discovery)
   */
  async getOllamaModels() {
    const ollama = this._getOllamaService();
    return ollama.listModels();
  }
}

/**
 * ConversationManager - Handles multi-model conversation state
 */
class ConversationManager {
  constructor(agentId, logger) {
    this.agentId = agentId;
    this.logger = logger;
    this.conversations = new Map();
  }

  async switchModel(newModel) {
    // Implementation would handle model switching logic
    // For now, return empty conversation
    return {
      messages: [],
      model: newModel,
      lastUpdated: new Date().toISOString()
    };
  }
}

export default AIService;