summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--htroot/LLMSelection_p.html500
-rw-r--r--source/net/yacy/ai/LLM.java75
-rw-r--r--source/net/yacy/ai/ToolCallProtocol.java11
-rw-r--r--source/net/yacy/htroot/LLMSelection_p.java18
4 files changed, 552 insertions, 52 deletions
diff --git a/htroot/LLMSelection_p.html b/htroot/LLMSelection_p.html
index 4c77e4d21..6e3e54f22 100644
--- a/htroot/LLMSelection_p.html
+++ b/htroot/LLMSelection_p.html
@@ -12,31 +12,58 @@
let availableModels = [];
const downloadActivities = new Map();
+ const testActivities = new Map();
let activeDownloadCount = 0;
const beforeUnloadHandler = event => {
+ const hasActiveTests = hasRunningCapabilityTests();
+ const hasActiveDownloads = activeDownloadCount > 0;
+ if (!hasActiveTests && !hasActiveDownloads) {
+ return;
+ }
event.preventDefault();
- event.returnValue = "Model downloads are still running. Please wait until they finish.";
+ event.returnValue = hasActiveTests
+ ? "Production model tests are still running. Please wait until they finish."
+ : "Model downloads are still running. Please wait until they finish.";
};
// Localization-friendly test strings and hints (translate/tune as needed)
const TEST_STRINGS = {
toolingEndpointPath: "/v1/chat/completions", // Endpoint used to probe tooling capability on OpenAI-compatible APIs
+ thinkingUserMessage: "Hello", // User prompt for thinking capability test
toolingSystemMessage: "You are a home assistant.", // System prompt for tooling capability test
toolingUserMessage: "Switch on the light", // User prompt for tooling capability test
visionSystemMessage: "you read out images", // System prompt for vision capability test
visionUserMessage: "what is in the image?", // User prompt for vision capability test
visionExpectedText: "42", // Expected mention in LLM response when reading the test image
- visionTestImagePath: "env/grafics/llmtest.png" // Image used for the vision capability test
+ visionTestImagePath: "env/grafics/llmtest.png", // Image used for the vision capability test
+ formatSystemMessage: "You are a mood classifier. Identify the mood of the request."
+ };
+
+ const FORMAT_TEST_CASES = [
+ { text: "I hate programming", expectedMood: "angry" },
+ { text: "I love programming", expectedMood: "happy" },
+ { text: "Wait, that worked perfectly?", expectedMood: "surprised" }
+ ];
+
+ const FORMAT_TEST_SCHEMA = {
+ title: "Classifier",
+ type: "object",
+ properties: {
+ mood: { type: "literal", enum: ["surprised", "angry", "happy"] }
+ },
+ required: ["mood"]
};
- const PRODUCTION_MODEL_TOTAL_COLUMNS = 15;
+ const PRODUCTION_MODEL_TOTAL_COLUMNS = 17;
const PRODUCTION_MODEL_MODEL_COLUMN_INDEX = 1;
const PRODUCTION_MODEL_USAGE_COLUMN_START = 5;
const PRODUCTION_MODEL_USAGE_COLUMN_END = 11; // including
const PRODUCTION_MODEL_FEATURE_COLUMN_START = 12;
- const PRODUCTION_MODEL_FEATURE_COLUMN_END = 13; // including
+ const PRODUCTION_MODEL_FEATURE_COLUMN_END = 15; // including
const PRODUCTION_MODEL_ACTION_COLUMN_INDEX = PRODUCTION_MODEL_TOTAL_COLUMNS - 1;
- const PRODUCTION_MODEL_TOOLING_COLUMN_INDEX = PRODUCTION_MODEL_FEATURE_COLUMN_START;
- const PRODUCTION_MODEL_VISION_COLUMN_INDEX = PRODUCTION_MODEL_FEATURE_COLUMN_START + 1;
+ const PRODUCTION_MODEL_THINKING_COLUMN_INDEX = PRODUCTION_MODEL_FEATURE_COLUMN_START;
+ const PRODUCTION_MODEL_TOOLING_COLUMN_INDEX = PRODUCTION_MODEL_FEATURE_COLUMN_START + 1;
+ const PRODUCTION_MODEL_VISION_COLUMN_INDEX = PRODUCTION_MODEL_FEATURE_COLUMN_START + 2;
+ const PRODUCTION_MODEL_FORMAT_COLUMN_INDEX = PRODUCTION_MODEL_FEATURE_COLUMN_START + 3;
const PRODUCTION_MODEL_ENABLED_USAGE_COLUMNS = new Set([
6, // chat
11 // tldr
@@ -56,8 +83,10 @@
"qapairs",
"tldr",
+ "thinking",
"tooling",
- "vision"
+ "vision",
+ "format"
];
const PRODUCTION_MODEL_SUBMIT_URL = "LLMSelection_p.html";
const TOOLING_EXPECTED_FUNCTION_NAME = "lightswitch";
@@ -86,7 +115,7 @@
];
const MODEL_TABLE_HEADERS = ["Model", "Ranking", "Size", "Description", "Provider", "License", "Actions"];
- const AVAILABLE_MODEL_TABLE_HEADERS = ["Model", "Ranking", "Size", "Description", "Provider", "License", "Tooling", "Vision", "Actions"];
+ const AVAILABLE_MODEL_TABLE_HEADERS = ["Model", "Ranking", "Size", "Description", "Provider", "License", "Thinking", "Tooling", "Vision", "Format", "Actions"];
const RECOMMENDED_MODEL_MAP = new Map(
RECOMMENDED_MODELS.map(([name, ranking, size, description, provider, license]) => [
name,
@@ -143,8 +172,10 @@
if (!persistedModelCapabilities[key] || typeof persistedModelCapabilities[key] !== "object") {
persistedModelCapabilities[key] = {};
}
+ persistedModelCapabilities[key].thinking = normalizeCapabilityStatus(persistedModelCapabilities[key].thinking);
persistedModelCapabilities[key].tooling = normalizeCapabilityStatus(persistedModelCapabilities[key].tooling);
persistedModelCapabilities[key].vision = normalizeCapabilityStatus(persistedModelCapabilities[key].vision);
+ persistedModelCapabilities[key].format = normalizeCapabilityStatus(persistedModelCapabilities[key].format);
return persistedModelCapabilities[key];
}
@@ -152,11 +183,13 @@
const key = modelCapabilityKey(service, hoststub, modelName);
const entry = key ? persistedModelCapabilities[key] : null;
if (!entry || typeof entry !== "object") {
- return { tooling: "unknown", vision: "unknown" };
+ return { thinking: "unknown", tooling: "unknown", vision: "unknown", format: "unknown" };
}
return {
+ thinking: normalizeCapabilityStatus(entry.thinking),
tooling: normalizeCapabilityStatus(entry.tooling),
- vision: normalizeCapabilityStatus(entry.vision)
+ vision: normalizeCapabilityStatus(entry.vision),
+ format: normalizeCapabilityStatus(entry.format)
};
}
@@ -271,13 +304,19 @@
***/
function updateBeforeUnloadGuard() {
- if (activeDownloadCount > 0) {
+ if (activeDownloadCount > 0 || hasRunningCapabilityTests()) {
window.addEventListener("beforeunload", beforeUnloadHandler);
} else {
window.removeEventListener("beforeunload", beforeUnloadHandler);
}
}
+ function hasRunningCapabilityTests() {
+ return !!document.querySelector(
+ 'tr[data-thinking-test-in-flight="true"], tr[data-tooling-test-in-flight="true"], tr[data-vision-test-in-flight="true"], tr[data-format-test-in-flight="true"]'
+ );
+ }
+
function getDownloadActivityElements() {
return {
container: document.getElementById("downloadActivityContainer"),
@@ -285,6 +324,96 @@
};
}
+ function getTestActivityElements() {
+ return {
+ container: document.getElementById("testActivityContainer"),
+ list: document.getElementById("testActivityList")
+ };
+ }
+
+ function addTestActivity(modelName, testName) {
+ const { container, list } = getTestActivityElements();
+ if (!container || !list || !modelName || !testName) return null;
+
+ const activityId = `test_${modelName}`;
+ const existing = testActivities.get(activityId);
+ if (existing) {
+ existing.activeTests.add(testName);
+ updateTestActivitySubtitle(existing);
+ return activityId;
+ }
+
+ const wrapper = document.createElement("div");
+ wrapper.className = "test-activity";
+ wrapper.dataset.activityId = activityId;
+ wrapper.style.marginBottom = "8px";
+ wrapper.style.padding = "8px";
+ wrapper.style.border = "1px solid #ddd";
+ wrapper.style.borderRadius = "4px";
+ wrapper.style.backgroundColor = "#f8f8f8";
+
+ const title = document.createElement("div");
+ title.className = "test-activity-title";
+ title.textContent = `Testing ${modelName}`;
+ title.style.fontWeight = "bold";
+ title.style.marginBottom = "4px";
+ wrapper.appendChild(title);
+
+ const progress = document.createElement("progress");
+ progress.max = 100;
+ progress.style.width = "100%";
+ progress.removeAttribute("value");
+ progress.setAttribute("aria-busy", "true");
+ wrapper.appendChild(progress);
+
+ const subtitle = document.createElement("div");
+ subtitle.className = "test-activity-subtitle";
+ subtitle.style.fontSize = "0.9em";
+ subtitle.style.marginTop = "4px";
+ wrapper.appendChild(subtitle);
+
+ list.appendChild(wrapper);
+ container.style.display = "block";
+ const activity = {
+ wrapper,
+ subtitle,
+ activeTests: new Set([testName])
+ };
+ updateTestActivitySubtitle(activity);
+ testActivities.set(activityId, activity);
+ return activityId;
+ }
+
+ function updateTestActivitySubtitle(activity) {
+ if (!activity || !activity.subtitle) return;
+ const names = Array.from(activity.activeTests);
+ activity.subtitle.textContent = names.length <= 1
+ ? `${names[0]} test in progress...`
+ : `${names.join(", ")} tests in progress...`;
+ }
+
+ function removeTestActivity(activityId, testName) {
+ const activity = testActivities.get(activityId);
+ if (!activity) return;
+ if (testName) {
+ activity.activeTests.delete(testName);
+ }
+ if (activity.activeTests.size > 0) {
+ updateTestActivitySubtitle(activity);
+ return;
+ }
+ if (activity.wrapper && activity.wrapper.parentNode) {
+ activity.wrapper.parentNode.removeChild(activity.wrapper);
+ }
+ if (testActivities.has(activityId)) {
+ testActivities.delete(activityId);
+ }
+ const { container, list } = getTestActivityElements();
+ if (container && list && !list.hasChildNodes()) {
+ container.style.display = "none";
+ }
+ }
+
function addDownloadActivity(modelName) {
const { container, list } = getDownloadActivityElements();
if (!container || !list) return null;
@@ -464,8 +593,10 @@
description: info.description || "",
provider: info.provider || "",
license: info.license || "",
+ thinking: capabilityStatusLabel(capabilities.thinking),
tooling: capabilityStatusLabel(capabilities.tooling),
vision: capabilityStatusLabel(capabilities.vision),
+ format: capabilityStatusLabel(capabilities.format),
renderActions: () => createAvailableModelActionButtons(service, id)
});
});
@@ -539,8 +670,10 @@
row.description || "",
row.provider || "",
row.license || "",
+ row.thinking || "?",
row.tooling || "?",
- row.vision || "?"
+ row.vision || "?",
+ row.format || "?"
]
: [
row.model || "",
@@ -720,8 +853,10 @@
});
ensureProductionRowUsageCells(targetRow, !hadRowsBeforeInsert);
+ setThinkingFlagForRow(targetRow, persistedCaps.thinking);
setToolingFlagForRow(targetRow, persistedCaps.tooling);
setVisionFlagForRow(targetRow, persistedCaps.vision);
+ setFormatFlagForRow(targetRow, persistedCaps.format);
ensureProductionRowActionButton(targetRow);
persistProductionModels();
scheduleCapabilityVerificationForRow(targetRow, persistedCaps);
@@ -757,12 +892,19 @@
const apikey = row.cells[3] ? row.cells[3].textContent.trim() : "";
if (!service || !modelName || !hoststub) return;
const statuses = capabilityStatuses || getPersistedCapabilitiesForModel(service, hoststub, modelName);
+ if (statuses.thinking === "unknown") {
+ triggerThinkingCapabilityVerification(row, { service, hoststub, modelName, apikey });
+ return;
+ }
if (statuses.tooling === "unknown") {
triggerToolingCapabilityVerification(row, { service, hoststub, modelName, apikey });
}
if (statuses.vision === "unknown") {
triggerVisionCapabilityVerification(row, { service, hoststub, modelName, apikey });
}
+ if (statuses.format === "unknown") {
+ triggerFormatCapabilityVerification(row, { service, hoststub, modelName, apikey });
+ }
}
function persistInferenceSystem() {
@@ -969,6 +1111,140 @@
updateAvailableModelButtons();
}
+ function maybeApplyNoThinkingParameters(service, hoststub, modelName, payload) {
+ if (!payload) return payload;
+ const capabilities = getPersistedCapabilitiesForModel(service, hoststub, modelName);
+ if (capabilities.thinking === "supported") {
+ payload.reasoning_effort = "none";
+ payload.enable_thinking = false;
+ }
+ return payload;
+ }
+
+ function buildThinkingTestPayload(modelName) {
+ return {
+ model: modelName,
+ temperature: 0.1,
+ max_tokens: 64,
+ messages: [
+ { role: "user", content: TEST_STRINGS.thinkingUserMessage }
+ ],
+ stream: true
+ };
+ }
+
+ function chunkContainsThinkingTokens(chunkText) {
+ if (!chunkText) return false;
+ return /<think\b/i.test(chunkText)
+ || /"reasoning_content"\s*:/i.test(chunkText)
+ || /"reasoning"\s*:/i.test(chunkText)
+ || /"thinking"\s*:/i.test(chunkText);
+ }
+
+ function setThinkingFlagForRow(row, status) {
+ if (!row || row.cells.length <= PRODUCTION_MODEL_THINKING_COLUMN_INDEX) {
+ return;
+ }
+ const cell = row.cells[PRODUCTION_MODEL_THINKING_COLUMN_INDEX];
+ if (!cell) return;
+ cell.textContent = capabilityStatusLabel(status);
+ }
+
+ function triggerThinkingCapabilityVerification(row, { service, hoststub, modelName, apikey }) {
+ if (!row) return;
+ const normalizedHoststub = (hoststub || "").trim();
+ if (!normalizedHoststub || !modelName) {
+ return;
+ }
+ if (row.dataset.thinkingTestInFlight === "true") {
+ return;
+ }
+ row.dataset.thinkingTestInFlight = "true";
+ const activityId = addTestActivity(modelName, "thinking");
+ updateBeforeUnloadGuard();
+ runThinkingCapabilityTest(service, normalizedHoststub, modelName, apikey)
+ .then(success => {
+ const rowStillMounted = !!(document && document.body && document.body.contains(row));
+ setPersistedCapability(service, normalizedHoststub, modelName, "thinking", success ? "supported" : "unsupported");
+ if (!rowStillMounted) {
+ persistProductionModels();
+ return;
+ }
+ setThinkingFlagForRow(row, success ? "supported" : "unsupported");
+ persistProductionModels();
+ updateAvailableModelCapabilityCells(service, normalizedHoststub, modelName);
+ scheduleCapabilityVerificationForRow(row, getPersistedCapabilitiesForModel(service, normalizedHoststub, modelName));
+ })
+ .catch(error => {
+ if (isCapabilityUnsupportedError(error)) {
+ setPersistedCapability(service, normalizedHoststub, modelName, "thinking", "unsupported");
+ setThinkingFlagForRow(row, "unsupported");
+ persistProductionModels();
+ updateAvailableModelCapabilityCells(service, normalizedHoststub, modelName);
+ scheduleCapabilityVerificationForRow(row, getPersistedCapabilitiesForModel(service, normalizedHoststub, modelName));
+ return;
+ }
+ console.warn(`Thinking capability check failed for model "${modelName}".`, error);
+ })
+ .finally(() => {
+ delete row.dataset.thinkingTestInFlight;
+ if (activityId) removeTestActivity(activityId, "thinking");
+ updateBeforeUnloadGuard();
+ });
+ }
+
+ async function runThinkingCapabilityTest(service, hoststub, modelName, apikey) {
+ const endpointBase = hoststub.replace(/\/+$/, "");
+ if (!endpointBase) {
+ return false;
+ }
+ const targetUrl = `${endpointBase}${TEST_STRINGS.toolingEndpointPath}`;
+ const headers = { "Content-Type": "application/json" };
+ if (apikey) {
+ headers.Authorization = `Bearer ${apikey}`;
+ }
+ const controller = new AbortController();
+ const response = await fetch(targetUrl, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(buildThinkingTestPayload(modelName)),
+ signal: controller.signal
+ });
+ if (!response.ok) {
+ const error = new Error(`HTTP status ${response.status}`);
+ error.status = response.status;
+ throw error;
+ }
+ if (!response.body) {
+ return false;
+ }
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let detectedThinking = false;
+ try {
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ const chunkText = decoder.decode(value, { stream: true });
+ if (chunkContainsThinkingTokens(chunkText)) {
+ detectedThinking = true;
+ controller.abort();
+ break;
+ }
+ }
+ } catch (error) {
+ if (!(detectedThinking && error && error.name === "AbortError")) {
+ throw error;
+ }
+ } finally {
+ try {
+ reader.releaseLock();
+ } catch (error) {
+ }
+ }
+ return detectedThinking;
+ }
+
/**
* Tooling capability check
*/
@@ -982,7 +1258,9 @@
return;
}
row.dataset.toolingTestInFlight = "true";
- runToolingCapabilityTest(normalizedHoststub, modelName, apikey)
+ const activityId = addTestActivity(modelName, "tooling");
+ updateBeforeUnloadGuard();
+ runToolingCapabilityTest(service, normalizedHoststub, modelName, apikey)
.then(success => {
const rowStillMounted = !!(document && document.body && document.body.contains(row));
setPersistedCapability(service, normalizedHoststub, modelName, "tooling", success ? "supported" : "unsupported");
@@ -1006,10 +1284,12 @@
})
.finally(() => {
delete row.dataset.toolingTestInFlight;
+ if (activityId) removeTestActivity(activityId, "tooling");
+ updateBeforeUnloadGuard();
});
}
- async function runToolingCapabilityTest(hoststub, modelName, apikey) {
+ async function runToolingCapabilityTest(service, hoststub, modelName, apikey) {
const endpointBase = hoststub.replace(/\/+$/, "");
if (!endpointBase) {
return false;
@@ -1019,7 +1299,7 @@
if (apikey) {
headers.Authorization = `Bearer ${apikey}`;
}
- const payload = buildToolingTestPayload(modelName);
+ const payload = buildToolingTestPayload(service, hoststub, modelName);
const response = await fetch(targetUrl, {
method: "POST",
headers,
@@ -1034,8 +1314,8 @@
return toolingResponseIncludesExpectedToolCall(result);
}
- function buildToolingTestPayload(modelName) {
- return {
+ function buildToolingTestPayload(service, hoststub, modelName) {
+ return maybeApplyNoThinkingParameters(service, hoststub, modelName, {
model: modelName,
temperature: 0.1,
max_tokens: 1024,
@@ -1063,7 +1343,7 @@
}
}],
stream: false
- };
+ });
}
function toolingResponseIncludesExpectedToolCall(response) {
@@ -1118,7 +1398,9 @@
return;
}
row.dataset.visionTestInFlight = "true";
- runVisionCapabilityTest(normalizedHoststub, modelName, apikey)
+ const activityId = addTestActivity(modelName, "vision");
+ updateBeforeUnloadGuard();
+ runVisionCapabilityTest(service, normalizedHoststub, modelName, apikey)
.then(success => {
const rowStillMounted = !!(document && document.body && document.body.contains(row));
setPersistedCapability(service, normalizedHoststub, modelName, "vision", success ? "supported" : "unsupported");
@@ -1135,10 +1417,12 @@
})
.finally(() => {
delete row.dataset.visionTestInFlight;
+ if (activityId) removeTestActivity(activityId, "vision");
+ updateBeforeUnloadGuard();
});
}
- async function runVisionCapabilityTest(hoststub, modelName, apikey) {
+ async function runVisionCapabilityTest(service, hoststub, modelName, apikey) {
const endpointBase = hoststub.replace(/\/+$/, "");
if (!endpointBase) {
return false;
@@ -1149,7 +1433,7 @@
headers.Authorization = `Bearer ${apikey}`;
}
const base64Image = await loadVisionTestImageBase64();
- const payload = buildVisionTestPayload(modelName, base64Image);
+ const payload = buildVisionTestPayload(service, hoststub, modelName, base64Image);
const response = await fetch(targetUrl, {
method: "POST",
headers,
@@ -1164,8 +1448,8 @@
return visionResponseContainsExpectedAnswer(result);
}
- function buildVisionTestPayload(modelName, base64Image) {
- return {
+ function buildVisionTestPayload(service, hoststub, modelName, base64Image) {
+ return maybeApplyNoThinkingParameters(service, hoststub, modelName, {
model: modelName,
temperature: 0.1,
max_tokens: 512,
@@ -1184,7 +1468,7 @@
]
}
]
- };
+ });
}
function visionResponseContainsExpectedAnswer(response) {
@@ -1276,6 +1560,157 @@
cell.textContent = capabilityStatusLabel(status);
}
+ function triggerFormatCapabilityVerification(row, { service, hoststub, modelName, apikey }) {
+ if (!row) return;
+ const normalizedHoststub = (hoststub || "").trim();
+ if (!normalizedHoststub || !modelName) {
+ return;
+ }
+ if (row.dataset.formatTestInFlight === "true") {
+ return;
+ }
+ row.dataset.formatTestInFlight = "true";
+ const activityId = addTestActivity(modelName, "format");
+ updateBeforeUnloadGuard();
+ runFormatCapabilityTest(service, normalizedHoststub, modelName, apikey)
+ .then(success => {
+ const rowStillMounted = !!(document && document.body && document.body.contains(row));
+ setPersistedCapability(service, normalizedHoststub, modelName, "format", success ? "supported" : "unsupported");
+ if (!rowStillMounted) {
+ persistProductionModels();
+ return;
+ }
+ setFormatFlagForRow(row, success ? "supported" : "unsupported");
+ persistProductionModels();
+ updateAvailableModelCapabilityCells(service, normalizedHoststub, modelName);
+ })
+ .catch(error => {
+ if (isCapabilityUnsupportedError(error)) {
+ setPersistedCapability(service, normalizedHoststub, modelName, "format", "unsupported");
+ setFormatFlagForRow(row, "unsupported");
+ persistProductionModels();
+ updateAvailableModelCapabilityCells(service, normalizedHoststub, modelName);
+ return;
+ }
+ console.warn(`Format capability check failed for model "${modelName}".`, error);
+ })
+ .finally(() => {
+ delete row.dataset.formatTestInFlight;
+ if (activityId) removeTestActivity(activityId, "format");
+ updateBeforeUnloadGuard();
+ });
+ }
+
+ async function runFormatCapabilityTest(service, hoststub, modelName, apikey) {
+ const endpointBase = hoststub.replace(/\/+$/, "");
+ if (!endpointBase) {
+ return false;
+ }
+ for (const testCase of FORMAT_TEST_CASES) {
+ const mood = await runSingleFormatCapabilityTest(service, endpointBase, modelName, apikey, testCase.text);
+ if (mood !== testCase.expectedMood) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ async function runSingleFormatCapabilityTest(service, endpointBase, modelName, apikey, inputText) {
+ const targetUrl = `${endpointBase}${TEST_STRINGS.toolingEndpointPath}`;
+ const headers = { "Content-Type": "application/json" };
+ if (apikey) {
+ headers.Authorization = `Bearer ${apikey}`;
+ }
+ const payload = buildFormatTestPayload(service, endpointBase, modelName, inputText);
+ const response = await fetch(targetUrl, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(payload)
+ });
+ if (!response.ok) {
+ const error = new Error(`HTTP status ${response.status}`);
+ error.status = response.status;
+ throw error;
+ }
+ const result = await response.json();
+ return extractMoodFromFormatResponse(result);
+ }
+
+ function buildFormatTestPayload(service, hoststub, modelName, inputText) {
+ return maybeApplyNoThinkingParameters(service, hoststub, modelName, {
+ model: modelName,
+ temperature: 0.1,
+ max_tokens: 128,
+ messages: [
+ { role: "system", content: TEST_STRINGS.formatSystemMessage },
+ { role: "user", content: inputText }
+ ],
+ stream: false,
+ response_format: {
+ type: "json_schema",
+ json_schema: {
+ strict: true,
+ schema: FORMAT_TEST_SCHEMA
+ }
+ }
+ });
+ }
+
+ function extractMoodFromFormatResponse(response) {
+ const candidates = [];
+ if (response && Array.isArray(response.choices)) {
+ response.choices.forEach(choice => {
+ if (choice && choice.message) {
+ candidates.push(choice.message);
+ }
+ });
+ }
+ if (response && response.message) {
+ candidates.push(response.message);
+ }
+ for (const message of candidates) {
+ const parsedMood = extractMoodFromMessage(message);
+ if (parsedMood) {
+ return parsedMood;
+ }
+ }
+ return "";
+ }
+
+ function extractMoodFromMessage(message) {
+ if (!message) return "";
+ if (message.parsed && typeof message.parsed === "object") {
+ const mood = normalizeMoodValue(message.parsed.mood);
+ if (mood) return mood;
+ }
+ if (message.content && typeof message.content === "object" && !Array.isArray(message.content)) {
+ const mood = normalizeMoodValue(message.content.mood);
+ if (mood) return mood;
+ }
+ const normalizedText = normalizeMessageText(message);
+ if (!normalizedText) return "";
+ try {
+ const parsed = JSON.parse(normalizedText);
+ return normalizeMoodValue(parsed && parsed.mood);
+ } catch (error) {
+ return "";
+ }
+ }
+
+ function normalizeMoodValue(value) {
+ const mood = typeof value === "string" ? value.trim().toLowerCase() : "";
+ return mood === "angry" || mood === "happy" || mood === "surprised" ? mood : "";
+ }
+
+ function setFormatFlagForRow(row, status) {
+ if (!row || row.cells.length <= PRODUCTION_MODEL_FORMAT_COLUMN_INDEX) {
+ return;
+ }
+ const cell = row.cells[PRODUCTION_MODEL_FORMAT_COLUMN_INDEX];
+ if (!cell) return;
+ cell.textContent = capabilityStatusLabel(status);
+ }
+
function updateAvailableModelCapabilityCells(service, hoststub, modelName) {
const container = document.getElementById("availableModelsContainer");
if (!container) return;
@@ -1283,8 +1718,10 @@
container.querySelectorAll("tbody tr").forEach(row => {
const modelCell = row.cells && row.cells[0];
if (!modelCell || modelCell.textContent.trim() !== modelName) return;
- if (row.cells[6]) row.cells[6].textContent = capabilityStatusLabel(capabilities.tooling);
- if (row.cells[7]) row.cells[7].textContent = capabilityStatusLabel(capabilities.vision);
+ if (row.cells[6]) row.cells[6].textContent = capabilityStatusLabel(capabilities.thinking);
+ if (row.cells[7]) row.cells[7].textContent = capabilityStatusLabel(capabilities.tooling);
+ if (row.cells[8]) row.cells[8].textContent = capabilityStatusLabel(capabilities.vision);
+ if (row.cells[9]) row.cells[9].textContent = capabilityStatusLabel(capabilities.format);
});
}
@@ -1415,6 +1852,9 @@
<fieldset id="availableModelsContainer" style="display:none"><a name="availableModels"></a></fieldset>
<fieldset id="productionModelsContainer" style="display: block;"><a name="productionModels"></a><legend>Production Models Matrix</legend>
+ <div id="testActivityContainer" style="display:none; margin-bottom:12px;">
+ <div id="testActivityList"></div>
+ </div>
<table class="table table-striped" id="productionModelsTable">
<thead class="thead-dark">
<tr>
@@ -1432,8 +1872,10 @@
<td class="narrow">qa-pairs<br/><span class="info"><img src="env/grafics/i16.gif" width="16" height="16" alt="info"/><span>This model can be used to produce query-answer pairs which enhance search from chat prompts</span></span></td>
<td class="narrow">tldr-shortener<br/><span class="info"><img src="env/grafics/i16.gif" width="16" height="16" alt="info"/><span>This model is used to make summaries from web content</span></span></td>
- <td>tooling</td>
- <td>vision</td>
+ <td class="narrow">thinking<br/><span class="info"><img src="env/grafics/i16.gif" width="16" height="16" alt="info"/><span>we detect thinking only to be able to suppress thinking. thinking is not used in YaCy</span></span></td>
+ <td class="narrow">tooling<br/><span class="info"><img src="env/grafics/i16.gif" width="16" height="16" alt="info"/><span>tooling is required for agentic abilities.</span></span></td>
+ <td class="narrow">vision<br/><span class="info"><img src="env/grafics/i16.gif" width="16" height="16" alt="info"/><span>this enables image recognition in the chat</span></span></td>
+ <td class="narrow">format<br/><span class="info"><img src="env/grafics/i16.gif" width="16" height="16" alt="info"/><span>this is required for classification</span></span></td>
<td>Actions</td>
</tr>
</thead>
@@ -1454,8 +1896,10 @@
<td><input type="checkbox" #(qapairs)#::checked=true#(/qapairs)# disabled="disabled"></td>
<td><input type="checkbox" #(tldr)#::checked=true#(/tldr)#></td>
+ <td>#[thinking]#</td>
<td>#[tooling]#</td>
<td>#[vision]#</td>
+ <td>#[format]#</td>
<td></td>
</tr>
#{/productionmodels}#
diff --git a/source/net/yacy/ai/LLM.java b/source/net/yacy/ai/LLM.java
index 5a32b6592..7f74943d0 100644
--- a/source/net/yacy/ai/LLM.java
+++ b/source/net/yacy/ai/LLM.java
@@ -43,6 +43,7 @@ import net.yacy.search.Switchboard;
public class LLM {
+ private static final String MODEL_CAPABILITIES_CONFIG = "ai.model_capabilities";
private static String[] STOPTOKENS = new String[]{"[/INST]", "<|im_end|>", "<|end_of_turn|>", "<|eot_id|>", "<|end_header_id|>", "<EOS_TOKEN>", "</s>", "<|end|>"};
public static enum LLMType {
@@ -71,10 +72,12 @@ public class LLM {
public LLM llm;
public String model;
public boolean tooling;
- public LLMModel(LLM llm, String model, boolean tooling) {
+ public boolean thinking;
+ public LLMModel(LLM llm, String model, boolean tooling, boolean thinking) {
this.llm = llm;
this.model = model;
this.tooling = tooling;
+ this.thinking = thinking;
}
}
@@ -98,13 +101,7 @@ public class LLM {
public static LLMModel llmFromUsage(LLMUsage llmUsage) {
Switchboard sb = Switchboard.getSwitchboard();
String pms = sb.getConfig("ai.production_models", "[]");
- String mcs = sb.getConfig("ai.model_capabilities", "{}");
- JSONObject model_capabilities = new JSONObject(true);
- try {
- model_capabilities = new JSONObject(new JSONTokener(mcs));
- } catch (JSONException e) {
- model_capabilities = new JSONObject(true);
- }
+ JSONObject model_capabilities = readModelCapabilities();
try {
JSONArray production_models = new JSONArray(new JSONTokener(pms));
// got through all the selected models to find which one has the wanted usage flag switched on
@@ -117,15 +114,18 @@ public class LLM {
final String api_key = row.optString("api_key", "");
final int max_tokens = Integer.parseInt(row.optString("max_tokens", "4096"));
final String model = row.optString("model", "");
+ final LLMType type = LLMType.valueOf(row.optString("service", "OLLAMA"));
boolean tooling = row.optBoolean("tooling", false);
- if (!tooling) {
- final String capabilityKey = row.optString("service", "OLLAMA") + "|" + hoststub.replaceAll("/+$", "") + "|" + model;
- final JSONObject capabilityEntry = model_capabilities.optJSONObject(capabilityKey);
- tooling = capabilityEntry != null && "supported".equals(capabilityEntry.optString("tooling", ""));
+ boolean thinking = row.optBoolean("thinking", false);
+ if (!tooling || !thinking) {
+ final JSONObject capabilityEntry = model_capabilities.optJSONObject(capabilityKey(type, hoststub, model));
+ if (capabilityEntry != null) {
+ if (!tooling) tooling = "supported".equals(capabilityEntry.optString("tooling", ""));
+ if (!thinking) thinking = "supported".equals(capabilityEntry.optString("thinking", ""));
+ }
}
- final LLMType type = LLMType.valueOf(row.optString("service", "OLLAMA"));
LLM llm = new LLM(hoststub, api_key, max_tokens, type);
- LLMModel llmmodel = new LLMModel(llm, model, tooling);
+ LLMModel llmmodel = new LLMModel(llm, model, tooling, thinking);
return llmmodel;
}
}
@@ -140,6 +140,47 @@ public class LLM {
return this.hoststub;
}
+ public static String capabilityKey(final LLMType type, final String hoststub, final String model) {
+ final String normalizedType = type == null ? "" : type.name();
+ final String normalizedHoststub = hoststub == null ? "" : hoststub.replaceAll("/+$", "");
+ final String normalizedModel = model == null ? "" : model.trim();
+ return normalizedType + "|" + normalizedHoststub + "|" + normalizedModel;
+ }
+
+ private static JSONObject readModelCapabilities() {
+ final Switchboard sb = Switchboard.getSwitchboard();
+ if (sb == null) return new JSONObject(true);
+ final String capabilitiesJson = sb.getConfig(MODEL_CAPABILITIES_CONFIG, "{}");
+ try {
+ return new JSONObject(new JSONTokener(capabilitiesJson));
+ } catch (JSONException e) {
+ return new JSONObject(true);
+ }
+ }
+
+ public static boolean isCapabilitySupported(final LLMType type, final String hoststub, final String model, final String capabilityName) {
+ if (capabilityName == null || capabilityName.isEmpty()) return false;
+ final JSONObject modelCapabilities = readModelCapabilities();
+ final JSONObject capabilityEntry = modelCapabilities.optJSONObject(capabilityKey(type, hoststub, model));
+ return capabilityEntry != null && "supported".equalsIgnoreCase(capabilityEntry.optString(capabilityName, ""));
+ }
+
+ public static void applyNoThinkingParameters(final JSONObject data) {
+ if (data == null) return;
+ try {
+ data.put("reasoning_effort", "none");
+ data.put("enable_thinking", false);
+ } catch (JSONException e) {
+ }
+ }
+
+ public void applyNoThinkingParametersIfNeeded(final String model, final JSONObject data) {
+ final String normalizedModel = model == null ? "" : model.toLowerCase();
+ if (normalizedModel.contains("qwen3.5") || isCapabilitySupported(this.type, this.hoststub, model, "thinking")) {
+ applyNoThinkingParameters(data);
+ }
+ }
+
// API Helper Methods
@@ -287,11 +328,7 @@ public class LLM {
data.put("messages", context);
data.put("stop", new JSONArray(STOPTOKENS));
data.put("stream", false);
-
- if (model.toLowerCase().contains("qwen3.5")) { // we don't think
- data.put("reasoning_effort", "none");
- data.put("enable_thinking", false);
- }
+ applyNoThinkingParametersIfNeeded(model, data);
if (schema != null) {
System.out.println(schema.toString());
diff --git a/source/net/yacy/ai/ToolCallProtocol.java b/source/net/yacy/ai/ToolCallProtocol.java
index b545c85b7..2eab71687 100644
--- a/source/net/yacy/ai/ToolCallProtocol.java
+++ b/source/net/yacy/ai/ToolCallProtocol.java
@@ -88,11 +88,6 @@ public final class ToolCallProtocol {
try {
final JSONObject prepared = body == null ? new JSONObject(true) : new JSONObject(body.toString());
if (forceStream) prepared.put("stream", true);
- final String model = prepared.optString("model", "");
- if (model.toLowerCase().contains("qwen3.5")) {
- prepared.put("reasoning_effort", "none");
- prepared.put("enable_thinking", false);
- }
if (toolingEnabled) net.yacy.ai.ToolProvider.ensureTools(prepared);
return prepared;
} catch (JSONException e) {
@@ -123,6 +118,9 @@ public final class ToolCallProtocol {
*/
public static int proxyToolLifecycle(ServletOutputStream out, LLM.LLMModel llm4Chat, JSONObject originalBody, JSONArray messages, JSONObject initialMetadata) throws IOException {
final JSONObject preparedBody = prepareToolRequestBody(originalBody, false, llm4Chat != null && llm4Chat.tooling);
+ if (llm4Chat != null && llm4Chat.thinking) {
+ LLM.applyNoThinkingParameters(preparedBody);
+ }
final HttpURLConnection conn = openChatCompletionConnection(llm4Chat, preparedBody);
final int status = conn.getResponseCode();
//final String message = conn.getResponseMessage();
@@ -284,6 +282,9 @@ public final class ToolCallProtocol {
// Build follow-up completion request from original body template.
final JSONObject followup = prepareToolRequestBody(originalBody, true, llm4Chat != null && llm4Chat.tooling);
+ if (llm4Chat != null && llm4Chat.thinking) {
+ LLM.applyNoThinkingParameters(followup);
+ }
followup.put("messages", newMessages);
final HttpURLConnection followConn = openChatCompletionConnection(llm4Chat, followup);
if (followConn.getResponseCode() != 200) {
diff --git a/source/net/yacy/htroot/LLMSelection_p.java b/source/net/yacy/htroot/LLMSelection_p.java
index 4a5955b32..03229a9ce 100644
--- a/source/net/yacy/htroot/LLMSelection_p.java
+++ b/source/net/yacy/htroot/LLMSelection_p.java
@@ -52,11 +52,15 @@ public class LLMSelection_p {
final JSONObject entry = source.optJSONObject(key);
final JSONObject normalizedEntry = new JSONObject(true);
if (entry != null) {
+ normalizedEntry.put("thinking", normalizeCapabilityStatus(entry.opt("thinking")));
normalizedEntry.put("tooling", normalizeCapabilityStatus(entry.opt("tooling")));
normalizedEntry.put("vision", normalizeCapabilityStatus(entry.opt("vision")));
+ normalizedEntry.put("format", normalizeCapabilityStatus(entry.opt("format")));
} else {
+ normalizedEntry.put("thinking", "unknown");
normalizedEntry.put("tooling", "unknown");
normalizedEntry.put("vision", "unknown");
+ normalizedEntry.put("format", "unknown");
}
normalized.put(key, normalizedEntry);
}
@@ -88,8 +92,10 @@ public class LLMSelection_p {
normalized.put("qapairs", false);
normalized.put("tldr", row.optBoolean("tldr", false));
+ normalized.put("thinking", row.optBoolean("thinking", false));
normalized.put("tooling", row.optBoolean("tooling", false));
normalized.put("vision", row.optBoolean("vision", false));
+ normalized.put("format", row.optBoolean("format", false));
return normalized;
}
@@ -183,14 +189,22 @@ public class LLMSelection_p {
final String key = capabilityKey(row);
JSONObject capabilityEntry = key.isEmpty() ? null : capabilities.optJSONObject(key);
+ String thinkingStatus = capabilityEntry == null ? "unknown" : normalizeCapabilityStatus(capabilityEntry.opt("thinking"));
String toolingStatus = capabilityEntry == null ? "unknown" : normalizeCapabilityStatus(capabilityEntry.opt("tooling"));
String visionStatus = capabilityEntry == null ? "unknown" : normalizeCapabilityStatus(capabilityEntry.opt("vision"));
+ String formatStatus = capabilityEntry == null ? "unknown" : normalizeCapabilityStatus(capabilityEntry.opt("format"));
+ if (row.optBoolean("thinking", false)) thinkingStatus = "supported";
if (row.optBoolean("tooling", false)) toolingStatus = "supported";
if (row.optBoolean("vision", false)) visionStatus = "supported";
+ if (row.optBoolean("format", false)) formatStatus = "supported";
+ prop.put("productionmodels_" + i + "_thinking",
+ "supported".equals(thinkingStatus) ? "yes" : "unsupported".equals(thinkingStatus) ? "no" : "?");
prop.put("productionmodels_" + i + "_tooling",
"supported".equals(toolingStatus) ? "yes" : "unsupported".equals(toolingStatus) ? "no" : "?");
prop.put("productionmodels_" + i + "_vision",
"supported".equals(visionStatus) ? "yes" : "unsupported".equals(visionStatus) ? "no" : "?");
+ prop.put("productionmodels_" + i + "_format",
+ "supported".equals(formatStatus) ? "yes" : "unsupported".equals(formatStatus) ? "no" : "?");
}
prop.put("productionmodels", production_models.length());
} catch (JSONException e) {
@@ -206,12 +220,16 @@ public class LLMSelection_p {
JSONObject entry = capabilities.optJSONObject(key);
if (entry == null) {
entry = new JSONObject(true);
+ entry.put("thinking", "unknown");
entry.put("tooling", "unknown");
entry.put("vision", "unknown");
+ entry.put("format", "unknown");
capabilities.put(key, entry);
}
+ if (row.optBoolean("thinking", false)) entry.put("thinking", "supported");
if (row.optBoolean("tooling", false)) entry.put("tooling", "supported");
if (row.optBoolean("vision", false)) entry.put("vision", "supported");
+ if (row.optBoolean("format", false)) entry.put("format", "supported");
}
}
prop.putHTML("model_capabilities", capabilities.toString());