token count for search

This commit is contained in:
sebseb7
2026-04-04 21:51:52 +02:00
parent 92b313fa79
commit d2920fd39a
10 changed files with 477 additions and 30 deletions

9
package-lock.json generated
View File

@@ -13,7 +13,8 @@
"chalk": "^5.6.2",
"dotenv": "^17.2.3",
"exa-js": "^2.11.0",
"express": "^5.2.1"
"express": "^5.2.1",
"tiktoken": "^1.0.22"
}
},
"node_modules/@openrouter/sdk": {
@@ -889,6 +890,12 @@
"node": ">= 0.8"
}
},
"node_modules/tiktoken": {
"version": "1.0.22",
"resolved": "https://registry.npmjs.org/tiktoken/-/tiktoken-1.0.22.tgz",
"integrity": "sha512-PKvy1rVF1RibfF3JlXBSP0Jrcw2uq3yXdgcEXtKTYn3QJ/cBRBHDnrJ5jHky+MENZ6DIPwNUGWpkVx+7joCpNA==",
"license": "MIT"
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",

View File

@@ -16,6 +16,7 @@
"chalk": "^5.6.2",
"dotenv": "^17.2.3",
"exa-js": "^2.11.0",
"express": "^5.2.1"
"express": "^5.2.1",
"tiktoken": "^1.0.22"
}
}

View File

@@ -289,6 +289,67 @@
text-decoration: underline;
}
/* Cost Breakdown Styles */
.cost-breakdown-details {
margin-top: 10px;
}
.cost-breakdown-summary {
cursor: pointer;
font-weight: 600;
color: #555;
padding: 12px 15px;
background: #f8f9fa;
border-radius: 6px;
border-left: 4px solid #667eea;
transition: background 0.2s;
list-style: none;
}
.cost-breakdown-summary:hover {
background: #e9ecef;
}
.cost-breakdown-summary::-webkit-details-marker {
display: none;
}
.cost-breakdown-content {
padding: 15px;
margin-top: 10px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 6px;
}
.cost-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.cost-table th,
.cost-table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
.cost-table th {
background: #f8f9fa;
font-weight: 600;
color: #555;
}
.cost-table tr:last-child td {
border-bottom: none;
font-weight: 600;
}
.cost-table tbody tr:hover {
background: #f8f9fa;
}
.error {
background: #fee;
color: #c00;
@@ -464,6 +525,7 @@
<button class="example-btn" onclick="setExample('Wie ist die Lage im Iran?')">Lage im Iran</button>
<button class="example-btn" onclick="setExample('Welche KI Modelle wurden in den letzten Tagen veröffentlicht?')">Neue KI-Modelle</button>
<button class="example-btn" onclick="setExample('Wie ist das Wetter in Dresden?')">Wetter in Dresden</button>
<button class="example-btn" onclick="setExample('Was ist neu in React 19.2?')">React 19.2</button>
</div>
<div class="search-box">
@@ -493,6 +555,29 @@
<h2>🔗 Most Relevant Sources</h2>
<ul class="sources" id="sources"></ul>
</div>
<!-- Cost Breakdown Section -->
<div class="result-section">
<details class="cost-breakdown-details" id="costBreakdownDetails">
<summary class="cost-breakdown-summary">💰 Cost Breakdown</summary>
<div class="cost-breakdown-content">
<table class="cost-table" id="costTable">
<thead>
<tr>
<th>#</th>
<th>Type</th>
<th>Input</th>
<th>Output</th>
<th>Cost (USD)</th>
</tr>
</thead>
<tbody id="costTableBody">
<!-- Cost rows will be inserted here -->
</tbody>
</table>
</div>
</details>
</div>
</div>
<!-- Log Panel -->
@@ -532,6 +617,10 @@
let eventSource = null;
let logEntries = [];
// Clarification state
let isAwaitingClarification = false;
let clarificationData = null;
function connectToSSE() {
eventSource = new EventSource('/stream');
@@ -680,7 +769,108 @@
}
}
async function performSearchWithClarification(clarificationText) {
if (!clarificationData || !clarificationData.originalQuestion) {
showError('Clarification data not available');
return;
}
// Show loading
hideError();
hideResults();
showLoading();
try {
const response = await fetch('/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
question: clarificationData.originalQuestion + ' ' + clarificationText,
previousClarification: clarificationText,
originalQuestion: clarificationData.originalQuestion
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Search failed');
}
displayResults(data);
} catch (err) {
showError(err.message);
} finally {
hideLoading();
}
}
function displayResults(data) {
// Check if clarification is needed
if (data.clarificationNeeded) {
isAwaitingClarification = true;
clarificationData = {
originalQuestion: data.originalQuestion
};
// Show clarification prompt in the answer area
answerEl.innerHTML = `
<div style="background: #fff3cd; padding: 20px; border-radius: 8px; border-left: 4px solid #ffc107;">
<p style="margin-bottom: 15px; font-weight: 600;">${data.fullAnswerHTMLSnippet}</p>
<input
type="text"
id="clarificationInput"
class="search-input"
style="margin-bottom: 10px;"
>
<button
class="search-btn"
id="clarificationSubmitBtn"
style="padding: 10px 20px; font-size: 1rem;"
>
Submit Clarification
</button>
</div>
`;
// Clear sources since we're waiting for clarification
sourcesEl.innerHTML = '<li>Waiting for clarification...</li>';
// Add event listener for clarification submission
setTimeout(() => {
const clarificationInput = document.getElementById('clarificationInput');
const clarificationSubmitBtn = document.getElementById('clarificationSubmitBtn');
if (clarificationInput && clarificationSubmitBtn) {
clarificationInput.focus();
const handleClarification = () => {
const clarificationText = clarificationInput.value.trim();
if (clarificationText) {
// Search again with the clarification
performSearchWithClarification(clarificationText);
}
};
clarificationSubmitBtn.addEventListener('click', handleClarification);
clarificationInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
handleClarification();
}
});
}
}, 100);
showResults();
return;
}
// Reset clarification state for normal results
isAwaitingClarification = false;
clarificationData = null;
// Display answer as HTML
answerEl.innerHTML = data.fullAnswerHTMLSnippet || '<p>No answer generated</p>';
@@ -703,6 +893,27 @@
sourcesEl.appendChild(li);
}
// Display cost breakdown
const costTableBody = document.getElementById('costTableBody');
costTableBody.innerHTML = '';
if (data.costBreakdown && data.costBreakdown.length > 0) {
data.costBreakdown.forEach(item => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${item.index}</td>
<td>${item.type}</td>
<td>${item.prompt_tokens || '-'}</td>
<td>${item.completion_tokens || '-'}</td>
<td>${item.cost}</td>
`;
costTableBody.appendChild(tr);
});
} else {
const tr = document.createElement('tr');
tr.innerHTML = '<td colspan="5">No cost data available</td>';
costTableBody.appendChild(tr);
}
showResults();
}

View File

@@ -19,9 +19,6 @@ function createSimpleBroadcast() {
const timestamp = new Date().toLocaleTimeString();
const prefix = type === 'error' ? '❌' : type === 'warning' ? '⚠️' : type === 'success' ? '✅' : '';
console.log(`[${timestamp}] ${prefix} ${message}`);
if (data) {
console.log(` Data: ${JSON.stringify(data, null, 2)}`);
}
};
}

View File

@@ -5,7 +5,7 @@ export function createSearchRouter(searchService, broadcast) {
const router = Router();
router.post('/', async (request, response) => {
const { question } = request.body;
const { question, previousClarification, originalQuestion } = request.body;
if (!question) {
response.status(400).json({
@@ -16,7 +16,7 @@ export function createSearchRouter(searchService, broadcast) {
}
try {
const result = await searchService.search(question);
const result = await searchService.search(question, previousClarification, originalQuestion);
response.json(result);
} catch (error) {
if (error instanceof SearchServiceError) {

View File

@@ -33,7 +33,7 @@ function formatTimestamp() {
* @param {Object} response - The response from OpenRouter
* @param {Error} [error] - Optional error if the call failed
*/
export async function logOpenRouterCall(operation, params, response, error = null) {
export async function logOpenRouterCall(operation, text, params, response, error = null) {
try {
await ensureLogDirectory();
@@ -43,7 +43,7 @@ export async function logOpenRouterCall(operation, params, response, error = nul
const logContent = `${timestamp} | ${operation}\n\nParams:\n${JSON.stringify(params, null, 2)}\n\nResponse:\n${JSON.stringify(response, null, 2)}${error ? `\n\nError: ${error.message}` : ''}\n`;
await fs.writeFile(logFilePath, logContent, 'utf8');
await fs.writeFile(logFilePath, text + logContent, 'utf8');
} catch (logError) {
console.error('Failed to log OpenRouter call:', logError);
// Don't throw - logging should not break the main functionality

View File

@@ -2,8 +2,13 @@ import { logOpenRouterCall } from './openRouterLogger.js';
function parseResponse(response) {
if(!response?.usage?.cost) console.log(response);
return { cost: response?.usage?.cost, data: JSON.parse(response.choices[0].message.content) };
console.log('OpenRouter API call cost:', response?.usage);
return {
cost: response?.usage?.cost,
prompt_tokens: response?.usage?.prompt_tokens,
completion_tokens: response?.usage?.completion_tokens,
data: JSON.parse(response.choices[0].message.content)
};
}
export async function summarizeSources({ openrouter, text, question }) {
@@ -60,7 +65,95 @@ export async function summarizeSources({ openrouter, text, question }) {
body: JSON.stringify(params),
});
const response = await fetchResponse.json();
await logOpenRouterCall('summarizeSources', params, response);
await logOpenRouterCall('summarizeSources', text, params, response);
return parseResponse(response);
}
export async function rephraseQuestion({ question, previousClarification, originalQuestion }) {
if(previousClarification) {
const prompt = `
You are a search query expert. You are given a question and you return
a search query for a web search engine that would return the best results to answer the question.
Do NOT restrict the query using site: operator.
Also give a list of 2 supplementary search queries to deepen the search.
Today is the date of ${new Date().toLocaleDateString()}.
The user has provided this clarification "${previousClarification}" to the original question: ` + originalQuestion;
const params = {
model: 'openai/gpt-5.4-mini',
messages: [
{ role: 'system', content: prompt },
{ role: 'user', content: question },
],
reasoning: { effort: 'none' },
stream: false,
response_format: {
type: 'json_schema', json_schema: {
name: 'queries', strict: true, schema: {
required: [ 'query', 'supplementaryQueries'], type: 'object', additionalProperties: false, properties: {
query: { type: 'string' },
supplementaryQueries: { type: 'array', items: { type: 'string' } },
} } } }
};
const apiKey = process.env.OPENROUTER_API_KEY;
const fetchResponse = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
});
const response = await fetchResponse.json();
await logOpenRouterCall('rephraseQuestion', question,params, response);
return parseResponse(response);
}
const prompt = `
You are a search query expert. You are given a question and you return
a search query for a web search engine that would return the best results to answer the question.
Do NOT restrict the query using site: operator.
Also give a list of 2 supplementary search queries to deepen the search.
Today is the date of ${new Date().toLocaleDateString()}.
The user cannot ask questions that a web search engine cannot answer.
Like "Who are you".
If the question is ambiguous or unsuited for a web search, you can ask for clarification.
${previousClarification ? `The user has provided this clarification to the original question: "${previousClarification}". Use this clarification to refine the search query.` : ''}
`;
const params = {
model: 'openai/gpt-5.4-mini',
messages: [
{ role: 'system', content: prompt },
{ role: 'user', content: question },
],
reasoning: { effort: 'none' },
stream: false,
response_format: {
type: 'json_schema', json_schema: {
name: 'queries', strict: true, schema: {
required: ['needsClarification', 'clarification', 'query', 'supplementaryQueries'], type: 'object', additionalProperties: false, properties: {
needsClarification: { type: 'boolean', description: 'Indicates if the question is ambiguous and needs clarification' },
clarification: { type: 'string', description: 'If needsClarification is true, this field contains the clarification question to ask the user. Otherwise, it is an empty string.' },
query: { type: 'string' },
supplementaryQueries: { type: 'array', items: { type: 'string' } },
} } } }
};
const apiKey = process.env.OPENROUTER_API_KEY;
const fetchResponse = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
});
const response = await fetchResponse.json();
await logOpenRouterCall('rephraseQuestion', question,params, response);
return parseResponse(response);
}
@@ -68,8 +161,8 @@ export async function summarizeFinalAnswer({ openrouter, text, question }) {
const prompt = `
You are a search result analyst. Today is the date of ${new Date().toLocaleDateString()}.
Based on the following search results for the query "${question}",
Summarize the search results to answer the original query. Use Emoji and HTML. Tags allowed: <b>, <i>, <u>, <ul>, <li>, <span style="color:...">, <p> <div> <hr/>
Also provide the most relevant sources.
Summarize the search results to answer the original query. Use Emoji and HTML. Tags allowed: <b>, <i>, <u>, <pre>, <ul>, <li>, <span style="color:...">, <p> <div> <hr/>
Also provide the most relevant sources. Answer in the language of the question.
`;
const params = {
@@ -112,7 +205,7 @@ export async function summarizeFinalAnswer({ openrouter, text, question }) {
});
const response = await fetchResponse.json();
await logOpenRouterCall('summarizeFinalAnswer', params, response);
await logOpenRouterCall('summarizeFinalAnswer', text, params, response);
return parseResponse(response);
}

View File

@@ -19,7 +19,7 @@ function wrapText(text, maxLineLength = 68) {
return output;
}
export function formatSummarySources(sources) {
export function formatSummarySources(sources,supplementaryResults=[]) {
let output = `\n${'='.repeat(70)}\n`;
output += ` ${'SUMMARY'.padStart(35).padEnd(69)}\n`;
output += `${'='.repeat(70)}\n`;
@@ -38,6 +38,13 @@ export function formatSummarySources(sources) {
}
});
if (supplementaryResults.length > 0) {
supplementaryResults.forEach((result) => {
output += `\n${'-'.repeat(70)}\n`;
output += JSON.stringify(result, null, 2);
});
}
output += `\n${'='.repeat(70)}\n`;
output += `Total sources: ${sources.length}\n`;
output += `${'='.repeat(70)}\n`;

View File

@@ -1,7 +1,16 @@
import { extractContent } from './extractContent.js';
import { logExtraction } from './extractionLogger.js';
import { summarizeFinalAnswer, summarizeSources } from './openRouterService.js';
import { summarizeFinalAnswer, summarizeSources, rephraseQuestion } from './openRouterService.js';
import { formatSummarySources } from './searchFormatter.js';
import { get_encoding } from 'tiktoken';
const encoding = get_encoding('cl100k_base');
function countTokens(text) {
if (!text) return 0;
const tokens = encoding.encode(text);
return tokens.length;
}
const EXA_SEARCH_OPTIONS = {
numResults: 10,
@@ -72,18 +81,114 @@ function enrichSourcesWithDetails(sources, detailedContents) {
}
}
// Helper function to build cost breakdown
function buildCostBreakdown(cost) {
// Print cost breakdown as a formatted table
console.log('\n=== Cost Breakdown ===');
console.table(
cost.map((item, index) => ({
'#': index + 1,
Type: item.type,
'Cost (USD)': `$${item.amount.toFixed(6)}`,
})),
);
const totalCost = cost.reduce((sum, item) => sum + item.amount, 0);
console.log(`Total: $${totalCost.toFixed(6)}\n`);
// Build cost breakdown table for API response
const costBreakdown = cost.map((item, index) => ({
index: index + 1,
type: item.type,
prompt_tokens: item.prompt_tokens,
completion_tokens: item.completion_tokens,
cost: `$${item.amount.toFixed(6)}`,
}));
// Add total to the breakdown
costBreakdown.push({
index: costBreakdown.length + 1,
type: 'Total',
cost: `$${totalCost.toFixed(6)}`,
});
return costBreakdown;
}
export function createSearchService({ exa, openrouter, broadcast }) {
return {
async search(question) {
broadcast(`🔍 Search request: "${question}"`, 'info', { question });
async search(question, previousClarification, originalQuestion) {
let finalSummary;
const cost=[];
let rephrasedQuestion;
broadcast('Searching with Exa...', 'info');
const result = await exa.search(question, EXA_SEARCH_OPTIONS);
try {
const rephraseResult = await rephraseQuestion({question, previousClarification, originalQuestion});
rephrasedQuestion = rephraseResult.data;
cost.push({
type:'openrouter_rephrase',
amount: rephraseResult.cost,
prompt_tokens: rephraseResult.prompt_tokens,
completion_tokens: rephraseResult.completion_tokens
});
} catch (error) {
throw new SearchServiceError('Failed to generate summary', 500, error);
}
if (rephrasedQuestion.needsClarification) {
finalSummary = {
fullAnswerHTMLSnippet: rephrasedQuestion.clarification,
clarificationNeeded: true,
originalQuestion: question,
mostRelevantSources: [],
};
// Attach cost breakdown to the final summary
finalSummary.costBreakdown = buildCostBreakdown(cost);
return finalSummary;
}
// Limit to first 2 supplementary queries
const limitedQueries = rephrasedQuestion.supplementaryQueries.slice(0, 2);
//limitedQueries.push(rephrasedQuestion.query);
const supplementaryResults = [];
for (const item of limitedQueries) {
console.log('Supplementary Query:', item);
const result = await exa.search(item, {
numResults: 2,
type: 'auto',
contents: {
highlights: {
maxCharacters: 2000,
},
},
});
supplementaryResults.push({
query: item,
highlights: result.results.map(r => r.highlights),
});
const outputTokens = countTokens(JSON.stringify(result.results));
cost.push({
type: 'exa_search_complement',
amount: result.costDollars.total,
prompt_tokens: 0,
completion_tokens: outputTokens
});
}
broadcast('Searching with Exa for '+rephrasedQuestion.query, 'info');
const result = await exa.search(rephrasedQuestion.query, EXA_SEARCH_OPTIONS);
broadcast(`✅ Found ${result.results.length} results`, 'success', {
count: result.results.length,
});
const cost=[];
cost.push({type:'exa_search', amount: result.costDollars.total});
const outputTokens = countTokens(JSON.stringify(result.results));
cost.push({
type:'exa_search',
amount: result.costDollars.total,
prompt_tokens: 0,
completion_tokens: outputTokens
});
const extractedContent = extractContent(result);
@@ -105,13 +210,18 @@ export function createSearchService({ exa, openrouter, broadcast }) {
question,
});
summary = summaryResult.data;
cost.push({type:'openrouter_summarize', amount: summaryResult.cost});
cost.push({
type:'openrouter_rank',
amount: summaryResult.cost,
prompt_tokens: summaryResult.prompt_tokens,
completion_tokens: summaryResult.completion_tokens
});
} catch (error) {
throw new SearchServiceError('Failed to generate summary', 500, error);
}
if (!summary?.sources) {
throw new SearchServiceError('Failed to generate summary');
throw new SearchServiceError('Failed to generate summary for query: ' + question + '. No sources returned:'+ JSON.stringify(summary, null, 2));
}
broadcast(`✅ Generated summaries for ${summary.sources.length} sources`, 'success', {
@@ -126,25 +236,35 @@ export function createSearchService({ exa, openrouter, broadcast }) {
broadcast,
});
broadcast('🔧 Enhancing summaries with detailed content...', 'info');
enrichSourcesWithDetails(summary.sources, detailedContents);
for (const detailed of detailedContents) {
if (detailed.cost) {
cost.push({type:'exa_get_content', amount: detailed.cost});
const outputTokens = countTokens(JSON.stringify(detailed.content));
cost.push({
type:'exa_get_content',
amount: detailed.cost,
prompt_tokens: 0,
completion_tokens: outputTokens
});
}
}
broadcast('📊 Generating final summary...', 'info');
let finalSummary;
try {
const finalSummaryResult = await summarizeFinalAnswer({
openrouter,
text: formatSummarySources(summary.sources),
text: formatSummarySources(summary.sources,supplementaryResults),
question,
});
finalSummary = finalSummaryResult.data;
cost.push({type:'openrouter_final_summary', amount: finalSummaryResult.cost});
cost.push({
type:'openrouter_final_summary',
amount: finalSummaryResult.cost,
prompt_tokens: finalSummaryResult.prompt_tokens,
completion_tokens: finalSummaryResult.completion_tokens
});
} catch (error) {
throw new SearchServiceError('Failed to generate final summary', 500, error);
}
@@ -158,6 +278,9 @@ export function createSearchService({ exa, openrouter, broadcast }) {
sourcesCount: finalSummary.mostRelevantSources?.length || 0,
});
// Attach cost breakdown to the final summary
finalSummary.costBreakdown = buildCostBreakdown(cost);
return finalSummary;
},
};

View File

@@ -59,6 +59,14 @@ export function renderHTML(html) {
// Handle <div>content</div> - block with newline
text = text.replace(/<div>([\s\S]*?)<\/div>/gi, (match, content) => `${content.trim()}\n\n`);
// Handle <pre>content</pre> - preformatted text with monospace styling
text = text.replace(/<pre>([\s\S]*?)<\/pre>/gi, (match, content) => {
// Preserve internal whitespace and newlines, apply dimmed styling for code-like appearance
const lines = content.split('\n');
const formattedLines = lines.map(line => chalk.dim(line));
return '\n' + formattedLines.join('\n') + '\n\n';
});
// Clean up any remaining HTML tags that weren't processed
text = text.replace(/<[^>]+>/g, '');