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 }) - }) }) 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) {