From 784a10a592f8590f2993f262c90766bec4cd2aca Mon Sep 17 00:00:00 2001 From: john Date: Thu, 23 Jul 2026 22:46:10 +0800 Subject: [PATCH] feat: configure deep search llm models --- deep-search-engine.mjs | 122 +++++++++++++++++++++++++++++++++--- deep-search-llm-gateway.mjs | 63 +++++++++++++++++++ deep-search-server.mjs | 13 +++- deep-search-store.mjs | 35 +++++++++-- deep-search.test.mjs | 98 ++++++++++++++++++++++++----- docs/deep-search-service.md | 17 +++-- mindsearch-config.mjs | 12 ++++ mindsearch-providers.mjs | 5 ++ mindsearch.test.mjs | 8 +++ server.mjs | 20 ++++++ tkmind-search-mcp.mjs | 2 + 11 files changed, 360 insertions(+), 35 deletions(-) create mode 100644 deep-search-llm-gateway.mjs diff --git a/deep-search-engine.mjs b/deep-search-engine.mjs index ee58bfe..2ffdbcf 100644 --- a/deep-search-engine.mjs +++ b/deep-search-engine.mjs @@ -291,6 +291,69 @@ export function createOpenAiCompatibleResearchLlm({ }; } +export function createPortalGatewayResearchLlmResolver({ + endpoint = process.env.TKMIND_DEEP_SEARCH_LLM_GATEWAY_URL + || 'http://127.0.0.1:8081/api/internal/deep-search/llm', + secret = process.env.TKMIND_DEEP_SEARCH_LLM_GATEWAY_SECRET + || process.env.TKMIND_DEEP_SEARCH_SECRET, + fetchImpl = fetch, + timeoutMs = 90_000, +} = {}) { + const resolver = async ({ providerKeyId, model }) => { + if (!providerKeyId || !model) throw new Error('Deep Search LLM provider and model are required'); + if (!secret) throw new Error('Deep Search LLM gateway secret is not configured'); + async function complete(messages, { json = false } = {}) { + const response = await fetchImpl(endpoint, { + method: 'POST', + signal: AbortSignal.timeout(timeoutMs), + headers: { + accept: 'application/json', + authorization: `Bearer ${secret}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + providerKeyId, + model, + messages, + temperature: 0.2, + json, + }), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok || !body.ok || !body.reply) { + throw new Error(body.message || `Deep Search LLM gateway returned ${response.status}`); + } + return String(body.reply).trim(); + } + return { + async plan(question, depth) { + const content = await complete([ + { + role: 'system', + content: 'You are a research planner. Return JSON {"research_plan":[{"goal":"","queries":[""],"sources":["web"]}]}. Create distinct, verifiable goals and search queries.', + }, + { role: 'user', content: `Depth: ${depth}\nQuestion: ${question}` }, + ], { json: true }); + return JSON.parse(content); + }, + async synthesize({ question, plan, evidence }) { + return complete([ + { + role: 'system', + content: 'Write a rigorous Markdown research report using only the supplied evidence. Cite every factual claim with [n]. Include executive summary, findings by research goal, uncertainties, and sources. Never invent citations.', + }, + { + role: 'user', + content: JSON.stringify({ question, plan, evidence }, null, 2).slice(0, 120_000), + }, + ]); + }, + }; + }; + resolver.configured = Boolean(endpoint && secret); + return resolver; +} + function sourceAuthority(url) { try { const hostname = new URL(url).hostname; @@ -446,6 +509,7 @@ export function createDeepSearchEngine({ searchProvider = createSearxngResearchProvider(), reader = readResearchSource, llm = createOpenAiCompatibleResearchLlm(), + llmResolver = null, memorySink = null, idFactory = () => randomUUID(), now = () => Date.now(), @@ -460,10 +524,10 @@ export function createDeepSearchEngine({ emitter.emit(taskId, { type, payload, createdAt: now() }); } - async function planResearch(question, depth) { - if (llm?.plan) { + async function planResearch(question, depth, activeLlm) { + if (activeLlm?.plan) { try { - return normalizePlan(await llm.plan(question, depth), question, depth); + return normalizePlan(await activeLlm.plan(question, depth), question, depth); } catch { // The deterministic planner keeps Deep Search available when the LLM is unavailable. } @@ -477,7 +541,23 @@ export function createDeepSearchEngine({ try { store.updateTask(taskId, { status: 'researching', phase: 'planning', progress: 5 }); publish(taskId, 'phase', { phase: 'planning', progress: 5 }); - const plan = await planResearch(task.question, task.depth); + let activeLlm = llm; + if (task.llmProviderKeyId && typeof llmResolver === 'function') { + try { + activeLlm = await llmResolver({ + providerKeyId: task.llmProviderKeyId, + model: task.llmModel, + }); + publish(taskId, 'llm_selected', { + providerKeyId: task.llmProviderKeyId, + model: task.llmModel, + }); + } catch (error) { + activeLlm = null; + publish(taskId, 'llm_error', { message: String(error?.message ?? error) }); + } + } + const plan = await planResearch(task.question, task.depth, activeLlm); throwIfAborted(signal); store.updateTask(taskId, { plan, phase: 'searching', progress: 12 }); publish(taskId, 'plan', { plan }); @@ -561,13 +641,17 @@ export function createDeepSearchEngine({ store.updateTask(taskId, { phase: 'synthesizing', progress: 78 }); publish(taskId, 'phase', { phase: 'synthesizing', progress: 78, evidence: evidence.length }); let report = ''; - if (llm?.synthesize && finalSources.length) { + if (activeLlm?.synthesize && finalSources.length) { try { const numberedEvidence = evidence.map((item) => ({ ...item, citation: finalSources.findIndex((source) => source.url === item.url) + 1, })); - const candidate = await llm.synthesize({ question: task.question, plan, evidence: numberedEvidence }); + const candidate = await activeLlm.synthesize({ + question: task.question, + plan, + evidence: numberedEvidence, + }); if (validLlmReport(candidate, finalSources.length)) report = candidate; } catch { // Fall through to the citation-safe deterministic report. @@ -637,14 +721,33 @@ export function createDeepSearchEngine({ } return { - start({ question, depth = 'standard', userId = null } = {}) { + start({ + question, + depth = 'standard', + userId = null, + llmProviderKeyId = '', + llmModel = '', + } = {}) { const normalizedQuestion = String(question ?? '').trim(); if (!normalizedQuestion || normalizedQuestion.length > 2000) { throw new Error('question must be 1-2000 characters'); } const normalizedDepth = Object.hasOwn(DEPTH_PROFILES, depth) ? depth : 'standard'; + const normalizedProviderKeyId = /^[a-zA-Z0-9._:-]{1,128}$/.test(String(llmProviderKeyId)) + ? String(llmProviderKeyId) + : ''; + const normalizedModel = normalizedProviderKeyId + ? String(llmModel ?? '').trim().slice(0, 200) + : ''; const taskId = idFactory(); - store.createTask({ id: taskId, userId, question: normalizedQuestion, depth: normalizedDepth }); + store.createTask({ + id: taskId, + userId, + question: normalizedQuestion, + depth: normalizedDepth, + llmProviderKeyId: normalizedProviderKeyId, + llmModel: normalizedModel, + }); const controller = new AbortController(); running.set(taskId, controller); queueMicrotask(() => executeTask(taskId, controller.signal)); @@ -690,7 +793,8 @@ export function createDeepSearchEngine({ service: 'tkmind-deep-search', running: running.size, tasks: store.getStats(), - llmEnabled: Boolean(llm), + llmEnabled: Boolean(llm || llmResolver?.configured), + llmMode: llmResolver?.configured ? 'portal-gateway' : (llm ? 'direct' : 'deterministic'), provider: searchProvider.name ?? 'custom', }; }, diff --git a/deep-search-llm-gateway.mjs b/deep-search-llm-gateway.mjs new file mode 100644 index 0000000..27f8245 --- /dev/null +++ b/deep-search-llm-gateway.mjs @@ -0,0 +1,63 @@ +import crypto from 'node:crypto'; + +function secureEqual(left, right) { + const a = Buffer.from(String(left ?? '')); + const b = Buffer.from(String(right ?? '')); + return a.length === b.length && crypto.timingSafeEqual(a, b); +} + +function normalizeMessages(value) { + if (!Array.isArray(value) || !value.length || value.length > 20) return null; + const messages = value.map((message) => ({ + role: ['system', 'user', 'assistant'].includes(message?.role) ? message.role : '', + content: String(message?.content ?? '').trim(), + })); + if (messages.some((message) => !message.role || !message.content || message.content.length > 120_000)) { + return null; + } + if (messages.reduce((total, message) => total + message.content.length, 0) > 150_000) return null; + return messages; +} + +export async function executeDeepSearchLlmGateway({ + llmProviderService, + expectedSecret, + providedSecret, + input = {}, +} = {}) { + if (!expectedSecret || !secureEqual(expectedSecret, providedSecret)) { + return { status: 401, body: { ok: false, message: 'Deep Search LLM gateway credential is invalid' } }; + } + if (!llmProviderService?.createChatCompletion) { + return { status: 503, body: { ok: false, message: 'LLM provider service is unavailable' } }; + } + const providerKeyId = String(input.providerKeyId ?? '').trim(); + const model = String(input.model ?? '').trim(); + const messages = normalizeMessages(input.messages); + if (!/^[a-zA-Z0-9._:-]{1,128}$/.test(providerKeyId) || !model || model.length > 200 || !messages) { + return { status: 400, body: { ok: false, message: 'Invalid Deep Search LLM request' } }; + } + const result = await llmProviderService.createChatCompletion({ + providerKeyId, + model, + messages, + temperature: Math.max(0, Math.min(1, Number(input.temperature ?? 0.2) || 0.2)), + }); + if (!result?.ok) { + return { + status: Number(result?.status) >= 400 ? Number(result.status) : 502, + body: { ok: false, message: result?.message || 'Deep Search LLM request failed' }, + }; + } + return { + status: 200, + body: { + ok: true, + reply: result.reply, + providerKeyId: result.providerKeyId, + providerId: result.providerId, + model: result.model, + usage: result.usage ?? null, + }, + }; +} diff --git a/deep-search-server.mjs b/deep-search-server.mjs index 9565d32..9cb2abd 100644 --- a/deep-search-server.mjs +++ b/deep-search-server.mjs @@ -1,7 +1,10 @@ import http from 'node:http'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { createDeepSearchEngine } from './deep-search-engine.mjs'; +import { + createDeepSearchEngine, + createPortalGatewayResearchLlmResolver, +} from './deep-search-engine.mjs'; import { createDeepSearchStore } from './deep-search-store.mjs'; function sendJson(res, status, body) { @@ -83,6 +86,8 @@ export function createDeepSearchHttpServer({ const task = engine.start({ ...input, userId: requestUserId || input.userId || null, + llmProviderKeyId: input.llmProviderKeyId || input.llm_provider_key_id || '', + llmModel: input.llmModel || input.llm_model || '', }); return sendJson(res, 202, { task_id: task.taskId, @@ -163,7 +168,11 @@ export function startDeepSearchServer({ logger = console, } = {}) { const store = createDeepSearchStore({ databasePath }); - const engine = createDeepSearchEngine({ store }); + const engine = createDeepSearchEngine({ + store, + llm: null, + llmResolver: createPortalGatewayResearchLlmResolver(), + }); const server = createDeepSearchHttpServer({ engine, logger }); server.listen(port, host, () => { logger.info?.(`[deep-search] listening on http://${host}:${port}`); diff --git a/deep-search-store.mjs b/deep-search-store.mjs index d67c1a9..0ca7b87 100644 --- a/deep-search-store.mjs +++ b/deep-search-store.mjs @@ -29,6 +29,8 @@ function mapTask(row) { userId: row.user_id || null, question: row.question, depth: row.depth, + llmProviderKeyId: row.llm_provider_key_id || '', + llmModel: row.llm_model || '', status: row.status, progress: row.progress, phase: row.phase, @@ -72,6 +74,8 @@ export function createDeepSearchStore({ user_id TEXT, question TEXT NOT NULL, depth TEXT NOT NULL, + llm_provider_key_id TEXT NOT NULL DEFAULT '', + llm_model TEXT NOT NULL DEFAULT '', status TEXT NOT NULL, progress INTEGER NOT NULL DEFAULT 0, phase TEXT NOT NULL DEFAULT 'queued', @@ -114,11 +118,18 @@ export function createDeepSearchStore({ CREATE INDEX IF NOT EXISTS idx_research_sources_task ON research_sources(task_id, score DESC); CREATE INDEX IF NOT EXISTS idx_research_events_task ON research_events(task_id, seq); `); + const taskColumns = new Set(db.prepare('PRAGMA table_info(research_tasks)').all().map((row) => row.name)); + if (!taskColumns.has('llm_provider_key_id')) { + db.exec("ALTER TABLE research_tasks ADD COLUMN llm_provider_key_id TEXT NOT NULL DEFAULT ''"); + } + if (!taskColumns.has('llm_model')) { + db.exec("ALTER TABLE research_tasks ADD COLUMN llm_model TEXT NOT NULL DEFAULT ''"); + } const createTaskStmt = db.prepare(` INSERT INTO research_tasks - (id, user_id, question, depth, status, progress, phase, created_at, updated_at) - VALUES (?, ?, ?, ?, 'queued', 0, 'queued', ?, ?) + (id, user_id, question, depth, llm_provider_key_id, llm_model, status, progress, phase, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 'queued', 0, 'queued', ?, ?) `); const getTaskStmt = db.prepare('SELECT * FROM research_tasks WHERE id = ?'); const listTasksStmt = db.prepare('SELECT * FROM research_tasks ORDER BY created_at DESC LIMIT ?'); @@ -165,9 +176,25 @@ export function createDeepSearchStore({ } return { - createTask({ id, userId = null, question, depth }) { + createTask({ + id, + userId = null, + question, + depth, + llmProviderKeyId = '', + llmModel = '', + }) { const timestamp = now(); - createTaskStmt.run(id, userId, question, depth, timestamp, timestamp); + createTaskStmt.run( + id, + userId, + question, + depth, + llmProviderKeyId, + llmModel, + timestamp, + timestamp, + ); appendEventStmt.run(id, 'queued', JSON.stringify({ depth }), timestamp); return getTask(id); }, diff --git a/deep-search.test.mjs b/deep-search.test.mjs index a24134c..d4e81b3 100644 --- a/deep-search.test.mjs +++ b/deep-search.test.mjs @@ -5,10 +5,12 @@ import { buildFallbackResearchPlan, canonicalizeUrl, createDeepSearchEngine, + createPortalGatewayResearchLlmResolver, extractEvidence, htmlToPlainText, readResearchSource, } from './deep-search-engine.mjs'; +import { executeDeepSearchLlmGateway } from './deep-search-llm-gateway.mjs'; import { createDeepSearchHttpServer } from './deep-search-server.mjs'; import { createDeepSearchStore } from './deep-search-store.mjs'; @@ -133,19 +135,23 @@ This model-generated analysis is intentionally long enough to pass report valida const engine = createDeepSearchEngine({ store, idFactory: () => 'task-llm', - llm: { - async plan() { - return { - research_plan: [{ - goal: 'Primary evidence', - queries: ['official evidence', 'independent evidence'], - sources: ['web'], - }], - }; - }, - async synthesize() { - return llmReport; - }, + llmResolver: async ({ providerKeyId, model }) => { + assert.equal(providerKeyId, 'provider-key-1'); + assert.equal(model, 'research-model'); + return { + async plan() { + return { + research_plan: [{ + goal: 'Primary evidence', + queries: ['official evidence', 'independent evidence'], + sources: ['web'], + }], + }; + }, + async synthesize() { + return llmReport; + }, + }; }, memorySink: async (payload) => { memoryPayload = payload; @@ -172,18 +178,75 @@ This model-generated analysis is intentionally long enough to pass report valida question: 'Evaluate the tested claim', depth: 'quick', userId: 'memory-user', + llmProviderKeyId: 'provider-key-1', + llmModel: 'research-model', }); const task = await waitFor(() => { const current = engine.getTask('task-llm'); return current.status === 'completed' ? current : null; }); assert.equal(task.report, llmReport); + assert.equal(task.llmProviderKeyId, 'provider-key-1'); + assert.equal(task.llmModel, 'research-model'); + assert.ok(task.events.some((event) => event.type === 'llm_selected')); assert.equal(memoryPayload.userId, 'memory-user'); assert.equal(memoryPayload.taskId, 'task-llm'); assert.ok(task.events.some((event) => event.type === 'memory_saved')); store.close(); }); +test('Deep Search portal LLM gateway keeps provider secrets inside the portal', async () => { + let request = null; + const unauthorized = await executeDeepSearchLlmGateway({ + llmProviderService: {}, + expectedSecret: 'expected', + providedSecret: 'wrong', + input: {}, + }); + assert.equal(unauthorized.status, 401); + const authorized = await executeDeepSearchLlmGateway({ + expectedSecret: 'expected', + providedSecret: 'expected', + input: { + providerKeyId: 'key-1', + model: 'model-1', + messages: [{ role: 'user', content: 'Plan research' }], + }, + llmProviderService: { + async createChatCompletion(input) { + request = input; + return { + ok: true, + providerKeyId: 'key-1', + providerId: 'custom', + model: 'model-1', + reply: '{"research_plan":[]}', + }; + }, + }, + }); + assert.equal(authorized.status, 200); + assert.equal(request.providerKeyId, 'key-1'); + assert.equal(request.model, 'model-1'); + + const resolver = createPortalGatewayResearchLlmResolver({ + endpoint: 'http://portal.local/api/internal/deep-search/llm', + secret: 'expected', + fetchImpl: async (_url, options) => { + const body = JSON.parse(options.body); + assert.equal(options.headers.authorization, 'Bearer expected'); + assert.equal(body.providerKeyId, 'key-1'); + return new Response(JSON.stringify({ ok: true, reply: '{"research_plan":[]}' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }, + }); + assert.equal(resolver.configured, true); + const resolved = await resolver({ providerKeyId: 'key-1', model: 'model-1' }); + assert.deepEqual(await resolved.plan('Question', 'quick'), { research_plan: [] }); +}); + test('Deep Search supports cancellation while a provider is running', async () => { const store = createDeepSearchStore({ databasePath: ':memory:' }); const engine = createDeepSearchEngine({ @@ -239,7 +302,12 @@ test('Deep Search HTTP service exposes health, async start, status, search, and const started = await fetch(`${base}/v1/research`, { method: 'POST', headers: { ...headers, 'x-user-id': 'user-a' }, - body: JSON.stringify({ question: 'Evaluate Deep Search', depth: 'quick' }), + body: JSON.stringify({ + question: 'Evaluate Deep Search', + depth: 'quick', + llm_provider_key_id: 'key-http', + llm_model: 'model-http', + }), }).then((response) => response.json()); assert.equal(started.task_id, 'task-http'); assert.equal((await fetch(`${base}/v1/research/${started.task_id}`, { @@ -253,5 +321,7 @@ test('Deep Search HTTP service exposes health, async start, status, search, and return task.status === 'completed' ? task : null; }); assert.equal(completed.progress, 100); + assert.equal(completed.llmProviderKeyId, 'key-http'); + assert.equal(completed.llmModel, 'model-http'); assert.ok(completed.sources.every((source) => !Object.hasOwn(source, 'content'))); }); diff --git a/docs/deep-search-service.md b/docs/deep-search-service.md index 376bdaa..5059a22 100644 --- a/docs/deep-search-service.md +++ b/docs/deep-search-service.md @@ -15,7 +15,7 @@ The standalone runtime uses the built-in `node:sqlite` module and therefore requ 7. Generate a Markdown report with numbered citations. 8. Persist task state, sources, events, report, and research memory in SQLite. -The planner and report writer use an OpenAI-compatible model when configured. If the model is unavailable, deterministic planning and citation-safe extractive reporting keep the service operational. +The planner and report writer use an optional model selected from TKMind's Unified Model Center. MindSearch stores only the Provider Key ID and model name. Deep Search calls a secret-protected Portal gateway, and the provider API key never leaves the Portal process. If the selected model is unavailable, deterministic planning and citation-safe extractive reporting keep the service operational. `createDeepSearchEngine` also accepts an explicit `memorySink` callback. It is disabled by default; an in-process TKMind integration can inject `memoryV2.write` without giving the standalone service an unrestricted outbound memory endpoint. Memory sink failures are fail-open and recorded as task events. @@ -24,6 +24,7 @@ The planner and report writer use an OpenAI-compatible model when configured. If ```bash TKMIND_DEEP_SEARCH_SEARXNG_URL=http://127.0.0.1:8080/search \ TKMIND_DEEP_SEARCH_DB=.deep-search/research.sqlite \ +TKMIND_DEEP_SEARCH_SECRET=local-shared-secret \ npm run dev:deep-search ``` @@ -70,14 +71,14 @@ Start response: | `TKMIND_DEEP_SEARCH_DB` | `.deep-search/research.sqlite` | SQLite database | | `TKMIND_DEEP_SEARCH_SEARXNG_URL` | `http://127.0.0.1:8080/search` | Upstream search endpoint | | `TKMIND_DEEP_SEARCH_SECRET` | empty | Optional `X-Secret-Key` required by non-health routes | -| `TKMIND_DEEP_SEARCH_LLM_URL` | empty | OpenAI-compatible chat-completions endpoint | -| `TKMIND_DEEP_SEARCH_LLM_API_KEY` | empty | Optional model API key | -| `TKMIND_DEEP_SEARCH_LLM_MODEL` | empty | Planner and report model | +| `TKMIND_DEEP_SEARCH_LLM_GATEWAY_URL` | `http://127.0.0.1:8081/api/internal/deep-search/llm` | Portal LLM gateway | +| `TKMIND_DEEP_SEARCH_LLM_GATEWAY_SECRET` | `TKMIND_DEEP_SEARCH_SECRET` | Portal gateway bearer secret | ## Isolation and safety - The default bind address is loopback only. - An optional service secret protects all non-health endpoints. +- The same secret can authenticate the local service to the Portal LLM gateway. - MCP forwards the current TKMind user ID as `X-User-Id`. - Task status, event, and cancellation routes enforce user ownership when a user ID is present. - Source reading rejects credentials, loopback, link-local, private, multicast, and private-DNS destinations. @@ -94,11 +95,15 @@ The built-in disabled service is: "adapter": "research-http", "endpoint": "http://127.0.0.1:20100", "healthPath": "/health", - "enabled": false + "enabled": false, + "llmProviderKeyId": "", + "llmModel": "" } ``` -After local verification, enable the service and MindSearch in `memindadm`, then select `deep-search` for the `research` route. New Goose sessions receive: +In `memindadm` → `MindSearch` → `TKMind Deep Search`, select an optional active Provider configuration and one of its models. The list comes directly from Unified Model Center. The `测试 LLM` action checks that exact Provider/model combination before saving the MindSearch configuration. Selecting “不使用 LLM(确定性降级)” leaves both fields empty. + +After local verification, enable the service and MindSearch, then select `deep-search` for the `research` route. New Goose sessions receive: - `tkmind_research` - `tkmind_research_status` diff --git a/mindsearch-config.mjs b/mindsearch-config.mjs index ba598b9..021baf5 100644 --- a/mindsearch-config.mjs +++ b/mindsearch-config.mjs @@ -53,6 +53,8 @@ const BUILTIN_SERVICES = Object.freeze([ enabled: false, timeoutMs: 120000, priority: 100, + llmProviderKeyId: '', + llmModel: '', }, ]); @@ -104,6 +106,14 @@ function normalizeService(input = {}, fallback = {}) { if (!isValidSearchServiceEndpoint(endpoint)) return null; const healthPathValue = String(input.healthPath ?? fallback.healthPath ?? '').trim(); const healthPath = healthPathValue && /^\/[^\s]{0,255}$/.test(healthPathValue) ? healthPathValue : ''; + const llmProviderKeyIdValue = String(input.llmProviderKeyId ?? fallback.llmProviderKeyId ?? '').trim(); + const llmProviderKeyId = /^[a-zA-Z0-9._:-]{1,128}$/.test(llmProviderKeyIdValue) + ? llmProviderKeyIdValue + : ''; + const llmModelValue = String(input.llmModel ?? fallback.llmModel ?? '').trim(); + const llmModel = llmProviderKeyId && !/[\u0000-\u001f\u007f]/.test(llmModelValue) + ? llmModelValue.slice(0, 200) + : ''; return { id, name: String(input.name ?? fallback.name ?? id).trim().slice(0, 80) || id, @@ -115,6 +125,8 @@ function normalizeService(input = {}, fallback = {}) { enabled: bool(input.enabled, bool(fallback.enabled)), timeoutMs: boundedNumber(input.timeoutMs, fallback.timeoutMs ?? 8000, 1000, 120000), priority: boundedNumber(input.priority, fallback.priority ?? 100, 1, 1000), + llmProviderKeyId, + llmModel, }; } diff --git a/mindsearch-providers.mjs b/mindsearch-providers.mjs index e27bc6c..d21929f 100644 --- a/mindsearch-providers.mjs +++ b/mindsearch-providers.mjs @@ -90,6 +90,8 @@ export async function startResearchTask(question, { endpoint, depth = 'standard', userId = null, + llmProviderKeyId = '', + llmModel = '', timeoutMs = 30000, fetchImpl = fetch, secret = process.env.TKMIND_DEEP_SEARCH_SECRET, @@ -98,6 +100,9 @@ export async function startResearchTask(question, { question, depth, ...(userId ? { userId } : {}), + ...(llmProviderKeyId && llmModel + ? { llm_provider_key_id: llmProviderKeyId, llm_model: llmModel } + : {}), }, { timeoutMs, fetchImpl, userId, secret }); if (!body || typeof body !== 'object' || !body.task_id) throw new Error('Research service returned an invalid task'); return { diff --git a/mindsearch.test.mjs b/mindsearch.test.mjs index 7980bc8..42cc1cb 100644 --- a/mindsearch.test.mjs +++ b/mindsearch.test.mjs @@ -43,6 +43,8 @@ test('MindSearch service registry accepts research orchestrators and rejects uns capabilities: ['search.web', 'research.execute', 'research.execute', 'INVALID CAPABILITY'], enabled: true, timeoutMs: 500000, + llmProviderKeyId: 'provider-key-1', + llmModel: 'research-model', }, { id: 'metadata', adapter: 'research-http', endpoint: 'http://169.254.169.254/latest', enabled: true }, ], @@ -51,6 +53,8 @@ test('MindSearch service registry accepts research orchestrators and rejects uns assert.equal(config.services.some((service) => service.id === 'metadata'), false); assert.deepEqual(config.services.find((service) => service.id === 'research-main').capabilities, ['search.web', 'research.execute']); assert.equal(config.services.find((service) => service.id === 'research-main').timeoutMs, 120000); + assert.equal(config.services.find((service) => service.id === 'research-main').llmProviderKeyId, 'provider-key-1'); + assert.equal(config.services.find((service) => service.id === 'research-main').llmModel, 'research-model'); assert.equal(resolveMindSearchService(config, 'research').id, 'research-main'); }); @@ -146,6 +150,8 @@ test('Research service adapter uses the stable HTTP contract', async () => { endpoint: 'http://research.local', depth: 'deep', userId: 'user-1', + llmProviderKeyId: 'provider-key-1', + llmModel: 'research-model', secret: 'secret-1', fetchImpl, }); @@ -160,6 +166,8 @@ test('Research service adapter uses the stable HTTP contract', async () => { question: 'Analyze AI Agent', depth: 'deep', userId: 'user-1', + llm_provider_key_id: 'provider-key-1', + llm_model: 'research-model', }); assert.equal(requests[1].options.headers['x-user-id'], 'user-1'); assert.equal(requests[1].options.headers['x-secret-key'], 'secret-1'); diff --git a/server.mjs b/server.mjs index 489224e..c2ed47a 100644 --- a/server.mjs +++ b/server.mjs @@ -198,6 +198,7 @@ import { createScheduleService } from './schedule-service.mjs'; import { createFeedbackService } from './user-feedback.mjs'; import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs'; import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.mjs'; +import { executeDeepSearchLlmGateway } from './deep-search-llm-gateway.mjs'; import { createDirectChatService, isDirectChatSessionId, isPortalDirectChatSnapshot, sendDirectChatSessionEvents, shouldExpirePortalDirectChatSnapshot } from './direct-chat-service.mjs'; import { filterUserVisibleConversation, @@ -254,6 +255,7 @@ const API_TARGETS = parseApiTargets(); const API_TARGET = API_TARGETS[0] ?? 'https://127.0.0.1:18006'; const API_SECRET = process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret'; const INTERNAL_AGENT_SECRET = process.env.MINDSPACE_INTERNAL_AGENT_SECRET ?? API_SECRET; +const DEEP_SEARCH_INTERNAL_SECRET = process.env.TKMIND_DEEP_SEARCH_SECRET ?? INTERNAL_AGENT_SECRET; const mindSpaceServerRuntime = resolveMindSpaceServerRuntimeOptions(__dirname, process.env); const ACCESS_PASSWORD = process.env.H5_ACCESS_PASSWORD; const WECHAT_MP_CONFIG = loadWechatMpConfig(); @@ -2741,6 +2743,24 @@ function requireInternalAgentSecret(req, res) { return false; } +api.post('/internal/deep-search/llm', async (req, res) => { + try { + const result = await executeDeepSearchLlmGateway({ + llmProviderService, + expectedSecret: DEEP_SEARCH_INTERNAL_SECRET, + providedSecret: bearerToken(req) ?? req.get('x-secret-key'), + input: req.body ?? {}, + }); + return res.status(result.status).json(result.body); + } catch (error) { + console.warn('[deep-search] LLM gateway failed:', error); + return res.status(502).json({ + ok: false, + message: error instanceof Error ? error.message : 'Deep Search LLM gateway failed', + }); + } +}); + api.post('/mindspace/v1/agent/jobs', async (req, res) => { if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; try { diff --git a/tkmind-search-mcp.mjs b/tkmind-search-mcp.mjs index 20a6bfb..da459cd 100644 --- a/tkmind-search-mcp.mjs +++ b/tkmind-search-mcp.mjs @@ -107,6 +107,8 @@ function handle(message) { endpoint: service.endpoint, depth, userId: process.env.TKMIND_SEARCH_USER_ID || null, + llmProviderKeyId: service.llmProviderKeyId || '', + llmModel: service.llmModel || '', timeoutMs: service.timeoutMs, }) .then((result) => response(id, { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result }))