DEV Community

Mattias chaw
Mattias chaw

Posted on

Build a Pricing-Aware Request Queue for AI Gateways

Build a Pricing-Aware Request Queue for AI Gateways

Most AI cost controls are added after dispatch. A request enters the gateway, the SDK picks a model, the provider accepts or rejects the call, and only then does the team calculate what happened. That is too late for production AI systems that serve many tenants, task classes, and model families.

A better place to control spend is the request queue. The queue sees demand before tokens are spent. It can separate interactive work from batch work, attach dated rate cards, check concurrency limits, preserve cache affinity, and decide whether a job should run now, wait for a cheaper operating window, or be sent to a different approved route.

This article shows how to build that queue as a small production primitive for OpenAI-compatible AI gateways. The goal is not to turn a queue into a magic optimizer. The goal is to make dispatch decisions explicit, auditable, and tied to source-checked pricing.

All source and pricing checks below were refreshed on August 23, 2026. AIWave's public pricing page lists DeepSeek V4 Flash at $0.638 input, $1.914 output, and $0.0203 cache-hit input per 1M tokens; DeepSeek V4 Pro at $1.914 input, $5.742 output, and $0.0638 cache-hit input per 1M tokens. DeepSeek's official page lists peak and off-peak rows for V4 Flash and V4 Pro, plus account-level concurrency limits of 2500 for Flash and 500 for Pro. QwenCloud documents request-tiered token billing, Batch API rates at 50% of real-time pricing, context caching, thinking-token output billing, and tool fees. Z.AI lists GLM-5.3, GLM-5.2, and GLM-5.1 at $1.40 input, $0.26 cached input, and $4.40 output per 1M tokens. Kimi K3 public material lists $0.30/MTok cache-hit input, $3.00/MTok cache-miss input, and $15.00/MTok output for its API path.

The important lesson is not that one provider has a better table. The lesson is that route economics differ by model, cache state, output cap, request timing, and provider feature. A queue that ignores those fields will make expensive decisions in a hurry.

Why the Queue Should Know Pricing

A normal job queue knows priority, retry count, creation time, and worker capacity. An AI request queue also needs pricing context. The same prompt can produce very different exposure depending on whether it is fresh input or cache-hit input, whether the response cap is 256 or 8,000 tokens, whether the job is interactive or asynchronous, and whether a provider charges tool calls separately.

If the queue only stores model and priority, it cannot answer practical questions:

Question Queue field needed
Can this request wait for a scheduled batch lane? deadline_at, task_class, batch_allowed
Should it preserve a provider cache key? cache_namespace, cache_affinity_required
Is the output budget larger than the tenant allows? max_output_tokens, tenant_budget_usd
Can it use a Pro route during a capacity incident? approved_route_set, quality_floor
Could a retry exceed the daily retry envelope? attempt, estimated_retry_usd
Should it avoid a provider peak window? rate_card_window, dispatch_after

These are queue decisions because they happen before the call. Once the request is in flight, the gateway can still retry or reroute, but it has already accepted latency, capacity, and cost exposure.

The Dispatch Contract

Start by making the request queue accept a dispatch contract instead of a raw prompt. A dispatch contract is a small document that says what the job is allowed to do.

{
  "tenant_id": "acme-support",
  "task_class": "support_summary",
  "latency_class": "interactive",
  "model_preferences": ["deepseek-v4-flash", "glm-5.3"],
  "max_input_tokens": 12000,
  "max_output_tokens": 900,
  "cache_namespace": "acme-support-policy-v3",
  "batch_allowed": false,
  "reroute_allowed": true,
  "deadline_at": "2026-08-23T14:05:00Z",
  "budget_ceiling_usd": 0.01,
  "policy_version": "dispatch-queue-2026-08-23"
}
Enter fullscreen mode Exit fullscreen mode

This contract should be created by your product or SDK layer, not invented by a worker after the request is already late. A support summary, coding patch, compliance review, and offline benchmark should not share one global policy.

The queue then adds operational metadata:

Field Added by
rate_card_source_date Pricing loader
estimated_fresh_input_usd Queue estimator
estimated_cached_input_usd Queue estimator
estimated_output_usd Queue estimator
selected_route Scheduler
dispatch_reason Scheduler
queue_wait_ms Queue runtime
capacity_bucket Provider adapter

That final record becomes the explanation for why the request ran where it ran.

Route Classes

Do not build one queue. Build lanes.

Lane Purpose Typical policy
interactive_fast Chat, support drafts, small extraction Short wait, strict output cap, no batch delay
interactive_reasoning Planning, coding review, complex analysis Higher quality floor, lower concurrency share
batch_async Evaluations, document conversion, nightly jobs Deadline-based scheduling, batch route allowed
cache_warm Repeated system prompts or reference packs Preserve namespace and provider affinity
retry_repair Bounded replay after transient failure Small daily envelope, no blind escalation
manual_review Risky or high-spend jobs Human or policy approval before dispatch

The queue should move jobs between lanes only through policy. For example, an offline evaluation can move from batch_async to interactive_reasoning if the deadline is near and the budget allows it. A customer-facing chat request should not move into a delayed batch lane just because a cheaper path exists.

A Minimal Python Scheduler

The following example implements a compact pricing-aware scheduler. It uses AIWave's dated DeepSeek rows for a unified route and keeps official provider fields in the same shape so you can add DeepSeek peak windows, Qwen batch routes, GLM cache rows, or Kimi long-context routes without changing the queue API.

import os
import time
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
from typing import Literal

from openai import OpenAI

client = OpenAI(
    base_url="https://aiwave.live/v1",
    api_key=os.environ.get("AIWAVE_API_KEY", "YOUR_API_KEY_HERE"),
)

LatencyClass = Literal["interactive", "async"]

@dataclass(frozen=True)
class RateCard:
    route: str
    family: str
    input_per_m: float
    cached_input_per_m: float | None
    output_per_m: float
    concurrency_limit: int | None
    source_url: str
    source_date: str
    window: str

@dataclass(frozen=True)
class DispatchContract:
    tenant_id: str
    task_class: str
    latency_class: LatencyClass
    prompt: str
    max_output_tokens: int
    estimated_input_tokens: int
    estimated_cached_tokens: int
    budget_ceiling_usd: float
    batch_allowed: bool
    reroute_allowed: bool
    deadline_epoch: float

@dataclass(frozen=True)
class DispatchDecision:
    route: str
    lane: str
    estimated_usd: float
    dispatch_now: bool
    reason: str
    policy_version: str
    rate_card_source_date: str

RATE_CARDS = {
    "deepseek-v4-flash": RateCard(
        route="deepseek-v4-flash",
        family="deepseek",
        input_per_m=0.638,
        cached_input_per_m=0.0203,
        output_per_m=1.914,
        concurrency_limit=2500,
        source_url="https://aiwave.live/pricing",
        source_date="2026-08-23",
        window="aiwave_all_day",
    ),
    "deepseek-v4-pro": RateCard(
        route="deepseek-v4-pro",
        family="deepseek",
        input_per_m=1.914,
        cached_input_per_m=0.0638,
        output_per_m=5.742,
        concurrency_limit=500,
        source_url="https://aiwave.live/pricing",
        source_date="2026-08-23",
        window="aiwave_all_day",
    ),
}

def estimate_usd(contract: DispatchContract, row: RateCard) -> float:
    cached = min(contract.estimated_cached_tokens, contract.estimated_input_tokens)
    fresh = contract.estimated_input_tokens - cached
    cached_rate = row.cached_input_per_m if row.cached_input_per_m is not None else row.input_per_m
    total = (
        fresh / 1_000_000 * row.input_per_m
        + cached / 1_000_000 * cached_rate
        + contract.max_output_tokens / 1_000_000 * row.output_per_m
    )
    return round(total, 6)

def choose_lane(contract: DispatchContract) -> str:
    if contract.latency_class == "async" and contract.batch_allowed:
        return "batch_async"
    if contract.task_class in {"planning", "code_review", "reasoning"}:
        return "interactive_reasoning"
    return "interactive_fast"

def decide(contract: DispatchContract) -> DispatchDecision:
    lane = choose_lane(contract)
    candidates = ["deepseek-v4-flash", "deepseek-v4-pro"] if contract.reroute_allowed else ["deepseek-v4-flash"]
    scored = []
    for route in candidates:
        row = RATE_CARDS[route]
        cost = estimate_usd(contract, row)
        scored.append((cost, route, row))

    scored.sort(key=lambda item: item[0])
    cost, route, row = scored[0]

    now = time.time()
    if cost > contract.budget_ceiling_usd:
        return DispatchDecision(
            route=route,
            lane="manual_review",
            estimated_usd=cost,
            dispatch_now=False,
            reason="estimated_cost_exceeds_contract_ceiling",
            policy_version="dispatch-queue-2026-08-23",
            rate_card_source_date=row.source_date,
        )

    if lane == "batch_async" and contract.deadline_epoch - now > 900:
        return DispatchDecision(
            route=route,
            lane=lane,
            estimated_usd=cost,
            dispatch_now=False,
            reason="batch_job_can_wait_for_async_window",
            policy_version="dispatch-queue-2026-08-23",
            rate_card_source_date=row.source_date,
        )

    return DispatchDecision(
        route=route,
        lane=lane,
        estimated_usd=cost,
        dispatch_now=True,
        reason="within_budget_and_latency_policy",
        policy_version="dispatch-queue-2026-08-23",
        rate_card_source_date=row.source_date,
    )

def dispatch(contract: DispatchContract):
    decision = decide(contract)
    print(asdict(decision))
    if not decision.dispatch_now:
        return {"status": "queued", "decision": asdict(decision)}

    response = client.chat.completions.create(
        model=decision.route,
        messages=[{"role": "user", "content": contract.prompt}],
        max_tokens=contract.max_output_tokens,
    )
    return response

deadline = datetime(2026, 8, 23, 14, 5, tzinfo=timezone.utc).timestamp()
dispatch(
    DispatchContract(
        tenant_id="acme-support",
        task_class="support_summary",
        latency_class="interactive",
        prompt="Summarize the incident notes for a customer-facing reply.",
        max_output_tokens=900,
        estimated_input_tokens=12000,
        estimated_cached_tokens=8000,
        budget_ceiling_usd=0.01,
        batch_allowed=False,
        reroute_allowed=True,
        deadline_epoch=deadline,
    )
)
Enter fullscreen mode Exit fullscreen mode

This code is intentionally small. In production you would replace the rough token estimates, store decisions in a durable queue, track in-flight provider capacity, and reconcile estimates with actual usage after the response. The core shape still holds: every dispatch decision returns a route, lane, estimated cost, source date, and reason.

Cache Affinity Is a Scheduling Constraint

Cache savings only exist if the provider sees the same reusable context in a compatible way. If your queue randomly moves repeated long-context work across providers, tenants, or cache namespaces, the rate card may say cache-hit input is available while the runtime rarely earns it.

Treat cache affinity like a scheduling constraint:

Workload Queue behavior
Repeated coding-agent repository context Keep a stable route unless quality policy says otherwise
Support bot with a large policy document Use a named cache namespace and measure hit share
One-off customer prompt Do not over-optimize for cache
Evaluation batch with repeated system prompt Group jobs by prompt fingerprint
Retry after provider failure Preserve cache namespace only if reroute policy allows

The queue should store cache_namespace, prompt_fingerprint, and cached_token_estimate. The worker should later record actual cache-hit tokens when the provider or aggregator returns them. That closes the loop between planning and billing.

Concurrency Is Not Just Throughput

DeepSeek's public rate-limit docs list account-level concurrency limits for V4 Flash and V4 Pro, and describe HTTP 429 behavior when limits are exceeded. That kind of field should live in the same scheduler as pricing. If the Pro lane has less concurrency than the Flash lane, the queue should avoid filling it with low-value work during a spike.

A simple capacity policy can reserve slices:

Capacity slice Example use
50% Interactive production traffic
20% High-confidence reasoning jobs
15% Async jobs near deadline
10% Retry repair lane
5% Internal tests and smoke checks

These percentages are not universal. The useful part is the isolation. A bad evaluation batch should not consume the same lane that support traffic needs. Retry storms should have a small envelope, not unlimited access to every worker.

Batch Windows Need Deadlines

QwenCloud's pricing docs describe Batch API rates at 50% of real-time pricing for eligible async workloads. That is exactly the kind of feature a request queue should understand. A job cannot choose batch mode by itself; the product has to declare whether delay is acceptable.

Use a deadline-based rule:

Deadline Dispatch choice
Under 60 seconds Interactive lane only
1 to 15 minutes Interactive or short queue wait
15 minutes to 24 hours Batch lane allowed
No deadline Require a product owner to set one

The phrase "batch allowed" is not enough. A daily report due at 23:00 UTC and a customer chat response due now are both asynchronous in code, but only one can wait.

Tool Fees Belong in the Queue

Tool calls change dispatch economics. QwenCloud lists built-in tool fees for some tools, and states that tool descriptions can count as input tokens for function calling and MCP. Z.AI lists web search as a per-use tool cost. Even if your gateway abstracts those details, the queue should receive a tool plan before dispatch.

Add fields such as:

{
  "tools_requested": ["web_search", "code_interpreter"],
  "tool_call_cap": 3,
  "tool_budget_ceiling_usd": 0.02,
  "tool_descriptions_token_estimate": 1800
}
Enter fullscreen mode Exit fullscreen mode

Then decide whether the job still fits its budget. A request with a small prompt and a large tool schema can be more expensive than expected because tool descriptions and tool outputs become part of the model context. A queue that sees only user text will underestimate it.

Observability Fields

Every queue decision should produce fields that can be joined with the final usage ledger.

Field Example
ai.queue.lane interactive_fast
ai.queue.reason within_budget_and_latency_policy
ai.route.model deepseek-v4-flash
ai.route.family deepseek
ai.estimate.usd 0.004842
ai.actual.usd 0.004501
ai.rate_card.source_date 2026-08-23
ai.cache.estimated_tokens 8000
ai.cache.actual_tokens 7912
ai.deadline.ms_remaining_at_dispatch 48000
ai.policy.version dispatch-queue-2026-08-23

These fields let finance and engineering debug the same event. If estimates are consistently above actual usage, improve token prediction. If estimates are consistently below actual usage, inspect output caps, tool schemas, and cache assumptions. If one lane dominates retry spend, isolate it before it becomes a platform incident.

Rollout Plan

Start with shadow mode. Keep dispatch behavior unchanged, but make the queue calculate lane, route, estimated spend, and dispatch reason for every request. Compare those estimates with actual usage for one week.

Next, enforce only the safest rule: output caps and tenant budget ceilings. If a request estimates above its contract, send it to review or ask the caller to lower max output. This prevents the most obvious budget surprises without changing model quality.

Then add lane isolation. Give interactive traffic, async jobs, retries, and tests separate capacity buckets. This reduces incident blast radius even before you optimize routing.

After that, add batch scheduling for declared async jobs. Require a real deadline. Store the reason when the queue waits instead of dispatching immediately.

Finally, add model rerouting. Do this last because it can change quality, latency, privacy posture, and cost. Every approved route pair should have replay fixtures, acceptance thresholds, and source-dated rate cards.

Source Links for Implementation

If you are building this on AIWave, start with the pricing page, predictable-pricing calculator, model catalog, and Chat Completions documentation. Those pages provide the rate-card and OpenAI-compatible request context a queue needs.

For provider checks, keep dated links to DeepSeek pricing, DeepSeek rate limits, QwenCloud pricing, Z.AI pricing, and the Kimi K3 public page. Recheck them before changing route budgets or queue policy.

Final Checklist

Before you ship a pricing-aware queue, make sure it can answer these questions:

Requirement Pass condition
Dated rate cards Every route stores source URL and source date
Lane isolation Interactive, async, retry, and test traffic have separate buckets
Budget estimates Fresh input, cached input, output, and tool exposure are estimated separately
Cache affinity Repeated contexts keep namespace and route metadata
Deadline handling Batch-eligible jobs have explicit deadlines
Capacity awareness Provider concurrency limits are part of scheduling
Decision logging Every dispatch has a policy version and human-readable reason
Reconciliation Estimated and actual usage are joined after completion

The queue is the right place to slow down before a request becomes spend. Once pricing, cache state, concurrency, deadlines, and route policy are visible before dispatch, the gateway can make boring decisions under pressure: run now when the contract allows it, wait when the job can wait, preserve cache when it matters, and stop when the request does not fit its budget.

Top comments (0)