add configurable max_tokens per AI activity

Allows users to configure the max_tokens sent to the AI endpoint for
each activity (tagging, description, extraction, translation) individually,
with per-library overrides following the same pattern as model overrides.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Garret Patti
2026-04-13 13:57:07 -04:00
parent 236f168eeb
commit 2fc9a34626
6 changed files with 219 additions and 17 deletions

View File

@@ -171,7 +171,8 @@ async function callVisionApi(
endpoint: string,
model: string,
base64Images: string[],
systemPrompt: string
systemPrompt: string,
maxTokens: number,
): Promise<string[]> {
const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
@@ -195,7 +196,7 @@ async function callVisionApi(
})),
},
],
max_tokens: 8192,
max_tokens: maxTokens,
temperature: 0.1,
}),
})
@@ -338,7 +339,7 @@ export async function tagSingleItem(itemKey: string): Promise<string[]> {
customInstruction: config.promptTagger || undefined,
})
const suggestedIds = await callVisionApi(config.endpoint, taggingModel, base64Images, systemPromptWithContext)
const suggestedIds = await callVisionApi(config.endpoint, taggingModel, base64Images, systemPromptWithContext, config.maxTokensTag)
const validIds = suggestedIds.filter((id) => validTagIds.has(id))
for (const tagId of validIds) {
@@ -359,7 +360,8 @@ async function callVisionApiText(
endpoint: string,
model: string,
base64Images: string[],
systemPrompt: string
systemPrompt: string,
maxTokens: number,
): Promise<string> {
const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
@@ -383,7 +385,7 @@ async function callVisionApiText(
})),
},
],
max_tokens: 8192,
max_tokens: maxTokens,
temperature: 0.1,
}),
})
@@ -410,7 +412,8 @@ async function callChatApiText(
endpoint: string,
model: string,
systemPrompt: string,
userMessage: string
userMessage: string,
maxTokens: number,
): Promise<string> {
const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
@@ -428,7 +431,7 @@ async function callChatApiText(
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage },
],
max_tokens: 8192,
max_tokens: maxTokens,
temperature: 0.1,
}),
})
@@ -496,7 +499,7 @@ export async function generateItemDescription(itemKey: string): Promise<string>
: ''
const systemPrompt = `You are a media cataloging assistant. Describe the given image briefly and objectively in 1-3 sentences.${config.promptDescribe ? ' ' + config.promptDescribe : ''}${tagContext}`
const description = await callVisionApiText(config.endpoint, describeModel, base64Images, systemPrompt)
const description = await callVisionApiText(config.endpoint, describeModel, base64Images, systemPrompt, config.maxTokensDescribe)
db.prepare('UPDATE media_items SET ai_description = ? WHERE item_key = ?').run(description, itemKey)
@@ -585,7 +588,7 @@ Rules:
systemPrompt = `You are an OCR assistant. Extract ALL text visible in the image exactly as it appears. Preserve line breaks and formatting.${customInstruction} If there is no text in the image, respond with exactly: [NO TEXT]`
}
const rawResponse = await callVisionApiText(config.endpoint, extractModel, base64Images, systemPrompt)
const rawResponse = await callVisionApiText(config.endpoint, extractModel, base64Images, systemPrompt, config.maxTokensExtract)
// Parse the response — structured JSON when a preferred language is set, plain text otherwise
let extractedText: string
@@ -618,7 +621,7 @@ Rules:
if (preferredLanguage && needsTranslation) {
const translateModel = config.modelTranslate || config.model
try {
translatedText = await translateText(config.endpoint, translateModel, extractedText, preferredLanguage, config.promptTranslate)
translatedText = await translateText(config.endpoint, translateModel, extractedText, preferredLanguage, config.promptTranslate, config.maxTokensTranslate)
if (translatedText) {
db.prepare('UPDATE media_items SET extracted_text_translated = ? WHERE item_key = ?').run(translatedText, itemKey)
}
@@ -656,7 +659,7 @@ export async function translateItemText(itemKey: string, sourceLanguage?: string
const preferredLanguage = getPreferredLanguage()
if (!preferredLanguage) return null
const translatedText = await translateText(config.endpoint, translateModel, row.extracted_text, preferredLanguage, config.promptTranslate, sourceLanguage)
const translatedText = await translateText(config.endpoint, translateModel, row.extracted_text, preferredLanguage, config.promptTranslate, config.maxTokensTranslate, sourceLanguage)
if (translatedText) {
db.prepare('UPDATE media_items SET extracted_text_translated = ? WHERE item_key = ?').run(translatedText, itemKey)
}
@@ -682,6 +685,7 @@ async function translateText(
text: string,
targetLanguage: string,
customInstruction = '',
maxTokens = 8192,
sourceLanguage?: string,
): Promise<string | null> {
let systemPrompt: string
@@ -691,7 +695,7 @@ async function translateText(
systemPrompt = `You are a translator. Determine if the following text is already in ${targetLanguage}. If it is, respond with exactly: [ALREADY_TARGET_LANGUAGE]. If it is not, translate it to ${targetLanguage}.${customInstruction ? ' ' + customInstruction : ''}`
}
const result = await callChatApiText(endpoint, model, systemPrompt, text)
const result = await callChatApiText(endpoint, model, systemPrompt, text, maxTokens)
if (!sourceLanguage && (result === '[ALREADY_TARGET_LANGUAGE]' || !result)) {
return null

View File

@@ -58,6 +58,10 @@ export interface AiConfig {
promptTagger: string
promptExtract: string
promptTranslate: string
maxTokensTag: number
maxTokensDescribe: number
maxTokensExtract: number
maxTokensTranslate: number
}
export function getAiConfig(): AiConfig {
@@ -76,9 +80,14 @@ export function getAiConfig(): AiConfig {
const promptExtract = promptExtractRaw !== null ? promptExtractRaw : DEFAULT_PROMPT_EXTRACT
const promptTranslateRaw = getSetting('ai_prompt_translate')
const promptTranslate = promptTranslateRaw !== null ? promptTranslateRaw : DEFAULT_PROMPT_TRANSLATE
const maxTokensTag = parseInt(getSetting('ai_max_tokens_tag') ?? '8192', 10) || 8192
const maxTokensDescribe = parseInt(getSetting('ai_max_tokens_describe') ?? '8192', 10) || 8192
const maxTokensExtract = parseInt(getSetting('ai_max_tokens_extract') ?? '8192', 10) || 8192
const maxTokensTranslate = parseInt(getSetting('ai_max_tokens_translate') ?? '8192', 10) || 8192
return {
endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled,
promptDescribe, promptTagger, promptExtract, promptTranslate,
maxTokensTag, maxTokensDescribe, maxTokensExtract, maxTokensTranslate,
}
}
@@ -94,6 +103,10 @@ export function updateAiConfig(
promptTagger?: string,
promptExtract?: string,
promptTranslate?: string,
maxTokensTag?: number,
maxTokensDescribe?: number,
maxTokensExtract?: number,
maxTokensTranslate?: number,
): void {
setSetting('ai_endpoint', endpoint)
setSetting('ai_model', model)
@@ -106,6 +119,10 @@ export function updateAiConfig(
if (promptTagger !== undefined) setSetting('ai_prompt_tagger', promptTagger)
if (promptExtract !== undefined) setSetting('ai_prompt_extract', promptExtract)
if (promptTranslate !== undefined) setSetting('ai_prompt_translate', promptTranslate)
if (maxTokensTag !== undefined) setSetting('ai_max_tokens_tag', String(Math.max(1, Math.floor(maxTokensTag))))
if (maxTokensDescribe !== undefined) setSetting('ai_max_tokens_describe', String(Math.max(1, Math.floor(maxTokensDescribe))))
if (maxTokensExtract !== undefined) setSetting('ai_max_tokens_extract', String(Math.max(1, Math.floor(maxTokensExtract))))
if (maxTokensTranslate !== undefined) setSetting('ai_max_tokens_translate', String(Math.max(1, Math.floor(maxTokensTranslate))))
}
export function getPreferredLanguage(): string {
@@ -127,6 +144,10 @@ export interface LibraryAiOverrides {
promptTagger: string
promptExtract: string
promptTranslate: string
maxTokensTag: number | null
maxTokensDescribe: number | null
maxTokensExtract: number | null
maxTokensTranslate: number | null
}
interface LibraryAiSettingsRow {
@@ -138,6 +159,10 @@ interface LibraryAiSettingsRow {
prompt_tagger: string | null
prompt_extract: string | null
prompt_translate: string | null
max_tokens_tag: number | null
max_tokens_describe: number | null
max_tokens_extract: number | null
max_tokens_translate: number | null
}
export function getLibraryAiOverrides(libraryId: string): LibraryAiOverrides {
@@ -154,6 +179,10 @@ export function getLibraryAiOverrides(libraryId: string): LibraryAiOverrides {
promptTagger: row?.prompt_tagger ?? '',
promptExtract: row?.prompt_extract ?? '',
promptTranslate: row?.prompt_translate ?? '',
maxTokensTag: row?.max_tokens_tag ?? null,
maxTokensDescribe: row?.max_tokens_describe ?? null,
maxTokensExtract: row?.max_tokens_extract ?? null,
maxTokensTranslate: row?.max_tokens_translate ?? null,
}
}
@@ -164,7 +193,7 @@ export function setLibraryAiOverrides(libraryId: string, overrides: Partial<Libr
'INSERT OR IGNORE INTO library_ai_settings (library_id) VALUES (?)'
).run(libraryId)
const fields: Record<string, string | undefined> = {
const stringFields: Record<string, string | undefined> = {
model_tagging: overrides.modelTagging,
model_describe: overrides.modelDescribe,
model_extract: overrides.modelExtract,
@@ -175,7 +204,7 @@ export function setLibraryAiOverrides(libraryId: string, overrides: Partial<Libr
prompt_translate: overrides.promptTranslate,
}
for (const [col, val] of Object.entries(fields)) {
for (const [col, val] of Object.entries(stringFields)) {
if (val !== undefined) {
db.prepare(`UPDATE library_ai_settings SET ${col} = ? WHERE library_id = ?`).run(
val === '' ? null : val,
@@ -183,6 +212,22 @@ export function setLibraryAiOverrides(libraryId: string, overrides: Partial<Libr
)
}
}
const numberFields: Record<string, number | null | undefined> = {
max_tokens_tag: overrides.maxTokensTag,
max_tokens_describe: overrides.maxTokensDescribe,
max_tokens_extract: overrides.maxTokensExtract,
max_tokens_translate: overrides.maxTokensTranslate,
}
for (const [col, val] of Object.entries(numberFields)) {
if (val !== undefined) {
db.prepare(`UPDATE library_ai_settings SET ${col} = ? WHERE library_id = ?`).run(
val === null ? null : Math.max(1, Math.floor(val)),
libraryId,
)
}
}
}
export function getEffectiveAiConfig(libraryId: string): AiConfig {
@@ -200,6 +245,10 @@ export function getEffectiveAiConfig(libraryId: string): AiConfig {
promptTagger: overrides.promptTagger || global.promptTagger,
promptExtract: overrides.promptExtract || global.promptExtract,
promptTranslate: overrides.promptTranslate || global.promptTranslate,
maxTokensTag: overrides.maxTokensTag ?? global.maxTokensTag,
maxTokensDescribe: overrides.maxTokensDescribe ?? global.maxTokensDescribe,
maxTokensExtract: overrides.maxTokensExtract ?? global.maxTokensExtract,
maxTokensTranslate: overrides.maxTokensTranslate ?? global.maxTokensTranslate,
}
}

View File

@@ -119,6 +119,10 @@ function seedAppSettings(db: Database.Database): void {
ai_model: '',
preferred_language: 'English',
ai_max_retries: '3',
ai_max_tokens_tag: '8192',
ai_max_tokens_describe: '8192',
ai_max_tokens_extract: '8192',
ai_max_tokens_translate: '8192',
}
const insert = db.prepare(
'INSERT OR IGNORE INTO app_settings (key, value) VALUES (?, ?)'
@@ -276,6 +280,19 @@ function migrateLibraryAiSettings(db: Database.Database): void {
prompt_translate TEXT
);
`)
// Add max_tokens columns if they don't exist yet
const row = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='library_ai_settings'")
.get() as { sql: string } | undefined
if (row && !row.sql.includes('max_tokens_tag')) {
db.exec(`
ALTER TABLE library_ai_settings ADD COLUMN max_tokens_tag INTEGER;
ALTER TABLE library_ai_settings ADD COLUMN max_tokens_describe INTEGER;
ALTER TABLE library_ai_settings ADD COLUMN max_tokens_extract INTEGER;
ALTER TABLE library_ai_settings ADD COLUMN max_tokens_translate INTEGER;
`)
}
}
function migrateLibrariesType(db: Database.Database): void {