Files
dflike/docs/plans/2026-03-09-llm-fallback-implementation.md
T
root ee7f22c2c3 feat: ensure default stockpiles on load, use full stat names in backstory prompts
- Add ensureDefaultStockpiles() to handle saves from before stockpile spawning
- Switch backstory prompt stat formatting from abbreviations (STR, DEX) to
  full names (strength, dexterity) for better LLM comprehension
- Add prior design/plan docs for LLM fallback and NPC sleep system

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:05:14 +00:00

27 KiB

LLM Model Fallback Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Automatically fall back from free to paid LLM model when OpenRouter returns 429, with per-model usage counters for observability.

Architecture: The openRouterClient returns structured rate-limit info on 429. The llmService owns model-switching state and usage counters. The generationQueue drops daily-limit gatekeeping but keeps per-minute throttling.

Tech Stack: TypeScript, vitest, OpenRouter REST API


Task 1: Update llmConfig to add fallbackModel and remove hardcoded default

Files:

  • Modify: server/src/config/llmConfig.ts
  • Modify: server/src/config/__tests__/llmConfig.test.ts
  • Modify: server/.env.example
  • Modify: server/.env

Step 1: Write the failing tests

In server/src/config/__tests__/llmConfig.test.ts, update the existing "returns default config values" test and add new tests:

it('requires LLM_MODEL from environment (no hardcoded default)', () => {
  delete process.env.LLM_MODEL;
  const config = getLlmConfig();
  expect(config.model).toBe('');
});

it('reads fallback model from environment', () => {
  process.env.OPENROUTER_API_KEY = 'key';
  process.env.LLM_MODEL = 'free/model:free';
  process.env.LLM_FALLBACK_MODEL = 'openai/gpt-oss-120b';
  const config = getLlmConfig();
  expect(config.fallbackModel).toBe('openai/gpt-oss-120b');
});

it('fallbackModel is null when not set', () => {
  delete process.env.LLM_FALLBACK_MODEL;
  const config = getLlmConfig();
  expect(config.fallbackModel).toBeNull();
});

Update the "returns default config values" test: change expect(config.model).toBe('arcee-ai/trinity-large-preview:free') to expect(config.model).toBe('') and remove the requestsPerDay assertion. Add expect(config.fallbackModel).toBeNull().

Step 2: Run tests to verify they fail

Run: npm -w server run test -- --run src/config/__tests__/llmConfig.test.ts Expected: FAIL — fallbackModel property doesn't exist, model still has hardcoded default

Step 3: Implement the changes

In server/src/config/llmConfig.ts:

export interface LlmConfig {
  apiKey: string;
  model: string;
  fallbackModel: string | null;
  maxTokens: number;
  temperature: number;
  requestsPerMinute: number;
  timeoutMs: number;
  enabled: boolean;
}

export function getLlmConfig(): LlmConfig {
  const apiKey = process.env.OPENROUTER_API_KEY ?? '';
  return {
    apiKey,
    model: process.env.LLM_MODEL ?? '',
    fallbackModel: process.env.LLM_FALLBACK_MODEL ?? null,
    maxTokens: 200,
    temperature: 0.8,
    requestsPerMinute: 20,
    timeoutMs: 15000,
    enabled: apiKey.length > 0,
  };
}

Update server/.env.example:

# OpenRouter API key — get one at https://openrouter.ai/keys
# LLM features are disabled when this is not set.
OPENROUTER_API_KEY=

# Primary model (free tier)
LLM_MODEL=arcee-ai/trinity-large-preview:free

# Fallback model — used automatically when primary hits rate limit (429)
# Costs per-token. Remove or leave empty to disable fallback.
# LLM_FALLBACK_MODEL=openai/gpt-oss-120b

Update server/.env to add both model vars:

OPENROUTER_API_KEY=<existing key>
LLM_MODEL=arcee-ai/trinity-large-preview:free
LLM_FALLBACK_MODEL=openai/gpt-oss-120b

Step 4: Run tests to verify they pass

Run: npm -w server run test -- --run src/config/__tests__/llmConfig.test.ts Expected: PASS

Step 5: Fix downstream compile errors

The mockConfig objects in server/src/llm/__tests__/openRouterClient.test.ts and any other test files referencing LlmConfig need requestsPerDay removed and fallbackModel added. Update all mockConfig objects to match the new interface (add fallbackModel: null, remove requestsPerDay if present).

Step 6: Run full test suite

Run: npm -w server run test -- --run Expected: PASS (all 408+ tests)

Step 7: Commit

git add server/src/config/llmConfig.ts server/src/config/__tests__/llmConfig.test.ts server/.env.example server/.env server/src/llm/__tests__/openRouterClient.test.ts
git commit -m "feat(llm): add fallbackModel config, remove hardcoded model default"

Task 2: Update openRouterClient to return rate-limit info on 429

Files:

  • Modify: server/src/llm/openRouterClient.ts
  • Modify: server/src/llm/__tests__/openRouterClient.test.ts

Step 1: Write the failing tests

Add to server/src/llm/__tests__/openRouterClient.test.ts:

it('returns rateLimited result with reset timestamp on 429', async () => {
  globalThis.fetch = vi.fn().mockResolvedValue({
    ok: false,
    status: 429,
    statusText: 'Too Many Requests',
    json: () => Promise.resolve({
      error: {
        code: 429,
        message: 'Rate limit exceeded',
        metadata: {
          headers: {
            'X-RateLimit-Reset': '1741305600000',
          },
        },
      },
    }),
  });

  const client = createOpenRouterClient(mockConfig);
  const result = await client.complete({
    system: 'sys',
    user: 'usr',
  });

  expect(result).toEqual({
    rateLimited: true,
    resetAt: 1741305600000,
  });
});

it('returns rateLimited result with null resetAt when 429 has no reset header', async () => {
  globalThis.fetch = vi.fn().mockResolvedValue({
    ok: false,
    status: 429,
    statusText: 'Too Many Requests',
    json: () => Promise.resolve({
      error: { code: 429, message: 'Rate limit exceeded' },
    }),
  });

  const client = createOpenRouterClient(mockConfig);
  const result = await client.complete({
    system: 'sys',
    user: 'usr',
  });

  expect(result).toEqual({
    rateLimited: true,
    resetAt: null,
  });
});

it('returns null on non-429 HTTP errors', async () => {
  globalThis.fetch = vi.fn().mockResolvedValue({
    ok: false,
    status: 500,
    statusText: 'Internal Server Error',
  });

  const client = createOpenRouterClient(mockConfig);
  const result = await client.complete({
    system: 'sys',
    user: 'usr',
  });

  expect(result).toBeNull();
});

Update the existing "returns null on HTTP error" test — it currently uses status 429 but expects null. Change it to use status 500 instead, or remove it since the new "non-429 HTTP errors" test covers it.

Step 2: Run tests to verify they fail

Run: npm -w server run test -- --run src/llm/__tests__/openRouterClient.test.ts Expected: FAIL — current code returns null for all errors

Step 3: Implement the changes

In server/src/llm/openRouterClient.ts:

import type { LlmConfig } from '../config/llmConfig.js';
import type { RenderedPrompt } from './promptTemplate.js';

const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';

export interface CompletionRequest extends RenderedPrompt {
  maxTokens?: number;
  temperature?: number;
  model?: string;
}

export interface RateLimitInfo {
  rateLimited: true;
  resetAt: number | null;
}

export type CompletionResult = string | null | RateLimitInfo;

export function isRateLimited(result: CompletionResult): result is RateLimitInfo {
  return result != null && typeof result === 'object' && 'rateLimited' in result;
}

export interface OpenRouterClient {
  complete(request: CompletionRequest): Promise<CompletionResult>;
}

export function createOpenRouterClient(config: LlmConfig): OpenRouterClient {
  return {
    async complete(request: CompletionRequest): Promise<CompletionResult> {
      try {
        const response = await fetch(OPENROUTER_URL, {
          method: 'POST',
          signal: AbortSignal.timeout(config.timeoutMs),
          headers: {
            'Authorization': `Bearer ${config.apiKey}`,
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({
            model: request.model ?? config.model,
            max_tokens: request.maxTokens ?? config.maxTokens,
            temperature: request.temperature ?? config.temperature,
            messages: [
              { role: 'system', content: request.system },
              { role: 'user', content: request.user },
            ],
          }),
        });

        if (!response.ok) {
          if (response.status === 429) {
            try {
              const data = await response.json();
              const resetStr = data?.error?.metadata?.headers?.['X-RateLimit-Reset'];
              const resetAt = resetStr ? Number(resetStr) : null;
              return { rateLimited: true, resetAt };
            } catch {
              return { rateLimited: true, resetAt: null };
            }
          }
          console.warn(`OpenRouter API error: ${response.status} ${response.statusText}`);
          return null;
        }

        const data = await response.json();
        const content = data?.choices?.[0]?.message?.content;
        return content ?? null;
      } catch (error) {
        console.warn('OpenRouter request failed:', (error as Error).message);
        return null;
      }
    },
  };
}

Note: The model field on CompletionRequest allows the caller to override the model per-request. This will be used by llmService to send requests to the fallback model.

Step 4: Run tests to verify they pass

Run: npm -w server run test -- --run src/llm/__tests__/openRouterClient.test.ts Expected: PASS

Step 5: Commit

git add server/src/llm/openRouterClient.ts server/src/llm/__tests__/openRouterClient.test.ts
git commit -m "feat(llm): return structured rate-limit info on 429 responses"

Task 3: Simplify generationQueue — remove daily limit gatekeeping

Files:

  • Modify: server/src/llm/generationQueue.ts
  • Modify: server/src/llm/__tests__/generationQueue.test.ts

Step 1: Update the implementation

The queue no longer gatekeeps on daily limits. It only does per-minute throttling and passes through results (including RateLimitInfo) from the client. Remove dailyCount, dailyResetTime, getNextMidnight, checkDailyReset, isDailyLimitReached, and the queue-draining logic.

In server/src/llm/generationQueue.ts:

import type { OpenRouterClient, CompletionRequest, CompletionResult } from './openRouterClient.js';

interface QueueItem {
  request: CompletionRequest;
  resolve: (value: CompletionResult) => void;
}

export interface GenerationQueue {
  enqueue(request: CompletionRequest): Promise<CompletionResult>;
  depth(): number;
  clear(): void;
}

export function createGenerationQueue(
  client: OpenRouterClient,
  options: { requestsPerMinute: number },
): GenerationQueue {
  const queue: QueueItem[] = [];
  const intervalMs = Math.ceil(60000 / options.requestsPerMinute);
  let processing = false;

  function scheduleNext(): void {
    if (processing || queue.length === 0) return;

    processing = true;

    const item = queue.shift()!;
    client.complete(item.request).then(result => {
      item.resolve(result);
    }).catch(() => {
      item.resolve(null);
    }).finally(() => {
      processing = false;
      if (queue.length > 0) {
        setTimeout(() => scheduleNext(), intervalMs);
      }
    });
  }

  return {
    enqueue(request: CompletionRequest): Promise<CompletionResult> {
      return new Promise(resolve => {
        queue.push({ request, resolve });
        scheduleNext();
      });
    },

    depth(): number {
      return queue.length;
    },

    clear(): void {
      const remaining = queue.splice(0);
      for (const item of remaining) {
        item.resolve(null);
      }
    },
  };
}

Step 2: Update the tests

Remove the following tests from server/src/llm/__tests__/generationQueue.test.ts:

  • "exposes isDailyLimitReached as false initially"
  • "resolves with null when daily limit is reached"
  • "resets daily count at midnight"
  • "works without requestsPerDay (no daily limit)"

Update remaining tests: remove requestsPerDay from options objects, remove references to isDailyLimitReached.

Add a test to verify rate-limit info passes through:

it('passes through rate limit info from client', async () => {
  const rateLimitResult = { rateLimited: true as const, resetAt: 1741305600000 };
  const client: OpenRouterClient = {
    complete: vi.fn().mockResolvedValue(rateLimitResult),
  };
  const queue = createGenerationQueue(client, { requestsPerMinute: 60 });

  const promise = queue.enqueue({ system: 's', user: 'u' });
  await vi.advanceTimersByTimeAsync(100);
  const result = await promise;

  expect(result).toEqual(rateLimitResult);
});

Step 3: Run tests to verify they pass

Run: npm -w server run test -- --run src/llm/__tests__/generationQueue.test.ts Expected: PASS

Step 4: Commit

git add server/src/llm/generationQueue.ts server/src/llm/__tests__/generationQueue.test.ts
git commit -m "refactor(llm): remove daily limit gatekeeping from generationQueue"

Task 4: Add usage counters module

Files:

  • Create: server/src/llm/usageCounters.ts
  • Create: server/src/llm/__tests__/usageCounters.test.ts

Step 1: Write the failing tests

In server/src/llm/__tests__/usageCounters.test.ts:

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createUsageCounters } from '../usageCounters.js';

describe('usageCounters', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it('tracks requests per model', () => {
    const counters = createUsageCounters();
    counters.record('free/model');
    counters.record('free/model');
    counters.record('paid/model');

    const stats = counters.getStats();
    expect(stats['free/model'].total).toBe(2);
    expect(stats['paid/model'].total).toBe(1);
  });

  it('resets daily counts at midnight UTC', () => {
    // Set time to 23:59:59 UTC
    const nearMidnight = new Date('2026-03-09T23:59:59Z');
    vi.setSystemTime(nearMidnight);

    const counters = createUsageCounters();
    counters.record('free/model');
    expect(counters.getStats()['free/model'].total).toBe(1);

    // Advance past midnight UTC
    vi.advanceTimersByTime(2000);
    counters.record('free/model');

    // After reset, count should be 1 (just the new one)
    expect(counters.getStats()['free/model'].total).toBe(1);
  });

  it('returns empty stats when no requests recorded', () => {
    const counters = createUsageCounters();
    expect(counters.getStats()).toEqual({});
  });

  it('formats a summary string', () => {
    const counters = createUsageCounters();
    counters.record('free/model');
    counters.record('free/model');
    counters.record('paid/model');

    const summary = counters.getSummary();
    expect(summary).toContain('free/model: 2');
    expect(summary).toContain('paid/model: 1');
  });
});

Step 2: Run tests to verify they fail

Run: npm -w server run test -- --run src/llm/__tests__/usageCounters.test.ts Expected: FAIL — module doesn't exist

Step 3: Implement

In server/src/llm/usageCounters.ts:

interface ModelStats {
  total: number;
}

export interface UsageCounters {
  record(model: string): void;
  getStats(): Record<string, ModelStats>;
  getSummary(): string;
}

export function createUsageCounters(): UsageCounters {
  let counts = new Map<string, number>();
  let resetTime = getNextMidnightUTC();

  function getNextMidnightUTC(): number {
    const now = new Date();
    const midnight = new Date(Date.UTC(
      now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1,
      0, 0, 0, 0,
    ));
    return midnight.getTime();
  }

  function checkReset(): void {
    if (Date.now() >= resetTime) {
      counts = new Map();
      resetTime = getNextMidnightUTC();
    }
  }

  return {
    record(model: string): void {
      checkReset();
      counts.set(model, (counts.get(model) ?? 0) + 1);
    },

    getStats(): Record<string, ModelStats> {
      checkReset();
      const result: Record<string, ModelStats> = {};
      for (const [model, total] of counts) {
        result[model] = { total };
      }
      return result;
    },

    getSummary(): string {
      checkReset();
      if (counts.size === 0) return 'No LLM requests today';
      const parts: string[] = [];
      for (const [model, total] of counts) {
        parts.push(`${model}: ${total}`);
      }
      return `LLM usage today — ${parts.join(', ')}`;
    },
  };
}

Step 4: Run tests to verify they pass

Run: npm -w server run test -- --run src/llm/__tests__/usageCounters.test.ts Expected: PASS

Step 5: Commit

git add server/src/llm/usageCounters.ts server/src/llm/__tests__/usageCounters.test.ts
git commit -m "feat(llm): add per-model usage counters for observability"

Task 5: Rewrite llmService with model switching and usage tracking

Files:

  • Modify: server/src/llm/llmService.ts
  • Modify: server/src/llm/__tests__/llmService.test.ts

Step 1: Write the failing tests

Replace server/src/llm/__tests__/llmService.test.ts with:

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createLlmService } from '../llmService.js';

describe('llmService', () => {
  let originalFetch: typeof globalThis.fetch;

  beforeEach(() => {
    originalFetch = globalThis.fetch;
    vi.useFakeTimers();
  });

  afterEach(() => {
    globalThis.fetch = originalFetch;
    delete process.env.OPENROUTER_API_KEY;
    delete process.env.LLM_MODEL;
    delete process.env.LLM_FALLBACK_MODEL;
    vi.useRealTimers();
  });

  function setupEnv(opts?: { fallback?: string }) {
    process.env.OPENROUTER_API_KEY = 'test-key';
    process.env.LLM_MODEL = 'free/model:free';
    if (opts?.fallback) {
      process.env.LLM_FALLBACK_MODEL = opts.fallback;
    }
  }

  function mockFetchOk(content: string) {
    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: true,
      json: () => Promise.resolve({
        choices: [{ message: { content } }],
      }),
    });
  }

  function mockFetch429(resetAt?: number) {
    const metadata = resetAt != null
      ? { headers: { 'X-RateLimit-Reset': String(resetAt) } }
      : undefined;
    globalThis.fetch = vi.fn().mockResolvedValue({
      ok: false,
      status: 429,
      statusText: 'Too Many Requests',
      json: () => Promise.resolve({
        error: { code: 429, message: 'Rate limit exceeded', metadata },
      }),
    });
  }

  it('returns null when LLM is disabled', async () => {
    delete process.env.OPENROUTER_API_KEY;
    const service = createLlmService();
    const result = await service.generate('backstory', { npcName: 'Test' });
    expect(result).toBeNull();
  });

  it('generates text using primary model', async () => {
    setupEnv();
    mockFetchOk('A brave warrior.');

    const service = createLlmService();
    const result = await service.generate('backstory', {
      npcName: 'Brynn',
      stats: 'STR:15',
    });
    await vi.advanceTimersByTimeAsync(100);

    expect(result).toBe('A brave warrior.');
  });

  it('switches to fallback model on 429', async () => {
    setupEnv({ fallback: 'paid/model' });
    const resetAt = Date.now() + 3600000; // 1 hour from now

    // First call hits 429
    mockFetch429(resetAt);
    const service = createLlmService();
    const result1 = await service.generate('backstory', {
      npcName: 'Brynn',
      stats: 'STR:15',
    });
    await vi.advanceTimersByTimeAsync(100);
    expect(result1).toBeNull(); // The 429 request itself returns null

    // Second call should use fallback model
    mockFetchOk('Fallback response.');
    const result2 = await service.generate('backstory', {
      npcName: 'Brynn',
      stats: 'STR:15',
    });
    await vi.advanceTimersByTimeAsync(100);
    expect(result2).toBe('Fallback response.');

    // Verify the model sent was the fallback
    const body = JSON.parse(
      (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body,
    );
    expect(body.model).toBe('paid/model');
  });

  it('switches back to primary model after reset time', async () => {
    setupEnv({ fallback: 'paid/model' });
    const resetAt = Date.now() + 5000; // 5 seconds from now

    // Hit 429
    mockFetch429(resetAt);
    const service = createLlmService();
    await service.generate('backstory', { npcName: 'X', stats: 'S:1' });
    await vi.advanceTimersByTimeAsync(100);

    expect(service.activeModel()).toBe('paid/model');

    // Advance past reset time
    await vi.advanceTimersByTimeAsync(6000);

    expect(service.activeModel()).toBe('free/model:free');
  });

  it('returns null on 429 when no fallback model configured', async () => {
    setupEnv(); // no fallback
    mockFetch429(Date.now() + 3600000);

    const service = createLlmService();
    const result = await service.generate('backstory', {
      npcName: 'X',
      stats: 'S:1',
    });
    await vi.advanceTimersByTimeAsync(100);

    expect(result).toBeNull();
  });

  it('tracks usage per model', async () => {
    setupEnv({ fallback: 'paid/model' });
    mockFetchOk('ok');

    const service = createLlmService();
    await service.generate('backstory', { npcName: 'X', stats: 'S:1' });
    await vi.advanceTimersByTimeAsync(100);

    const stats = service.usageStats();
    expect(stats['free/model:free']?.total).toBe(1);
  });

  it('exposes queue depth and clear', async () => {
    setupEnv();
    mockFetchOk('ok');

    const service = createLlmService();
    expect(service.queueDepth()).toBe(0);
    service.clear();
  });

  it('exposes activeModel', () => {
    setupEnv({ fallback: 'paid/model' });
    mockFetchOk('ok');

    const service = createLlmService();
    expect(service.activeModel()).toBe('free/model:free');
  });
});

Step 2: Run tests to verify they fail

Run: npm -w server run test -- --run src/llm/__tests__/llmService.test.ts Expected: FAIL — new methods don't exist, return type changes

Step 3: Implement

In server/src/llm/llmService.ts:

import { getLlmConfig } from '../config/llmConfig.js';
import { createOpenRouterClient, isRateLimited } from './openRouterClient.js';
import { createGenerationQueue } from './generationQueue.js';
import { createUsageCounters, type UsageCounters } from './usageCounters.js';
import { renderTemplate } from './promptTemplate.js';
import { templates } from './templates.js';

export interface LlmService {
  generate(templateName: string, variables: Record<string, string>): Promise<string | null>;
  queueDepth(): number;
  clear(): void;
  activeModel(): string;
  usageStats(): Record<string, { total: number }>;
  usageSummary(): string;
}

export function createLlmService(): LlmService {
  const config = getLlmConfig();

  if (!config.enabled || !config.model) {
    return {
      generate: async () => null,
      queueDepth: () => 0,
      clear: () => {},
      activeModel: () => '',
      usageStats: () => ({}),
      usageSummary: () => 'LLM disabled',
    };
  }

  const client = createOpenRouterClient(config);
  const queue = createGenerationQueue(client, {
    requestsPerMinute: config.requestsPerMinute,
  });
  const counters = createUsageCounters();

  let currentModel = config.model;
  let switchBackTimer: ReturnType<typeof setTimeout> | null = null;

  function getNextMidnightUTC(): number {
    const now = new Date();
    return new Date(Date.UTC(
      now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1,
      0, 0, 0, 0,
    )).getTime();
  }

  function switchToFallback(resetAt: number | null): void {
    if (!config.fallbackModel) return;

    currentModel = config.fallbackModel;
    console.log(`[LLM] Switched to fallback model: ${currentModel}`);

    const resetTime = resetAt ?? getNextMidnightUTC();
    const delay = Math.max(0, resetTime - Date.now());

    if (switchBackTimer) clearTimeout(switchBackTimer);
    switchBackTimer = setTimeout(() => {
      currentModel = config.model;
      switchBackTimer = null;
      console.log(`[LLM] Switched back to primary model: ${currentModel}`);
    }, delay);
  }

  return {
    async generate(
      templateName: string,
      variables: Record<string, string>,
    ): Promise<string | null> {
      const template = templates[templateName];
      if (!template) {
        console.warn(`Unknown LLM template: ${templateName}`);
        return null;
      }
      const rendered = renderTemplate(template, variables);
      const result = await queue.enqueue({ ...rendered, model: currentModel });

      if (isRateLimited(result)) {
        switchToFallback(result.resetAt);
        return null;
      }

      if (typeof result === 'string') {
        counters.record(currentModel);
      }

      return result;
    },

    queueDepth(): number {
      return queue.depth();
    },

    clear(): void {
      queue.clear();
    },

    activeModel(): string {
      return currentModel;
    },

    usageStats(): Record<string, { total: number }> {
      return counters.getStats();
    },

    usageSummary(): string {
      return counters.getSummary();
    },
  };
}

Step 4: Run tests to verify they pass

Run: npm -w server run test -- --run src/llm/__tests__/llmService.test.ts Expected: PASS

Step 5: Commit

git add server/src/llm/llmService.ts server/src/llm/__tests__/llmService.test.ts
git commit -m "feat(llm): add model switching on 429 and usage tracking to llmService"

Task 6: Fix downstream consumers — remove isDailyLimitReached references

Files:

  • Modify: any files that reference isDailyLimitReached on LlmService

Step 1: Search for references

Search for isDailyLimitReached across the codebase. Expected locations:

  • server/src/game/GameLoop.ts or systems that check the daily limit
  • Any test files referencing the old interface

Step 2: Remove or replace references

The isDailyLimitReached method no longer exists on LlmService. Remove any checks or replace with the new API. Systems that previously checked this to skip LLM calls can simply call generate() — the service handles model switching internally.

Step 3: Run full test suite

Run: npm -w server run test -- --run Expected: PASS (all tests)

Step 4: Commit

git add -u
git commit -m "refactor(llm): remove isDailyLimitReached from downstream consumers"

Task 7: Add periodic usage logging

Files:

  • Modify: server/src/llm/llmService.ts
  • Modify: server/src/llm/__tests__/llmService.test.ts

Step 1: Add periodic logging

In createLlmService(), after creating the counters, add a logging interval. Log the usage summary every 100 requests (check in the generate method) and expose a destroy() method to clean up timers (for tests and graceful shutdown).

Add to the generate method, after counters.record(currentModel):

const totalRequests = Object.values(counters.getStats()).reduce((sum, s) => sum + s.total, 0);
if (totalRequests % 100 === 0) {
  console.log(`[LLM] ${counters.getSummary()}`);
}

Add destroy() to the LlmService interface and implementation:

destroy(): void {
  if (switchBackTimer) {
    clearTimeout(switchBackTimer);
    switchBackTimer = null;
  }
}

Step 2: Run full test suite

Run: npm -w server run test -- --run Expected: PASS

Step 3: Commit

git add server/src/llm/llmService.ts server/src/llm/__tests__/llmService.test.ts
git commit -m "feat(llm): add periodic usage logging and destroy cleanup"

Task 8: Integration — wire destroy into GameLoop shutdown

Files:

  • Modify: server/src/game/GameLoop.ts

Step 1: Add destroy call

In GameLoop.stop() (or wherever the game loop shuts down), call this.llmService.destroy() to clean up the switch-back timer.

Step 2: Run full test suite

Run: npm -w server run test -- --run Expected: PASS

Step 3: Commit

git add server/src/game/GameLoop.ts
git commit -m "chore(llm): wire llmService destroy into GameLoop shutdown"