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.

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.

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.

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

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

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
- Docker and Docker Compose installed
- An API key from a model provider. If you don’t have one:
- Doubleword: app.doubleword.ai/api-keys
- OpenAI: platform.openai.com/api-keys
- Anthropic: console.anthropic.com/settings/keys
- Or any OpenAI-compatible endpoint (Together, Groq, local vLLM, etc.)
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:3001in your browser. You should see the login page.
Step 2: Log in to the dashboard
Sign in with the default admin credentials:
| Field | Value |
|---|---|
test@doubleword.ai | |
| Password | hunter2 |
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:
- Use the dropdown in the Base URL field to select a popular endpoint (OpenAI, Anthropic, Google), or enter a custom URL
- Paste your API key in the API Key field
- Click Discover Models
The Control Layer connects to your provider and fetches available models.
- 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
- Click API Keys in the sidebar
- Click Create API Key
- Enter a name (e.g., “test-key”) and click Create Key
- 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:
- Add more endpoints to access additional providers
- Set up users and groups to manage team access
- Configure for production
Before deploying to production
The default configuration is for local development only. Before exposing the Control Layer:
- Change the admin password -
hunter2is not secure- Set a secret key - generate with
openssl rand -base64 32and set viaSECRET_KEYenvironment variable- Use a production database - set
DATABASE_URLto a real PostgreSQL instance- Configure CORS - update
auth.security.cors.allowed_originsfor your domainSee Configuration Reference for details.
-
On first run, Docker downloads the required images which may take several minutes. Run
docker compose logs -fto 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
- Click API Keys in the sidebar
- Click Create API Key
- Enter a name and click Create
- 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
- Click Endpoints in the sidebar
- Click Add Endpoint
- Select a provider from the dropdown (OpenAI, Anthropic, Google) or enter a custom base URL
- Enter your API key
- Click Discover Models
- Select which models to enable
- 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:
- OpenAI —
https://api.openai.com - Anthropic —
https://api.anthropic.com - Google —
https://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
- Click the endpoint in the list
- Update the name, description, or URL
- 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:
- Click the endpoint in the list
- Click Synchronize
- 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
- Select the endpoint (checkbox)
- 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
- Users are created when they first sign in (or by an admin)
- Groups organize users and define what models they can access
- 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
- Click Users & Groups in the sidebar
- Click the Groups tab
- Click Create Group
- Enter a name and optional description
- Click Create
Common patterns for group organization:
| Pattern | Example |
|---|---|
| By team | engineering, data-science, marketing |
| By access level | basic-models, advanced-models |
| By project | project-alpha, summer-intern |
| By model | claude-users, gpt4-users, gemini-users |
| Default access | everyone (all users get baseline models) |
Add users to a group
- Click Users & Groups in the sidebar
- Click on the user you want to modify
- Under Groups, select the groups to add them to
- 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
- Click Users & Groups in the sidebar
- Click on the user
- Under Groups, deselect the groups to remove them from
Alternatively, from the group view:
- Click the Groups tab
- Click on the group
- Find the user in the members list
- 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.
- Click on the user
- 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
- Click Users & Groups in the sidebar
- Click on the user
- Click Delete User
- Confirm the deletion
Deleting a user removes their access immediately. Their API keys are also revoked.
Delete a group
- Click Users & Groups in the sidebar
- Click the Groups tab
- Click on the group
- Click Delete Group
- 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:
- Click Configure Monitoring
- Choose a monitor type:
- Default: Sends lightweight requests to the model endpoint
- Custom HTTP probe: Check a specific URL path
- Set the check interval (1 minute, 5 minutes, 15 minutes, or 30 minutes)
- Click Save
The probe starts running immediately after you save.
Custom HTTP probes
Use a custom HTTP probe when:
- Your endpoint has a dedicated
/healthor/statuspath - 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:
- Select Custom HTTP probe
- Enter the path (e.g.,
/health,/v1/models) - Optionally add custom headers if the health endpoint requires authentication
- 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:
- Go to the model’s detail page
- Click Monitoring Settings
- 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
- Go to the model’s detail page
- Click Monitoring Settings
- 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:
- The endpoint URL is correct
- Authentication credentials are valid
- 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:
- Set up health monitoring for your endpoints (see Set Up Health Monitoring)
- Monitor the Control Layer’s
/healthendpoint from your infrastructure - Set up log aggregation for request logs
Quick reference
| Setting | Dev default | Production |
|---|---|---|
admin_password | hunter2 | Strong password |
secret_key | None | Random 32+ bytes |
cookie_secure | true | true |
allow_registration | false | false |
cors.allowed_origins | localhost:3001 | Your 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
- A deployed model (see Add Endpoints)
- Platform Manager role
Understanding Tariffs
A tariff defines per-token pricing for a model. Each model can have multiple tariffs for different purposes:
| Purpose | Description | Limit |
|---|---|---|
| Realtime | Standard API requests | One per model |
| Batch | Asynchronous batch processing | One per SLA (e.g., 24h) |
| Playground | Dashboard testing | One per model |
Models without tariffs are free to use.
Set Pricing via the Dashboard
- Go to Models in the sidebar
- Click on the model you want to price
- Click Manage Pricing Tariffs
- Click + Add Tariff
- 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
- For batch tariffs, select the SLA (completion window like “24h”)
- 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
| Field | Required | Description |
|---|---|---|
name | Yes | Descriptive name for the tariff |
input_price_per_token | Yes | Price per input token (decimal) |
output_price_per_token | Yes | Price per output token (decimal) |
api_key_purpose | No | "realtime", "batch", or "playground" |
completion_window | Batch only | SLA 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:
- Closes old tariffs by setting their end date to now
- Creates new tariffs effective immediately
- 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.
Related Topics
- How Billing Works — Understand the credits system
- Configuration Reference — Batch SLA configuration
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
| Option | Required | Description |
|---|---|---|
api_key | Yes | Your Stripe secret key (sk_live_... or sk_test_...) |
webhook_secret | Yes | Webhook signing secret (whsec_...) |
price_id | Yes | Stripe Price ID for credit purchases |
host_url | Yes | Your app’s URL (for redirect after payment) |
enable_invoice_creation | No | Generate invoices for payments (default: false) |
Create a Stripe Price
In Stripe Dashboard:
- Go to Products → Add product
- Name it something like “API Credits”
- Set the price (e.g., $10.00 for 1000 credits)
- 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
- Go to Developers → Webhooks
- Click Add endpoint
- Enter your webhook URL:
https://your-app.example.com/admin/api/v1/webhooks/payments - Select events to listen for:
checkout.session.completed
- Click Add endpoint
- 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:
- User navigates to Cost Management
- User clicks Buy Credits
- User is redirected to Stripe’s hosted checkout page
- User enters payment details and completes purchase
- User is redirected back to your app
- 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:
- Go to Cost Management
- Select a user
- Click Grant Credits or Remove Credits
- 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:
- The webhook is configured correctly in Stripe
- The webhook secret matches your configuration
- The webhook endpoint is accessible from the internet
- 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.
Related Topics
- How Billing Works — Understand credits and transactions
- Set Up Model Pricing — Configure per-token pricing
- Configuration Reference — All payment configuration options
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:
- Path specified via
--configCLI flag - Path in
DWCTL_CONFIGenvironment variable ./config.yamlin 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
| Field | Type | Default | Description |
|---|---|---|---|
host | string | "0.0.0.0" | Network interface to bind to. Use 127.0.0.1 for local-only access. |
port | integer | 3001 | TCP 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:
External Database (Recommended)
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
| Field | Type | Default | Description |
|---|---|---|---|
url | string | - | PostgreSQL connection URL. |
replica_url | string | - | Optional read replica URL. |
pool.max_connections | integer | 10 | Maximum connections in pool. |
pool.min_connections | integer | 0 | Minimum idle connections. |
pool.acquire_timeout_secs | integer | 30 | Max wait for a connection. |
pool.idle_timeout_secs | integer | 600 | Close idle connections after N seconds. |
pool.max_lifetime_secs | integer | 1800 | Maximum 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
| Field | Type | Default | Description |
|---|---|---|---|
data_dir | string | - | Directory for database files. |
persistent | boolean | false | Persist 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"
| Field | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Enable native login. |
allow_registration | boolean | false | Allow self-registration. |
password.min_length | integer | 8 | Minimum password length. |
password.max_length | integer | 64 | Maximum password length. |
password.argon2_* | integer | - | Argon2 hashing parameters. Lower values speed up tests. |
session.timeout | duration | "24h" | Session expiration. |
session.cookie_secure | boolean | true | Require HTTPS for cookies. |
session.cookie_same_site | string | "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"
| Field | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Enable proxy header auth. |
header_name | string | "x-doubleword-user" | Header with unique user ID. |
email_header_name | string | "x-doubleword-email" | Header with user email. |
groups_field_name | string | "x-doubleword-user-groups" | Header with comma-separated groups. |
auto_create_users | boolean | true | Create users automatically. |
import_idp_groups | boolean | false | Sync groups from IdP. |
blacklisted_sso_groups | list | [] | 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 logsBillingManager- Credit and billing managementBatchAPIUser- 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"
| Field | Type | Default | Description |
|---|---|---|---|
jwt_expiry | duration | "24h" | JWT token lifetime. Must be 5min-30days. |
cors.allowed_origins | list | ["http://localhost:3001"] | Allowed CORS origins. Use "*" for any. |
cors.allow_credentials | boolean | true | Allow credentials. Cannot use with wildcard origin. |
cors.max_age | integer | 3600 | Preflight 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
| Field | Type | Description |
|---|---|---|
api_key | string | Stripe secret key (starts with sk_). |
webhook_secret | string | Webhook signing secret (starts with whsec_). |
price_id | string | Stripe price ID for credit purchases (starts with price_). |
host_url | string | Base URL for success/cancel redirects. |
enable_invoice_creation | boolean | Create 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
| Field | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Enable /ai/v1/files and /ai/v1/batches endpoints. |
allowed_completion_windows | list | ["24h"] | SLA options users can select. |
files.max_file_size | integer | 104857600 | Maximum upload size in bytes. |
files.default_expiry_seconds | integer | 86400 | Default 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
| Field | Type | Default | Description |
|---|---|---|---|
enabled | string | "leader" | When to run: always, leader, or never. |
claim_batch_size | integer | 100 | Requests claimed per iteration. |
default_model_concurrency | integer | 10 | Concurrent requests per model. |
max_retries | integer | 1000 | Max retry attempts. null = unlimited until deadline. |
timeout_ms | integer | 600000 | Per-request timeout (10 min). |
stop_before_deadline_ms | integer | 900000 | Stop 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
| Field | Type | Default | Description |
|---|---|---|---|
name | string | - | Identifier for the model source. |
url | string | - | Base URL of the OpenAI-compatible API. |
api_key | string | - | API key for authentication. |
sync_interval | duration | "10s" | How often to refresh model list. |
default_models | list | - | 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"
| Field | Type | Default | Description |
|---|---|---|---|
region | string | - | Region displayed in UI header. |
organization | string | - | Organization name in UI header. |
title | string | - | Custom browser tab title. |
docs_url | string | "https://doublewordai.github.io/control-layer/" | Documentation link in header. |
docs_jsonl_url | string | - | 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_keyis missing - No authentication method is enabled
jwt_expiryis 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:
- Token usage — How many tokens were consumed (input and output separately)
- 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:
| Type | Effect | Created By |
|---|---|---|
| Purchase | Adds credits | Payment completion (Stripe) |
| AdminGrant | Adds credits | Admin action |
| AdminRemoval | Removes credits | Admin action |
| Usage | Removes credits | API 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:
- The current request completes (balances can go slightly negative)
- The system immediately notifies the API proxy
- The proxy invalidates the user’s API keys in its cache
- 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:
- User clicks “Buy Credits” in the dashboard
- Backend creates a checkout session with Stripe
- User is redirected to Stripe’s hosted payment page
- User completes payment
- Stripe sends a webhook notification (or the frontend triggers manual processing)
- Backend verifies the payment and creates a Purchase transaction
- 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.
Related Topics
- Set Up Model Pricing — Configure tariffs for your models
- Set Up Payments — Enable credit purchases via Stripe
- Configuration Reference — All billing-related configuration options