Skip to content

Add per-workload Claude model and effort configuration #54

Description

@vycdev

Summary

Claudify currently routes every Claude model request through the same global BOT_MODEL and BOT_EFFORT values. That couples latency, quality, and cost for unrelated workloads.

A user-facing answer may deserve Sonnet with higher effort, while deterministic background maintenance such as profile extraction, server-memory compaction, or daily summaries can often use Haiku or a lower effort level. Each workload should be independently configurable without duplicating spawn logic or breaking existing deployments.

Current state

All model calls ultimately use runClaude(args, input, model, effort), but every caller passes the same two configuration values:

Workload Call site Current model/effort
Interactive Discord response, mention, reply, or reaction src/askClaude.ts BOT_MODEL / BOT_EFFORT
Background user-profile extraction backgroundProfileUpdate() in src/storage/profiles.ts BOT_MODEL / BOT_EFFORT
Background server-memory update backgroundServerMemoryUpdate() in src/storage/profiles.ts BOT_MODEL / BOT_EFFORT
Daily channel summary generateDailySummary() in src/storage/summaries.ts BOT_MODEL / BOT_EFFORT

Claude authentication and ccusage do not make model requests and are out of scope.

The current global spawn log also does not identify which workload requested the model, making cost/latency troubleshooting harder:

[Claude CLI] Spawning with model=..., effort=...

Goals

  • Make model and effort independently configurable for every current Claude workload.
  • Keep one centralized, typed source of truth for workload configuration.
  • Preserve BOT_MODEL and BOT_EFFORT as backward-compatible global fallbacks.
  • Make the resolved workload visible in logs without logging prompt contents or secrets.
  • Make future Claude call sites declare their workload explicitly.
  • Preserve the existing shared concurrency/rate limiter and background-processing behavior.
  • Document a cost-optimized configuration where user responses use a stronger model and maintenance tasks use Haiku/lower effort.

Non-goals

  • Dynamically selecting a model by inspecting prompt content.
  • Runtime configuration through Discord commands or writing configuration to storage.
  • Separate API keys/accounts per workload.
  • Changing prompts, storage formats, context limits, or the shared Claude queue.
  • Changing authentication or usage-reporting behavior.

Proposed workload taxonomy

Introduce a closed union or enum rather than passing arbitrary strings:

type ClaudeWorkload =
    | response
    | profile-update
    | server-memory-update
    | daily-summary;

type ClaudeEffort = low | medium | high | xhigh | max;

interface ClaudeWorkloadConfig {
    workload: ClaudeWorkload;
    model?: string;       // undefined means Claude CLI default
    effort?: ClaudeEffort; // undefined means omit --effort
}

Mapping:

  • response: all user-facing calls through askClaude().
  • profile-update: backgroundProfileUpdate().
  • server-memory-update: backgroundServerMemoryUpdate().
  • daily-summary: generateDailySummary().

The type should force a deliberate update when a new workload is added.

Configuration contract

Keep the existing global variables and add explicit per-workload overrides:

Workload Model variable Effort variable
Global fallback BOT_MODEL BOT_EFFORT
User response CLAUDE_RESPONSE_MODEL CLAUDE_RESPONSE_EFFORT
Profile update CLAUDE_PROFILE_MODEL CLAUDE_PROFILE_EFFORT
Server memory CLAUDE_SERVER_MEMORY_MODEL CLAUDE_SERVER_MEMORY_EFFORT
Daily summary CLAUDE_SUMMARY_MODEL CLAUDE_SUMMARY_EFFORT

Resolution precedence for each property:

  1. A non-empty workload-specific override.
  2. The corresponding legacy global BOT_MODEL or BOT_EFFORT value.
  3. The existing built-in fallback (claude-haiku-4-5 for model and no explicit effort flag).

Recommended special-value behavior:

  • Unset, blank, or inherit: inherit the global value.
  • default: deliberately bypass the global value and let the Claude CLI choose its default by omitting --model or --effort for that workload.
  • Effort values remain case-insensitive and normalize to lowercase.
  • Validate efforts against low, medium, high, xhigh, and max.
  • Do not hardcode a model allowlist because aliases and model IDs evolve; trim the value and reject control characters/invalid whitespace instead.
  • An invalid explicit override must produce a clear startup warning and use a deterministic documented fallback. It must not fail silently.

If special values feel too complex for the first implementation, inheritance is required and explicit CLI-default override can be split into a follow-up. The resolution function should still be designed so it can support that state without changing every caller.

Backward compatibility

When none of the new variables are configured, every workload must resolve exactly as it does today:

BOT_MODEL -> all workload models
BOT_EFFORT -> all workload efforts

This ensures existing deployments do not unexpectedly change model, quality, latency, or cost after upgrading.

A cost-optimized deployment can then opt in explicitly:

# User-facing answers
BOT_MODEL=claude-sonnet-5
BOT_EFFORT=high

# Background maintenance
CLAUDE_PROFILE_MODEL=claude-haiku-4-5
CLAUDE_PROFILE_EFFORT=low
CLAUDE_SERVER_MEMORY_MODEL=claude-haiku-4-5
CLAUDE_SERVER_MEMORY_EFFORT=low
CLAUDE_SUMMARY_MODEL=claude-haiku-4-5
CLAUDE_SUMMARY_EFFORT=low

The exact model IDs remain operator-controlled; this example is guidance, not a hardcoded routing table.

Suggested implementation design

1. Centralize parsing and resolution

Add a typed configuration map in src/config.ts (or a focused lower-level module if it keeps config.ts manageable):

export const CLAUDE_WORKLOAD_CONFIG: Readonly<
    Record<ClaudeWorkload, ClaudeWorkloadConfig>
> = {
    response: resolveClaudeWorkloadConfig(response),
    profile-update: resolveClaudeWorkloadConfig(profile-update),
    server-memory-update: resolveClaudeWorkloadConfig(server-memory-update),
    daily-summary: resolveClaudeWorkloadConfig(daily-summary),
};

Resolve once at startup. Do not re-read process.env at each request. Keep parsing, normalization, fallback behavior, and warnings in one place.

2. Replace positional spawn configuration

The current positional signature is easy to misuse:

runClaude(args, input, model, effort)

Prefer an options object carrying workload identity and resolved execution settings:

interface RunClaudeOptions {
    workload: ClaudeWorkload;
    model?: string;
    effort?: ClaudeEffort;
}

runClaude(args, input, options)

Alternatively, accept only workload and let runClaude() resolve the settings centrally. The important property is that callers cannot accidentally swap model/effort or omit workload attribution.

Avoid importing higher-level storage or Discord modules into claude.ts; preserve the current dependency direction and no-circular-import rule.

3. Update every call site

  • askClaude() uses response.
  • backgroundProfileUpdate() uses profile-update.
  • backgroundServerMemoryUpdate() uses server-memory-update.
  • generateDailySummary() uses daily-summary.
  • Update injected ClaudeRunner test types in profiles.ts so tests can assert workload/model/effort.
  • If summary generation lacks runner injection, consider adding the same narrow injection point for testability.

4. Keep process configuration isolated

For each spawn:

  • Set --model and ANTHROPIC_MODEL only from that invocation's resolved model.
  • Set --effort only from that invocation's resolved effort.
  • Never mutate process.env globally.
  • Ensure concurrent workloads cannot leak model or effort into one another.
  • Keep the current global queue, maximum concurrency, spawn delay, timeout, and force-kill behavior unchanged.

5. Improve observability

Include the workload in lifecycle logs:

[Claude CLI][response] Spawning model=claude-sonnet-5 effort=high ...
[Claude CLI][profile-update] Spawning model=claude-haiku-4-5 effort=low ...
[Claude CLI][daily-summary] Failed after 12.4s: ...

At startup, print a compact resolved routing table once:

[Claude Config] response: model=..., effort=...
[Claude Config] profile-update: model=..., effort=...
[Claude Config] server-memory-update: model=..., effort=...
[Claude Config] daily-summary: model=..., effort=...

Requirements:

  • Do not log prompts, OAuth codes, tokens, API keys, or other secrets.
  • Preserve enough workload context in errors to identify which background task failed.
  • The queue log should still show active/queued counts.

6. Update user-visible model reporting

!help currently says the bot is powered by BOT_MODEL. It should display the resolved response model because background models are an implementation detail and may differ.

The bot system prompt's botModel template value should likewise use the resolved response model, not the global fallback when a response-specific override is configured.

Validation and failure behavior

  • Normalize whitespace/casing before resolving effort.
  • Detect unsupported effort values at startup rather than on the first background job.
  • Do not silently substitute a more expensive model due to a typo without a warning.
  • Do not validate model names against a frozen list; Claude aliases change.
  • Treat model and effort independently: overriding a model must not require overriding effort, and vice versa.
  • A failure in a profile, server-memory, or summary request must remain non-blocking for the user-facing response.
  • Error messages should include workload identity but should not expose the prompt or environment.

Tests

Configuration resolution

Use subprocess-based config tests because config.ts is module-cached:

  • No new variables: all workloads inherit BOT_MODEL and BOT_EFFORT.
  • Only a workload model is set: effort still inherits globally.
  • Only a workload effort is set: model still inherits globally.
  • All workload overrides set: each resolves independently.
  • Case normalization for effort.
  • Blank/unset/inherit behavior.
  • Explicit default behavior if implemented.
  • Invalid effort behavior and warning/fallback contract.
  • Model values are trimmed and control characters rejected.

Invocation routing

Using a fake Claude executable or injected runner, assert:

  • Response requests receive response settings.
  • Profile updates receive profile settings.
  • Server-memory updates receive server-memory settings.
  • Summaries receive summary settings.
  • --model, ANTHROPIC_MODEL, and --effort agree for each invocation.
  • Two queued/concurrent workloads retain their own settings with no cross-contamination.
  • Workload identity is present in spawn/failure logs.

Regression coverage

  • Existing profile serialization tests still pass.
  • Existing server-memory serialization tests still pass.
  • User responses remain independent from background task failure.
  • !help and the system prompt report the resolved response model.
  • npm run build and the complete test suite pass.

Documentation and deployment

Update:

  • README.md environment-variable table and add a complete mixed-model example.
  • AGENTS.md configuration table and model-selection guidance.
  • docker-compose.yml with commented or blank opt-in overrides, while ensuring blank values do not accidentally disable inheritance.
  • CHANGELOG.md.

Document:

  • Precedence and inheritance rules.
  • The difference between the user-facing response model and maintenance models.
  • That stronger background models may improve extraction/summary quality but increase cost and latency.
  • That lower-effort/Haiku background settings are recommendations, not forced defaults.
  • Whether configuration changes require a container restart (expected: yes).

Acceptance criteria

  • Response, profile update, server-memory update, and daily summary each have independent model configuration.
  • All four workloads each have independent effort configuration.
  • Model and effort can be overridden separately.
  • Existing BOT_MODEL and BOT_EFFORT remain backward-compatible global fallbacks.
  • With no new variables set, behavior is identical to the current release.
  • Every runClaude() call declares a typed workload.
  • --model, ANTHROPIC_MODEL, and --effort are scoped per subprocess with no global environment mutation.
  • Spawn and error logs identify the workload and resolved model/effort without exposing sensitive data.
  • !help and the bot system prompt use the resolved response model.
  • Background maintenance remains asynchronous and cannot block the Discord reply path.
  • README, Docker Compose, AGENTS guidance, and changelog are updated.
  • Resolution, routing, invalid configuration, inheritance, and concurrency have automated tests.
  • npm run build and the full test suite pass.

Follow-up opportunities (not required here)

  • Per-workload timeouts and retry policies.
  • Priority-aware queues so user responses can outrank background maintenance.
  • Per-workload concurrency limits.
  • Usage/cost attribution by workload.
  • Runtime admin visibility such as an owner-only !config models command.
  • Structured configuration via a JSON file if the environment-variable matrix grows substantially.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions