/**
 * OpenAI adapter (chat completions API) for the Vvveb AI pipeline.
 *
 * Handles: plain text, reasoning ("reasoning_content"/"reasoning" deltas as
 * returned by o-series / DeepSeek-R1 style models), function/tool calls
 * (incrementally accumulated from stream deltas), images, PDFs and other
 * files (as data urls), usage stats and provider error shapes.
 *
 * Also registers the OpenAI-compatible providers (DeepSeek, Groq, Mistral,
 * xAI, OpenRouter) which all speak the same chat completions dialect.
 *
 * Backward compatibility with legacy aiAssistantOptions
 * - the saved `url` setting is treated as an OpenAI override only: a legacy
 *   /v1/completions value is rewritten to /v1/chat/completions;
 * - legacy instruct-only models (gpt-3.5-turbo-instruct...) are mapped to a
 *   chat capable default model;
 * - other providers always use their own endpoint unless the saved url points
 *   at the same host (an explicit per-provider override).
 */
(function (window) {
	"use strict";

	const Ai = window.VvvebAi;

	const OPENAI_URL = 'https://api.openai.com/v1/chat/completions';
	const LEGACY_COMPLETIONS = /\/v1\/completions\/?$/;
	const LEGACY_MODELS = /(^|-)(gpt-3(\.\d+)?-turbo-(instruct|text)|davinci|curie|babbage|ada)(-|$)/i;
	// reasoning models reject max_tokens: o-series takes max_completion_tokens
	// and no custom temperature; gpt-5 takes max_completion_tokens. The
	// Responses API takes max_output_tokens. Sending max_tokens to any of
	// these is silently ignored -> provider default output cap applies
	// (often 16k tokens ~= 65KB, the classic "response got cut" symptom).
	const O_SERIES = /(^|-)o[134](-|$)/i;
	const GPT5 = /(^|-)gpt-5([.-]|$)/i;

	function normalizeUrl(url) {
		if (!url) return OPENAI_URL;
		if (LEGACY_COMPLETIONS.test(url)) return url.replace(LEGACY_COMPLETIONS, '/v1/chat/completions');
		return url;
	}

	function normalizeModel(model, defaultModel) {
		if (!model || LEGACY_MODELS.test(model)) return defaultModel;
		return model;
	}

	function contentParts(blocks) {
		let parts = [];
		let block = {};
		for (const b of blocks || []) {
			//make sure no duplicate text is added
			if (parts && parts.length && parts[parts.length -1] && 
				parts[parts.length -1].hasOwnProperty('text') &&
				b.hasOwnProperty('text') &&
				(parts[parts.length -1].text === b.text || 
				parts[parts.length -1].text === ('[thinking] ' + b.text))
				) {
					continue;
			}
			switch (b.type) {
				case 'text':
					parts.push({ type: 'text', text: b.text });
					break;
				case 'thinking':
					block = { reasoning_content:  b.text };
					//parts.push({ type: 'text', text: '[thinking] ' + b.text });
					break;
				case 'image':
					parts.push({ type: 'image_url', image_url: { url: b.url || b.dataUrl } });
					break;
				case 'file':
				case 'audio':
					parts.push({ type: 'text', text: '[attachment] ' + (b.filename || b.mediaType || 'file') });
					parts.push({ type: 'file', file: { filename: b.filename || 'attachment', file_data: (b.dataUrl ? (Ai.dataUrlParts(b.dataUrl)[1] || b.dataUrl) : '') } });
					break;
			}
		}
		return [block, parts];
	}

	function buildResponsesInput(payload){
		// Responses API expects `input` to contain full conversation when custom tools are used.
		// payload.messages already = history + new user (built by Ai.buildPayload). Convert to Responses input.
		let msgs = payload.messages || [];
		if (!msgs.length && payload.input != null) return String(payload.input);
		// If single user message with input, just use input string (preserves simple case)
		if (msgs.length === 1 && msgs[0].role === 'user' && payload.input != null && Ai.textOf(msgs[0]) === String(payload.input)) return String(payload.input);
		// Build array input preserving history + tool results for faithful replay after network errors
		let out = [];
		for (let m of msgs){
			if (m.role === 'system') continue; // instructions handled separately
			if (m.role === 'tool'){
				// tool output → user message for Responses API
				out.push({role: 'user', content: '[tool output for '+m.name+' id='+ (m.toolCallId||'') +'] ' + Ai.textOf(m)});
			} else if (m.role === 'assistant'){
				let txt = Ai.textOf(m);
				if (m.content && m.content.some(b=>b.type==='tool_call')){
					// assistant with tool calls → keep tool calls as separate items? For input, embed as text
					let tcs = m.content.filter(b=>b.type==='tool_call').map(b=> b.name+':'+JSON.stringify(b.arguments||{})).join(' ');
					if (tcs) txt = (txt ? txt + ' ' : '') + '[tool_calls: '+tcs+']';
				}
				if (txt) out.push({role: 'assistant', content: txt});
			} else {
				let txt = Ai.textOf(m);
				if (txt) out.push({role: 'user', content: txt});
			}
		}
		// Responses API accepts string or array; use array to preserve roles after errors
		return out.length ? out : String(payload.input || '');
	}

	function buildMessages(messages, payload) {
		const out = [];
		for (const m of messages || []) {
			if (m.role === 'system') {
				out.push({ role: 'system', content: Ai.textOf(m) });
				continue;
			}
			if (m.role === 'tool') {
				let isCustomTool = false;
				try {
					let tools = payload && payload.tools ? payload.tools : [];
					let def = tools.find(t => t.name===m.name || (t.function && t.function.name===m.name));
					if (def && (def.type||'').toLowerCase()==='custom') isCustomTool = true;
					if (!isCustomTool && m.name==='code_exec' && tools.some(t=> (t.type||'').toLowerCase()==='custom')) isCustomTool = true;
				} catch(e){}
				if (isCustomTool && payload && payload.input !== undefined){
					out.push({ role: 'user', content: [{ type: 'text', text: '[tool output for '+m.name+' id='+m.toolCallId+'] '+Ai.textOf(m) }] });
				} else {
					out.push({ role: 'tool', tool_call_id: m.toolCallId, name: m.name, content: Ai.textOf(m) });
				}
				continue;
			}

			const [block, parts] = contentParts(m.content);
			if (!parts) parts = null;
			let msg = { role: m.role === 'assistant' ? 'assistant' : 'user', content: parts};
			if (block)  {
				msg = {...msg, ...block};
			}
			
			const toolCalls = (m.content || [])
				.filter(b => b.type === 'tool_call')
				.map(b => {
					let isCustom = false;
					try {
						let tools = (typeof payload !== 'undefined' && payload.tools) ? payload.tools : (window.VvvebAi && VvvebAi._lastPayloadTools) || [];
						let def = tools.find(t => (t.name===b.name) || (t.function && t.function.name===b.name));
						if (def && (def.type||'').toLowerCase()==='custom') isCustom = true;
						if (b.type ==='custom') isCustom = true;
						if (!isCustom && b.name==='code_exec' && tools.some(t=> (t.type||'').toLowerCase()==='custom')) isCustom = true;
					} catch(e){}
					if (isCustom){
						return {
							id: b.id || ('call_' + Math.random().toString(36).slice(2)),
							type: 'custom',
							custom: { name: b.name, input: typeof b.arguments === 'string' ? b.arguments : JSON.stringify(b.arguments || {}) }
						};
					}
					return {
						id: b.id || ('call_' + Math.random().toString(36).slice(2)),
						type: 'function',
						function: { name: b.name, arguments: JSON.stringify(b.arguments || {}) }
					};
				});
			if (toolCalls.length) {
				msg.tool_calls = toolCalls;
				if (!msg.content.length) msg.content = null;
			}
			out.push(msg);
		}
		return out;
	}

	function buildRequestFor(defaultUrl, defaultModel, legacyRewrite) {
		const host = (() => { try { return new URL(defaultUrl).host; } catch (e) { return ''; } })();

		return function (payload, options) {
			options = options || {};
			const hasCustom = payload.tools && payload.tools.some(t => (t.type||'').toLowerCase()==='custom');
			const isOpenAI = defaultUrl === OPENAI_URL;
			const useResponsesApi = isOpenAI && hasCustom && payload.input != null && String(payload.input).trim() !== '';
			// store last tools for buildMessages custom detection
			try { window.VvvebAi._lastPayloadTools = payload.tools || []; } catch(e){}
			const modelName = normalizeModel(payload.model || options.model, defaultModel);
			const maxOut = parseInt(payload.maxTokens || options.max_tokens, 10) || 110592;
			const body = {
				model: modelName,
				...(useResponsesApi ? {input: buildResponsesInput(payload)} : {messages: buildMessages(payload.messages, payload)}),
				stream: !!payload.stream
			};
			// output-limit param must match the endpoint/model or it is ignored
			if (useResponsesApi) body.max_output_tokens = maxOut;
			else if (O_SERIES.test(modelName) || GPT5.test(modelName)) body.max_completion_tokens = maxOut;
			else body.max_tokens = maxOut;
			// o-series rejects non-default temperature - omit it there
			if (!O_SERIES.test(modelName)) body.temperature = Ai.numOr(payload.temperature, options.temperature, 0);
			if (payload.stream) body.stream_options = { include_usage: true };
			if (payload.system) {
				if (useResponsesApi) body.instructions = String(payload.system);
				else if (!body.messages) body.messages = [{ role: 'system', content: String(payload.system) }];
				else body.messages.unshift({ role: 'system', content: String(payload.system) });
			}
			if (payload.tools && payload.tools.length) {
				let hasCustom2 = payload.tools.some(t => (t.type||'').toLowerCase() === 'custom');
				if (hasCustom2) {
					if (useResponsesApi) {
						body.tools = payload.tools.map(t => {
							if ((t.type||'').toLowerCase() === 'custom') return { type: 'custom', name: t.name, description: t.description || '', parameters: t.parameters || undefined };
							return { type: t.type || 'function', function: { name: t?.function?.name || t.name, description: t?.function?.description || t?.description || '', parameters: t?.function?.parameters || t?.parameters || null } };
						});
					} else {
						// OpenAI-compatible providers via /chat/completions don't support type:custom → map to function
						body.tools = payload.tools.map(t => ({
							type: 'function',
							function: {
								name: t?.function?.name || t.name,
								description: t?.function?.description || t?.description || '',
								parameters: t?.function?.parameters || t?.parameters || { type: 'object', properties: { code: {type:'string', description:'Code to execute'} } }
							}
						}));
					}
					body.tool_choice = 'auto';
				} else {
					body.tools = payload.tools.map(t => ({
						type: t.type || 'function',
						function: { name: t?.function?.name || t.name, description: t?.function?.description || t?.description || '', parameters: t?.function?.parameters || t?.parameters || null }
					}));
					body.tool_choice = 'auto';
				}
			}
			if (payload.thinking && payload.thinking.budgetTokens) body.reasoning_effort = payload.thinking.effort || 'medium';
			// keep input sync for history/tool-continuation (Responses API keeps input, chat keeps messages)
			if (!useResponsesApi && payload.input != null && String(payload.input).trim() !== '' && body.messages && body.messages.length) {
				// ensure last user message reflects input if provided (for custom tools fallback to chat)
			}

			const headers = {};
			if (!options.omitAuth && options.key) headers['Authorization'] = 'Bearer ' + options.key;

			// the shared saved url belongs to openai; other providers use their
			// own endpoint unless the saved url explicitly points at their host
			let url = defaultUrl;
			if (useResponsesApi) {
				url = OPENAI_URL.replace('/chat/completions', '/responses');
				if (body.stream_options) { delete body.stream_options; body.stream = !!payload.stream; }
			}
			if (options.url) {
				try {
					if (new URL(options.url).host === host) {
						// don't override Responses API url with legacy chat url
						if (!useResponsesApi) url = legacyRewrite ? normalizeUrl(options.url) : options.url;
					}
				} catch (e) { /* malformed saved url -> keep default */ }
			}

			return { url, headers, body };
		};
	}

	function parseResponse(data) {
		let text = '';
		let thinking = '';
		let extraCalls = [];
		let choice = null;
		let msg = null;
		if (data.output && Array.isArray(data.output)){
			for (let item of data.output){
				if (item.type === 'text' || item.type === 'output_text') text += (item.text || item.content || '');
				else if (item.type === 'reasoning' || item.type === 'thinking') thinking += (item.text || item.content || '');
				else if (item.type === 'custom_tool_call' || item.type === 'tool_call' || item.type === 'custom'){
					extraCalls.push({ id: item.id || item.call_id || ('call_'+Math.random().toString(36).slice(2)), name: item.name || item.tool_name, arguments: typeof item.input === 'string' ? Ai.safeJsonParse(item.input, {code: item.input}) : (item.arguments || item.input || {}) });
				}
				else if (item.type === 'message' && item.content){
					for (let c of item.content) if (c.type === 'output_text' || c.type === 'text') text += c.text || '';
				}
			}
			if (!text && data.output_text) text = String(data.output_text);
		} else {
			choice = (data.choices && data.choices[0]) || {};
			msg = choice.message || {};
			if (typeof msg.content === 'string') text = msg.content;
			else if (Array.isArray(msg.content)) text = msg.content.filter(p => p.type === 'text').map(p => p.text).join('\n');
			if (typeof msg.reasoning_content === 'string') thinking = msg.reasoning_content;
			else if (msg.reasoning && typeof msg.reasoning === 'object' && typeof msg.reasoning.text === 'string') thinking = msg.reasoning.text;
			else if (typeof msg.reasoning === 'string') thinking = msg.reasoning;
		}
		if (!extraCalls.length) {
			let moreCalls = [];
			if (text && (/<(tool_calls|tool_call|invoke|function)/i.test(text) || /```(?:js|javascript)?\n[\s\S]*?```/.test(text))){
				moreCalls = moreCalls.concat(Ai.extractToolCallsFromText ? Ai.extractToolCallsFromText(text) : []);
				let cleaned = Ai.stripToolCallTags ? Ai.stripToolCallTags(text) : text;
				if (Ai.extractToolCallsFromText && Ai.extractToolCallsFromText(text).length) cleaned = cleaned.replace(/```(?:js|javascript)?\n[\s\S]*?```/gi, '').trim();
				text = cleaned;
			}
			if (thinking && (/<(tool_calls|tool_call|invoke|function)/i.test(thinking) || /\[thinking|\[think|<think/i.test(thinking))){
				moreCalls = moreCalls.concat(Ai.extractToolCallsFromText ? Ai.extractToolCallsFromText(thinking) : []);
				if (Ai.stripToolCallTags) thinking = Ai.stripToolCallTags(thinking);
				if (Ai.stripThinkingTags) thinking = Ai.stripThinkingTags(thinking);
			}
			extraCalls = extraCalls.concat(moreCalls);
		}
		const toolCalls = (msg && msg.tool_calls ? msg.tool_calls.map(tc => ({
			id: tc.id || ('call_' + Math.random().toString(36).slice(2)),
			name: tc.function && tc.function.name,
			arguments: Ai.safeJsonParse(tc.function && tc.function.arguments, {})
		})) : []).concat(extraCalls);
		return {
			text, thinking, toolCalls,
			usage: {
				inputTokens: data.usage && (data.usage.prompt_tokens || data.usage.input_tokens),
				outputTokens: data.usage && (data.usage.completion_tokens || data.usage.output_tokens),
				cachedTokens: data.usage && (data.usage.cached_tokens ?? data.usage.prompt_tokens_details?.cached_tokens ?? data.usage.prompt_tokens_details?.cachedTokens ?? 0),
				totalTokens: data.usage && (data.usage.total_tokens || data.usage.totalTokens || ((data.usage.prompt_tokens||data.usage.input_tokens||0)+(data.usage.completion_tokens||data.usage.output_tokens||0)))
			},
			stopReason: choice ? choice.finish_reason : (data.status || null),
			raw: data
		};
	}

	function createStreamParser(emit) {
		const toolAcc = new Map(); // index -> {id, name, arguments, type}
		let text = '', thinking = '', usage = null, stopReason = null;
		let pendingText = '';

		const sse = Ai.createSseParser(function (data) {
			if (data === '[DONE]') return;
			const json = Ai.safeJsonParse(data, null);
			if (!json) return;
			if (json.error) { emit({ type: 'error', error: new Error(Ai.extractError(json)) }); return; }
			if (json.type && String(json.type).startsWith('response.')){
				if (json.type === 'response.output_text.delta' && json.delta){
					let t = json.delta.text || json.delta.delta || '';
					if (t){ text += t; emit({ type: 'text-delta', text: t }); }
				} else if (json.type === 'response.reasoning.delta' && json.delta){
					let t = json.delta.text || '';
					if (t){ thinking += t; emit({ type: 'thinking-delta', text: t }); }
				} else if ((json.type === 'response.custom_tool_call.delta' || json.type === 'response.tool_call.delta') && json.delta){
					let d = json.delta;
					let key = d.call_id || d.id || 'custom:0';
					let acc = toolAcc.get(key) || { id: d.call_id || d.id || key, name: d.name || 'code_exec', arguments: '', type: 'custom' };
					if (d.name) acc.name = d.name;
					if (d.arguments) acc.arguments += typeof d.arguments === 'string' ? d.arguments : JSON.stringify(d.arguments);
					if (d.input) acc.arguments += typeof d.input === 'string' ? d.input : JSON.stringify(d.input);
					toolAcc.set(key, acc);
					emit({ type: 'tool-call-delta', call: { id: acc.id, name: acc.name, arguments: acc.arguments, rawArguments: acc.arguments, type: 'custom' } });
				} else if (json.type === 'response.output_item.added' && json.item){
					let it = json.item;
					if (it.type === 'custom_tool_call' || it.type === 'tool_call'){
						let key = it.id || it.call_id || 'custom:'+Math.random().toString(36).slice(2);
						toolAcc.set(key, { id: it.id || key, name: it.name, arguments: typeof it.input === 'string' ? it.input : JSON.stringify(it.input || it.arguments || {}), type: 'custom' });
						emit({ type: 'tool-call', call: { id: it.id || key, name: it.name, arguments: typeof it.input === 'string' ? Ai.safeJsonParse(it.input, {code: it.input}) : (it.input || it.arguments || {}) , type: 'custom'} });
					}
				} else if (json.type === 'response.completed' && json.response){
					let r = json.response;
					if (r.output) {
						for (let o of r.output){
							if (o.type === 'custom_tool_call' || o.type === 'tool_call'){
								let key = o.id || 'custom:'+Math.random().toString(36).slice(2);
								toolAcc.set(key, { id: o.id || key, name: o.name, arguments: typeof o.input === 'string' ? o.input : JSON.stringify(o.input || o.arguments || {}), type: 'custom' });
								emit({ type: 'tool-call', call: { id: o.id || key, name: o.name, arguments: typeof o.input === 'string' ? Ai.safeJsonParse(o.input, {code: o.input}) : (o.input || o.arguments || {}), type: 'custom' } });
							}
						}
					}
					if (r.usage) usage = r.usage;
				}
				return;
			}
			if (json.usage) usage = json.usage;

			for (const choice of json.choices || []) {
				if (choice.finish_reason) stopReason = choice.finish_reason;
				const delta = choice.delta || {};

				if (delta.content) {
					let raw = delta.content;
					pendingText += raw;
					let hasCompleteBlock = /```(?:js|javascript)?\n[\s\S]*?```/.test(pendingText) || /<(tool_calls|tool_call|invoke)[\s\S]*?<\/(tool_calls|tool_call|invoke)>/i.test(pendingText);
					let isPartialBlock = pendingText.includes('```') && !hasCompleteBlock;
					let isPartialTag = /<(tool_calls|tool_call|invoke)[^>]*$/i.test(pendingText.trim()) && !hasCompleteBlock;
					if (isPartialBlock || isPartialTag){} else {
						let toProcess = pendingText; pendingText = '';
						let hasXml = Ai.stripToolCallTags && /<(tool_calls|tool_call|invoke)/i.test(toProcess);
						let hasMdExec = Ai.extractToolCallsFromText && Ai.extractToolCallsFromText(toProcess).length;
						if (hasXml || hasMdExec){
							let extracted = Ai.extractToolCallsFromText ? Ai.extractToolCallsFromText(toProcess) : [];
							for (let call of extracted){ let key = 'xml:' + call.id; toolAcc.set(key, { id: call.id, name: call.name, arguments: JSON.stringify(call.arguments || {}), type: 'function' }); emit({ type: 'tool-call', call }); }
							let cleaned = Ai.stripToolCallTags ? Ai.stripToolCallTags(toProcess) : toProcess;
							if (extracted.length) cleaned = cleaned.replace(/```(?:js|javascript)?\n[\s\S]*?```/gi, '').trim();
							if (cleaned && cleaned.trim()){ text += cleaned; emit({ type: 'text-delta', text: cleaned }); }
						} else {
							let cleaned = Ai.stripToolCallTags ? Ai.stripToolCallTags(toProcess) : toProcess;
							if (cleaned) { text += cleaned; emit({ type: 'text-delta', text: cleaned }); }
							else { text += toProcess; emit({ type: 'text-delta', text: toProcess }); }
						}
					}
				}

				let thinkRaw = delta.reasoning_content !== undefined ? delta.reasoning_content : delta.reasoning;
				let thinkText = null;
				if (typeof thinkRaw === 'string' && thinkRaw) thinkText = thinkRaw;
				else if (thinkRaw && typeof thinkRaw === 'object' && typeof thinkRaw.text === 'string') thinkText = thinkRaw.text;
				if (thinkText){
					let cleanedThink = thinkText;
					if (Ai.stripToolCallTags) cleanedThink = Ai.stripToolCallTags(cleanedThink);
					if (Ai.stripThinkingTags) cleanedThink = Ai.stripThinkingTags(cleanedThink);
					if (Ai.shouldEmitThinking && !Ai.shouldEmitThinking('openai', cleanedThink || thinkText)) {} else if (cleanedThink !== thinkText){
						let extracted = Ai.extractToolCallsFromText ? Ai.extractToolCallsFromText(thinkText) : [];
						for (let call of extracted){ let key = 'xml-think:' + call.id; toolAcc.set(key, { id: call.id, name: call.name, arguments: JSON.stringify(call.arguments || {}), type: 'function' }); emit({ type: 'tool-call', call }); }
						if (cleanedThink && cleanedThink.trim()){ let capped = Ai.capThinking ? Ai.capThinking(thinking, cleanedThink) : cleanedThink; if (capped !== null){ thinking += capped; emit({ type: 'thinking-delta', text: capped }); } }
					} else {
						let capped = Ai.capThinking ? Ai.capThinking(thinking, thinkText) : thinkText;
						if (capped !== null){ thinking += capped; emit({ type: 'thinking-delta', text: capped }); }
					}
				}

				for (const tc of delta.tool_calls || []) {
					const key = (choice.index ?? 0) + ':' + (tc.index ?? 0);
					const acc = toolAcc.get(key) || { id: null, name: '', arguments: '', type: tc.type || 'function' };
					if (tc.id) acc.id = tc.id;
					if (tc.type) acc.type = tc.type;
					if (tc.function) {
						if (tc.function.name) acc.name += tc.function.name;
						if (tc.function.arguments) acc.arguments += tc.function.arguments;
					}
					if (tc.custom) {
						if (tc.custom.name) acc.name = tc.custom.name;
						let inp = tc.custom.input || tc.custom.arguments || '';
						if (typeof inp === 'object') inp = JSON.stringify(inp);
						acc.arguments += inp;
						acc.type = 'custom';
					}
					if (tc.name && !acc.name) acc.name = tc.name;
					toolAcc.set(key, acc);
					emit({ type: 'tool-call-delta', call: { id: acc.id || ('call_' + key), name: acc.name, arguments: acc.arguments, rawArguments: acc.arguments, index: key, type: acc.type } });
				}
			}
		});

			const finishedCalls = () => [...toolAcc.entries()]
				.sort((a, b) => String(a[0]).localeCompare(String(b[0])))
				.map(([i, c]) => {
					let args = c.arguments || '{}';
					let parsed = Ai.safeJsonParse(args, null);
					if (parsed === null) parsed = { input: args, code: args };
					if (c.type === 'custom') return { id: c.id || ('call_' + i), name: c.name, arguments: parsed, type: 'custom' };
					return { id: c.id || ('call_' + i), name: c.name, arguments: parsed };
				});

			return {
				feed: sse.feed,
				end() {
					sse.end();
					if (pendingText){
						let extra = Ai.extractToolCallsFromText ? Ai.extractToolCallsFromText(pendingText) : [];
						for (let call of extra){ let key = 'xml-pending:' + call.id; toolAcc.set(key, { id: call.id, name: call.name, arguments: JSON.stringify(call.arguments || {}), type: 'function' }); emit({ type: 'tool-call', call }); }
						let cleaned = Ai.stripToolCallTags ? Ai.stripToolCallTags(pendingText) : pendingText;
						if (extra.length) cleaned = cleaned.replace(/```(?:js|javascript)?\n[\s\S]*?```/gi, '').trim();
						if (cleaned && cleaned.trim()){ text += cleaned; emit({ type: 'text-delta', text: cleaned }); }
						pendingText = '';
					}
					if (toolAcc.size) { const calls = finishedCalls(); for (const c of calls) emit({ type: 'tool-call', call: c }); }
				},
				getResult() {
					let finalCalls = finishedCalls();
					let extraFromText = [];
					if (text && Ai.extractToolCallsFromText){
						extraFromText = Ai.extractToolCallsFromText(text).filter(c => !finalCalls.some(f => f.arguments && f.arguments.code === c.arguments.code));
						if (extraFromText.length){ let cleaned = text.replace(/```(?:js|javascript)?\n[\s\S]*?```/gi, '').trim(); if (cleaned !== text) text = cleaned; }
					}
					finalCalls = finalCalls.concat(extraFromText);
					return {
						text, thinking, toolCalls: finalCalls,
						usage: {
							inputTokens: usage && (usage.prompt_tokens || usage.input_tokens),
							outputTokens: usage && (usage.completion_tokens || usage.output_tokens),
							cachedTokens: usage && (usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? 0),
							totalTokens: usage && (usage.total_tokens || ((usage.prompt_tokens||usage.input_tokens||0)+(usage.completion_tokens||usage.output_tokens||0)))
						},
						stopReason,
						raw: null
					};
				}
			};
	}

	/**
	 * Registers an OpenAI chat-completions compatible provider.
	 */
	function registerOpenAiCompatible(name, label, defaultUrl, defaultModel, extraHeaders, legacyRewrite) {
		Ai.registerProvider(name, {
			name,
			label,
			defaultModel,
			buildRequest(payload, options) {
				const req = buildRequestFor(defaultUrl, defaultModel, legacyRewrite)(payload, options);
				if (extraHeaders && !options.omitAuth) Object.assign(req.headers, extraHeaders(payload, options));
				return req;
			},
			parseResponse,
			createStreamParser,
			normalizeError: Ai.extractError
		});
	}

	registerOpenAiCompatible('openai', 'OpenAI', OPENAI_URL, 'gpt-4o-mini', null, true);
	registerOpenAiCompatible('deepseek', 'DeepSeek', 'https://api.deepseek.com/v1/chat/completions', 'deepseek-chat');
	registerOpenAiCompatible('groq', 'Groq', 'https://api.groq.com/openai/v1/chat/completions', 'llama-3.3-70b-versatile');
	registerOpenAiCompatible('mistral', 'Mistral', 'https://api.mistral.ai/v1/chat/completions', 'mistral-large-latest');
	registerOpenAiCompatible('xai', 'xAI Grok', 'https://api.x.ai/v1/chat/completions', 'grok-3-latest');
	registerOpenAiCompatible('openrouter', 'OpenRouter', 'https://openrouter.ai/api/v1/chat/completions', 'openai/gpt-4o-mini',
		// attribution headers recommended by openrouter
		(payload, options) => ({ 'HTTP-Referer': options.referer || (window.location && window.location.origin) || '', 'X-Title': 'Vvveb CMS' }));
	registerOpenAiCompatible('opencode', 'OpenCode Zen', 'https://opencode.ai/zen/v1/chat/completions', 'qwen3-coder');
	registerOpenAiCompatible('nim', 'NVIDIA NIM', 'https://integrate.api.nvidia.com/v1/chat/completions', 'meta/llama-3.3-70b-instruct');
})(window);
