Vercel Logo

Prevent runaway AI costs with pre-flight checks and token budgeting

After this lesson, you'll:

  • Reject requests costing more than a limit you pick (this lesson uses $0.02) BEFORE making them (watch logs block expensive queries)
  • See cost estimates priced from AI Gateway's model catalog in your logs
  • Switch to cheaper models automatically based on your budget thresholds
# Cost-aware request handling in logs:
[INFO] Pre-flight cost check {
  inputTokens: 2341,
  estimatedOutputTokens: 500,
  estimatedCost: 0.00400575,
  model: 'openai/gpt-5.4-mini',
  status: 'approved'
}
 
[INFO] AI request completed {
  model: 'openai/gpt-5.4-mini',
  actualCost: 0.00394725,
  estimatedCost: 0.00400575,
  estimationAccuracy: '99%'
}
 
# When requests are too expensive:
[WARN] Request rejected - cost limit exceeded {
  estimatedCost: 0.023559,
  limit: 0.02,
  inputTokens: 28412,
  estimatedOutputTokens: 500,
  model: 'openai/gpt-5.4-mini'
}

What you'll build: Pre-flight cost checks + token estimation + automatic model switching + spend review.

Your bot crashes at 3 AM with "rate limit exceeded." By morning, you've burned through $500 in retries because nobody was watching. AI Gateway's spend report will explain every dollar of it over coffee. This lesson builds guardrails that stop expensive requests before they happen, so the report stays boring.

Outcome

Implement cost controls that prevent expensive AI requests before they burn money, using token estimation and pre-flight checks.

Understanding AI Gateway

AI Gateway provides:

  • A unified API endpoint routing to hundreds of models across providers
  • Automatic provider fallback when primary fails
  • Request/response passthrough with no markup on BYOK pricing
  • A dashboard showing: Requests by Model, TTFT, Token Counts, Spend
  • Spend data from code: the Custom Reporting API breaks down cost by model, user, or tag, and Generation Lookup returns the cost of one request
  • Budgets that stop requests once a team, project, or API key reaches its limit

What this means for production: A spend report tells you what last week cost. A budget works like a circuit breaker: once the limit hits, every request stops, including the cheap, useful ones. Neither one notices the single 200-message thread that's about to become the most expensive question of the week.

Pre-flight checks catch that one. We estimate the tokens, price them with AI Gateway's own model catalog, and reject the request before it runs.

The production pattern: Pre-flight checks on every request, with a Budget as the backstop.

Fast Track

  1. Build token estimation for pre-flight cost checks
  2. Reject requests exceeding cost thresholds BEFORE making them
  3. Log actual vs estimated costs to tune your estimates
  4. Check spend trends in the Gateway dashboard

Hands-On Exercise 4.5

Implement production cost controls that act on each request before it runs:

Requirements:

  1. Token Estimation for pre-flight cost checks:

    • Estimate input tokens from message array
    • Assume conservative output token count
    • Price the tokens with AI Gateway's model catalog
  2. Request Rejection based on estimated cost:

    • Set per-request cost limit (this lesson uses $0.02; any amount works)
    • Reject before making expensive API calls
    • Return user-friendly error messages
  3. Model Switching based on manual budget thresholds:

    • Environment variable for "cheap mode" toggle
    • Use openai/gpt-5.4-nano when over budget
    • Log when switching occurs
  4. Cost Logging for post-request analysis:

    • Log estimated vs actual costs
    • Track accuracy of estimates over time
    • Compare against the spend in the Gateway dashboard

Implementation hints:

  • Open the AI Gateway Overview and select your project from the project dropdown
  • Gateway dashboard shows: Requests by Model, TTFT, Token Counts, Spend
  • gateway.getAvailableModels() returns each model's price per token. Read prices from there instead of hardcoding a table that goes stale. The AI Gateway pricing docs explain how Gateway charges, and the models page lists current per-model prices
  • AI SDK returns a usage field with actual token counts after the request completes, totaled across every step (tool calls included)

Try It

  1. Test normal request with cost logging:

    • Ask the bot a simple question in a short thread
    • Check logs for pre-flight and post-request cost tracking:
    [INFO] Pre-flight cost check {
      inputTokens: 2341,
      estimatedOutputTokens: 500,
      estimatedCost: 0.00400575,
      model: 'openai/gpt-5.4-mini',
      status: 'approved'
    }
    
    [INFO] AI request completed {
      model: 'openai/gpt-5.4-mini',
      usage: {
        promptTokens: 2341,
        completionTokens: 487,
        totalTokens: 2828
      },
      actualCost: 0.00394725,
      estimatedCost: 0.00400575,
      estimationAccuracy: '99%'
    }
    
  2. Test cost rejection with large context:

    • Create a thread with 50+ messages (pasting the same long stack trace a few times gets there fast)
    • Ask a question that would include all context
    • Watch the pre-flight check reject it BEFORE making the request:
    [WARN] Request rejected - cost limit exceeded {
      estimatedCost: 0.023559,
      limit: 0.02,
      inputTokens: 28412,
      estimatedOutputTokens: 500,
      model: 'openai/gpt-5.4-mini'
    }
    
    • Bot responds: "Your request is too large. Please break it into smaller questions or provide less context. (Estimated cost: $0.0236)"
  3. Test model switching with budget threshold:

    • Set FORCE_CHEAP_MODEL=true in .env to simulate over-budget state
    • Ask a question
    • Watch logs show openai/gpt-5.4-nano selection:
    [WARN] Budget threshold triggered - using cheap model {
      reason: 'FORCE_CHEAP_MODEL environment variable set',
      selectedModel: 'openai/gpt-5.4-nano',
      normalModel: 'openai/gpt-5.4-mini'
    }
    
  4. Review Gateway spend:

    • Open the AI Gateway Overview and select your project from the project dropdown
    • Check "Requests by Model" chart
    • Review "Spend" over time
    • Compare dashboard spend with your logged costs
Spend Data From Code

The dashboard numbers are also available from code. gateway.getSpendReport() calls the Custom Reporting API, and gateway.getGenerationInfo() returns the exact cost of one request by its generation ID. Either way, review spend periodically to:

  • Verify your cost estimates are accurate
  • Spot unusual spending patterns
  • Compare different models' actual costs
  • Track TTFT trends over time

Commit

git add -A
git commit -m "feat(cost-control): add pre-flight checks and token budgeting for AI requests"

Done-When

  • Pre-flight cost checks reject expensive requests before making them
  • Token estimation calculates cost from message array
  • Model switching based on budget threshold (environment variable)
  • Actual vs estimated costs logged for accuracy tracking
  • User-friendly error messages when requests rejected

Solution

Create /slack-agent/server/lib/ai/cost-control.ts:

/slack-agent/server/lib/ai/cost-control.ts
import { gateway, type ModelMessage } from "ai";
 
/**
 * Estimate tokens for messages array using rough character-to-token ratio
 * Use this for pre-flight cost estimation
 *
 * Note: This is an approximation. Actual tokens depend on tokenizer.
 * Rule of thumb: 1 token ≈ 4 characters for English text.
 * In production, use a real tokenizer like OpenAI's tiktoken for more accurate estimates:
 * https://github.com/openai/tiktoken
 */
export function estimateTokens(messages: ModelMessage[]): number {
  return messages.reduce((sum, msg) => {
    const content = typeof msg.content === 'string'
      ? msg.content
      : JSON.stringify(msg.content);
    // Rough estimate: 1 token per 4 characters
    return sum + Math.ceil(content.length / 4);
  }, 0);
}
 
/**
 * Look up a model's price per token from AI Gateway's model catalog.
 * Prices change, so read them instead of hardcoding a table.
 * The gateway provider caches the catalog, so this isn't a network call per request.
 */
async function getModelPricing(model: string) {
  const { models } = await gateway.getAvailableModels();
  const pricing = models.find((m) => m.id === model)?.pricing;
 
  if (!pricing) {
    throw new Error(`AI Gateway has no pricing for ${model}`);
  }
 
  return { input: Number(pricing.input), output: Number(pricing.output) };
}
 
/**
 * Calculate request cost before making it
 * Use for budget decisions and request rejection
 */
export async function estimateRequestCost(
  inputTokens: number,
  outputTokens: number,
  model: string
): Promise<number> {
  const pricing = await getModelPricing(model);
  return inputTokens * pricing.input + outputTokens * pricing.output;
}
 
/**
 * Calculate actual cost from AI SDK usage response
 * Use to compare estimated vs actual and tune your estimates
 */
export async function calculateActualCost(
  inputTokens: number,
  outputTokens: number,
  model: string
): Promise<number> {
  return estimateRequestCost(inputTokens, outputTokens, model);
}
 
/**
 * Pre-flight check: Should we reject this request due to cost?
 * Reject obviously expensive requests BEFORE they burn money
 */
export function shouldRejectRequest(estimatedCost: number): {
  reject: boolean;
  reason?: string;
} {
  const MAX_REQUEST_COST = 0.02; // Example cap: $0.02 per request. Pick your own.
 
  if (estimatedCost > MAX_REQUEST_COST) {
    return {
      reject: true,
      reason: `Request would cost $${estimatedCost.toFixed(4)}, exceeds $${MAX_REQUEST_COST} limit`
    };
  }
 
  return { reject: false };
}
 
/**
 * Check if we should use cheap mode based on environment variable
 * In production, set this based on your own budget tracking
 */
export function shouldUseCheapModel(): boolean {
  return process.env.FORCE_CHEAP_MODEL === 'true';
}

Update /slack-agent/server/lib/ai/respond-to-message.ts:

/slack-agent/server/lib/ai/respond-to-message.ts
import type { KnownEventFromType } from "@slack/bolt";
import { generateText, isStepCount, type ModelMessage } from "ai";
import { app } from "~/app";
import {
  calculateActualCost,
  estimateRequestCost,
  estimateTokens,
  shouldRejectRequest,
  shouldUseCheapModel,
} from "./cost-control";
import { simulateFailure, withRetry } from "./retry-wrapper";
// ... rest of imports ...
 
export const respondToMessage = async ({
  messages,
  event,
  channel,
  thread_ts,
  botId,
  correlation,
}: RespondToMessageOptions) => {
  // Pre-flight cost estimation - catch expensive requests BEFORE they cost money
  const inputTokens = estimateTokens(messages);
  const estimatedOutputTokens = 500; // Conservative estimate for response
  const primaryModel = "openai/gpt-5.4-mini";
  const estimatedCost = await estimateRequestCost(
    inputTokens,
    estimatedOutputTokens,
    primaryModel
  );
 
  app.logger.info("Pre-flight cost check", {
    ...correlation,
    inputTokens,
    estimatedOutputTokens,
    estimatedCost,
    model: primaryModel,
    status: estimatedCost > 0.02 ? "rejected" : "approved",
  });
 
  // Reject expensive requests BEFORE making them
  const rejectCheck = shouldRejectRequest(estimatedCost);
  if (rejectCheck.reject) {
    app.logger.warn("Request rejected - cost limit exceeded", {
      ...correlation,
      estimatedCost,
      limit: 0.02,
      inputTokens,
      estimatedOutputTokens,
      model: primaryModel,
    });
 
    // Return user-friendly error instead of making expensive request
    return `Your request is too large. Please break it into smaller questions or provide less context. (Estimated cost: $${estimatedCost.toFixed(4)})`;
  }
 
  // Model selection with budget awareness
  // In production, set FORCE_CHEAP_MODEL based on your own budget tracking
  const useCheapMode = shouldUseCheapModel();
  const models = useCheapMode
    ? ["openai/gpt-5.4-nano"] // Force cheap model only
    : [primaryModel, "anthropic/claude-haiku-4.5"];
 
  if (useCheapMode) {
    app.logger.warn("Budget threshold triggered - using cheap model", {
      ...correlation,
      reason: "FORCE_CHEAP_MODEL environment variable set",
      selectedModel: "openai/gpt-5.4-nano",
      normalModel: primaryModel,
    });
  }
 
  let lastError: unknown;
 
  for (const currentModel of models) {
    try {
      const result = await withRetry(
        async () => {
          app.logger.info("Attempting AI request", {
            ...correlation,
            model: currentModel,
            inputTokens,
            estimatedCost,
          });
 
          return await generateText({
            model: currentModel,
            instructions: `You are Slack Agent, a helpful assistant in Slack.
            // ... rest of system prompt ...
            `,
            messages,
            // ... rest of config ...
          });
        },
        {
          maxRetries: 3,
          initialDelayMs: 1000,
        }
      );
 
      // Log actual cost after request completes
      const actualCost = await calculateActualCost(
        result.usage.inputTokens ?? 0,
        result.usage.outputTokens ?? 0,
        currentModel
      );
 
      const estimationAccuracy = ((actualCost / estimatedCost) * 100).toFixed(0);
 
      app.logger.info("AI request completed", {
        ...correlation,
        model: currentModel,
        usage: {
          promptTokens: result.usage.inputTokens,
          completionTokens: result.usage.outputTokens,
          totalTokens: result.usage.totalTokens,
        },
        actualCost,
        estimatedCost,
        estimationAccuracy: `${estimationAccuracy}%`,
      });
 
      return result.text;
    } catch (error) {
      // ... existing error handling ...
      lastError = error;
    }
  }
 
  throw lastError;
};

Screenshot placeholders:

[TODO: Add screenshot of Gateway dashboard showing "Requests by Model" chart] [TODO: Add screenshot of Gateway dashboard showing "Spend" over time] [TODO: Add screenshot of Gateway dashboard showing "TTFT" metrics]

Advanced: A /gateway-status command

The dashboard is great until someone in #incidents asks "how much has the bot cost today?" and you're on your phone. A slash command can answer from Slack, because gateway.getSpendReport() wraps the Custom Reporting API.

/slack-agent/server/listeners/commands/gateway-status.ts
import type { AllMiddlewareArgs, SlackCommandMiddlewareArgs } from "@slack/bolt";
import { gateway } from "ai";
 
export const gatewayStatusCallback = async ({
  ack,
  respond,
}: AllMiddlewareArgs & SlackCommandMiddlewareArgs) => {
  await ack();
 
  const today = new Date().toISOString().slice(0, 10);
  const report = await gateway.getSpendReport({
    startDate: today,
    endDate: today,
    groupBy: "model",
  });
 
  const lines = report.results.map(
    (row) => `• ${row.model}: $${row.totalCost.toFixed(4)}`
  );
 
  await respond(lines.length ? lines.join("\n") : "No AI spend yet today.");
};

Register it with app.command("/gateway-status", gatewayStatusCallback) and add the command to your manifest, the same way you added /test-resilience in error handling.

Check Your Plan Before You Ship This

The reporting endpoint isn't on every plan, and each query is billed. The Custom Reporting docs list which plans include it and what a query costs, so cache the answer for a few minutes instead of querying on every command.

Building on Previous Lessons

This lesson applies cost awareness to everything we've built:

  • From Repository Flyover: You saw how the bot fetches context—now you'll optimize token usage for those contexts
  • From system prompts: System prompts stay the same across budget-aware model selection
  • From status communication: Status updates can inform users when using cheaper models
  • From error handling: Retry logic combined with cost checks prevents retry storms burning money
  • From Bolt Middleware: Correlation-style logging makes pre-flight and post-request cost logs queryable in production
  • Production reality: Pre-flight checks stop the single expensive request, and Gateway reporting shows whether those checks are doing their job

What's Next

Section 5 covers deployment to Vercel and production operations—taking your cost-controlled bot live in Deploy to Vercel.

Was this helpful?

supported.