Normalize completions endpoint base

This commit is contained in:
Jordan Wages 2026-01-06 20:45:31 -06:00
commit 0f2f148b71
Notes: Jordan Wages 2026-01-06 21:41:49 -06:00
This should fix #1.
5 changed files with 64 additions and 12 deletions

View file

@ -30,6 +30,10 @@ This file provides guidelines for codex agents contributing to the Sortana proje
There are currently no automated tests for this project. If you add tests in the future, specify the commands to run them here. For now, verification must happen manually in Thunderbird. Do **not** run the `ps1` build script or the SVG processing script. There are currently no automated tests for this project. If you add tests in the future, specify the commands to run them here. For now, verification must happen manually in Thunderbird. Do **not** run the `ps1` build script or the SVG processing script.
## 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.
## Documentation ## Documentation
Additional documentation exists outside this repository. Additional documentation exists outside this repository.
@ -73,4 +77,3 @@ Toolbar and menu icons reside under `resources/img` and are provided in 16, 32
and 64 pixel variants. When changing these icons, pass a dictionary mapping the and 64 pixel variants. When changing these icons, pass a dictionary mapping the
sizes to the paths in `browserAction.setIcon` or `messageDisplayAction.setIcon`. sizes to the paths in `browserAction.setIcon` or `messageDisplayAction.setIcon`.
Use `resources/svg2img.ps1` to regenerate PNGs from the SVG sources. Use `resources/svg2img.ps1` to regenerate PNGs from the SVG sources.

View file

@ -4,12 +4,13 @@
Sortana is an experimental Thunderbird add-on that integrates an AI-powered filter rule. 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 It allows you to classify email messages by sending their contents to a configurable
HTTP endpoint. The endpoint should respond with JSON indicating whether the HTTP endpoint. Sortana uses the `/v1/completions` API; the options page stores a base
message meets a specified criterion. URL and appends `/v1/completions` when sending requests. The endpoint should respond
with JSON indicating whether the message meets a specified criterion.
## Features ## Features
- **Configurable endpoint** set the classification service URL on the options page. - **Configurable endpoint** set the classification service base URL on the options page.
- **Prompt templates** choose between several model formats or provide your own custom template. - **Prompt templates** choose between several model formats 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 to the model for more precise results.
- **Persistent result caching** classification results and reasoning are saved to disk so messages aren't re-evaluated across restarts. - **Persistent result caching** classification results and reasoning are saved to disk so messages aren't re-evaluated across restarts.
@ -72,7 +73,8 @@ Sortana is implemented entirely with standard WebExtension scripts—no custom e
## Usage ## Usage
1. Open the add-on's options and set the URL of your classification service. 1. Open the add-on's options and set the base URL of your classification service
(Sortana will append `/v1/completions`).
2. Use the **Classification Rules** section to add a criterion and optional 2. Use the **Classification Rules** section to add a criterion and optional
actions such as tagging, moving, copying, forwarding, replying, actions such as tagging, moving, copying, forwarding, replying,
deleting or archiving a message when it matches. Drag rules to deleting or archiving a message when it matches. Drag rules to
@ -158,4 +160,3 @@ how Thunderbird's WebExtension and experiment APIs can be extended. Their code
provided invaluable guidance during development. provided invaluable guidance during development.
- Icons from [cc0-icons.jonh.eu](https://cc0-icons.jonh.eu/) are used under the CC0 license. - Icons from [cc0-icons.jonh.eu](https://cc0-icons.jonh.eu/) are used under the CC0 license.

View file

@ -15,6 +15,8 @@ try {
Services = undefined; Services = undefined;
} }
const COMPLETIONS_PATH = "/v1/completions";
const SYSTEM_PREFIX = `You are an email-classification assistant. const SYSTEM_PREFIX = `You are an email-classification assistant.
Read the email below and the classification criterion provided by the user. Read the email below and the classification criterion provided by the user.
`; `;
@ -28,7 +30,8 @@ Return ONLY a JSON object on a single line of the form:
Do not add any other keys, text, or formatting.`; Do not add any other keys, text, or formatting.`;
let gEndpoint = "http://127.0.0.1:5000/v1/classify"; let gEndpointBase = "http://127.0.0.1:5000";
let gEndpoint = buildEndpointUrl(gEndpointBase);
let gTemplateName = "openai"; let gTemplateName = "openai";
let gCustomTemplate = ""; let gCustomTemplate = "";
let gCustomSystemPrompt = DEFAULT_CUSTOM_SYSTEM_PROMPT; let gCustomSystemPrompt = DEFAULT_CUSTOM_SYSTEM_PROMPT;
@ -39,6 +42,28 @@ let gAiParams = Object.assign({}, DEFAULT_AI_PARAMS);
let gCache = new Map(); let gCache = new Map();
let gCacheLoaded = false; let gCacheLoaded = false;
function normalizeEndpointBase(endpoint) {
if (typeof endpoint !== "string") {
return "";
}
let base = endpoint.trim();
if (!base) {
return "";
}
base = base.replace(/\/v1\/completions\/?$/i, "");
return base;
}
function buildEndpointUrl(endpointBase) {
const base = normalizeEndpointBase(endpointBase);
if (!base) {
return "";
}
const withScheme = /^https?:\/\//i.test(base) ? base : `https://${base}`;
const needsSlash = withScheme.endsWith("/");
return `${withScheme}${needsSlash ? "" : "/"}v1/completions`;
}
function sha256HexSync(str) { function sha256HexSync(str) {
try { try {
const hasher = Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash); const hasher = Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash);
@ -158,8 +183,12 @@ function loadTemplateSync(name) {
} }
async function setConfig(config = {}) { async function setConfig(config = {}) {
if (config.endpoint) { if (typeof config.endpoint === "string") {
gEndpoint = config.endpoint; const base = normalizeEndpointBase(config.endpoint);
if (base) {
gEndpointBase = base;
}
gEndpoint = buildEndpointUrl(gEndpointBase);
} }
if (config.templateName) { if (config.templateName) {
gTemplateName = config.templateName; gTemplateName = config.templateName;
@ -187,6 +216,10 @@ async function setConfig(config = {}) {
} else { } else {
gTemplateText = await loadTemplate(gTemplateName); 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] Endpoint set to ${gEndpoint}`, {debug: true});
aiLog(`[AiClassifier] Template set to ${gTemplateName}`, {debug: true}); aiLog(`[AiClassifier] Template set to ${gTemplateName}`, {debug: true});
} }
@ -344,4 +377,4 @@ async function init() {
await loadCache(); await loadCache();
} }
export { classifyText, setConfig, removeCacheEntries, clearCache, getReason, getCachedResult, buildCacheKey, getCacheSize, init }; export { buildEndpointUrl, normalizeEndpointBase, classifyText, setConfig, removeCacheEntries, clearCache, getReason, getCachedResult, buildCacheKey, getCacheSize, init };

View file

@ -73,6 +73,7 @@
<div class="control"> <div class="control">
<input class="input" type="text" id="endpoint" placeholder="https://api.example.com"> <input class="input" type="text" id="endpoint" placeholder="https://api.example.com">
</div> </div>
<p class="help" id="endpoint-preview"></p>
</div> </div>
<div class="field"> <div class="field">

View file

@ -99,7 +99,21 @@ document.addEventListener('DOMContentLoaded', async () => {
markDirty(); markDirty();
await applyTheme(themeSelect.value); await applyTheme(themeSelect.value);
}); });
document.getElementById('endpoint').value = defaults.endpoint || 'http://127.0.0.1:5000/v1/completions'; const endpointInput = document.getElementById('endpoint');
const endpointPreview = document.getElementById('endpoint-preview');
const fallbackEndpoint = 'http://127.0.0.1:5000';
const storedEndpoint = defaults.endpoint || fallbackEndpoint;
const endpointBase = AiClassifier.normalizeEndpointBase(storedEndpoint) || storedEndpoint;
endpointInput.value = endpointBase;
function updateEndpointPreview() {
const resolved = AiClassifier.buildEndpointUrl(endpointInput.value);
endpointPreview.textContent = resolved
? `Resolved endpoint: ${resolved}`
: 'Resolved endpoint: (invalid)';
}
endpointInput.addEventListener('input', updateEndpointPreview);
updateEndpointPreview();
const templates = { const templates = {
openai: browser.i18n.getMessage('template.openai'), openai: browser.i18n.getMessage('template.openai'),
@ -806,7 +820,7 @@ document.addEventListener('DOMContentLoaded', async () => {
initialized = true; initialized = true;
document.getElementById('save').addEventListener('click', async () => { document.getElementById('save').addEventListener('click', async () => {
const endpoint = document.getElementById('endpoint').value; const endpoint = endpointInput.value.trim();
const templateName = templateSelect.value; const templateName = templateSelect.value;
const customTemplateText = customTemplate.value; const customTemplateText = customTemplate.value;
const customSystemPrompt = systemBox.value; const customSystemPrompt = systemBox.value;