diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/AGENTS.md b/AGENTS.md index 11876f7..1dcb02d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,6 @@ This file provides guidelines for codex agents contributing to the Sortana proje - `options/`: The options page HTML, JavaScript and bundled Bulma CSS (v1.0.3). - `details.html` and `details.js`: View AI reasoning and clear cache for a message. - `resources/`: Images and other static files. -- `prompt_templates/`: Prompt template files for the AI service (openai, qwen, mistral, harmony). - `build-xpi.ps1`: PowerShell script to package the extension. - `build-xpi.sh`: Bash script to package the extension. - `resources/svg2img.ps1`: PowerShell script to regenerate themed PNG icons from SVGs. @@ -35,10 +34,10 @@ There are currently no automated tests for this project. If you add tests in the ## Endpoint Notes -Sortana targets the `/v1/completions` API. The endpoint value stored in settings is a base URL; the full request URL is constructed by appending `/v1/completions` (adding a slash when needed) and defaulting to `https://` if no scheme is provided. +Sortana targets the OpenAI-compatible `/v1/chat/completions` API. The endpoint value stored in settings is a base URL; the full request URL is constructed by appending `/v1/chat/completions` (adding a slash when needed) and defaulting to `https://` if no scheme is provided. The options page can query `/v1/models` from the same base URL to populate the Model dropdown; selecting **None** omits the `model` field from the request payload. Advanced options allow an optional API key plus `OpenAI-Organization` and `OpenAI-Project` headers; these headers are only sent when values are provided. -Responses are expected to include a JSON object with `match` (or `matched`) plus a short `reason` string; the parser extracts the last JSON object in the response text and ignores any surrounding commentary. +Requests use `messages` with system and user roles. Responses are expected to include text in `choices[0].message.content` containing a JSON object with `match` (or `matched`) plus a short `reason` string; the parser extracts the last JSON object in that content and ignores any surrounding commentary. ## Documentation diff --git a/README.md b/README.md index fe67874..a0625d8 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,12 @@ Sortana is an experimental Thunderbird add-on that integrates an AI-powered filter rule. It allows you to classify email messages by sending their contents to a configurable -HTTP endpoint. Sortana uses the `/v1/completions` API; the options page stores a base -URL and appends `/v1/completions` when sending requests. The endpoint should respond -with JSON indicating whether the message meets a specified criterion, including a -short reasoning summary. -Responses are parsed by extracting the last JSON object in the response text and +HTTP endpoint. Sortana uses the OpenAI-compatible `/v1/chat/completions` API; the options page stores a base +URL and appends `/v1/chat/completions` when sending requests. Requests contain a system +message and a user message. The endpoint should respond +with text in `choices[0].message.content` containing JSON indicating whether the +message meets a specified criterion, including a short reasoning summary. +Responses are parsed by extracting the last JSON object in that content and expecting a `match` (or `matched`) boolean plus a `reason` string. ## Features @@ -16,10 +17,10 @@ expecting a `match` (or `matched`) boolean plus a `reason` string. - **Configurable endpoint** – set the classification service base URL on the options page. - **Model selection** – load available models from the endpoint and choose one (or omit the model field). - **Optional OpenAI auth headers** – provide an API key plus optional organization/project headers when needed. -- **Prompt templates** – choose between OpenAI/ChatML, Qwen, Mistral, Harmony (gpt-oss), or provide your own custom template. -- **Custom system prompts** – tailor the instructions sent to the model for more precise results. +- **Custom system prompts** – tailor the instructions sent as the system message for more precise results. - **Persistent result caching** – classification results and reasoning are saved to disk so messages aren't re-evaluated across restarts. - **Advanced parameters** – tune generation settings like temperature, top‑p and more from the options page. + Provider-specific advanced fields are forwarded as configured and may be rejected by strict OpenAI endpoints. - **Markdown conversion** – optionally convert HTML bodies to Markdown before sending them to the AI service. - **Debug logging** – optional colorized logs help troubleshoot interactions with the AI service. - **Debug tab** – view the last request payload and a diff between the unaltered message text and the final prompt. @@ -83,7 +84,7 @@ Sortana is implemented entirely with documented MailExtension/WebExtension APIs. ## Usage 1. Open the add-on's options and set the base URL of your classification service - (Sortana will append `/v1/completions`). Use the Model dropdown to load + (Sortana will append `/v1/chat/completions`). Use the Model dropdown to load `/v1/models` and select a model or choose **None** to omit the `model` field. Advanced settings include optional API key, organization, and project headers for OpenAI-hosted endpoints. diff --git a/_locales/en-US/messages.json b/_locales/en-US/messages.json index 017e1a3..472956a 100644 --- a/_locales/en-US/messages.json +++ b/_locales/en-US/messages.json @@ -4,16 +4,8 @@ "doesntMatch": { "message": "doesn't match" }, "options.title": { "message": "AI Filter Options" }, "options.endpoint": { "message": "Endpoint" }, - "options.template": { "message": "Prompt template" }, - "options.customTemplate": { "message": "Custom template" }, "options.systemInstructions": { "message": "System instructions" }, "options.reset": { "message": "Reset to default" }, - "options.placeholders": { "message": "Placeholders: {{system}}, {{email}}, {{query}}" }, - "template.openai": { "message": "OpenAI / ChatML" }, - "template.qwen": { "message": "Qwen" }, - "template.mistral": { "message": "Mistral" }, - "template.harmony": { "message": "Harmony (gpt-oss)" }, - "template.custom": { "message": "Custom" }, "options.save": { "message": "Save" }, "options.debugLogging": { "message": "Enable debug logging" }, "options.htmlToMarkdown": { "message": "Convert HTML body to Markdown" }, diff --git a/ai-filter.sln b/ai-filter.sln index 7ded341..52e32fe 100644 --- a/ai-filter.sln +++ b/ai-filter.sln @@ -39,14 +39,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "en-US", "en-US", "{8BEA7793 _locales\en-US\messages.json = _locales\en-US\messages.json EndProjectSection EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "prompt_templates", "prompt_templates", "{86516D53-50D4-4FE2-9D8A-977A8F5EBDBD}" - ProjectSection(SolutionItems) = preProject - prompt_templates\mistral.txt = prompt_templates\mistral.txt - prompt_templates\openai.txt = prompt_templates\openai.txt - prompt_templates\qwen.txt = prompt_templates\qwen.txt - prompt_templates\harmony.txt = prompt_templates\harmony.txt - EndProjectSection -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "resources", "resources", "{68A87938-5C2B-49F5-8AAA-8A34FBBFD854}" ProjectSection(SolutionItems) = preProject resources\svg2img.ps1 = resources\svg2img.ps1 diff --git a/background.js b/background.js index b2efc89..5cfc112 100644 --- a/background.js +++ b/background.js @@ -484,7 +484,7 @@ async function clearCacheForMessages(idsInput) { } try { - const store = await storage.local.get(["endpoint", "model", "apiKey", "openaiOrganization", "openaiProject", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "htmlToMarkdown", "stripUrlParams", "altTextImages", "collapseWhitespace", "tokenReduction", "aiRules", "theme", "showDebugTab"]); + const store = await storage.local.get(["endpoint", "model", "apiKey", "openaiOrganization", "openaiProject", "customSystemPrompt", "aiParams", "debugLogging", "htmlToMarkdown", "stripUrlParams", "altTextImages", "collapseWhitespace", "tokenReduction", "aiRules", "theme", "showDebugTab"]); logger.setDebug(store.debugLogging); await AiClassifier.setConfig(store); userTheme = store.theme || 'auto'; @@ -514,15 +514,13 @@ async function clearCacheForMessages(idsInput) { aiRules = normalizeRules(newRules); logger.aiLog("aiRules updated from storage change", { debug: true }, aiRules); } - if (changes.endpoint || changes.model || changes.apiKey || changes.openaiOrganization || changes.openaiProject || changes.templateName || changes.customTemplate || changes.customSystemPrompt || changes.aiParams || changes.debugLogging) { + if (changes.endpoint || changes.model || changes.apiKey || changes.openaiOrganization || changes.openaiProject || changes.customSystemPrompt || changes.aiParams || changes.debugLogging) { const config = {}; if (changes.endpoint) config.endpoint = changes.endpoint.newValue; if (changes.model) config.model = changes.model.newValue; if (changes.apiKey) config.apiKey = changes.apiKey.newValue; if (changes.openaiOrganization) config.openaiOrganization = changes.openaiOrganization.newValue; if (changes.openaiProject) config.openaiProject = changes.openaiProject.newValue; - if (changes.templateName) config.templateName = changes.templateName.newValue; - if (changes.customTemplate) config.customTemplate = changes.customTemplate.newValue; if (changes.customSystemPrompt) config.customSystemPrompt = changes.customSystemPrompt.newValue; if (changes.aiParams) { config.aiParams = changes.aiParams.newValue; diff --git a/manifest.json b/manifest.json index 5dd0e72..139e60e 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "Sortana", - "version": "2.4.3", + "version": "3.0.0", "default_locale": "en-US", "applications": { "gecko": { diff --git a/modules/AiClassifier.js b/modules/AiClassifier.js index dbbde92..583eec0 100644 --- a/modules/AiClassifier.js +++ b/modules/AiClassifier.js @@ -4,7 +4,7 @@ import { DEFAULT_AI_PARAMS } from "./defaultParams.js"; const storage = (globalThis.messenger ?? globalThis.browser).storage; -const COMPLETIONS_PATH = "/v1/completions"; +const CHAT_COMPLETIONS_PATH = "/v1/chat/completions"; const MODELS_PATH = "/v1/models"; const SYSTEM_PREFIX = `You are an email-classification assistant. @@ -22,10 +22,7 @@ Do not add any other keys, text, or formatting.`; let gEndpointBase = "http://127.0.0.1:5000"; let gEndpoint = buildEndpointUrl(gEndpointBase); -let gTemplateName = "openai"; -let gCustomTemplate = ""; let gCustomSystemPrompt = DEFAULT_CUSTOM_SYSTEM_PROMPT; -let gTemplateText = ""; let gAiParams = Object.assign({}, DEFAULT_AI_PARAMS); let gModel = ""; @@ -44,7 +41,7 @@ function normalizeEndpointBase(endpoint) { if (!base) { return ""; } - base = base.replace(/\/v1\/(completions|models)\/?$/i, ""); + base = base.replace(/\/v1\/(chat\/completions|completions|models)\/?$/i, ""); return base; } @@ -55,7 +52,7 @@ function buildEndpointUrl(endpointBase) { } const withScheme = /^https?:\/\//i.test(base) ? base : `https://${base}`; const needsSlash = withScheme.endsWith("/"); - const path = COMPLETIONS_PATH.replace(/^\//, ""); + const path = CHAT_COMPLETIONS_PATH.replace(/^\//, ""); return `${withScheme}${needsSlash ? "" : "/"}${path}`; } @@ -147,21 +144,6 @@ async function saveCache(updatedKey, updatedValue) { } -async function loadTemplate(name) { - try { - const url = typeof browser !== "undefined" && browser.runtime?.getURL - ? browser.runtime.getURL(`prompt_templates/${name}.txt`) - : `resource://aifilter/prompt_templates/${name}.txt`; - const res = await fetch(url); - if (res.ok) { - return await res.text(); - } - } catch (e) { - aiLog(`Failed to load template '${name}':`, {level: 'error'}, e); - } - return ""; -} - async function setConfig(config = {}) { if (typeof config.endpoint === "string") { const base = normalizeEndpointBase(config.endpoint); @@ -170,12 +152,6 @@ async function setConfig(config = {}) { } gEndpoint = buildEndpointUrl(gEndpointBase); } - if (config.templateName) { - gTemplateName = config.templateName; - } - if (typeof config.customTemplate === "string") { - gCustomTemplate = config.customTemplate; - } if (typeof config.customSystemPrompt === "string") { gCustomSystemPrompt = config.customSystemPrompt; } @@ -201,17 +177,11 @@ async function setConfig(config = {}) { if (typeof config.debugLogging === "boolean") { setDebug(config.debugLogging); } - if (gTemplateName === "custom") { - gTemplateText = gCustomTemplate; - } else { - gTemplateText = await loadTemplate(gTemplateName); - } if (!gEndpoint) { gEndpoint = buildEndpointUrl(gEndpointBase); } aiLog(`[AiClassifier] Endpoint base set to ${gEndpointBase}`, {debug: true}); aiLog(`[AiClassifier] Endpoint set to ${gEndpoint}`, {debug: true}); - aiLog(`[AiClassifier] Template set to ${gTemplateName}`, {debug: true}); } function buildAuthHeaders() { @@ -234,13 +204,7 @@ function buildSystemPrompt() { function buildPrompt(body, criterion) { aiLog(`[AiClassifier] Building prompt with criterion: "${criterion}"`, {debug: true}); - const data = { - system: buildSystemPrompt(), - email: body, - query: criterion, - }; - let template = gTemplateText || ""; - return template.replace(/{{\s*(\w+)\s*}}/g, (m, key) => data[key] || ""); + return `**Email Contents**\n\`\`\`\n${body}\n\`\`\`\nClassification Criterion: ${criterion}`; } function getCachedResult(cacheKey) { @@ -265,7 +229,10 @@ function getReason(cacheKey) { function buildPayload(text, criterion) { let payloadObj = Object.assign({ - prompt: buildPrompt(text, criterion) + messages: [ + { role: "system", content: buildSystemPrompt() }, + { role: "user", content: buildPrompt(text, criterion) } + ] }, gAiParams); if (gModel) { payloadObj.model = gModel; @@ -337,7 +304,11 @@ function extractLastJsonObject(text) { } function parseMatch(result) { - const rawText = result.choices?.[0]?.text || ""; + const rawText = result.choices?.[0]?.message?.content; + if (typeof rawText !== "string") { + reportParseError("Chat response missing text content.", JSON.stringify(result).slice(0, 800)); + return { matched: false, reason: "" }; + } const candidate = extractLastJsonObject(rawText); if (!candidate) { reportParseError("No JSON object found in AI response.", rawText.slice(0, 800)); diff --git a/options/dataTransfer.js b/options/dataTransfer.js index 393b533..2c0b26e 100644 --- a/options/dataTransfer.js +++ b/options/dataTransfer.js @@ -7,8 +7,6 @@ const KEY_GROUPS = { 'apiKey', 'openaiOrganization', 'openaiProject', - 'templateName', - 'customTemplate', 'customSystemPrompt', 'aiParams', 'debugLogging', diff --git a/options/options.html b/options/options.html index cfc370d..cd3d581 100644 --- a/options/options.html +++ b/options/options.html @@ -92,23 +92,6 @@

-
- -
-
- -
-
-
- - -
diff --git a/options/options.js b/options/options.js index 9a61542..2a8d3c4 100644 --- a/options/options.js +++ b/options/options.js @@ -7,8 +7,6 @@ document.addEventListener('DOMContentLoaded', async () => { const { DEFAULT_AI_PARAMS } = await import(browser.runtime.getURL('modules/defaultParams.js')); const defaults = await storage.local.get([ 'endpoint', - 'templateName', - 'customTemplate', 'customSystemPrompt', 'model', 'apiKey', @@ -163,21 +161,47 @@ document.addEventListener('DOMContentLoaded', async () => { async function fetchModels(preferredModel = '') { if (!modelSelect || !refreshModelsBtn) return; const modelsUrl = AiClassifier.buildModelsUrl(endpointInput.value); + const selectedModel = preferredModel || modelSelect.value; if (!modelsUrl) { + logger.aiLog('[options] model refresh skipped: invalid endpoint', { level: 'warn' }, { + endpoint: endpointInput.value + }); setModelHelp('Set a valid endpoint to load models.', true); - populateModelOptions([], preferredModel || modelSelect.value); + populateModelOptions([], selectedModel); return; } refreshModelsBtn.disabled = true; setModelHelp('Loading models...'); + const headers = buildAuthHeaders(); + logger.aiLog('[options] loading models', {}, { + endpoint: endpointInput.value, + modelsUrl, + selectedModel, + hasAuthorization: typeof headers.Authorization === 'string', + hasOrganization: typeof headers['OpenAI-Organization'] === 'string', + hasProject: typeof headers['OpenAI-Project'] === 'string' + }); try { - const response = await fetch(modelsUrl, { method: 'GET', headers: buildAuthHeaders() }); + const response = await fetch(modelsUrl, { method: 'GET', headers }); + logger.aiLog('[options] model refresh response received', {}, { + status: response.status, + ok: response.ok, + contentType: response.headers.get('content-type') || '' + }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); + logger.aiLog('[options] model refresh payload parsed', {}, { + payloadType: Array.isArray(data) ? 'array' : typeof data, + hasDataArray: Array.isArray(data?.data), + hasModelsArray: Array.isArray(data?.models), + topLevelKeys: data && typeof data === 'object' && !Array.isArray(data) + ? Object.keys(data) + : [] + }); let models = []; if (Array.isArray(data?.data)) { models = data.data.map(model => model?.id ?? model?.name ?? model?.model ?? '').filter(Boolean); @@ -187,12 +211,23 @@ document.addEventListener('DOMContentLoaded', async () => { models = data.map(model => model?.id ?? model?.name ?? model?.model ?? model).filter(Boolean); } models = [...new Set(models)]; - populateModelOptions(models, preferredModel || modelSelect.value); + logger.aiLog('[options] model refresh parsed models', {}, { + count: models.length, + models + }); + populateModelOptions(models, selectedModel); setModelHelp(models.length ? `Loaded ${models.length} model${models.length === 1 ? '' : 's'}.` : 'No models returned.'); } catch (e) { logger.aiLog('[options] failed to load models', { level: 'warn' }, e); - setModelHelp('Failed to load models. Check the endpoint and network.', true); - populateModelOptions([], preferredModel || modelSelect.value); + const message = String(e?.message || e || ''); + const isLikelyCors = e instanceof TypeError || /Failed to fetch|NetworkError|Load failed/i.test(message); + setModelHelp( + isLikelyCors + ? 'Failed to load models. The server responded, but Thunderbird may have blocked access to the response. Check CORS on the AI server and verify the endpoint is reachable from the add-on.' + : 'Failed to load models. Check the endpoint, HTTP status, and network.', + true + ); + populateModelOptions([], selectedModel); } finally { refreshModelsBtn.disabled = false; } @@ -200,35 +235,10 @@ document.addEventListener('DOMContentLoaded', async () => { populateModelOptions([], storedModel); refreshModelsBtn?.addEventListener('click', () => { + logger.aiLog('[options] model refresh button clicked'); fetchModels(modelSelect.value); }); - const templates = { - openai: browser.i18n.getMessage('template.openai'), - qwen: browser.i18n.getMessage('template.qwen'), - mistral: browser.i18n.getMessage('template.mistral'), - harmony: browser.i18n.getMessage('template.harmony'), - custom: browser.i18n.getMessage('template.custom') - }; - const templateSelect = document.getElementById('template'); - for (const [value, label] of Object.entries(templates)) { - const opt = document.createElement('option'); - opt.value = value; - opt.textContent = label; - templateSelect.appendChild(opt); - } - templateSelect.value = defaults.templateName || 'openai'; - - const customBox = document.getElementById('custom-template-container'); - const customTemplate = document.getElementById('custom-template'); - customTemplate.value = defaults.customTemplate || ''; - - function updateVisibility() { - customBox.classList.toggle('is-hidden', templateSelect.value !== 'custom'); - } - templateSelect.addEventListener('change', updateVisibility); - updateVisibility(); - const advancedBox = document.getElementById('advanced-options'); const advancedBtn = document.getElementById('toggle-advanced'); advancedBtn.addEventListener('click', () => { @@ -1047,8 +1057,6 @@ document.addEventListener('DOMContentLoaded', async () => { const apiKey = apiKeyInput?.value.trim() || ''; const openaiOrganization = openaiOrgInput?.value.trim() || ''; const openaiProject = openaiProjectInput?.value.trim() || ''; - const templateName = templateSelect.value; - const customTemplateText = customTemplate.value; const customSystemPrompt = systemBox.value; const aiParamsSave = {}; for (const key of Object.keys(DEFAULT_AI_PARAMS)) { @@ -1112,10 +1120,11 @@ document.addEventListener('DOMContentLoaded', async () => { const tokenReduction = tokenReductionToggle.checked; const showDebugTab = debugTabToggle.checked; const theme = themeSelect.value; - await storage.local.set({ endpoint, model, apiKey, openaiOrganization, openaiProject, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging, htmlToMarkdown, stripUrlParams, altTextImages, collapseWhitespace, tokenReduction, aiRules: rules, theme, showDebugTab }); + await storage.local.set({ endpoint, model, apiKey, openaiOrganization, openaiProject, customSystemPrompt, aiParams: aiParamsSave, debugLogging, htmlToMarkdown, stripUrlParams, altTextImages, collapseWhitespace, tokenReduction, aiRules: rules, theme, showDebugTab }); + await storage.local.remove(['templateName', 'customTemplate']); await applyTheme(theme); try { - await AiClassifier.setConfig({ endpoint, model, apiKey, openaiOrganization, openaiProject, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging }); + await AiClassifier.setConfig({ endpoint, model, apiKey, openaiOrganization, openaiProject, customSystemPrompt, aiParams: aiParamsSave, debugLogging }); logger.setDebug(debugLogging); } catch (e) { logger.aiLog('[options] failed to apply config', {level: 'error'}, e); diff --git a/prompt_templates/harmony.txt b/prompt_templates/harmony.txt deleted file mode 100644 index 5054543..0000000 --- a/prompt_templates/harmony.txt +++ /dev/null @@ -1,21 +0,0 @@ -<|start|>system<|message|>You are ChatGPT, a large language model trained by OpenAI. -Knowledge cutoff: 2024-06 -Current date: 2025-06-28 - -Reasoning: medium - -# Valid channels: analysis, commentary, final. Channel must be included for every message.<|end|> -<|start|>developer<|message|># Instructions - -{{system}}<|end|> -<|start|>user<|message|>**Email Contents** -``` -{{email}} -``` -Classification Criterion: {{query}} -Remember, return ONLY a JSON object on a single line of the form: -{"match": true, "reason": ""} - if the email satisfies the criterion -{"match": false, "reason": ""} - otherwise - -Do not add any other keys, text, or formatting.<|end|> -<|start|>assistant diff --git a/prompt_templates/mistral.txt b/prompt_templates/mistral.txt deleted file mode 100644 index 1ee56fa..0000000 --- a/prompt_templates/mistral.txt +++ /dev/null @@ -1,12 +0,0 @@ -[INST] {{system}} - -Email: -{{email}} - -Criterion: {{query}} -Remember, return ONLY a JSON object on a single line of the form: -{"match": true, "reason": ""} - if the email satisfies the criterion -{"match": false, "reason": ""} - otherwise - -Do not add any other keys, text, or formatting. -[/INST] diff --git a/prompt_templates/openai.txt b/prompt_templates/openai.txt deleted file mode 100644 index c770394..0000000 --- a/prompt_templates/openai.txt +++ /dev/null @@ -1,14 +0,0 @@ -<|im_start|>system -{{system}}<|im_end|> -<|im_start|>user -**Email Contents** -``` -{{email}} -``` -Classification Criterion: {{query}} -Remember, return ONLY a JSON object on a single line of the form: -{"match": true, "reason": ""} - if the email satisfies the criterion -{"match": false, "reason": ""} - otherwise - -Do not add any other keys, text, or formatting.<|im_end|> -<|im_start|>assistant diff --git a/prompt_templates/qwen.txt b/prompt_templates/qwen.txt deleted file mode 100644 index 1aa0ef0..0000000 --- a/prompt_templates/qwen.txt +++ /dev/null @@ -1,15 +0,0 @@ -<|im_start|>system -{{system}} -<|im_end|> -<|im_start|>user -Email: -{{email}} - -Criterion: {{query}} -Remember, return ONLY a JSON object on a single line of the form: -{"match": true, "reason": ""} - if the email satisfies the criterion -{"match": false, "reason": ""} - otherwise - -Do not add any other keys, text, or formatting. -<|im_end|> -<|im_start|>assistant