diff options
| -rw-r--r-- | htroot/LLMSelection_p.html | 500 | ||||
| -rw-r--r-- | source/net/yacy/ai/LLM.java | 75 | ||||
| -rw-r--r-- | source/net/yacy/ai/ToolCallProtocol.java | 11 | ||||
| -rw-r--r-- | source/net/yacy/htroot/LLMSelection_p.java | 18 |
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()); |
