Skip to content

Commit afb8a6d

Browse files
committed
fix: fix quota error
1 parent 60fbac0 commit afb8a6d

14 files changed

Lines changed: 365 additions & 34 deletions

i18n/locales/en.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@
4343
"errorUnreachable": "Cannot reach the model. Is Ollama running?",
4444
"errorUnauthorized": "API key invalid or missing. Check your provider configuration.",
4545
"errorRateLimit": "Rate limit reached. Add your own API key in Settings to remove this limit.",
46+
"errorAppRateLimit": "Rate limit reached. Add your own API key in Settings to remove this limit.",
47+
"errorAppRateLimitCta": "Go to Settings",
48+
"errorProviderQuota": "Model provider limit reached (e.g. Gemini free tier). Wait {seconds} seconds, then try again — or enable billing, switch model, or use another provider.",
49+
"errorProviderQuotaNoWait": "Model provider limit reached. Wait about a minute, then try again — or check billing and usage at your provider, or switch model in Settings.",
50+
"errorProviderQuotaCta": "Provider usage limits",
4651
"errorRateLimitCta": "Go to Settings",
4752
"errorModel": "Model provider error",
4853
"sources": "sources",
@@ -160,6 +165,8 @@
160165
"userResetFail": "Could not clear overrides. Try again.",
161166
"rateLimitBanner": "rate limit reached",
162167
"rateLimitBannerHint": "Add your own API key below to bypass the shared usage limit. Your key is encrypted at rest and never shared.",
168+
"providerQuotaBanner": "model provider quota reached",
169+
"providerQuotaBannerHint": "Your API key is active, but the provider (e.g. Gemini free tier) limits requests per minute. Wait and retry, enable billing at Google AI, or switch to another model or provider below.",
163170
"ownKey": "your key ✓",
164171
"systemDefault": "system default",
165172
"privacyTitle": "privacy note",
@@ -297,7 +304,7 @@
297304
"dashboard": "open dashboard"
298305
},
299306
"geminiApiKey": {
300-
"helpText": "Used for embeddings with Gemini"
307+
"helpText": "Used for embeddings and chat with Gemini. Free tier limits chat requests (e.g. ~20/min for gemini-2.5-flash); enable billing or use SEARCH_HYDE=false to reduce usage."
301308
},
302309
"openaiApiKey": {
303310
"helpText": "Used for OpenAI embeddings/chat"

i18n/locales/es.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@
4343
"errorUnreachable": "No se puede alcanzar el modelo. ¿Está Ollama en marcha?",
4444
"errorUnauthorized": "API key inválida o ausente. Revisa la configuración del proveedor.",
4545
"errorRateLimit": "Límite de peticiones alcanzado. Añade tu propia API key en Ajustes para eliminar este límite.",
46+
"errorAppRateLimit": "Límite de peticiones alcanzado. Añade tu propia API key en Ajustes para eliminar este límite.",
47+
"errorAppRateLimitCta": "Ir a Ajustes",
48+
"errorProviderQuota": "Límite del proveedor alcanzado (p. ej. Gemini gratis). Espera {seconds} segundos e inténtalo de nuevo — o activa facturación, cambia de modelo o de proveedor.",
49+
"errorProviderQuotaNoWait": "Límite del proveedor alcanzado. Espera un minuto e inténtalo de nuevo — o revisa facturación y uso en tu proveedor, o cambia de modelo en Ajustes.",
50+
"errorProviderQuotaCta": "Límites del proveedor",
4651
"errorRateLimitCta": "Ir a Ajustes",
4752
"errorModel": "Error del proveedor del modelo",
4853
"sources": "fuentes",
@@ -160,6 +165,8 @@
160165
"userResetFail": "No se pudieron limpiar las sobreescrituras. Inténtalo de nuevo.",
161166
"rateLimitBanner": "límite de uso alcanzado",
162167
"rateLimitBannerHint": "Añade tu propia API key para saltarte el límite compartido. Tu clave se cifra en reposo y nunca se comparte.",
168+
"providerQuotaBanner": "cuota del proveedor del modelo alcanzada",
169+
"providerQuotaBannerHint": "Tu API key está activa, pero el proveedor (p. ej. Gemini gratis) limita peticiones por minuto. Espera y reintenta, activa facturación en Google AI o cambia de modelo o proveedor abajo.",
163170
"ownKey": "tu clave ✓",
164171
"systemDefault": "valor del sistema",
165172
"privacyTitle": "nota de privacidad",
@@ -297,7 +304,7 @@
297304
"dashboard": "abrir dashboard"
298305
},
299306
"geminiApiKey": {
300-
"helpText": "Se usa para embeddings con Gemini"
307+
"helpText": "Se usa para embeddings y chat con Gemini. El plan gratis limita peticiones de chat (p. ej. ~20/min en gemini-2.5-flash); activa facturación o SEARCH_HYDE=false para reducir uso."
301308
},
302309
"openaiApiKey": {
303310
"helpText": "Se usa para embeddings/chat de OpenAI"

nuxt.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ export default defineNuxtConfig({
8383
searchThreshold: Number(process.env.SEARCH_THRESHOLD ?? 0.2),
8484
searchHybrid: process.env.SEARCH_HYBRID === 'true',
8585
searchRerank: process.env.SEARCH_RERANK === 'true',
86+
searchHyde: process.env.SEARCH_HYDE !== 'false',
87+
conversationTitleLlm: process.env.CONVERSATION_TITLE_LLM !== 'false',
8688
// Step 6 - RAG
8789
ragTemperature: Number(process.env.RAG_TEMPERATURE ?? 0.3),
8890
ragCitations: process.env.RAG_CITATIONS !== 'false',

pages/index.vue

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -143,11 +143,19 @@
143143
</div>
144144
<p class="wz-muted text-xs">{{ chatErrorMessage }}</p>
145145
<NuxtLink
146-
v-if="isRateLimitError"
146+
v-if="isAppRateLimitError"
147147
to="/settings?reason=ratelimit"
148148
class="inline-block text-[11px] wz-accent underline"
149149
@click="dismissError"
150-
>{{ t('chat.errorRateLimitCta') }} →</NuxtLink>
150+
>{{ t('chat.errorAppRateLimitCta') }} →</NuxtLink>
151+
<a
152+
v-else-if="isProviderQuotaError"
153+
href="https://ai.dev/rate-limit"
154+
target="_blank"
155+
rel="noopener noreferrer"
156+
class="inline-block text-[11px] wz-accent underline"
157+
@click="dismissError"
158+
>{{ t('chat.errorProviderQuotaCta') }} →</a>
151159
</div>
152160
</div>
153161
</div>
@@ -415,6 +423,7 @@ import { marked } from 'marked'
415423
import type { SearchResult, ConverseSource } from '~/stores/documents'
416424
import { useSearchParams, type SearchParamsConfig } from '~/composables/useSearchParams'
417425
import { fetchLlmModels, getPersistedModel, persistModel, type LlmModelsConfig } from '~/composables/useLlmModels'
426+
import { classifyLlmError } from '~/utils/llm-errors'
418427
419428
interface KbToolOutput {
420429
context: string
@@ -600,29 +609,43 @@ const isSearching = computed(() => {
600609
601610
const dismissedError = ref<unknown>(null)
602611
const visibleChatError = computed(() => chat.error && chat.error !== dismissedError.value)
603-
const isRateLimitError = computed(() => {
612+
613+
const classifiedChatError = computed(() => {
604614
const err = chat.error
605-
return APICallError.isInstance(err) && err.statusCode === 429
615+
return err ? classifyLlmError(err) : null
606616
})
607617
618+
const isAppRateLimitError = computed(() => classifiedChatError.value?.kind === 'app_rate_limit')
619+
const isProviderQuotaError = computed(() => classifiedChatError.value?.kind === 'provider_quota')
620+
608621
function dismissError() {
609622
dismissedError.value = chat.error
610623
}
611624
612625
const chatErrorMessage = computed(() => {
613-
const err = chat.error
614-
if (!err) return ''
615-
if (APICallError.isInstance(err)) {
616-
if (err.statusCode === 401) return t('chat.errorUnauthorized')
617-
if (err.statusCode === 429) return t('chat.errorRateLimit')
618-
if (err.statusCode === 503) return t('chat.errorUnreachable')
619-
return `${t('chat.errorModel')} (${err.statusCode}): ${err.message}`
620-
}
621-
const msg = err instanceof Error ? err.message : String(err)
622-
if (msg.includes('fetch failed') || msg.includes('ECONNREFUSED') || msg.includes('503')) {
623-
return t('chat.errorUnreachable')
626+
const classified = classifiedChatError.value
627+
if (!classified) return ''
628+
629+
switch (classified.kind) {
630+
case 'app_rate_limit':
631+
return t('chat.errorAppRateLimit')
632+
case 'provider_quota':
633+
if (classified.retryAfterSeconds != null) {
634+
return t('chat.errorProviderQuota', { seconds: classified.retryAfterSeconds })
635+
}
636+
return t('chat.errorProviderQuotaNoWait')
637+
case 'unauthorized':
638+
return t('chat.errorUnauthorized')
639+
case 'unreachable':
640+
return t('chat.errorUnreachable')
641+
case 'model':
642+
if (APICallError.isInstance(chat.error)) {
643+
return `${t('chat.errorModel')} (${chat.error.statusCode}): ${chat.error.message}`
644+
}
645+
return classified.rawMessage || t('chat.errorGeneric')
646+
default:
647+
return classified.rawMessage || t('chat.errorGeneric')
624648
}
625-
return msg || t('chat.errorGeneric')
626649
})
627650
628651
interface DisplayMessage {
@@ -861,6 +884,15 @@ watch(
861884
},
862885
)
863886
887+
watch(
888+
() => chat.error,
889+
(err) => {
890+
if (err && classifyLlmError(err).kind === 'provider_quota' && typeof window !== 'undefined') {
891+
window.sessionStorage.setItem('rag-ui:providerQuotaAt', String(Date.now()))
892+
}
893+
},
894+
)
895+
864896
async function send() {
865897
const text = input.value.trim()
866898
if (!text || isBusy.value) return

pages/settings.vue

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@
1010
<p class="wz-muted">{{ t('settings.rateLimitBannerHint') }}</p>
1111
</div>
1212

13+
<div
14+
v-if="showProviderQuotaBanner"
15+
class="mb-5 wz-panel p-4 text-xs space-y-1"
16+
style="border-color: var(--term-accent);"
17+
>
18+
<p class="wz-accent font-mono">⚡ {{ t('settings.providerQuotaBanner') }}</p>
19+
<p class="wz-muted">{{ t('settings.providerQuotaBannerHint') }}</p>
20+
</div>
21+
1322
<div class="mb-6">
1423
<h1 class="text-lg font-semibold wz-strong">{{ t('settings.userTitle') }}</h1>
1524
<p class="text-xs wz-faint mt-1">{{ t('settings.userSubtitle') }}</p>
@@ -100,6 +109,7 @@ const { t } = useI18n()
100109
const route = useRoute()
101110
102111
const showRateLimitBanner = computed(() => route.query.reason === 'ratelimit')
112+
const showProviderQuotaBanner = computed(() => route.query.reason === 'provider-quota')
103113
104114
const activeTab = ref('apis')
105115
const saving = ref(false)

server/api/chat.post.ts

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ import {
1616
refreshConversationTitleFromUserPrompt,
1717
} from '../utils/conversations.service'
1818
import { scheduleRefineConversationTitle } from '../utils/conversation-title-llm'
19+
import {
20+
classifyLlmError,
21+
formatAppRateLimitMessage,
22+
formatProviderQuotaMessage,
23+
} from '../utils/llm-errors'
24+
import { markProviderQuotaHit } from '../utils/llm-quota-guard'
1925

2026
const MAX_MESSAGES = 50
2127
const MAX_TEXT_PER_PART = 64 * 1024
@@ -45,18 +51,34 @@ const bodySchema = z.object({
4551
})
4652

4753
function classifyError(error: unknown): { statusCode: number; message: string } {
48-
if (APICallError.isInstance(error)) {
49-
const code = error.statusCode ?? 502
50-
if (code === 401) return { statusCode: 401, message: 'Model API key is invalid or missing.' }
51-
if (code === 429) return { statusCode: 429, message: 'Model rate limit reached. Please wait and try again.' }
52-
if (code >= 500) return { statusCode: 502, message: `Model provider returned ${code}. Try again later.` }
53-
return { statusCode: code, message: error.message }
54-
}
55-
const msg = error instanceof Error ? error.message : String(error)
56-
if (msg.includes('fetch failed') || msg.includes('ECONNREFUSED') || msg.includes('ENOTFOUND')) {
57-
return { statusCode: 503, message: 'Cannot reach the model. Is Ollama running?' }
54+
const classified = classifyLlmError(error)
55+
switch (classified.kind) {
56+
case 'app_rate_limit':
57+
return { statusCode: 429, message: formatAppRateLimitMessage() }
58+
case 'provider_quota':
59+
return {
60+
statusCode: 429,
61+
message: formatProviderQuotaMessage(classified.retryAfterSeconds),
62+
}
63+
case 'unauthorized':
64+
return { statusCode: 401, message: 'Model API key is invalid or missing.' }
65+
case 'unreachable':
66+
return { statusCode: 503, message: 'Cannot reach the model. Is Ollama running?' }
67+
case 'model':
68+
if (APICallError.isInstance(error)) {
69+
const code = error.statusCode ?? 502
70+
if (code >= 500) {
71+
return { statusCode: 502, message: `Model provider returned ${code}. Try again later.` }
72+
}
73+
return { statusCode: code, message: error.message }
74+
}
75+
return { statusCode: classified.statusCode, message: classified.rawMessage || 'Model provider error.' }
76+
default:
77+
return {
78+
statusCode: classified.statusCode,
79+
message: classified.rawMessage || 'Unexpected model error.',
80+
}
5881
}
59-
return { statusCode: 502, message: msg || 'Unexpected model error.' }
6082
}
6183

6284
function validateMessageSize(messages: UIMessage[]): void {
@@ -165,6 +187,7 @@ export default defineEventHandler(async (event) => {
165187
// Stream setup failed (e.g. bad API key, unreachable Ollama). The user
166188
// message has NOT been persisted yet, so there's no orphan to clean up.
167189
console.error('[chat] pre-stream error:', error)
190+
if (classifyLlmError(error).kind === 'provider_quota') markProviderQuotaHit(error)
168191
const { statusCode, message } = classifyError(error)
169192
throw createError({ statusCode, statusMessage: message })
170193
}

server/utils/agent.service.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import {
2525
import { truncate } from './text'
2626
import { parseMemoryCommand, getMessageText, type MemoryCommand } from './agent-commands'
2727
import { sanitizeUIMessagesForAgent } from './agent-messages'
28+
import { isProviderQuotaError } from './llm-errors'
29+
import { markProviderQuotaHit } from './llm-quota-guard'
2830

2931
async function getLlmCfg() {
3032
const config = useRuntimeConfig()
@@ -76,13 +78,14 @@ async function getLlmCfg() {
7678

7779
async function getRagCfg() {
7880
const config = useRuntimeConfig()
79-
const [tempStr, citationsStr, maxCtxStr, langStr, promptStr, maxStepsStr] = await Promise.all([
81+
const [tempStr, citationsStr, maxCtxStr, langStr, promptStr, maxStepsStr, searchHydeStr] = await Promise.all([
8082
getSetting('RAG_TEMPERATURE', String(config.ragTemperature ?? 0.3)),
8183
getSetting('RAG_CITATIONS', String(config.ragCitations ?? true)),
8284
getSetting('RAG_MAX_CONTEXT', String(config.ragMaxContext ?? 4096)),
8385
getSetting('RAG_RESPONSE_LANG', String(config.ragResponseLang ?? 'auto')),
8486
getSetting('RAG_SYSTEM_PROMPT', String(config.ragSystemPrompt ?? '')),
8587
getSetting('AGENT_MAX_STEPS', String(config.agentMaxSteps ?? 5)),
88+
getSetting('SEARCH_HYDE', String(config.searchHyde ?? true)),
8689
])
8790
return {
8891
ragTemperature: getNumericSetting(tempStr, 0.3),
@@ -91,6 +94,7 @@ async function getRagCfg() {
9194
ragResponseLang: langStr || 'auto',
9295
ragSystemPrompt: promptStr,
9396
agentMaxSteps: Math.max(1, Math.min(20, getNumericSetting(maxStepsStr, 5))),
97+
searchHyde: getBoolSetting(searchHydeStr, true),
9498
}
9599
}
96100

@@ -166,7 +170,8 @@ function buildKbTools(
166170
execute: async ({ query }) => {
167171
const safeQuery = truncate(query.trim(), 512)
168172
const t0 = Date.now()
169-
const { results, sources, context } = await rag(safeQuery, limit, workspaceId, hydeModel)
173+
const hyde = hydeModel && ragCfg.searchHyde ? hydeModel : undefined
174+
const { results, sources, context } = await rag(safeQuery, limit, workspaceId, hyde)
170175
const latencyMs = Date.now() - t0
171176
for (const r of results) bucket.push(r)
172177
const truncatedContext = truncate(context || '(no matching passages found)', cfg.ragMaxContext)
@@ -243,13 +248,15 @@ export async function agentStreamText(
243248
model,
244249
system,
245250
messages: modelMessages,
251+
maxRetries: 0,
246252
...(searchMode !== 'direct' && {
247253
tools: buildKbTools(workspaceId, limit, retrievedChunks, model, cfg),
248254
stopWhen: stepCountIs(cfg.agentMaxSteps),
249255
...(searchMode === 'search' && { toolChoice: 'required' }),
250256
}),
251257
temperature: cfg.ragTemperature,
252258
onError: ({ error }) => {
259+
if (isProviderQuotaError(error)) markProviderQuotaHit(error)
253260
console.error('[agent] streamText error:', error)
254261
},
255262
})

server/utils/conversation-title-llm.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { generateText } from 'ai'
22
import { prisma } from './prisma'
33
import { stripNul, truncate } from './text'
44
import { getLlmModel } from './agent.service'
5+
import { getSetting, getBoolSetting } from './settings.service.ts'
6+
import { isProviderQuotaError } from './llm-errors'
7+
import { markProviderQuotaHit, shouldSuppressOptionalLlmCalls } from './llm-quota-guard'
58

69
const TITLE_MAX = 80
710
const TRANSCRIPT_MSG_CAP = 16
@@ -21,8 +24,17 @@ export function scheduleRefineConversationTitle(conversationId: string): void {
2124
void refineConversationTitleFromTranscript(conversationId)
2225
}
2326

27+
async function isConversationTitleLlmEnabled(): Promise<boolean> {
28+
const config = useRuntimeConfig()
29+
const raw = await getSetting('CONVERSATION_TITLE_LLM', String(config.conversationTitleLlm ?? true))
30+
return getBoolSetting(raw, true)
31+
}
32+
2433
async function refineConversationTitleFromTranscript(conversationId: string): Promise<void> {
2534
try {
35+
if (!(await isConversationTitleLlmEnabled())) return
36+
if (shouldSuppressOptionalLlmCalls()) return
37+
2638
const assistantN = await prisma.message.count({
2739
where: { conversationId, role: 'assistant' },
2840
})
@@ -52,6 +64,7 @@ async function refineConversationTitleFromTranscript(conversationId: string): Pr
5264
prompt: `Transcript:\n---\n${transcript}\n---\nTitle:`,
5365
maxOutputTokens: 48,
5466
temperature: 0.2,
67+
maxRetries: 0,
5568
})
5669

5770
const line = stripNul(text ?? '')
@@ -67,7 +80,8 @@ async function refineConversationTitleFromTranscript(conversationId: string): Pr
6780
where: { id: conversationId },
6881
data: { title },
6982
})
70-
} catch {
83+
} catch (err) {
84+
if (isProviderQuotaError(err)) markProviderQuotaHit(err)
7185
/* optional refinement */
7286
}
7387
}

server/utils/llm-errors.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export {
2+
classifyLlmError,
3+
errorMessageFromUnknown,
4+
formatAppRateLimitMessage,
5+
formatProviderQuotaMessage,
6+
isAppRateLimitError,
7+
isProviderQuotaError,
8+
parseRetryAfterSeconds,
9+
type ClassifiedLlmError,
10+
type LlmErrorKind,
11+
} from '../../utils/llm-errors'

server/utils/llm-quota-guard.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { isProviderQuotaError } from './llm-errors'
2+
3+
/** After a provider quota error, skip optional LLM calls (title refinement, etc.) for this long. */
4+
const SUPPRESS_MS = 5 * 60_000
5+
6+
let lastProviderQuotaAt = 0
7+
8+
export function markProviderQuotaHit(errorOrMessage?: unknown): void {
9+
if (errorOrMessage === undefined || isProviderQuotaError(errorOrMessage)) {
10+
lastProviderQuotaAt = Date.now()
11+
}
12+
}
13+
14+
export function shouldSuppressOptionalLlmCalls(): boolean {
15+
return Date.now() - lastProviderQuotaAt < SUPPRESS_MS
16+
}

0 commit comments

Comments
 (0)