Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Control Layer Overview

Overview of the Doubleword Control Layer, a platform for AI model management with secure and scalable access control.

The Doubleword Control Layer is the world’s fastest AI model gateway (450x less overhead than LiteLLM). It provides a single, high-performance interface for routing, managing, and securing LLM inference across model providers, users and deployments - both open-source and proprietary.

Why Use the Control Layer?

Transform your AI infrastructure from scattered API calls to a centralized, secure, and manageable platform that scales with your organization.

Key Resources

GitHub Repo — Explore the open-source Control Layer code.
Announcement Blog — Read the launch announcement and background story.
Demo Video — Watch the Control Layer in action.
CTO Blog — Technical deep dive by Fergus Finn.
Benchmarking Writeup — Performance comparison vs alternatives.

Key Capabilities

OpenAI-Compatible API

A single OpenAI-compatible gateway to all of your AI models across all your providers. Your existing code works without modification - point your applications to the Control Layer’s /ai/ endpoints and supply a Control Layer API key.

OpenAI-compatible API example

Learn more about API access ->

Learn more about adding endpoints ->

Centralized User Management & API Key Authentication

Manage all the users that can access your AI models from one interface. Map users to groups, let users create their own API keys, and monitor usage across your organization.

The Control Layer turns unauthenticated, self-hosted model deployments into production-ready services with user authentication and access control.

Control Layer Users & Groups Interface

Learn more about Users & Groups ->

Real-Time Monitoring & Analytics

Track request volumes, token usage, response times, and model performance across your entire organization. Built-in analytics help you understand usage patterns, optimize costs, and identify bottlenecks.

Control Layer Model Metrics

Drill down into user behaviour across providers - with opinionated analytics for understanding which models are being used, by whom, and for what purposes.

Control Layer Analytics Dashboard

Interactive Playground

Test and compare generative, embedding, and reranker models side-by-side before integrating them into your applications.

Control Layer Playground

Get started ->

Getting Started

By the end of this tutorial, you’ll have the Control Layer running locally and will have sent your first request to an LLM through it.

Prerequisites

Step 1: Start the Control Layer

Download the Docker Compose file and start the stack:

wget https://raw.githubusercontent.com/doublewordai/control-layer/refs/heads/main/docker-compose.yml
docker compose up -d

Wait about 30 seconds for the services to initialize1.

Verify

Open http://localhost:3001 in your browser. You should see the login page.

Step 2: Log in to the dashboard

Sign in with the default admin credentials:

FieldValue
Emailtest@doubleword.ai
Passwordhunter2

Verify

You see the Control Layer dashboard with Models, Endpoints, Playground, and other items in the sidebar.

Step 3: Add an endpoint

Click Endpoints in the sidebar, then click Add Endpoint.

In the dialog:

  1. Use the dropdown in the Base URL field to select a popular endpoint (OpenAI, Anthropic, Google), or enter a custom URL
  2. Paste your API key in the API Key field
  3. Click Discover Models

The Control Layer connects to your provider and fetches available models.

  1. Select the models you want to enable, then click Save

Verify

Go to Models in the sidebar. You should see your provider’s models listed.

Step 4: Grant access to a group

Models must be added to a group before users can access them.

On any model card, click + Add groups in the top right corner. Select Everyone (the default group that includes all users), then click Done.

Verify

The model card now shows the group badge.

Step 5: Test in the Playground

On a model card, click Playground.

Type a message and press Enter.

Verify

You receive a response from the model.

Step 6: Send a request via the API

Create an API key

  1. Click API Keys in the sidebar
  2. Click Create API Key
  3. Enter a name (e.g., “test-key”) and click Create Key
  4. Copy the key now - you won’t see it again

Make a request

Using curl (replace YOUR_API_KEY with the key you copied):

curl http://localhost:3001/ai/v1/chat/completions \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gpt-4o-mini",
    "messages": [{"role": "user", "content": "Hello!"}]
  }'

Or using Python:

from openai import OpenAI

client = OpenAI(
    base_url="http://localhost:3001/ai/v1",
    api_key="YOUR_API_KEY"
)

response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": "Hello!"}]
)

print(response.choices[0].message.content)

Verify

You receive a JSON response (curl) or printed output (Python) with the model’s reply.

Done!

You have a working Control Layer instance routing requests to your AI provider.

Next steps:

Before deploying to production

The default configuration is for local development only. Before exposing the Control Layer:

  1. Change the admin password - hunter2 is not secure
  2. Set a secret key - generate with openssl rand -base64 32 and set via SECRET_KEY environment variable
  3. Use a production database - set DATABASE_URL to a real PostgreSQL instance
  4. Configure CORS - update auth.security.cors.allowed_origins for your domain

See Configuration Reference for details.


  1. On first run, Docker downloads the required images which may take several minutes. Run docker compose logs -f to watch startup progress.

Connect to the API

Learn how to interact with AI models using the Control Layer’s OpenAI-compatible API, including configuration, request handling, and best practices.

The Control Layer provides an OpenAI-compatible API. Use any OpenAI client library by changing the base URL.

Create an API key

  1. Click API Keys in the sidebar
  2. Click Create API Key
  3. Enter a name and click Create
  4. Copy the key immediately—you won’t see it again

API keys inherit your user permissions. You can only access models assigned to your groups.

Key options

When creating a key, you can optionally set:

  • Description: Notes about what this key is for
  • Rate limit: Maximum requests per second (1–10,000) and burst size (1–50,000)

Leave rate limits empty for unlimited requests.

Configure your client

Point your OpenAI client to the Control Layer:

Python

from openai import OpenAI

client = OpenAI(
    base_url="https://your-control-layer/ai/v1",
    api_key="your-api-key"
)

response = client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "Hello!"}]
)

Node.js

import OpenAI from 'openai';

const client = new OpenAI({
    baseURL: 'https://your-control-layer/ai/v1',
    apiKey: 'your-api-key'
});

const response = await client.chat.completions.create({
    model: 'gpt-4o',
    messages: [{ role: 'user', content: 'Hello!' }]
});

curl

curl https://your-control-layer/ai/v1/chat/completions \
  -H "Authorization: Bearer your-api-key" \
  -H "Content-Type: application/json" \
  -d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hello!"}]}'

Model names

Use model names exactly as shown on the Models page. The Control Layer routes requests to the correct provider automatically.

If your admin configured model aliases, you can use either the original name or the alias.

Streaming responses

Streaming works the same as with OpenAI directly:

stream = client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "Hello!"}],
    stream=True
)

for chunk in stream:
    print(chunk.choices[0].delta.content or "", end="")

Managing keys

From the API Keys page:

  • View usage: The “Last used” column shows when each key was last used
  • Delete keys: Select keys and click Delete, or click the delete icon on a single key
  • Revoke compromised keys: Delete them immediately—there’s no separate revoke action

Create separate keys for different applications so you can revoke one without affecting others.

Troubleshooting

401 Unauthorized: Your API key is invalid or deleted. Check you copied it correctly, or create a new one.

403 Forbidden: Your user account doesn’t have access to the requested model. Ask your admin to add you to a group that has access.

404 Model not found: The model name doesn’t match any available model. Check the exact name on the Models page.

429 Too Many Requests: You’ve hit the rate limit configured on your API key. Wait and retry, or ask your admin to increase the limit.

502/503 errors: The upstream model provider is having issues. Check the provider’s status page.

Add Endpoints

Learn how to connect the Control Layer to AI inference endpoints by configuring model sources.

Endpoints connect the Control Layer to AI providers. Add an endpoint to make models available to your users.

Add an endpoint

  1. Click Endpoints in the sidebar
  2. Click Add Endpoint
  3. Select a provider from the dropdown (OpenAI, Anthropic, Google) or enter a custom base URL
  4. Enter your API key
  5. Click Discover Models
  6. Select which models to enable
  7. Click Save

The Control Layer queries the provider’s /v1/models endpoint and imports available models.

Model aliases

During setup, you can assign aliases to models. This lets you use a custom name (like our-gpt4) instead of the provider’s name. Users can call models by either name.

Supported providers

Any OpenAI-compatible API works:

  • OpenAIhttps://api.openai.com
  • Anthropichttps://api.anthropic.com
  • Googlehttps://generativelanguage.googleapis.com
  • Together, Groq, Fireworks — enter their API URL
  • Self-hosted — vLLM, Ollama, or any OpenAI-compatible server

Custom authentication

Some providers use non-standard authentication. When adding an endpoint, you can configure:

  • Auth header name: Default is Authorization
  • Auth header prefix: Default is Bearer

For example, some internal services might use X-API-Key with no prefix.

Edit an endpoint

  1. Click the endpoint in the list
  2. Update the name, description, or URL
  3. Click Save

To change the API key, delete the endpoint and create a new one.

Re-sync models

When a provider adds new models, re-sync to discover them:

  1. Click the endpoint in the list
  2. Click Synchronize
  3. The Control Layer fetches the current model list

New models appear but aren’t automatically enabled. Go to Models to enable them and assign group access.

Delete an endpoint

  1. Select the endpoint (checkbox)
  2. Click Delete

Or click the delete icon on a single endpoint.

Warning

Deleting an endpoint removes all its models from the Control Layer. Users will get “model not found” errors for any deleted models.

API key security

Provider API keys are stored encrypted in the Control Layer database. If credentials are exposed elsewhere, rotate them immediately with your provider, then delete and recreate the endpoint.

Troubleshooting

“Connection failed” during discovery: Check that the URL is correct and reachable. Test the API key directly with the provider using curl.

No models returned: The endpoint might not have a /v1/models endpoint, or your API key might lack permission to list models. Try adding models manually if you know their names.

“Alias already exists” error: Another endpoint already uses that alias. Choose a different alias, or remove it from the other endpoint first.

Models not appearing after sync: New models are discovered but disabled by default. Go to Models and enable them.

Authentication errors after setup: The API key may have been rotated or revoked. Delete the endpoint and recreate it with a fresh key.

Manage Users & Groups

Learn how to manage user and group access in Control Layer for efficient model permissions.

The Control Layer uses group-based access control. Users belong to groups, and groups have access to models.

How access works

  1. Users are created when they first sign in (or by an admin)
  2. Groups organize users and define what models they can access
  3. Models are assigned to groups from the model card

Users inherit access to all models assigned to their groups. Changes take effect immediately.

Create a group

  1. Click Users & Groups in the sidebar
  2. Click the Groups tab
  3. Click Create Group
  4. Enter a name and optional description
  5. Click Create

Common patterns for group organization:

PatternExample
By teamengineering, data-science, marketing
By access levelbasic-models, advanced-models
By projectproject-alpha, summer-intern
By modelclaude-users, gpt4-users, gemini-users
Default accesseveryone (all users get baseline models)

Add users to a group

  1. Click Users & Groups in the sidebar
  2. Click on the user you want to modify
  3. Under Groups, select the groups to add them to
  4. Changes save automatically

Users can belong to multiple groups. Their model access is the union of all their groups’ model access.

Remove users from a group

  1. Click Users & Groups in the sidebar
  2. Click on the user
  3. Under Groups, deselect the groups to remove them from

Alternatively, from the group view:

  1. Click the Groups tab
  2. Click on the group
  3. Find the user in the members list
  4. Click the remove button next to their name

Assign models to groups

On any model card, click + Add groups to grant access. Models can belong to multiple groups.

Grant admin privileges

Admin users have full system control: they can manage all users, groups, endpoints, and settings.

  1. Click on the user
  2. Enable the Admin toggle

Grant admin privileges sparingly. The initial admin user from config always has admin privileges and cannot be demoted.

Delete a user

  1. Click Users & Groups in the sidebar
  2. Click on the user
  3. Click Delete User
  4. Confirm the deletion

Deleting a user removes their access immediately. Their API keys are also revoked.

Delete a group

  1. Click Users & Groups in the sidebar
  2. Click the Groups tab
  3. Click on the group
  4. Click Delete Group
  5. Confirm the deletion

Users in the group will lose access to any models that were only assigned to that group.

Troubleshooting

User can’t see expected models Check their group memberships and verify the models are assigned to those groups. Remember that model access is the union of all groups, so check each group the model is assigned to.

User has unexpected access Review all their group memberships. They may belong to groups you didn’t expect. Click on the user to see all their groups at once.

Need to revoke access quickly Either:

  • Remove the user from the relevant groups
  • Remove the model from those groups (affects all users in the group)
  • Delete the user entirely (if they should have no access)

Can’t delete a group Groups can always be deleted. Users in the group will lose access to models that were only assigned through that group.

Set Up Health Monitoring

Learn how to monitor the status of AI model endpoints using Control Layer’s built-in health monitoring features.

The Control Layer can monitor your endpoints with health checks. Status indicators show whether models are online.

Status indicators

  • Green dot: Model is online and responding
  • Red dot: Model is offline or failing health checks
  • No dot: Monitoring not configured for this model

Set up monitoring

From any model’s detail page:

  1. Click Configure Monitoring
  2. Choose a monitor type:
    • Default: Sends lightweight requests to the model endpoint
    • Custom HTTP probe: Check a specific URL path
  3. Set the check interval (1 minute, 5 minutes, 15 minutes, or 30 minutes)
  4. Click Save

The probe starts running immediately after you save.

Custom HTTP probes

Use a custom HTTP probe when:

  • Your endpoint has a dedicated /health or /status path
  • You want to avoid sending inference requests (which may incur costs)
  • The default probe doesn’t work with your endpoint’s authentication

Configure a custom probe:

  1. Select Custom HTTP probe
  2. Enter the path (e.g., /health, /v1/models)
  3. Optionally add custom headers if the health endpoint requires authentication
  4. Choose the expected response code (usually 200)

Warning

Default probes send real inference requests to model endpoints. For cost-sensitive endpoints, use a custom HTTP probe pointed at a health endpoint that doesn’t incur usage charges.

Pause and resume monitoring

You can temporarily disable monitoring without deleting your configuration:

  1. Go to the model’s detail page
  2. Click Monitoring Settings
  3. Toggle Active off to pause, or on to resume

Paused probes retain their configuration but don’t run checks or affect status indicators.

View probe statistics

Each monitored model tracks:

  • Uptime percentage: Successful checks over total checks
  • Last check: When the probe last ran
  • Response time: How long the endpoint took to respond
  • Recent history: Pass/fail for recent checks

Access statistics from the model’s detail page under Monitoring.

View uptime history

The Models page includes an uptime toggle (top right) showing historical availability for all monitored models as a timeline visualization.

Delete a probe

  1. Go to the model’s detail page
  2. Click Monitoring Settings
  3. Click Delete Probe

This removes all monitoring configuration and history for that model.

Troubleshooting

False negatives (red dot but model works) The monitoring probe may not match the model’s actual API. Try a custom HTTP probe pointed at a known-good endpoint.

Intermittent status May indicate:

  • Rate limiting from the provider
  • Network issues between Control Layer and the endpoint
  • Provider instability

Check probe statistics for patterns (e.g., failures at specific times suggest rate limiting).

Probe never succeeds Verify:

  1. The endpoint URL is correct
  2. Authentication credentials are valid
  3. For custom probes, the path exists and returns the expected status code

High response times Response time is measured from the Control Layer to the endpoint. High times may indicate:

  • Geographic distance to the provider
  • Provider under load
  • Network congestion

Consider this baseline when evaluating actual request performance.

Deploy to Production

Checklist for deploying Control Layer to a production environment.

This guide covers the key changes needed when moving from a local setup to production.

Security checklist

1. Change the admin password

The default config uses hunter2. Change it immediately:

admin_email: "your-admin@company.com"
admin_password: "<strong-password>"

Or set via environment variables:

DWCTL_ADMIN_EMAIL=your-admin@company.com
DWCTL_ADMIN_PASSWORD=<strong-password>

2. Generate a secret key

The secret key signs JWT tokens. Generate a secure one:

openssl rand -base64 32

Set it in your environment:

SECRET_KEY=<your-generated-key>

3. Configure CORS

Add your production frontend URL to allowed origins:

auth:
  security:
    cors:
      allowed_origins:
        - "https://your-app.company.com"

4. Use a production database

Point to your production PostgreSQL instance:

DATABASE_URL=postgres://user:password@your-db-host:5432/control_layer

Infrastructure

Run behind a reverse proxy

In production, run the Control Layer behind nginx, Caddy, or a cloud load balancer that handles:

  • TLS termination
  • Rate limiting
  • Access logging

The Control Layer binds to 0.0.0.0:3001 by default. Your proxy should forward to this.

Enable secure cookies

For HTTPS deployments, enable secure cookies:

auth:
  native:
    session:
      cookie_secure: true
      cookie_same_site: "strict"

Disable registration

Unless you want open signups, keep registration disabled:

auth:
  native:
    allow_registration: false

Admins create users manually via the UI.

Monitoring

Once deployed:

  1. Set up health monitoring for your endpoints (see Set Up Health Monitoring)
  2. Monitor the Control Layer’s /health endpoint from your infrastructure
  3. Set up log aggregation for request logs

Quick reference

SettingDev defaultProduction
admin_passwordhunter2Strong password
secret_keyNoneRandom 32+ bytes
cookie_securetruetrue
allow_registrationfalsefalse
cors.allowed_originslocalhost:3001Your domain

Set Up Model Pricing

Configure per-token pricing for your models using tariffs. Set different rates for realtime, batch, and playground usage.

This guide shows you how to configure per-token pricing for your models using tariffs.

Prerequisites

Understanding Tariffs

A tariff defines per-token pricing for a model. Each model can have multiple tariffs for different purposes:

PurposeDescriptionLimit
RealtimeStandard API requestsOne per model
BatchAsynchronous batch processingOne per SLA (e.g., 24h)
PlaygroundDashboard testingOne per model

Models without tariffs are free to use.

Set Pricing via the Dashboard

  1. Go to Models in the sidebar
  2. Click on the model you want to price
  3. Click Manage Pricing Tariffs
  4. Click + Add Tariff
  5. Fill in the pricing details:
    • Name: Descriptive label (e.g., “Standard Pricing”)
    • Purpose: Select realtime, batch, or playground
    • Input price: Cost per 1M input tokens
    • Output price: Cost per 1M output tokens
  6. For batch tariffs, select the SLA (completion window like “24h”)
  7. Click Save Changes

Pricing takes effect immediately.

Set Pricing via the API

Include a tariffs array when creating or updating a model:

curl -X PATCH "https://your-instance/admin/api/v1/models/{model-id}" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "tariffs": [
      {
        "name": "Realtime Pricing",
        "input_price_per_token": "0.003",
        "output_price_per_token": "0.015",
        "api_key_purpose": "realtime"
      }
    ]
  }'

Tariff Fields

FieldRequiredDescription
nameYesDescriptive name for the tariff
input_price_per_tokenYesPrice per input token (decimal)
output_price_per_tokenYesPrice per output token (decimal)
api_key_purposeNo"realtime", "batch", or "playground"
completion_windowBatch onlySLA like "24h"

Note

Prices are stored per-token with 8 decimal places. The dashboard displays prices per 1M tokens for readability. To convert: \$3.00 per 1M tokens = 0.000003 per token.

Example: Pricing with Multiple Tiers

Configure different prices for realtime, batch, and playground:

{
  "tariffs": [
    {
      "name": "Realtime",
      "input_price_per_token": "0.00003",
      "output_price_per_token": "0.00006",
      "api_key_purpose": "realtime"
    },
    {
      "name": "Batch 24h",
      "input_price_per_token": "0.000015",
      "output_price_per_token": "0.00003",
      "api_key_purpose": "batch",
      "completion_window": "24h"
    },
    {
      "name": "Playground (Free)",
      "input_price_per_token": "0",
      "output_price_per_token": "0",
      "api_key_purpose": "playground"
    }
  ]
}

This configuration:

  • Charges full price for realtime API usage
  • Offers 50% discount for 24-hour batch jobs
  • Makes playground testing free

Updating Prices

When you update tariffs, the system:

  1. Closes old tariffs by setting their end date to now
  2. Creates new tariffs effective immediately
  3. Preserves historical pricing for accurate transaction records

Old transactions are charged at the rate that was active when they occurred. New transactions use the updated rates.

Viewing Current Tariffs

To see a model’s current pricing via API, include pricing in the query:

curl "https://your-instance/admin/api/v1/models/{model-id}?include=pricing" \
  -H "Authorization: Bearer $API_KEY"

The response includes a tariffs array with all active tariffs.

Removing Pricing

To make a model free, update it with an empty tariffs array:

curl -X PATCH "https://your-instance/admin/api/v1/models/{model-id}" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"tariffs": []}'

This closes all active tariffs. Requests to the model will no longer incur charges.

Set Up Payments

Enable credit purchases via Stripe so users can buy credits through a self-service checkout flow.

This guide shows you how to enable credit purchases via Stripe so users can buy credits through a self-service checkout flow.

Prerequisites

  • A Stripe account with API access
  • A Stripe Price configured for credit purchases
  • Platform Manager role (for testing)

Configure Stripe

Add your Stripe credentials to the configuration:

payment:
  stripe:
    api_key: "sk_live_..."
    webhook_secret: "whsec_..."
    price_id: "price_..."
    host_url: "https://your-app.example.com"

Or via environment variables:

DWCTL_PAYMENT__STRIPE__API_KEY=sk_live_...
DWCTL_PAYMENT__STRIPE__WEBHOOK_SECRET=whsec_...
DWCTL_PAYMENT__STRIPE__PRICE_ID=price_...
DWCTL_PAYMENT__STRIPE__HOST_URL=https://your-app.example.com

Configuration Options

OptionRequiredDescription
api_keyYesYour Stripe secret key (sk_live_... or sk_test_...)
webhook_secretYesWebhook signing secret (whsec_...)
price_idYesStripe Price ID for credit purchases
host_urlYesYour app’s URL (for redirect after payment)
enable_invoice_creationNoGenerate invoices for payments (default: false)

Create a Stripe Price

In Stripe Dashboard:

  1. Go to ProductsAdd product
  2. Name it something like “API Credits”
  3. Set the price (e.g., $10.00 for 1000 credits)
  4. Copy the Price ID (starts with price_)

The credit amount users receive is determined by the price amount. For example, if your price is $10.00, users receive 10 credits (1 credit = $1.00 by convention).

Set Up the Webhook

Stripe uses webhooks to notify your application when payments complete. This is more reliable than waiting for users to return to your site.

1. Create the webhook in Stripe

  1. Go to DevelopersWebhooks
  2. Click Add endpoint
  3. Enter your webhook URL: https://your-app.example.com/admin/api/v1/webhooks/payments
  4. Select events to listen for:
    • checkout.session.completed
  5. Click Add endpoint
  6. Copy the Signing secret (starts with whsec_)

2. Configure the webhook secret

Add the signing secret to your configuration:

DWCTL_PAYMENT__STRIPE__WEBHOOK_SECRET=whsec_...

The webhook secret is used to verify that incoming webhook requests actually came from Stripe.

Test with Dummy Provider

For development and testing, use the dummy payment provider instead of Stripe:

payment:
  dummy:
    amount: 50
    host_url: "http://localhost:3001"

Or via environment variables:

DWCTL_PAYMENT__DUMMY__AMOUNT=50
DWCTL_PAYMENT__DUMMY__HOST_URL=http://localhost:3001

The dummy provider:

  • Skips actual payment processing
  • Immediately grants the configured amount of credits
  • Useful for testing the checkout flow without a Stripe account

Warning

Never use the dummy provider in production. It grants credits without collecting payment.

How Users Purchase Credits

Once configured, users can purchase credits from the dashboard:

  1. User navigates to Cost Management
  2. User clicks Buy Credits
  3. User is redirected to Stripe’s hosted checkout page
  4. User enters payment details and completes purchase
  5. User is redirected back to your app
  6. Credits are added to their balance

The checkout flow is handled entirely by Stripe’s pre-built checkout page. You don’t need to build any payment UI.

Admin Credit Grants

Platform Managers can grant credits to users without requiring payment:

  1. Go to Cost Management
  2. Select a user
  3. Click Grant Credits or Remove Credits
  4. Enter the amount and confirm

This creates an AdminGrant or AdminRemoval transaction in the ledger.

Via API:

curl -X POST "https://your-instance/admin/api/v1/transactions" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "user-uuid",
    "transaction_type": "admin_grant",
    "amount": 100,
    "description": "Welcome bonus"
  }'

Initial Credits for New Users

To automatically grant credits to new users on signup, configure:

credits:
  initial_credits_for_standard_users: 10

Or:

DWCTL_CREDITS__INITIAL_CREDITS_FOR_STANDARD_USERS=10

This creates an AdminGrant transaction when a new StandardUser account is created.

Troubleshooting

Payments complete but credits don’t appear

Check that:

  1. The webhook is configured correctly in Stripe
  2. The webhook secret matches your configuration
  3. The webhook endpoint is accessible from the internet
  4. Check server logs for webhook processing errors

Webhook signature verification fails

The webhook secret must match exactly. Copy it again from Stripe Dashboard → Developers → Webhooks → your endpoint → Signing secret.

Users see “Payment not completed” error

The frontend may have tried to process the payment before Stripe sent the webhook. This is normal—the webhook will process the payment shortly. Users can refresh the page.

Configuration Reference

Reference guide for configuring the Control Layer using YAML and environment variables.

The Control Layer uses a YAML configuration file with environment variable overrides for all settings.

Configuration File Location

The configuration file is named config.yaml by default. The system checks:

  1. Path specified via --config CLI flag
  2. Path in DWCTL_CONFIG environment variable
  3. ./config.yaml in the current directory

For Docker deployments, mount your config at /app/config.yaml.

Environment Variable Overrides

Any setting can be overridden with environment variables prefixed with DWCTL_. Nested keys use double underscores:

DWCTL_PORT=8080
DWCTL_AUTH__NATIVE__ENABLED=false
DWCTL_DATABASE__POOL__MAX_CONNECTIONS=20

The special DATABASE_URL variable (no prefix) sets the database connection string.

Core Settings

Server

host: "0.0.0.0"
port: 3001
FieldTypeDefaultDescription
hoststring"0.0.0.0"Network interface to bind to. Use 127.0.0.1 for local-only access.
portinteger3001TCP port for the HTTP server.

Secret Key

secret_key: "your-secret-key-here"

Required when native authentication is enabled. Used for JWT signing. Generate with:

openssl rand -base64 32

Admin User

admin_email: "admin@example.com"
admin_password: "change-me-in-production"

Created on first startup if it doesn’t exist. The admin user has the PlatformManager role.

Danger

Change the default admin password immediately in production!

Database Configuration

The Control Layer requires PostgreSQL. Two modes are available:

database:
  type: external
  url: "postgres://user:pass@localhost:5432/control_layer"
  replica_url: "postgres://user:pass@replica:5432/control_layer"  # Optional
  pool:
    max_connections: 10
    min_connections: 0
    acquire_timeout_secs: 30
    idle_timeout_secs: 600
    max_lifetime_secs: 1800
FieldTypeDefaultDescription
urlstring-PostgreSQL connection URL.
replica_urlstring-Optional read replica URL.
pool.max_connectionsinteger10Maximum connections in pool.
pool.min_connectionsinteger0Minimum idle connections.
pool.acquire_timeout_secsinteger30Max wait for a connection.
pool.idle_timeout_secsinteger600Close idle connections after N seconds.
pool.max_lifetime_secsinteger1800Maximum connection lifetime.

Embedded Database

For development or single-node deployments, use the embedded PostgreSQL database:

database:
  type: embedded
  data_dir: ".dwctl_data/postgres"
  persistent: false
FieldTypeDefaultDescription
data_dirstring-Directory for database files.
persistentbooleanfalsePersist data between restarts.

Component Databases

The batch processing system (Fusillade) and request logging (Outlet) can use separate databases or schemas:

database:
  # ... main database config ...
  fusillade:
    mode: schema      # Use schema in main database
    name: "fusillade"
    pool:
      max_connections: 20
      min_connections: 2
  outlet:
    mode: dedicated   # Use separate database
    url: "postgres://user:pass@localhost:5432/outlet"
    pool:
      max_connections: 5

Authentication Configuration

At least one authentication method must be enabled.

Native Authentication

Username/password authentication with session cookies:

auth:
  native:
    enabled: true
    allow_registration: false
    password:
      min_length: 8
      max_length: 64
      argon2_memory_kib: 19456
      argon2_iterations: 2
      argon2_parallelism: 1
    session:
      timeout: "24h"
      cookie_name: "dwctl_session"
      cookie_secure: true
      cookie_same_site: "strict"
FieldTypeDefaultDescription
enabledbooleantrueEnable native login.
allow_registrationbooleanfalseAllow self-registration.
password.min_lengthinteger8Minimum password length.
password.max_lengthinteger64Maximum password length.
password.argon2_*integer-Argon2 hashing parameters. Lower values speed up tests.
session.timeoutduration"24h"Session expiration.
session.cookie_securebooleantrueRequire HTTPS for cookies.
session.cookie_same_sitestring"strict"SameSite attribute: strict, lax, or none.

Email for Password Resets

Configure email transport for password reset functionality:

File transport (development):

auth:
  native:
    email:
      type: file
      path: "./emails"
      from_email: "noreply@example.com"
      from_name: "Control Layer"
      password_reset:
        token_expiry: "30m"
        base_url: "http://localhost:3001"

SMTP transport (production):

auth:
  native:
    email:
      type: smtp
      host: "smtp.example.com"
      port: 587
      username: "noreply@example.com"
      password: "smtp-password"
      use_tls: true
      from_email: "noreply@example.com"
      from_name: "Control Layer"
      password_reset:
        token_expiry: "30m"
        base_url: "https://app.example.com"

Proxy Header Authentication

For use with identity-aware proxies (SSO):

auth:
  proxy_header:
    enabled: false
    header_name: "x-doubleword-user"
    email_header_name: "x-doubleword-email"
    groups_field_name: "x-doubleword-user-groups"
    provider_field_name: "x-doubleword-sso-provider"
    auto_create_users: true
    import_idp_groups: false
    blacklisted_sso_groups:
      - "external-contractors"
FieldTypeDefaultDescription
enabledbooleanfalseEnable proxy header auth.
header_namestring"x-doubleword-user"Header with unique user ID.
email_header_namestring"x-doubleword-email"Header with user email.
groups_field_namestring"x-doubleword-user-groups"Header with comma-separated groups.
auto_create_usersbooleantrueCreate users automatically.
import_idp_groupsbooleanfalseSync groups from IdP.
blacklisted_sso_groupslist[]Groups to exclude from import.

Default User Roles

Roles assigned to new users (admin excluded):

auth:
  default_user_roles:
    - StandardUser
    - BatchAPIUser

Available roles:

  • StandardUser - Base access (always included, cannot be removed)
  • RequestViewer - Read-only access to request logs
  • BillingManager - Credit and billing management
  • BatchAPIUser - Batch file and job management

Security Settings

auth:
  security:
    jwt_expiry: "24h"
    cors:
      allowed_origins:
        - "https://app.example.com"
      allow_credentials: true
      max_age: 3600
      exposed_headers:
        - "location"
FieldTypeDefaultDescription
jwt_expiryduration"24h"JWT token lifetime. Must be 5min-30days.
cors.allowed_originslist["http://localhost:3001"]Allowed CORS origins. Use "*" for any.
cors.allow_credentialsbooleantrueAllow credentials. Cannot use with wildcard origin.
cors.max_ageinteger3600Preflight cache duration (seconds).

Warning

In production, ensure your frontend URL is listed in allowed_origins.

Credits & Payments

Initial Credits

credits:
  initial_credits_for_standard_users: 10.00

Credits given to new users on creation. Set to 0 to disable.

Payment Provider

Stripe (production):

payment:
  stripe:
    api_key: "sk_live_..."
    webhook_secret: "whsec_..."
    price_id: "price_..."
    host_url: "https://app.example.com"
    enable_invoice_creation: false
FieldTypeDescription
api_keystringStripe secret key (starts with sk_).
webhook_secretstringWebhook signing secret (starts with whsec_).
price_idstringStripe price ID for credit purchases (starts with price_).
host_urlstringBase URL for success/cancel redirects.
enable_invoice_creationbooleanCreate invoices for sessions.

Dummy provider (testing):

payment:
  dummy:
    amount: 50.00
    host_url: "http://localhost:3001"

Adds a fixed amount without real payment processing.

Batches Configuration

Configure the batch inference API:

batches:
  enabled: true
  allowed_completion_windows:
    - "24h"
    - "1h"
    - "48h"
  files:
    max_file_size: 104857600        # 100 MB
    default_expiry_seconds: 86400   # 24 hours
    min_expiry_seconds: 3600        # 1 hour
    max_expiry_seconds: 2592000     # 30 days
    upload_buffer_size: 100
    download_buffer_size: 100
FieldTypeDefaultDescription
enabledbooleantrueEnable /ai/v1/files and /ai/v1/batches endpoints.
allowed_completion_windowslist["24h"]SLA options users can select.
files.max_file_sizeinteger104857600Maximum upload size in bytes.
files.default_expiry_secondsinteger86400Default file retention.

Background Services

Onwards Sync

Synchronizes configuration to the AI proxy routing layer:

background_services:
  onwards_sync:
    enabled: true

Note

Disable only if you’re not using the AI proxy functionality.

Probe Scheduler

Runs health checks against model endpoints:

background_services:
  probe_scheduler:
    enabled: true

Only runs on the leader instance when leader election is enabled.

Batch Daemon

Processes batch inference jobs:

background_services:
  batch_daemon:
    enabled: "leader"              # "always", "leader", or "never"
    claim_batch_size: 100
    default_model_concurrency: 10
    claim_interval_ms: 1000
    max_retries: 1000
    timeout_ms: 600000             # 10 minutes per request
    backoff_ms: 1000
    backoff_factor: 2
    max_backoff_ms: 10000
    stop_before_deadline_ms: 900000  # 15 min safety buffer
FieldTypeDefaultDescription
enabledstring"leader"When to run: always, leader, or never.
claim_batch_sizeinteger100Requests claimed per iteration.
default_model_concurrencyinteger10Concurrent requests per model.
max_retriesinteger1000Max retry attempts. null = unlimited until deadline.
timeout_msinteger600000Per-request timeout (10 min).
stop_before_deadline_msinteger900000Stop retrying before deadline (15 min buffer).

Model Escalation

Route requests to fallback models when approaching SLA deadlines:

background_services:
  batch_daemon:
    model_escalations:
      "llama-3.1-70b":
        escalation_model: "gpt-4o-mini"
        escalation_api_key: "OPENAI_API_KEY"  # Environment variable name
    sla_check_interval_seconds: 60
    sla_thresholds:
      - name: "warning"
        threshold_seconds: 3600
        action: "log"
        allowed_states: ["pending"]
      - name: "critical"
        threshold_seconds: 900
        action: "escalate"
        allowed_states: ["pending", "claimed"]

Leader Election

For multi-instance deployments:

background_services:
  leader_election:
    enabled: true

Uses PostgreSQL advisory locks. Only the leader runs probe scheduler and batch daemon (when set to "leader" mode).

Model Sources

Seed model endpoints on first startup:

model_sources:
  - name: "openai"
    url: "https://api.openai.com"
    api_key: "sk-..."
    sync_interval: "30s"
    default_models:
      - name: "gpt-4o"
        add_to_everyone_group: true
      - name: "gpt-4o-mini"
        add_to_everyone_group: true
FieldTypeDefaultDescription
namestring-Identifier for the model source.
urlstring-Base URL of the OpenAI-compatible API.
api_keystring-API key for authentication.
sync_intervalduration"10s"How often to refresh model list.
default_modelslist-Models to auto-import on first run.

Note

Model sources are only seeded on first startup. After that, manage endpoints through the UI or API.

Metadata

UI display settings:

metadata:
  region: "UK South"
  organization: "ACME Corp"
  title: "ACME AI Gateway"
  docs_url: "https://docs.example.com"
  docs_jsonl_url: "https://docs.example.com/jsonl"
FieldTypeDefaultDescription
regionstring-Region displayed in UI header.
organizationstring-Organization name in UI header.
titlestring-Custom browser tab title.
docs_urlstring"https://doublewordai.github.io/control-layer/"Documentation link in header.
docs_jsonl_urlstring-JSONL docs link in batch upload modal.

Observability

Metrics

enable_metrics: true

Exposes Prometheus metrics at /internal/metrics.

Request Logging

enable_request_logging: true

Logs all AI proxy requests and responses to PostgreSQL. Disable if you have sensitive data.

OpenTelemetry

enable_otel_export: false

Exports traces via OTLP. Configure the exporter endpoint with standard OpenTelemetry environment variables (OTEL_EXPORTER_OTLP_ENDPOINT, etc.).

Sample Files

Generate sample JSONL files for new users:

sample_files:
  enabled: true
  requests_per_file: 2000

Validation

The system validates configuration on startup and fails if:

  • Native auth is enabled but secret_key is missing
  • No authentication method is enabled
  • jwt_expiry is outside 5min-30day range
  • CORS uses wildcard origin with credentials enabled
  • Database URL is invalid or unreachable

Run validation without starting the server:

dwctl --config config.yaml --validate

How Billing Works

Understand the credits-based billing system: how charges are calculated, what happens at zero balance, and how payments work.

The Control Layer uses a credits-based billing system. Users have a credit balance that gets charged when they make API requests. When the balance runs out, API access stops until more credits are purchased or granted.

This page explains how the billing system works conceptually. For setup instructions, see Set Up Model Pricing and Set Up Payments.

Credits

Credits are the currency of the Control Layer. Every user has a credit balance, and API usage deducts from this balance.

Key characteristics:

  • Credits are stored as decimal values (up to 8 decimal places)
  • Balances can go negative (the last request that pushes you below zero still completes)
  • New users can receive initial credits automatically via configuration
  • Admins can grant or remove credits manually

Credits aren’t tied to any real currency by default. You decide what a credit is worth when you configure your tariffs and payment amounts.

How Charges Are Calculated

When a user makes an API request, the charge is calculated from two things:

  1. Token usage — How many tokens were consumed (input and output separately)
  2. Tariff — The price per token for that model

The formula is:

charge = (input_tokens × input_price) + (output_tokens × output_price)

For example, if a model’s tariff is $0.03 per 1K input tokens and $0.06 per 1K output tokens, a request using 1,000 input tokens and 500 output tokens would cost:

(1000 × 0.00003) + (500 × 0.00006) = 0.03 + 0.03 = \$0.06

What Are Tariffs?

Tariffs define per-token pricing for each model. A model can have different tariffs for different purposes:

  • Realtime — Standard API requests
  • Batch — Asynchronous batch processing (often cheaper)
  • Playground — Interactive testing in the UI

If a model has no tariff, requests to that model are free.

Tariffs are time-versioned, so you can change pricing without affecting how historical transactions are displayed. The system records which tariff was active when each charge occurred.

The Transaction Ledger

All credit movements are recorded in an append-only transaction ledger. Transactions are never modified or deleted — this creates a complete audit trail.

Transaction types:

TypeEffectCreated By
PurchaseAdds creditsPayment completion (Stripe)
AdminGrantAdds creditsAdmin action
AdminRemovalRemoves creditsAdmin action
UsageRemoves creditsAPI request completion

Each transaction has a unique source_id that prevents duplicates. For usage transactions, this is the analytics record ID. For purchases, it’s the payment session ID. This means the same payment or request can never be processed twice.

Balance Calculation

Rather than storing a running balance, the system calculates it from the transaction history. This append-only design means:

  • No race conditions when multiple requests complete simultaneously
  • Complete auditability — you can always recalculate the balance from transactions
  • No data loss from failed updates

For performance, the system maintains checkpoints that cache the balance at certain points, so it doesn’t need to sum every transaction from the beginning of time.

What Happens at Zero

When a user’s balance drops to zero or below:

  1. The current request completes (balances can go slightly negative)
  2. The system immediately notifies the API proxy
  3. The proxy invalidates the user’s API keys in its cache
  4. Subsequent requests fail with an authentication error

There’s no grace period — blocking is immediate. This prevents users from accumulating large negative balances.

When the user purchases more credits or an admin grants them credits, the process reverses: the proxy is notified, API keys become valid again, and requests start working.

Payment Flow

Users can purchase credits through a self-service checkout flow powered by Stripe (or a dummy provider for testing).

The flow works like this:

  1. User clicks “Buy Credits” in the dashboard
  2. Backend creates a checkout session with Stripe
  3. User is redirected to Stripe’s hosted payment page
  4. User completes payment
  5. Stripe sends a webhook notification (or the frontend triggers manual processing)
  6. Backend verifies the payment and creates a Purchase transaction
  7. User’s balance increases, API access is restored if it was blocked

The payment amount is configured on the Stripe side (via a Price ID). The Control Layer doesn’t handle payment amounts directly — it just records the credit value associated with successful payments.

Idempotent Processing

Payment processing is idempotent, meaning the same payment can’t be applied twice. The payment session ID serves as a unique key. If a webhook fires multiple times or the user refreshes the success page, only the first processing attempt creates a transaction.

Batch Requests and Billing

Batch requests are charged the same way as realtime requests — per token, based on the tariff. However:

  • Batch tariffs can be configured separately (often at a discount)
  • All requests in a batch are grouped in the transaction history for easier tracking
  • Cost estimates are available before submitting a batch

When viewing transactions, batch charges can be grouped into a single line item showing the total cost and request count, rather than listing hundreds of individual charges.

Free Usage Scenarios

API requests are free (no credits deducted) when:

  • The model has no tariff configured
  • The tariff prices are both zero
  • The request fails (non-2xx response)
  • The user is the system user (internal requests)

This means you can offer some models for free while charging for others, or run a deployment without any billing at all.