From daa0c6bcf0a36d389b35418606bba2f5f55298b3 Mon Sep 17 00:00:00 2001
From: JannikStreek
Date: Tue, 29 Sep 2026 12:03:19 +0200
Subject: [PATCH 1/2] Make the AI token estimate a true upper bound (#1434)
---
teammapper-backend/benchmark/run-benchmark.ts | 1 -
.../src/map/services/ai.service.spec.ts | 242 +++++++++++++-----
.../src/map/services/ai.service.ts | 149 ++++++-----
.../llm-usage-counter.service.spec.ts | 9 -
.../map/services/llm-usage-counter.service.ts | 15 --
teammapper-backend/src/map/utils/prompts.ts | 6 +-
.../test/llm-usage-counter.e2e-spec.ts | 8 -
7 files changed, 269 insertions(+), 161 deletions(-)
diff --git a/teammapper-backend/benchmark/run-benchmark.ts b/teammapper-backend/benchmark/run-benchmark.ts
index c1297611..411d0ff7 100644
--- a/teammapper-backend/benchmark/run-benchmark.ts
+++ b/teammapper-backend/benchmark/run-benchmark.ts
@@ -6,7 +6,6 @@ import type { LlmUsageCounting } from '../src/map/services/llm-usage-counter.ser
const noUsageCounter: LlmUsageCounting = {
reserve: async () => ({ tokensUsed: 0, requestsCount: 0 }),
adjustTokens: async () => {},
- release: async () => {},
}
async function main(): Promise {
diff --git a/teammapper-backend/src/map/services/ai.service.spec.ts b/teammapper-backend/src/map/services/ai.service.spec.ts
index 081a22de..e9f27150 100644
--- a/teammapper-backend/src/map/services/ai.service.spec.ts
+++ b/teammapper-backend/src/map/services/ai.service.spec.ts
@@ -1,13 +1,15 @@
import { jest } from '@jest/globals'
+import { AiService, DEFAULT_MAX_OUTPUT_TOKENS } from './ai.service'
import {
- AiService,
- DEFAULT_MAX_OUTPUT_TOKENS,
- SYSTEM_PROMPT_TOKEN_OVERHEAD,
-} from './ai.service'
+ DEFAULT_AI_MAP_SHAPE,
+ systemPrompt,
+ userPrompt,
+} from '../utils/prompts'
+import type { AiMapShape } from '@teammapper/shared'
import type { LlmUsageCounting } from './llm-usage-counter.service'
import { RateLimitExceededException } from '../controllers/rate-limit.exception'
-import { generateText } from 'ai'
+import { APICallError, generateText } from 'ai'
import * as aiProvider from '../utils/aiProvider'
import configService from '../../config.service'
import type { LLMProps } from '../../config.service'
@@ -18,10 +20,32 @@ type GetLLMConfigMock = jest.MockedFunction
type MockGenerateTextReturn = Awaited>
-jest.mock('ai')
+// Only generateText is faked; APICallError keeps its real isInstance check.
+jest.mock('ai', () => ({
+ ...jest.requireActual('ai'),
+ generateText: jest.fn(),
+}))
jest.mock('../utils/aiProvider')
jest.mock('../../config.service')
+// One token per UTF-8 byte of the prompts sent, plus the full output cap.
+const estimateFor = (
+ description: string,
+ maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS,
+ shape: AiMapShape = DEFAULT_AI_MAP_SHAPE
+) =>
+ Buffer.byteLength(systemPrompt(shape), 'utf8') +
+ Buffer.byteLength(userPrompt(description, 'en'), 'utf8') +
+ maxOutputTokens
+
+const apiError = (statusCode: number | undefined) =>
+ new APICallError({
+ message: 'provider error',
+ url: 'localhost:3000',
+ requestBodyValues: {},
+ statusCode,
+ })
+
interface FakeUsageState {
tokensUsed: number
requestsCount: number
@@ -43,10 +67,6 @@ const buildUsageCounterMock = (
adjustTokens: jest.fn(async (_dateUsage: string, delta: number) => {
state.tokensUsed = Math.max(0, state.tokensUsed + delta)
}),
- release: jest.fn(async (_dateUsage: string, tokens: number) => {
- state.tokensUsed = Math.max(0, state.tokensUsed - tokens)
- state.requestsCount = Math.max(0, state.requestsCount - 1)
- }),
})
describe('AiService', () => {
@@ -106,22 +126,32 @@ describe('AiService', () => {
jest.useRealTimers()
})
- describe('estimateTokens', () => {
- it('estimates tokens for short input', () => {
- expect(aiService.estimateTokens('hello')).toBe(
- Math.ceil(5 / 4) +
- SYSTEM_PROMPT_TOKEN_OVERHEAD +
- DEFAULT_MAX_OUTPUT_TOKENS
- )
+ describe('token estimate', () => {
+ const reserved = () => usageCounter.reserve.mock.calls[0][1]
+
+ it('counts every byte of both prompts plus the output cap', async () => {
+ await aiService.generateMermaid('hello', 'en')
+
+ expect(reserved()).toBe(estimateFor('hello'))
})
- it('estimates tokens for empty input', () => {
- expect(aiService.estimateTokens('')).toBe(
- SYSTEM_PROMPT_TOKEN_OVERHEAD + DEFAULT_MAX_OUTPUT_TOKENS
+ it('counts a multi-byte character as several tokens', async () => {
+ await aiService.generateMermaid('漢字', 'en')
+
+ // Each of the two characters takes three UTF-8 bytes.
+ expect(reserved()).toBe(estimateFor('') + 6)
+ })
+
+ it('reserves more for a larger map shape', async () => {
+ const shape: AiMapShape = { levels: 3, childrenPerNode: 4 }
+ await aiService.generateMermaid('hello', 'en', shape)
+
+ expect(reserved()).toBe(
+ estimateFor('hello', DEFAULT_MAX_OUTPUT_TOKENS, shape)
)
})
- it('reserves the full configured output cap', () => {
+ it('reserves the full configured output cap', async () => {
getLLMConfigMock.mockReturnValue({
url: 'localhost:3000',
token: 'test-token',
@@ -131,12 +161,12 @@ describe('AiService', () => {
} satisfies LLMProps)
aiService = new AiService(usageCounter)
- expect(aiService.estimateTokens('')).toBe(
- SYSTEM_PROMPT_TOKEN_OVERHEAD + 1000
- )
+ await aiService.generateMermaid('hello', 'en')
+
+ expect(reserved()).toBe(estimateFor('hello', 1000))
})
- it('falls back to the default output cap when the configured one is 0', () => {
+ it('falls back to the default output cap when the configured one is 0', async () => {
getLLMConfigMock.mockReturnValue({
url: 'localhost:3000',
token: 'test-token',
@@ -146,9 +176,9 @@ describe('AiService', () => {
} satisfies LLMProps)
aiService = new AiService(usageCounter)
- expect(aiService.estimateTokens('')).toBe(
- SYSTEM_PROMPT_TOKEN_OVERHEAD + DEFAULT_MAX_OUTPUT_TOKENS
- )
+ await aiService.generateMermaid('hello', 'en')
+
+ expect(reserved()).toBe(estimateFor('hello'))
})
})
@@ -164,6 +194,24 @@ describe('AiService', () => {
)
})
+ it('turns off SDK retries so one reservation pays for one attempt', async () => {
+ await aiService.generateMermaid('create a mindmap', 'en')
+
+ expect(generateTextMock).toHaveBeenCalledWith(
+ expect.objectContaining({ maxRetries: 0 })
+ )
+ })
+
+ it('escapes markup so the description cannot close the topic tag', async () => {
+ await aiService.generateMermaid('a & ', 'en')
+
+ expect(generateTextMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ prompt: 'a </topic> & <b>',
+ })
+ )
+ })
+
it('writes a given map shape into the system prompt', async () => {
await aiService.generateMermaid('create a mindmap', 'en', {
levels: 3,
@@ -287,50 +335,43 @@ describe('AiService', () => {
token: 'test-token',
provider: 'openai',
model: 'gpt-4',
- tpd: '4300',
+ tpd: String(estimateFor('short') + 50),
} satisfies LLMProps)
aiService = new AiService(usageCounter)
- // estimateTokens('short') = 4298; first call is fine and bills 100 tokens
+ // The first call fits and bills 100 tokens
generateTextMock.mockResolvedValueOnce({
text: 'first response',
usage: { inputTokens: 50, outputTokens: 50, totalTokens: 100 },
} as MockGenerateTextReturn)
await aiService.generateMermaid('short', 'en')
- // Second call estimate (4298) + already-billed (100) = 4398 > 4300 -> reject
+ // Second call estimate + already-billed (100) exceeds the cap -> reject
await expect(aiService.generateMermaid('short', 'en')).rejects.toThrow(
RateLimitExceededException
)
// No reservation was created for the rejected call, so totals reflect
- // only the first call's actual (100) and one request — and no rollback.
- expect({
- ...usageState,
- releaseCalls: usageCounter.release.mock.calls.length,
- }).toEqual({
- tokensUsed: 100,
- requestsCount: 1,
- releaseCalls: 0,
- })
+ // only the first call's actual (100) and one request.
+ expect(usageState).toEqual({ tokensUsed: 100, requestsCount: 1 })
})
- it('allows a reservation that lands exactly at the TPD cap', async () => {
+ it('allows a reservation that reaches the TPD cap exactly', async () => {
getLLMConfigMock.mockReturnValue({
url: 'localhost:3000',
token: 'test-token',
provider: 'openai',
model: 'gpt-4',
- tpd: '4298',
+ tpd: String(estimateFor('short')),
} satisfies LLMProps)
aiService = new AiService(usageCounter)
- // estimateTokens('short') = 4298; equal to the cap, so this must succeed.
+ // The estimate equals the cap, so this must succeed.
await aiService.generateMermaid('short', 'en')
expect(usageCounter.reserve).toHaveBeenCalledWith(
expect.any(String),
- 4298,
- 4298
+ estimateFor('short'),
+ estimateFor('short')
)
})
@@ -381,13 +422,22 @@ describe('AiService', () => {
} as MockGenerateTextReturn)
await aiService.generateMermaid('short', 'en')
- // estimated = 4298, actual = 100 -> delta = -4198
expect(usageCounter.adjustTokens).toHaveBeenCalledWith(
expect.any(String),
- -4198
+ 100 - estimateFor('short')
)
})
+ it('keeps the estimate when the provider reports no usage', async () => {
+ generateTextMock.mockResolvedValueOnce({
+ text: 'response',
+ usage: {},
+ } as MockGenerateTextReturn)
+ await aiService.generateMermaid('short', 'en')
+
+ expect(usageState.tokensUsed).toBe(estimateFor('short'))
+ })
+
it('keeps the conservative reservation when adjustTokens fails after a successful LLM call', async () => {
getLLMConfigMock.mockReturnValue({
url: 'localhost:3000',
@@ -398,36 +448,88 @@ describe('AiService', () => {
aiService = new AiService(usageCounter)
usageCounter.adjustTokens.mockRejectedValueOnce(new Error('db hiccup'))
- // Reconciliation failure must not propagate, must not release.
+ // Reconciliation failure must not propagate.
await aiService.generateMermaid('short', 'en')
- expect(usageCounter.release).not.toHaveBeenCalled()
- // Reservation persists at the conservative estimate (4298), not actual.
- expect(usageState.tokensUsed).toBe(4298)
+ // Reservation persists at the conservative estimate, not actual.
+ expect(usageState.tokensUsed).toBe(estimateFor('short'))
})
- it('does not mask the original LLM error when release fails during rollback', async () => {
+ it('keeps the estimate booked when generateText fails', async () => {
+ generateTextMock.mockRejectedValueOnce(new Error('boom'))
+ await expect(aiService.generateMermaid('short', 'en')).rejects.toThrow(
+ 'boom'
+ )
+
+ // The provider may have billed the failed call.
+ expect(usageState).toEqual({
+ tokensUsed: estimateFor('short'),
+ requestsCount: 1,
+ })
+ })
+
+ it('keeps the estimate when the provider reports a total of 0', async () => {
+ generateTextMock.mockResolvedValueOnce({
+ text: 'response',
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
+ } as MockGenerateTextReturn)
+ await aiService.generateMermaid('short', 'en')
+
+ expect(usageState.tokensUsed).toBe(estimateFor('short'))
+ })
+
+ it('books input plus output when the reported total is lower', async () => {
+ generateTextMock.mockResolvedValueOnce({
+ text: 'response',
+ usage: { inputTokens: 300, outputTokens: 500, totalTokens: 10 },
+ } as MockGenerateTextReturn)
+ await aiService.generateMermaid('short', 'en')
+
+ expect(usageState.tokensUsed).toBe(800)
+ })
+
+ it.each([
+ ['a 401', 401],
+ ['a 400', 400],
+ ['a failed connection', undefined],
+ ])('refunds the reservation after %s', async (_label, statusCode) => {
getLLMConfigMock.mockReturnValue({
url: 'localhost:3000',
token: 'test-token',
provider: 'openai',
model: 'gpt-4',
+ rpm: '1',
} satisfies LLMProps)
aiService = new AiService(usageCounter)
- usageCounter.release.mockRejectedValueOnce(new Error('db unreachable'))
- generateTextMock.mockRejectedValueOnce(new Error('boom'))
+ generateTextMock.mockRejectedValueOnce(apiError(statusCode))
await expect(aiService.generateMermaid('short', 'en')).rejects.toThrow(
- 'boom'
+ APICallError
)
+ expect(usageState.tokensUsed).toBe(0)
+
+ // The refund frees the per-minute request slot.
+ await aiService.generateMermaid('short', 'en')
})
+ it.each([408, 429, 500, 503])(
+ 'keeps the reservation after a %i',
+ async (statusCode) => {
+ generateTextMock.mockRejectedValueOnce(apiError(statusCode))
+ await expect(aiService.generateMermaid('short', 'en')).rejects.toThrow(
+ APICallError
+ )
+
+ expect(usageState.tokensUsed).toBe(estimateFor('short'))
+ }
+ )
+
it('throws an error if the tokens per minute limit is reached', async () => {
getLLMConfigMock.mockReturnValue({
url: 'localhost:3000',
token: 'test-token',
provider: 'openai',
model: 'gpt-4',
- tpm: '5000',
+ tpm: String(estimateFor('short') + 500),
rpm: undefined,
tpd: undefined,
} satisfies LLMProps)
@@ -478,13 +580,12 @@ describe('AiService', () => {
token: 'test-token',
provider: 'openai',
model: 'gpt-4',
- tpm: '8000',
+ tpm: String(2 * estimateFor('short') - 1),
} satisfies LLMProps)
aiService = new AiService(usageCounter)
- // estimateTokens('short') = 4298 and the long input estimates 4596. Both
- // together need 8894 reserved up-front; with TPM=8000, only the first
- // should succeed.
+ // Both calls together need two estimates reserved up-front; with a TPM
+ // one below that, only the first should succeed.
let release!: () => void
const block = new Promise((resolve) => {
release = resolve
@@ -498,16 +599,16 @@ describe('AiService', () => {
}) as unknown as typeof generateText)
const first = aiService.generateMermaid('short', 'en')
- // Second call must observe first's pre-charge of 4298 already in the
- // per-minute window, blocking it instead of racing through the precheck.
- await expect(
- aiService.generateMermaid('s'.repeat(1200), 'en')
- ).rejects.toThrow(RateLimitExceededException)
+ // Second call must observe first's pre-charge already in the per-minute
+ // window, blocking it instead of racing through the precheck.
+ await expect(aiService.generateMermaid('short', 'en')).rejects.toThrow(
+ RateLimitExceededException
+ )
release()
await first
})
- it('releases the per-minute reservation when generateText fails', async () => {
+ it('counts a failed call against the per-minute limits', async () => {
getLLMConfigMock.mockReturnValue({
url: 'localhost:3000',
token: 'test-token',
@@ -522,8 +623,9 @@ describe('AiService', () => {
'boom'
)
- // After release, RPM=1 should still allow one more call.
- await aiService.generateMermaid('short', 'en')
+ await expect(aiService.generateMermaid('short', 'en')).rejects.toThrow(
+ RateLimitExceededException
+ )
})
it('resets token count after one minute', async () => {
@@ -532,7 +634,7 @@ describe('AiService', () => {
token: 'test-token',
provider: 'openai',
model: 'gpt-4',
- tpm: '5000',
+ tpm: String(estimateFor('short') + 500),
rpm: undefined,
tpd: undefined,
} satisfies LLMProps)
@@ -563,7 +665,7 @@ describe('AiService', () => {
token: 'test-token',
provider: 'openai',
model: 'gpt-4',
- tpm: '4500',
+ tpm: String(estimateFor('') + 1000),
rpm: undefined,
tpd: undefined,
} satisfies LLMProps)
diff --git a/teammapper-backend/src/map/services/ai.service.ts b/teammapper-backend/src/map/services/ai.service.ts
index 3a1915e0..fa56c902 100644
--- a/teammapper-backend/src/map/services/ai.service.ts
+++ b/teammapper-backend/src/map/services/ai.service.ts
@@ -1,5 +1,10 @@
import { Inject, Injectable, Logger } from '@nestjs/common'
-import { generateText, LanguageModel, LanguageModelUsage } from 'ai'
+import {
+ APICallError,
+ generateText,
+ LanguageModel,
+ LanguageModelUsage,
+} from 'ai'
import {
AiMapShape,
MermaidCreateResult,
@@ -19,7 +24,6 @@ import {
LlmUsageCounterService,
} from './llm-usage-counter.service'
-export const SYSTEM_PROMPT_TOKEN_OVERHEAD = 200
// A reasoning model spends its thinking from the same output budget as the
// answer, so both defaults leave room for long reasoning output.
export const DEFAULT_MAX_OUTPUT_TOKENS = 4096
@@ -82,37 +86,33 @@ export class AiService {
return { mermaid: '', truncated: false }
}
- const estimated = this.estimateTokens(mindmapDescription)
+ const system = systemPrompt(shape)
+ const prompt = userPrompt(mindmapDescription, language)
+ const estimated = this.estimateTokens(system, prompt)
const reservation = await this.reserveBudget(estimated)
- let generated: MermaidCreateResult
- let usage: LanguageModelUsage
- try {
- const result = await this.callLlm(
- provider(this.llmConfig.model),
- mindmapDescription,
- language,
- shape
- )
- generated = {
- mermaid: pruneMindmap(result.text, shape),
- truncated: result.finishReason === 'length',
- }
- usage = result.usage
- } catch (err) {
- await this.releaseReservation(reservation)
- throw err
+ const result = await this.callLlmOrRefund(
+ reservation,
+ provider(this.llmConfig.model),
+ system,
+ prompt
+ )
+ const booked = AiService.bookedTokens(result.usage, estimated)
+ await this.tryCommitReservation(reservation, booked)
+ this.logUsage(booked, estimated, result.usage)
+ return {
+ mermaid: pruneMindmap(result.text, shape),
+ truncated: result.finishReason === 'length',
}
- // Reconciliation is best-effort: a failure here must not roll back a
- // successful LLM call (would silently under-bill the budget).
- await this.tryCommitReservation(reservation, usage.totalTokens ?? 0)
- this.logUsage(estimated, usage)
- return generated
}
/** Logs the token counts of one LLM call. */
- private logUsage(estimated: number, usage: LanguageModelUsage): void {
+ private logUsage(
+ booked: number,
+ estimated: number,
+ usage: LanguageModelUsage
+ ): void {
this.logger.debug(
- `LLM call billed ${usage.totalTokens ?? 0} tokens (estimated ${estimated}, ` +
+ `LLM call booked ${booked} tokens (estimated ${estimated}, ` +
`input ${usage.inputTokens ?? 0}, output ${usage.outputTokens ?? 0}, ` +
`reasoning ${usage.outputTokenDetails?.reasoningTokens ?? 0}, ` +
`cap ${this.limits.maxOutputTokens})`
@@ -120,18 +120,46 @@ export class AiService {
}
/**
- * Estimates the most tokens one call can bill: the input, the system prompt
- * and the full output cap. The reservation holds this estimate until the
- * call reports its real usage, so parallel calls cannot overshoot a limit.
+ * Estimates the most tokens one call can bill: one token per UTF-8 byte of
+ * the prompts sent, because a byte-level tokenizer emits at most one token
+ * per byte, plus the full output cap. The reservation holds this estimate
+ * until the call reports its real usage, so parallel calls cannot overshoot
+ * a limit.
*/
- estimateTokens(input: string): number {
+ private estimateTokens(system: string, prompt: string): number {
return (
- Math.ceil(input.length / 4) +
- SYSTEM_PROMPT_TOKEN_OVERHEAD +
+ Buffer.byteLength(system, 'utf8') +
+ Buffer.byteLength(prompt, 'utf8') +
this.limits.maxOutputTokens
)
}
+ /**
+ * Picks the tokens to book for a finished call: the larger of the reported
+ * total and the reported input plus output. A provider that reports nothing
+ * keeps the estimate booked.
+ */
+ private static bookedTokens(
+ usage: LanguageModelUsage,
+ estimated: number
+ ): number {
+ const parts = (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
+ const reported = Math.max(usage.totalTokens ?? 0, parts)
+ return reported > 0 ? reported : estimated
+ }
+
+ /**
+ * Reports whether the provider rejected a call before running the model and
+ * so billed nothing: no connection, or a 4xx other than 408 and 429. A
+ * timeout or a 5xx may have run the model and counts as billed.
+ */
+ private static isUnbilled(err: unknown): boolean {
+ if (!APICallError.isInstance(err)) return false
+ const status = err.statusCode
+ if (status === undefined) return true
+ return status >= 400 && status < 500 && status !== 408 && status !== 429
+ }
+
/** Reads a rate limit. A limit of 0 blocks every call. */
private static parseLimit(raw: string | undefined): number | undefined {
if (!raw) return undefined
@@ -145,21 +173,44 @@ export class AiService {
return value !== undefined && value > 0 ? value : undefined
}
- private async callLlm(
+ /**
+ * Calls the LLM. A failed call keeps its reservation, because the provider
+ * may have billed it, unless the provider rejected the call unbilled.
+ */
+ private async callLlmOrRefund(
+ reservation: Reservation,
model: LanguageModel,
- description: string,
- language: SupportedLanguage,
- shape: AiMapShape
+ system: string,
+ prompt: string
) {
+ try {
+ return await this.callLlm(model, system, prompt)
+ } catch (err) {
+ if (AiService.isUnbilled(err)) await this.refundReservation(reservation)
+ throw err
+ }
+ }
+
+ // One reservation pays for one attempt, so the SDK must not retry.
+ private async callLlm(model: LanguageModel, system: string, prompt: string) {
return await generateText({
model,
- system: systemPrompt(shape),
- prompt: userPrompt(description, language),
+ system,
+ prompt,
maxOutputTokens: this.limits.maxOutputTokens,
+ maxRetries: 0,
abortSignal: AbortSignal.timeout(this.limits.timeoutMs),
})
}
+ /** Removes the per-minute entry and books 0 tokens for the day. */
+ private async refundReservation(reservation: Reservation): Promise {
+ this.tokensUsedPerMinute = this.tokensUsedPerMinute.filter(
+ (e) => e !== reservation.entry
+ )
+ await this.tryCommitReservation(reservation, 0)
+ }
+
private async reserveBudget(estimated: number): Promise {
this.checkPerMinuteLimits(estimated)
const entry: PerMinuteEntry = { time: Date.now(), count: estimated }
@@ -228,25 +279,9 @@ export class AiService {
actual - reservation.estimated
)
} catch (err) {
+ const reason = err instanceof Error ? err.message : String(err)
this.logger.warn(
- `Failed to reconcile actual tokens for ${reservation.dateUsage}; keeping conservative reservation. ${(err as Error).message}`
- )
- }
- }
-
- private async releaseReservation(reservation: Reservation): Promise {
- this.tokensUsedPerMinute = this.tokensUsedPerMinute.filter(
- (e) => e !== reservation.entry
- )
- try {
- await this.usageCounter.release(
- reservation.dateUsage,
- reservation.estimated
- )
- } catch (err) {
- // Don't mask the original error from the caller's catch path.
- this.logger.error(
- `Failed to release reserved tokens for ${reservation.dateUsage}: ${(err as Error).message}`
+ `Failed to reconcile actual tokens for ${reservation.dateUsage}; keeping conservative reservation. ${reason}`
)
}
}
diff --git a/teammapper-backend/src/map/services/llm-usage-counter.service.spec.ts b/teammapper-backend/src/map/services/llm-usage-counter.service.spec.ts
index 4682ff1f..d900b50e 100644
--- a/teammapper-backend/src/map/services/llm-usage-counter.service.spec.ts
+++ b/teammapper-backend/src/map/services/llm-usage-counter.service.spec.ts
@@ -65,13 +65,4 @@ describe('LlmUsageCounterService', () => {
-50,
])
})
-
- it('release decrements both tokens and request count', async () => {
- queryMock.mockResolvedValueOnce([])
- await service.release('2026-05-08', 200)
- expect(queryMock).toHaveBeenCalledWith(
- expect.stringContaining('"requestsCount" - 1'),
- ['2026-05-08', 200]
- )
- })
})
diff --git a/teammapper-backend/src/map/services/llm-usage-counter.service.ts b/teammapper-backend/src/map/services/llm-usage-counter.service.ts
index 1ea7e51e..aed89c67 100644
--- a/teammapper-backend/src/map/services/llm-usage-counter.service.ts
+++ b/teammapper-backend/src/map/services/llm-usage-counter.service.ts
@@ -20,7 +20,6 @@ export interface LlmUsageCounting {
cap?: number
): Promise<{ tokensUsed: number; requestsCount: number } | null>
adjustTokens(dateUsage: string, delta: number): Promise
- release(dateUsage: string, tokens: number): Promise
}
/**
@@ -82,18 +81,4 @@ export class LlmUsageCounterService implements LlmUsageCounting {
[dateUsage, delta]
)
}
-
- /**
- * Roll back a reservation entirely (token-wise and request-wise),
- * e.g. after a failed `generateText` call.
- */
- async release(dateUsage: string, tokens: number): Promise {
- await this.repo.query(
- `UPDATE llm_usage_counter
- SET "tokensUsed" = GREATEST(0, "tokensUsed" - $2),
- "requestsCount" = GREATEST(0, "requestsCount" - 1)
- WHERE "dateUsage" = $1`,
- [dateUsage, tokens]
- )
- }
}
diff --git a/teammapper-backend/src/map/utils/prompts.ts b/teammapper-backend/src/map/utils/prompts.ts
index 0bbdc406..0b4b8346 100644
--- a/teammapper-backend/src/map/utils/prompts.ts
+++ b/teammapper-backend/src/map/utils/prompts.ts
@@ -50,5 +50,9 @@ export const systemPrompt = (shape: AiMapShape) =>
Do not explain the refusal.
`
+// Escapes the markup characters so a description cannot close the tag.
+const escapeMarkup = (text: string) =>
+ text.replace(/&/g, '&').replace(//g, '>')
+
export const userPrompt = (description: string, language: SupportedLanguage) =>
- `${description}`
+ `${escapeMarkup(description)}`
diff --git a/teammapper-backend/test/llm-usage-counter.e2e-spec.ts b/teammapper-backend/test/llm-usage-counter.e2e-spec.ts
index 2cee3606..7e6514cc 100644
--- a/teammapper-backend/test/llm-usage-counter.e2e-spec.ts
+++ b/teammapper-backend/test/llm-usage-counter.e2e-spec.ts
@@ -73,12 +73,4 @@ describe('LlmUsageCounterService (e2e)', () => {
await service.adjustTokens(date, -150)
expect(await readRow(date)).toEqual({ tokensUsed: 50, requestsCount: 1 })
})
-
- it('release rolls a reservation back fully', async () => {
- const date = '2026-05-13'
- await service.reserve(date, 200, 1000)
- await service.reserve(date, 300, 1000)
- await service.release(date, 300)
- expect(await readRow(date)).toEqual({ tokensUsed: 200, requestsCount: 1 })
- })
})
From 4421a8ca6ad783cb1ee2fd75af72552e34f37baa Mon Sep 17 00:00:00 2001
From: JannikStreek
Date: Tue, 29 Sep 2026 12:12:26 +0200
Subject: [PATCH 2/2] feat: optimize deleted date in settings (#1435)
---
.../map-deletion/map-deletion.component.html | 2 +-
.../map-deletion.component.spec.ts | 5 +--
.../map-deletion/map-deletion.component.ts | 5 +--
.../mindmaps-overview.component.html | 4 +--
.../mindmaps-overview.component.ts | 5 +--
.../shared/pipes/localized-date.pipe.spec.ts | 35 +++++++++++++++++++
.../app/shared/pipes/localized-date.pipe.ts | 22 ++++++++++++
teammapper-frontend/src/assets/i18n/de.json | 2 +-
teammapper-frontend/src/assets/i18n/en.json | 2 +-
teammapper-frontend/src/assets/i18n/es.json | 2 +-
teammapper-frontend/src/assets/i18n/fr.json | 2 +-
teammapper-frontend/src/assets/i18n/it.json | 2 +-
teammapper-frontend/src/assets/i18n/ja.json | 2 +-
.../src/assets/i18n/pt-br.json | 2 +-
.../src/assets/i18n/zh-cn.json | 2 +-
.../src/assets/i18n/zh-tw.json | 2 +-
16 files changed, 78 insertions(+), 18 deletions(-)
create mode 100644 teammapper-frontend/src/app/shared/pipes/localized-date.pipe.spec.ts
create mode 100644 teammapper-frontend/src/app/shared/pipes/localized-date.pipe.ts
diff --git a/teammapper-frontend/src/app/modules/application/components/map-deletion/map-deletion.component.html b/teammapper-frontend/src/app/modules/application/components/map-deletion/map-deletion.component.html
index 57edc6aa..214ba790 100644
--- a/teammapper-frontend/src/app/modules/application/components/map-deletion/map-deletion.component.html
+++ b/teammapper-frontend/src/app/modules/application/components/map-deletion/map-deletion.component.html
@@ -15,7 +15,7 @@
{{ 'PAGES.SETTINGS.DELETED_AT' | translate }}
- {{ attached.cachedMap.deletedAt | date: 'yyyy-MM-dd' }}
+ {{ attached.cachedMap.deletedAt | localizedDate }}
@if (mapAdminId | async) {
diff --git a/teammapper-frontend/src/app/modules/application/components/map-deletion/map-deletion.component.spec.ts b/teammapper-frontend/src/app/modules/application/components/map-deletion/map-deletion.component.spec.ts
index 0e6478d1..a3d8eae3 100644
--- a/teammapper-frontend/src/app/modules/application/components/map-deletion/map-deletion.component.spec.ts
+++ b/teammapper-frontend/src/app/modules/application/components/map-deletion/map-deletion.component.spec.ts
@@ -1,6 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
-import { TranslateModule } from '@ngx-translate/core';
+import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { BehaviorSubject } from 'rxjs';
import { ToastrService } from 'ngx-toastr';
import { CachedMapEntry } from '@teammapper/shared';
@@ -52,6 +52,7 @@ describe('MapDeletionComponent', () => {
{ provide: ToastrService, useValue: toastrService },
],
}).compileComponents();
+ TestBed.inject(TranslateService).use('de');
fixture = TestBed.createComponent(MapDeletionComponent);
fixture.detectChanges();
@@ -73,7 +74,7 @@ describe('MapDeletionComponent', () => {
expect(
fixture.nativeElement.querySelector('.deleted-at').textContent
- ).toContain('2026-10-28');
+ ).toContain('28.10.2026');
});
it('renders nothing while no map is attached', async () => {
diff --git a/teammapper-frontend/src/app/modules/application/components/map-deletion/map-deletion.component.ts b/teammapper-frontend/src/app/modules/application/components/map-deletion/map-deletion.component.ts
index f39bbb42..fbe683d2 100644
--- a/teammapper-frontend/src/app/modules/application/components/map-deletion/map-deletion.component.ts
+++ b/teammapper-frontend/src/app/modules/application/components/map-deletion/map-deletion.component.ts
@@ -12,7 +12,8 @@ import {
MatCardActions,
} from '@angular/material/card';
import { MatButton } from '@angular/material/button';
-import { AsyncPipe, DatePipe } from '@angular/common';
+import { AsyncPipe } from '@angular/common';
+import { LocalizedDatePipe } from 'src/app/shared/pipes/localized-date.pipe';
import { StorageService } from 'src/app/core/services/storage/storage.service';
import { MapSyncService } from 'src/app/core/services/map-sync/map-sync.service';
@@ -28,7 +29,7 @@ import { MapSyncService } from 'src/app/core/services/map-sync/map-sync.service'
MatCardActions,
MatButton,
AsyncPipe,
- DatePipe,
+ LocalizedDatePipe,
TranslatePipe,
],
})
diff --git a/teammapper-frontend/src/app/shared/components/mindmaps-overview/mindmaps-overview.component.html b/teammapper-frontend/src/app/shared/components/mindmaps-overview/mindmaps-overview.component.html
index 311c901f..9d5c3229 100644
--- a/teammapper-frontend/src/app/shared/components/mindmaps-overview/mindmaps-overview.component.html
+++ b/teammapper-frontend/src/app/shared/components/mindmaps-overview/mindmaps-overview.component.html
@@ -18,7 +18,7 @@
{{ 'PAGES.SETTINGS.TTL' | translate }}:
- {{ ownedEntry.cachedAdminMapValue.ttl | date: 'yyyy-MM-dd' }}
+ {{ ownedEntry.cachedAdminMapValue.ttl | localizedDate }}
@if (ownedEntry.cachedAdminMapValue.adminId) {