/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
import { createUserContent } from '@google/genai';
import { retryWithBackoff } from '../utils/retry.js';
import { AuthType } from './contentGenerator.js';
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
import { hasCycleInSchema } from '../tools/tools.js';
import { logContentRetry, logContentRetryFailure, logInvalidChunk, } from '../telemetry/loggers.js';
import { ContentRetryEvent, ContentRetryFailureEvent, InvalidChunkEvent, } from '../telemetry/types.js';
export var StreamEventType;
(function (StreamEventType) {
    /** A regular content chunk from the API. */
    StreamEventType["CHUNK"] = "chunk";
    /** A signal that a retry is about to happen. The UI should discard any partial
     * content from the attempt that just failed. */
    StreamEventType["RETRY"] = "retry";
})(StreamEventType || (StreamEventType = {}));
const INVALID_CONTENT_RETRY_OPTIONS = {
    maxAttempts: 3, // 1 initial call + 2 retries
    initialDelayMs: 500,
};
/**
 * Returns true if the response is valid, false otherwise.
 *
 * The DashScope provider may return the last 2 chunks as:
 * 1. A choice(candidate) with finishReason and empty content
 * 2. Empty choices with usage metadata
 * We'll check separately for both of these cases.
 */
function isValidResponse(response) {
    if (response.usageMetadata) {
        return true;
    }
    if (response.candidates === undefined || response.candidates.length === 0) {
        return false;
    }
    if (response.candidates.some((candidate) => candidate.finishReason)) {
        return true;
    }
    const content = response.candidates[0]?.content;
    return content !== undefined && isValidContent(content);
}
function isValidContent(content) {
    if (content.parts === undefined || content.parts.length === 0) {
        return false;
    }
    for (const part of content.parts) {
        if (part === undefined || Object.keys(part).length === 0) {
            return false;
        }
        if (!part.thought &&
            part.text !== undefined &&
            part.text === '' &&
            part.functionCall === undefined) {
            return false;
        }
    }
    return true;
}
/**
 * Validates the history contains the correct roles.
 *
 * @throws Error if the history does not start with a user turn.
 * @throws Error if the history contains an invalid role.
 */
function validateHistory(history) {
    for (const content of history) {
        if (content.role !== 'user' && content.role !== 'model') {
            throw new Error(`Role must be user or model, but got ${content.role}.`);
        }
    }
}
/**
 * Extracts the curated (valid) history from a comprehensive history.
 *
 * @remarks
 * The model may sometimes generate invalid or empty contents(e.g., due to safety
 * filters or recitation). Extracting valid turns from the history
 * ensures that subsequent requests could be accepted by the model.
 */
function extractCuratedHistory(comprehensiveHistory) {
    if (comprehensiveHistory === undefined || comprehensiveHistory.length === 0) {
        return [];
    }
    const curatedHistory = [];
    const length = comprehensiveHistory.length;
    let i = 0;
    while (i < length) {
        if (comprehensiveHistory[i].role === 'user') {
            curatedHistory.push(comprehensiveHistory[i]);
            i++;
        }
        else {
            const modelOutput = [];
            let isValid = true;
            while (i < length && comprehensiveHistory[i].role === 'model') {
                modelOutput.push(comprehensiveHistory[i]);
                if (isValid && !isValidContent(comprehensiveHistory[i])) {
                    isValid = false;
                }
                i++;
            }
            if (isValid) {
                curatedHistory.push(...modelOutput);
            }
        }
    }
    return curatedHistory;
}
/**
 * Custom error to signal that a stream completed without valid content,
 * which should trigger a retry.
 */
export class EmptyStreamError extends Error {
    constructor(message) {
        super(message);
        this.name = 'EmptyStreamError';
    }
}
/**
 * Chat session that enables sending messages to the model with previous
 * conversation context.
 *
 * @remarks
 * The session maintains all the turns between user and model.
 */
export class GeminiChat {
    config;
    contentGenerator;
    generationConfig;
    history;
    // A promise to represent the current state of the message being sent to the
    // model.
    sendPromise = Promise.resolve();
    constructor(config, contentGenerator, generationConfig = {}, history = []) {
        this.config = config;
        this.contentGenerator = contentGenerator;
        this.generationConfig = generationConfig;
        this.history = history;
        validateHistory(history);
    }
    /**
     * Handles falling back to Flash model when persistent 429 errors occur for OAuth users.
     * Uses a fallback handler if provided by the config; otherwise, returns null.
     */
    async handleFlashFallback(authType, error) {
        // Handle different auth types
        if (authType === AuthType.QWEN_OAUTH) {
            return this.handleQwenOAuthError(error);
        }
        // Only handle fallback for OAuth users
        if (authType !== AuthType.LOGIN_WITH_GOOGLE) {
            return null;
        }
        const currentModel = this.config.getModel();
        const fallbackModel = DEFAULT_GEMINI_FLASH_MODEL;
        // Don't fallback if already using Flash model
        if (currentModel === fallbackModel) {
            return null;
        }
        // Check if config has a fallback handler (set by CLI package)
        const fallbackHandler = this.config.flashFallbackHandler;
        if (typeof fallbackHandler === 'function') {
            try {
                const accepted = await fallbackHandler(currentModel, fallbackModel, error);
                if (accepted !== false && accepted !== null) {
                    this.config.setModel(fallbackModel);
                    this.config.setFallbackMode(true);
                    return fallbackModel;
                }
                // Check if the model was switched manually in the handler
                if (this.config.getModel() === fallbackModel) {
                    return null; // Model was switched but don't continue with current prompt
                }
            }
            catch (error) {
                console.warn('Flash fallback handler failed:', error);
            }
        }
        return null;
    }
    setSystemInstruction(sysInstr) {
        this.generationConfig.systemInstruction = sysInstr;
    }
    /**
     * Sends a message to the model and returns the response.
     *
     * @remarks
     * This method will wait for the previous message to be processed before
     * sending the next message.
     *
     * @see {@link Chat#sendMessageStream} for streaming method.
     * @param params - parameters for sending messages within a chat session.
     * @returns The model's response.
     *
     * @example
     * ```ts
     * const chat = ai.chats.create({model: 'gemini-2.0-flash'});
     * const response = await chat.sendMessage({
     * message: 'Why is the sky blue?'
     * });
     * console.log(response.text);
     * ```
     */
    async sendMessage(params, prompt_id) {
        await this.sendPromise;
        const userContent = createUserContent(params.message);
        const requestContents = this.getHistory(true).concat(userContent);
        let response;
        try {
            const apiCall = () => {
                const modelToUse = this.config.getModel() || DEFAULT_GEMINI_FLASH_MODEL;
                // Prevent Flash model calls immediately after quota error
                if (this.config.getQuotaErrorOccurred() &&
                    modelToUse === DEFAULT_GEMINI_FLASH_MODEL) {
                    throw new Error('Please submit a new query to continue with the Flash model.');
                }
                return this.contentGenerator.generateContent({
                    model: modelToUse,
                    contents: requestContents,
                    config: { ...this.generationConfig, ...params.config },
                }, prompt_id);
            };
            response = await retryWithBackoff(apiCall, {
                shouldRetry: (error) => {
                    // Check for known error messages and codes.
                    if (error instanceof Error && error.message) {
                        if (isSchemaDepthError(error.message))
                            return false;
                        if (error.message.includes('429'))
                            return true;
                        if (error.message.match(/5\d{2}/))
                            return true;
                    }
                    return false; // Don't retry other errors by default
                },
                onPersistent429: async (authType, error) => await this.handleFlashFallback(authType, error),
                authType: this.config.getContentGeneratorConfig()?.authType,
            });
            this.sendPromise = (async () => {
                const outputContent = response.candidates?.[0]?.content;
                const modelOutput = outputContent ? [outputContent] : [];
                // Because the AFC input contains the entire curated chat history in
                // addition to the new user input, we need to truncate the AFC history
                // to deduplicate the existing chat history.
                const fullAutomaticFunctionCallingHistory = response.automaticFunctionCallingHistory;
                const index = this.getHistory(true).length;
                let automaticFunctionCallingHistory = [];
                if (fullAutomaticFunctionCallingHistory != null) {
                    automaticFunctionCallingHistory =
                        fullAutomaticFunctionCallingHistory.slice(index) ?? [];
                }
                this.recordHistory(userContent, modelOutput, automaticFunctionCallingHistory);
            })();
            await this.sendPromise.catch((error) => {
                // Resets sendPromise to avoid subsequent calls failing
                this.sendPromise = Promise.resolve();
                // Re-throw the error so the caller knows something went wrong.
                throw error;
            });
            return response;
        }
        catch (error) {
            this.sendPromise = Promise.resolve();
            throw error;
        }
    }
    /**
     * Sends a message to the model and returns the response in chunks.
     *
     * @remarks
     * This method will wait for the previous message to be processed before
     * sending the next message.
     *
     * @see {@link Chat#sendMessage} for non-streaming method.
     * @param params - parameters for sending the message.
     * @return The model's response.
     *
     * @example
     * ```ts
     * const chat = ai.chats.create({model: 'gemini-2.0-flash'});
     * const response = await chat.sendMessageStream({
     * message: 'Why is the sky blue?'
     * });
     * for await (const chunk of response) {
     * console.log(chunk.text);
     * }
     * ```
     */
    async sendMessageStream(params, prompt_id) {
        await this.sendPromise;
        let streamDoneResolver;
        const streamDonePromise = new Promise((resolve) => {
            streamDoneResolver = resolve;
        });
        this.sendPromise = streamDonePromise;
        const userContent = createUserContent(params.message);
        // Add user content to history ONCE before any attempts.
        this.history.push(userContent);
        const requestContents = this.getHistory(true);
        // eslint-disable-next-line @typescript-eslint/no-this-alias
        const self = this;
        return (async function* () {
            try {
                let lastError = new Error('Request failed after all retries.');
                for (let attempt = 0; attempt < INVALID_CONTENT_RETRY_OPTIONS.maxAttempts; attempt++) {
                    try {
                        if (attempt > 0) {
                            yield { type: StreamEventType.RETRY };
                        }
                        const stream = await self.makeApiCallAndProcessStream(requestContents, params, prompt_id, userContent);
                        for await (const chunk of stream) {
                            yield { type: StreamEventType.CHUNK, value: chunk };
                        }
                        lastError = null;
                        break;
                    }
                    catch (error) {
                        lastError = error;
                        const isContentError = error instanceof EmptyStreamError;
                        if (isContentError) {
                            // Check if we have more attempts left.
                            if (attempt < INVALID_CONTENT_RETRY_OPTIONS.maxAttempts - 1) {
                                logContentRetry(self.config, new ContentRetryEvent(attempt, 'EmptyStreamError', INVALID_CONTENT_RETRY_OPTIONS.initialDelayMs));
                                await new Promise((res) => setTimeout(res, INVALID_CONTENT_RETRY_OPTIONS.initialDelayMs *
                                    (attempt + 1)));
                                continue;
                            }
                        }
                        break;
                    }
                }
                if (lastError) {
                    if (lastError instanceof EmptyStreamError) {
                        logContentRetryFailure(self.config, new ContentRetryFailureEvent(INVALID_CONTENT_RETRY_OPTIONS.maxAttempts, 'EmptyStreamError'));
                    }
                    // If the stream fails, remove the user message that was added.
                    if (self.history[self.history.length - 1] === userContent) {
                        self.history.pop();
                    }
                    throw lastError;
                }
            }
            finally {
                streamDoneResolver();
            }
        })();
    }
    async makeApiCallAndProcessStream(requestContents, params, prompt_id, userContent) {
        const apiCall = () => {
            const modelToUse = this.config.getModel();
            if (this.config.getQuotaErrorOccurred() &&
                modelToUse === DEFAULT_GEMINI_FLASH_MODEL) {
                throw new Error('Please submit a new query to continue with the Flash model.');
            }
            return this.contentGenerator.generateContentStream({
                model: modelToUse,
                contents: requestContents,
                config: { ...this.generationConfig, ...params.config },
            }, prompt_id);
        };
        const streamResponse = await retryWithBackoff(apiCall, {
            shouldRetry: (error) => {
                if (error instanceof Error && error.message) {
                    if (isSchemaDepthError(error.message))
                        return false;
                    if (error.message.includes('429'))
                        return true;
                    if (error.message.match(/^5\d{2}/))
                        return true;
                }
                return false;
            },
            onPersistent429: async (authType, error) => await this.handleFlashFallback(authType, error),
            authType: this.config.getContentGeneratorConfig()?.authType,
        });
        return this.processStreamResponse(streamResponse, userContent);
    }
    /**
     * Returns the chat history.
     *
     * @remarks
     * The history is a list of contents alternating between user and model.
     *
     * There are two types of history:
     * - The `curated history` contains only the valid turns between user and
     * model, which will be included in the subsequent requests sent to the model.
     * - The `comprehensive history` contains all turns, including invalid or
     * empty model outputs, providing a complete record of the history.
     *
     * The history is updated after receiving the response from the model,
     * for streaming response, it means receiving the last chunk of the response.
     *
     * The `comprehensive history` is returned by default. To get the `curated
     * history`, set the `curated` parameter to `true`.
     *
     * @param curated - whether to return the curated history or the comprehensive
     * history.
     * @return History contents alternating between user and model for the entire
     * chat session.
     */
    getHistory(curated = false) {
        const history = curated
            ? extractCuratedHistory(this.history)
            : this.history;
        // Deep copy the history to avoid mutating the history outside of the
        // chat session.
        return structuredClone(history);
    }
    /**
     * Clears the chat history.
     */
    clearHistory() {
        this.history = [];
    }
    /**
     * Adds a new entry to the chat history.
     */
    addHistory(content) {
        this.history.push(content);
    }
    setHistory(history) {
        this.history = history;
    }
    setTools(tools) {
        this.generationConfig.tools = tools;
    }
    async maybeIncludeSchemaDepthContext(error) {
        // Check for potentially problematic cyclic tools with cyclic schemas
        // and include a recommendation to remove potentially problematic tools.
        if (isSchemaDepthError(error.message) ||
            isInvalidArgumentError(error.message)) {
            const tools = this.config.getToolRegistry().getAllTools();
            const cyclicSchemaTools = [];
            for (const tool of tools) {
                if ((tool.schema.parametersJsonSchema &&
                    hasCycleInSchema(tool.schema.parametersJsonSchema)) ||
                    (tool.schema.parameters && hasCycleInSchema(tool.schema.parameters))) {
                    cyclicSchemaTools.push(tool.displayName);
                }
            }
            if (cyclicSchemaTools.length > 0) {
                const extraDetails = `\n\nThis error was probably caused by cyclic schema references in one of the following tools, try disabling them with excludeTools:\n\n - ` +
                    cyclicSchemaTools.join(`\n - `) +
                    `\n`;
                error.message += extraDetails;
            }
        }
    }
    async *processStreamResponse(streamResponse, userInput) {
        const modelResponseParts = [];
        let hasReceivedAnyChunk = false;
        let hasReceivedValidChunk = false;
        let hasToolCall = false;
        let lastChunk = null;
        let lastChunkIsInvalid = false;
        for await (const chunk of streamResponse) {
            hasReceivedAnyChunk = true;
            lastChunk = chunk;
            if (isValidResponse(chunk)) {
                hasReceivedValidChunk = true;
                lastChunkIsInvalid = false;
                const content = chunk.candidates?.[0]?.content;
                if (content?.parts) {
                    modelResponseParts.push(...content.parts);
                    if (content.parts.some((part) => part.functionCall)) {
                        hasToolCall = true;
                    }
                }
            }
            else {
                logInvalidChunk(this.config, new InvalidChunkEvent('Invalid chunk received from stream.'));
                lastChunkIsInvalid = true;
            }
            yield chunk;
        }
        if (!hasReceivedAnyChunk) {
            throw new EmptyStreamError('Model stream completed without any chunks.');
        }
        const hasFinishReason = lastChunk?.candidates?.some((candidate) => candidate.finishReason);
        // Stream validation logic: A stream is considered successful if:
        // 1. There's a tool call (tool calls can end without explicit finish reasons), OR
        // 2. There's a finish reason AND the last chunk is valid (or we haven't received any valid chunks)
        //
        // We throw an error only when there's no tool call AND:
        // - No finish reason, OR
        // - Last chunk is invalid after receiving valid content
        if (!hasToolCall &&
            (!hasFinishReason || (lastChunkIsInvalid && !hasReceivedValidChunk))) {
            throw new EmptyStreamError('Model stream ended with an invalid chunk or missing finish reason.');
        }
        // Bundle all streamed parts into a single Content object
        const modelOutput = modelResponseParts.length > 0
            ? [{ role: 'model', parts: modelResponseParts }]
            : [];
        // Pass the raw, bundled data to the new, robust recordHistory
        this.recordHistory(userInput, modelOutput);
    }
    recordHistory(userInput, modelOutput, automaticFunctionCallingHistory) {
        // Part 1: Handle the user's turn.
        if (automaticFunctionCallingHistory &&
            automaticFunctionCallingHistory.length > 0) {
            this.history.push(...extractCuratedHistory(automaticFunctionCallingHistory));
        }
        else {
            if (this.history.length === 0 ||
                this.history[this.history.length - 1] !== userInput) {
                const lastTurn = this.history[this.history.length - 1];
                // The only time we don't push is if it's the *exact same* object,
                // which happens in streaming where we add it preemptively.
                if (lastTurn !== userInput) {
                    if (lastTurn?.role === 'user') {
                        // This is an invalid sequence.
                        throw new Error('Cannot add a user turn after another user turn.');
                    }
                    this.history.push(userInput);
                }
            }
        }
        // Part 2: Process the model output into a final, consolidated list of turns.
        const finalModelTurns = [];
        for (const content of modelOutput) {
            // A. Preserve malformed content that has no 'parts' array.
            if (!content.parts) {
                finalModelTurns.push(content);
                continue;
            }
            // B. Filter out 'thought' parts.
            const visibleParts = content.parts.filter((part) => !part.thought);
            const newTurn = { ...content, parts: visibleParts };
            const lastTurnInFinal = finalModelTurns[finalModelTurns.length - 1];
            // Consolidate this new turn with the PREVIOUS turn if they are adjacent model turns.
            if (lastTurnInFinal &&
                lastTurnInFinal.role === 'model' &&
                newTurn.role === 'model' &&
                lastTurnInFinal.parts && // SAFETY CHECK: Ensure the destination has a parts array.
                newTurn.parts) {
                lastTurnInFinal.parts.push(...newTurn.parts);
            }
            else {
                finalModelTurns.push(newTurn);
            }
        }
        // Part 3: Add the processed model turns to the history, with one final consolidation pass.
        if (finalModelTurns.length > 0) {
            // Re-consolidate parts within any turns that were merged in the previous step.
            for (const turn of finalModelTurns) {
                if (turn.parts && turn.parts.length > 1) {
                    const consolidatedParts = [];
                    for (const part of turn.parts) {
                        const lastPart = consolidatedParts[consolidatedParts.length - 1];
                        if (lastPart &&
                            // Ensure lastPart is a pure text part
                            typeof lastPart.text === 'string' &&
                            !lastPart.functionCall &&
                            !lastPart.functionResponse &&
                            !lastPart.inlineData &&
                            !lastPart.fileData &&
                            !lastPart.thought &&
                            // Ensure current part is a pure text part
                            typeof part.text === 'string' &&
                            !part.functionCall &&
                            !part.functionResponse &&
                            !part.inlineData &&
                            !part.fileData &&
                            !part.thought) {
                            lastPart.text += part.text;
                        }
                        else {
                            consolidatedParts.push({ ...part });
                        }
                    }
                    turn.parts = consolidatedParts;
                }
            }
            this.history.push(...finalModelTurns);
        }
        else {
            // If, after all processing, there's NO model output, add the placeholder.
            this.history.push({ role: 'model', parts: [] });
        }
    }
    /**
     * Handles Qwen OAuth authentication errors and rate limiting
     */
    async handleQwenOAuthError(error) {
        if (!error) {
            return null;
        }
        const errorMessage = error instanceof Error
            ? error.message.toLowerCase()
            : String(error).toLowerCase();
        const errorCode = error?.status ||
            error?.code;
        // Check if this is an authentication/authorization error
        const isAuthError = errorCode === 401 ||
            errorCode === 403 ||
            errorMessage.includes('unauthorized') ||
            errorMessage.includes('forbidden') ||
            errorMessage.includes('invalid api key') ||
            errorMessage.includes('authentication') ||
            errorMessage.includes('access denied') ||
            (errorMessage.includes('token') && errorMessage.includes('expired'));
        // Check if this is a rate limiting error
        const isRateLimitError = errorCode === 429 ||
            errorMessage.includes('429') ||
            errorMessage.includes('rate limit') ||
            errorMessage.includes('too many requests');
        if (isAuthError) {
            console.warn('Qwen OAuth authentication error detected:', errorMessage);
            // The QwenContentGenerator should automatically handle token refresh
            // If it still fails, it likely means the refresh token is also expired
            console.log('Note: If this persists, you may need to re-authenticate with Qwen OAuth');
            return null;
        }
        if (isRateLimitError) {
            console.warn('Qwen API rate limit encountered:', errorMessage);
            // For rate limiting, we don't need to do anything special
            // The retry mechanism will handle the backoff
            return null;
        }
        // For other errors, don't handle them specially
        return null;
    }
}
/** Visible for Testing */
export function isSchemaDepthError(errorMessage) {
    return errorMessage.includes('maximum schema depth exceeded');
}
export function isInvalidArgumentError(errorMessage) {
    return errorMessage.includes('Request contains an invalid argument');
}
//# sourceMappingURL=geminiChat.js.map