English | 中文
100% AI-Coded — All code in this project was written entirely by AI (Claude Code). Human involvement was limited to product direction, requirements, and code review.
A Prometheus exporter for monitoring LLM API availability and performance. Periodically sends streaming probe requests to LLM API endpoints, collecting network latency, Time to First Token (TTFT), and token usage metrics.
Think of it as blackbox_exporter for LLM APIs — end-to-end probing tailored for large language models.
- Streaming TTFT measurement — All APIs probed via streaming requests for precise Time to First Token measurement
- Multi-provider support — OpenAI Chat Completions + Responses API, Anthropic, Google Gemini, Azure OpenAI, plus any OpenAI-compatible service
- Reasoning & cache token tracking —
reasoning_tokens,cached_input_tokens,cache_creation_tokenscaptured from OpenAI / Anthropic / Gemini usage fields - Wide provider coverage — xAI Grok, Groq, Cerebras, Together, Fireworks, Moonshot/Kimi, Zhipu GLM, SiliconFlow, DashScope (Alibaba), Volcengine Ark (ByteDance), DeepSeek, Mistral, OpenRouter, local Ollama, and more
- TLS cert expiry + rate-limit visibility — Exporter surfaces earliest-cert
NotAfterand providerx-ratelimit-remaining-*headers as Prometheus gauges - Lightweight — Only depends on
prometheus/client_golang,gopkg.in/yaml.v3, andfsnotify/fsnotify— no LLM SDKs - Single binary — Go 1.23+ compiled, deploys as binary / Docker / Kubernetes
- Flexible config — YAML with
${ENV_VAR}expansion, custom headers, and custom API paths - Hot reload — SIGHUP signal, HTTP
/-/reloadendpoint, or--watch-configfile watcher - Multi-target
/probemode — blackbox_exporter-style/probe?target=<url>&module=<name>for file_sd / dynamic target discovery - Native histograms — Duration / TTFT histograms dual-emit classic + native (Prometheus 2.50+ stores the native form)
- Webhook alerting — Sends webhook notifications (Slack/Teams/etc.) after consecutive probe failures
- Prompt rotation — Cycle through multiple prompts to avoid provider-side caching
- Response validation — Regex match on model output to verify the model is actually working
- Adaptive probe interval — Automatically reduces probe frequency when stable, resets to base interval on failure
- Non-streaming mode —
stream: falsefor APIs that don't support streaming - Ops-friendly —
--validateconfig check,--versioninfo,/api/v1/targetsstatus API,llm_exporter_build_info+ Go runtime metrics, example Prometheus rules
# Build
make build
# Configure
cp config.example.yaml config.yaml
# Edit config.yaml, fill in your API keys
# Set environment variables (or write directly in config.yaml)
export OPENAI_API_KEY="sk-..."
export ANTHROPIC_API_KEY="sk-ant-..."
# Run
./llm-exporter --config config.yaml
# Verify
curl http://localhost:9101/metrics | grep llm_probe
curl http://localhost:9101/healthz# Configure API keys
cp .env.example .env
# Edit .env
# Start the full monitoring stack
docker compose up --build -d
# Access
# LLM Exporter: http://localhost:9101/metrics
# Prometheus: http://localhost:9090
# Grafana: http://localhost:3000 (admin/admin)Grafana auto-loads a pre-built LLM monitoring dashboard on startup.
Suitable for direct deployment on Linux servers.
1. Install binary
make build
sudo cp llm-exporter /usr/local/bin/
sudo chmod +x /usr/local/bin/llm-exporter
sudo mkdir -p /etc/llm-exporter
sudo cp config.example.yaml /etc/llm-exporter/config.yaml
sudo vim /etc/llm-exporter/config.yaml2. Create system user
sudo useradd --system --no-create-home --shell /usr/sbin/nologin llm-exporter3. Create systemd service
sudo cat > /etc/systemd/system/llm-exporter.service << 'EOF'
[Unit]
Description=LLM Exporter - Prometheus LLM API Probe Exporter
Documentation=https://github.com/oh-my-vibe-coding/llm-exporter
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=llm-exporter
Group=llm-exporter
ExecStart=/usr/local/bin/llm-exporter --config /etc/llm-exporter/config.yaml
# Optional: append --watch-config to auto-reload on config file changes
# (systemd deployments typically rely on `systemctl reload` instead)
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5
LimitNOFILE=65536
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadOnlyPaths=/etc/llm-exporter
PrivateTmp=yes
EnvironmentFile=-/etc/llm-exporter/env
[Install]
WantedBy=multi-user.target
EOF4. Environment file (optional)
sudo cat > /etc/llm-exporter/env << 'EOF'
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
EOF
sudo chmod 600 /etc/llm-exporter/env
sudo chown llm-exporter:llm-exporter /etc/llm-exporter/env5. Start
sudo systemctl daemon-reload
sudo systemctl enable --now llm-exporter
sudo systemctl status llm-exporter
sudo journalctl -u llm-exporter -fdocker run -d \
--name llm-exporter \
--restart unless-stopped \
-p 9101:9101 \
-v $(pwd)/config.yaml:/etc/llm-exporter/config.yaml:ro \
--env-file .env \
llm-exporterDocker Compose (full stack):
docker compose up --build -d| Service | Port | Description |
|---|---|---|
| llm-exporter | 9101 | Exporter metrics endpoint |
| prometheus | 9090 | Prometheus server |
| grafana | 3000 | Grafana (admin/admin), auto-provisioned dashboard |
1. ConfigMap and Secret
apiVersion: v1
kind: ConfigMap
metadata:
name: llm-exporter-config
namespace: monitoring
data:
config.yaml: |
listen_addr: ":9101"
targets:
- name: openai-gpt4o
endpoint: "https://api.openai.com"
api_key: "${OPENAI_API_KEY}"
model: "gpt-4o"
api_format: openai
timeout: 30s
interval: 60s
---
apiVersion: v1
kind: Secret
metadata:
name: llm-exporter-secrets
namespace: monitoring
type: Opaque
stringData:
OPENAI_API_KEY: "sk-..."
ANTHROPIC_API_KEY: "sk-ant-..."2. Deployment + Service
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-exporter
namespace: monitoring
labels:
app: llm-exporter
spec:
replicas: 1
selector:
matchLabels:
app: llm-exporter
template:
metadata:
labels:
app: llm-exporter
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "9101"
prometheus.io/path: "/metrics"
spec:
containers:
- name: llm-exporter
image: llm-exporter:latest
args: ["--config", "/etc/llm-exporter/config.yaml", "--watch-config"]
ports:
- containerPort: 9101
name: metrics
envFrom:
- secretRef:
name: llm-exporter-secrets
volumeMounts:
- name: config
mountPath: /etc/llm-exporter
readOnly: true
livenessProbe:
httpGet:
path: /healthz
port: 9101
initialDelaySeconds: 5
periodSeconds: 30
readinessProbe:
httpGet:
path: /healthz
port: 9101
initialDelaySeconds: 3
periodSeconds: 10
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
volumes:
- name: config
configMap:
name: llm-exporter-config
---
apiVersion: v1
kind: Service
metadata:
name: llm-exporter
namespace: monitoring
labels:
app: llm-exporter
spec:
selector:
app: llm-exporter
ports:
- port: 9101
targetPort: metrics
name: metrics3. ServiceMonitor (Prometheus Operator)
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: llm-exporter
namespace: monitoring
labels:
release: prometheus
spec:
selector:
matchLabels:
app: llm-exporter
endpoints:
- port: metrics
interval: 15s
path: /metrics4. Deploy
kubectl apply -f llm-exporter-config.yaml
kubectl apply -f llm-exporter-deployment.yaml
kubectl apply -f llm-exporter-servicemonitor.yamlThree ways to reload config without restarting:
SIGHUP signal:
sudo systemctl reload llm-exporter
# or: kill -HUP $(pidof llm-exporter)
# Docker: docker kill --signal=HUP llm-exporterHTTP endpoint:
curl -X POST http://localhost:9101/-/reloadFile watcher (recommended for Kubernetes):
llm-exporter --config /etc/llm-exporter/config.yaml --watch-configHandles Kubernetes ConfigMap symlink swaps with 2-second debounce. On reload failure, existing probes continue running unaffected.
listen_addr: ":9101" # Listen address, default :9101
# Webhook alerting (optional)
# webhook:
# url: "https://hooks.slack.com/services/xxx"
# consecutive_failures: 3 # default 3
targets:
- name: openai-gpt4o # Target name (required, used as provider label)
endpoint: "https://api.openai.com" # API endpoint (required)
api_key: "${OPENAI_API_KEY}" # API key, supports env vars
model: "gpt-4o" # Model name (required)
prompt: "Hi" # Probe prompt, default "Hi"
api_format: openai # API format: openai / anthropic / google / azure
timeout: 30s # Probe timeout, default 30s
interval: 300s # Probe interval, default 300s
max_tokens: 20 # Max output tokens, default 20
chat_path: "/v1/chat/completions" # Custom API path (openai format only)
extra_headers: # Custom request headers
X-Custom-Auth: "token"
# stream: false # Non-streaming mode (default true)
# api_version: "2024-10-21" # Azure API version (azure format only)
# prompts: ["Hi", "Hello", "Hey"] # Prompt rotation (mutually exclusive with prompt)
# expect_pattern: "\\d+" # Response validation regex
# adaptive_interval: true # Adaptive interval (default off)
# max_interval: 1200s # Adaptive cap, default 4x interval
# backoff_after: 5 # Consecutive successes before backoff, default 5OpenAI
- name: openai-gpt4o
endpoint: "https://api.openai.com"
api_key: "${OPENAI_API_KEY}"
model: "gpt-4o"
api_format: openaiAnthropic Claude
- name: anthropic-claude
endpoint: "https://api.anthropic.com"
api_key: "${ANTHROPIC_API_KEY}"
model: "claude-sonnet-4-20250514"
api_format: anthropicGoogle Gemini
- name: google-gemini
endpoint: "https://generativelanguage.googleapis.com"
api_key: "${GOOGLE_API_KEY}"
model: "gemini-2.0-flash"
api_format: googleAzure OpenAI
- name: azure-gpt4o
endpoint: "${AZURE_OPENAI_ENDPOINT}"
api_key: "${AZURE_OPENAI_API_KEY}"
model: "gpt-4o"
api_format: azure
# api_version: "2024-10-21" # defaultDashScope (Alibaba)
- name: dashscope-qwen
endpoint: "https://dashscope.aliyuncs.com/compatible-mode"
api_key: "${DASHSCOPE_API_KEY}"
model: "qwen-plus"
api_format: openaiVolcengine Ark (ByteDance)
- name: ark-pro
endpoint: "https://ark.cn-beijing.volces.com/api"
api_key: "${ARK_API_KEY}"
model: "${ARK_ENDPOINT_ID}"
api_format: openaiDeepSeek / Mistral / OpenRouter / Ollama
# DeepSeek
- name: deepseek-chat
endpoint: "https://api.deepseek.com"
api_key: "${DEEPSEEK_API_KEY}"
model: "deepseek-chat"
api_format: openai
# Mistral
- name: mistral-large
endpoint: "https://api.mistral.ai"
api_key: "${MISTRAL_API_KEY}"
model: "mistral-large-latest"
api_format: openai
# OpenRouter
- name: openrouter-claude
endpoint: "https://openrouter.ai/api"
api_key: "${OPENROUTER_API_KEY}"
model: "anthropic/claude-sonnet-4"
api_format: openai
# Local Ollama
- name: local-ollama
endpoint: "http://localhost:11434"
model: "llama3"
api_format: openai
interval: 30sCustom proxy
- name: my-proxy
endpoint: "https://my-proxy.example.com"
api_key: "${PROXY_API_KEY}"
model: "gpt-4o"
api_format: openai
chat_path: "/api/v1/chat"
extra_headers:
X-Proxy-Token: "${PROXY_TOKEN}"All ${VAR_NAME} values in the config file are automatically replaced with the corresponding environment variable at load time. If the variable is not set, the literal string is preserved.
| Metric | Type | Description |
|---|---|---|
llm_probe_success |
Gauge | Whether the last probe succeeded (1=success, 0=failure) |
llm_probe_duration_seconds |
Histogram (+ native) | Full request duration (from request start to stream end) |
llm_probe_connect_duration_seconds |
Histogram (+ native) | Connection setup time (DNS + TCP + TLS) |
llm_probe_ttft_seconds |
Histogram (+ native) | Time to First Token (from request start to first content token) |
llm_probe_input_tokens |
Gauge | Input tokens for the last probe |
llm_probe_output_tokens |
Gauge | Output tokens for the last probe |
llm_probe_total_tokens |
Gauge | Total tokens consumed by the last probe |
llm_probe_reasoning_tokens |
Gauge | Reasoning/thinking tokens (OpenAI o-series/Responses, Gemini thoughts). Anthropic reports 0 here — thinking tokens are billed as output_tokens. |
llm_probe_cached_input_tokens |
Gauge | Prompt tokens served from provider prompt cache |
llm_probe_cache_creation_tokens |
Gauge | Tokens written to the provider prompt cache on the last probe (Anthropic) |
llm_probe_token_rate |
Gauge | Token generation rate (output_tokens / generation_time, tok/s) |
llm_probe_last_success_timestamp_seconds |
Gauge | Unix timestamp of the last successful probe |
llm_probe_rate_limit_remaining{kind} |
Gauge | Remaining rate-limit budget from provider response headers. kind is requests or tokens |
llm_probe_ssl_earliest_cert_expiry_timestamp_seconds |
Gauge | Earliest TLS peer certificate NotAfter observed on the last probe |
llm_probe_errors_total |
Counter | Cumulative error count by error_type and HTTP status |
llm_exporter_build_info |
Gauge | Constant 1 labelled with version, git_commit, build_time, go_version |
Classic histograms are emitted alongside native histograms (NativeHistogramBucketFactor=1.1). Prometheus 2.50+ will store the native form; older versions transparently fall back to classic buckets.
All metrics carry these labels:
| Label | Description |
|---|---|
provider |
Target name (from config name field) |
model |
Model name |
endpoint |
API endpoint URL |
api_format |
API format (openai / openai-responses / anthropic / google / azure) |
llm_probe_errors_total has two additional labels: error_type (category) and status (HTTP status code; 0 for non-HTTP failures).
| error_type | Description |
|---|---|
timeout |
Request timed out |
canceled |
Request canceled (during reload or shutdown) |
auth |
Authentication failure (HTTP 401/403 or invalid_api_key) |
rate_limit |
Rate limited (HTTP 429 or Gemini RESOURCE_EXHAUSTED) |
overloaded |
Provider overloaded (HTTP 529 or Anthropic overloaded_error) |
quota_exceeded |
Billing / quota exhausted (OpenAI insufficient_quota) |
context_length |
Prompt exceeded the model's context window |
content_filter |
Blocked by the provider's content policy |
http_4xx |
Other 4xx error |
http_5xx |
5xx server error |
network / dns_error / tls_error / connection_refused |
Network-layer failures |
parse_error |
Response parse failure or empty stream |
validation_error |
Response didn't match expect_pattern regex |
llm_probe_duration_seconds: 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 20, 30, 60, 120, 300, 600
llm_probe_connect_duration_seconds: 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5
llm_probe_ttft_seconds: 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 20, 60, 120
scrape_configs:
- job_name: llm-exporter
scrape_interval: 15s
static_configs:
- targets: ["localhost:9101"]Alert rules examples
groups:
- name: llm-probe
rules:
- alert: LLMProbeDown
expr: llm_probe_success == 0
for: 5m
labels:
severity: critical
annotations:
summary: "LLM endpoint {{ $labels.provider }} is down"
- alert: LLMHighTTFT
expr: histogram_quantile(0.99, sum(rate(llm_probe_ttft_seconds_bucket[10m])) by (le, provider, model)) > 10
for: 5m
labels:
severity: warning
annotations:
summary: "High TTFT for {{ $labels.provider }}"
- alert: LLMRateLimited
expr: increase(llm_probe_errors_total{error_type="rate_limit"}[10m]) > 3
labels:
severity: warning
annotations:
summary: "{{ $labels.provider }} is being rate limited"# Availability
llm_probe_success # Current status
avg_over_time(llm_probe_success[24h]) # 24h availability
# TTFT percentiles
histogram_quantile(0.50, sum(rate(llm_probe_ttft_seconds_bucket[5m])) by (le, provider, model))
histogram_quantile(0.95, sum(rate(llm_probe_ttft_seconds_bucket[5m])) by (le, provider, model))
histogram_quantile(0.99, sum(rate(llm_probe_ttft_seconds_bucket[5m])) by (le, provider, model))
# Errors by type
sum(rate(llm_probe_errors_total[5m])) by (provider, error_type)
# Token rate
llm_probe_token_rate
# Connection latency P95
histogram_quantile(0.95, sum(rate(llm_probe_connect_duration_seconds_bucket[5m])) by (le, provider, model))
A pre-built Grafana dashboard is included at grafana/dashboards/llm-exporter.json.
| Section | Description |
|---|---|
| Overview | 9 stat panels summarizing all targets |
| Target Detail | Per-provider: 11 stats + 8 time series (repeated by provider) |
| Cross-Provider | 6 side-by-side comparisons across providers |
| TTFT Heatmap | Collapsed by default, shows TTFT distribution patterns |
Supports provider and model template variables for filtering. Auto-provisioned when using Docker Compose.
All APIs use streaming mode. TTFT is measured from the moment the HTTP request is sent:
| API Format | TTFT Trigger |
|---|---|
| OpenAI | First SSE event with non-empty choices[0].delta.content |
| Anthropic | First content_block_delta with non-empty text_delta |
| First candidate containing text content | |
| Azure | Same as OpenAI |
| Strategy | Purpose |
|---|---|
| No connection reuse | Each probe creates a fresh TCP connection (DisableKeepAlives) for accurate DNS + TCP + TLS timing |
| Prompt randomization | Appends [t=<unix_millis>] to prevent proxy/CDN caching |
| Configurable output | Default max_tokens=20, increase for more accurate token rate measurement |
| Token rate threshold | Only calculated when output >= 5 tokens and generation time >= 100ms |
| Three-phase timing | Connect (DNS+TCP+TLS) -> Wait (TTFT - Connect) -> Generation (Duration - TTFT) |
When adaptive_interval: true:
- Stable state: After
backoff_after(default 5) consecutive successes, interval doubles each time (e.g., 5m -> 10m -> 20m), capped atmax_interval(default 4x base) - On failure: Immediately resets to base interval
Combine with light probe mode for maximum savings: interval=300s + adaptive_interval=true + full_probe_every=10 reduces token usage by 80%+.
make build # Build with version injection
make test # Run tests
make docker # Build Docker image
./llm-exporter --version # Check version
./llm-exporter --validate --config config.yaml # Validate config| Endpoint | Method | Description |
|---|---|---|
/metrics |
GET | Prometheus metrics for all scheduled targets |
/probe |
GET | Run a one-shot probe: /probe?target=<endpoint>&module=<name>. Uses a module defined under modules: in the config. blackbox_exporter-compatible pattern — useful with file_sd or Consul service discovery |
/healthz |
GET | Health check, returns "ok" |
/-/reload |
POST | Trigger config hot reload |
/api/v1/targets |
GET | Current status of all probe targets (JSON) |
/version |
GET | Version info (JSON) |
| Flag | Default | Description |
|---|---|---|
--config |
config.yaml |
Config file path |
--watch-config |
false |
Watch config file for changes and auto-reload |
--validate |
false |
Validate config and exit |
--version |
false |
Print version and exit |
MIT