Install
openclaw skills install @avikalpg/byok-relayOpenAI-compatible LLM gateway for any client-side application (browser, mobile, React Native, Flutter, VS Code extensions, browser extensions, Electron, smart TV, and more). Routes requests to OpenAI, Anthropic, Gemini, Groq, Mistral, and 200+ models, handling CORS, key encryption, and streaming without a dedicated backend. Uniquely supports BYOK (bring your own key): users connect their own provider API keys so the developer pays nothing for inference. Use for multi-provider LLM access, client-safe AI API calls, or any product where users supply their own API credentials.
openclaw skills install @avikalpg/byok-relayThis skill helps you integrate byok-relay — an OpenAI-compatible LLM gateway for any client-side application (browser, mobile, extensions, Electron, smart TV, and more) that handles CORS, encrypts API keys, and supports multiple providers through a single relay endpoint.
Zero backend required. Frontend-only apps connect directly to the managed relay at https://relay.byokrelay.com. No cloning, no server setup, no environment variables on your side.
Use this skill whenever:
Browser apps can't call AI APIs directly:
api.openai.com, api.anthropic.com, etc. block cross-origin requests from browsers, mobile webviews, and extensions via CORSbyok-relay is a single relay that handles all of this: OpenAI-compatible endpoint, CORS-safe, multi-provider, with optional BYOK so users (or their organization) supply their own credentials and the developer pays nothing for inference.
Individuals / prosumers: Each user registers their own personal API key. Common for developer tools, research UIs, AI power-user products.
Teams / B2B (company-managed key): Keys are currently scoped per relay token. A company admin can register the same company API key via POST /keys/:provider (e.g. /keys/openai) under each member's relay token, or implement an explicit org-scoped design for one stored key serving multiple relay tokens. Do not share a relay token across team members: it shares all saved keys with every holder and is not a supported team feature. The developer doesn't touch the key — it belongs to the customer's organization.
If you are building a frontend-only, browser-only, or client-side-only application (Lovable, Bolt, Replit, plain HTML, Vite, React without a backend, mobile app, extension) — this is your path. You need exactly 0 lines of server code.
const RELAY_URL = 'https://relay.byokrelay.com';
That is the only configuration step on your side. The managed relay:
Skip directly to the Integration section below.
Note: The managed relay is best for development and low-stakes production. For sensitive or high-volume production use, self-host so you control the encryption key.
Use this if you want to run your own relay on a VPS, Railway, Fly.io, etc.
git clone https://github.com/avikalpg/byok-relay.git
cd byok-relay && npm install
echo "ENCRYPTION_SECRET=$(openssl rand -hex 32)" > .env
echo "ALLOWED_ORIGINS=https://your-app.com" >> .env
npm start
For production: see the systemd + nginx setup in the README.
Docker (quickest self-hosted path):
git clone https://github.com/avikalpg/byok-relay.git
cd byok-relay
cp .env.example .env # edit ENCRYPTION_SECRET and ALLOWED_ORIGINS
docker compose up -d
Use RELAY_URL = 'https://relay.byokrelay.com' for the managed relay, or your own host for self-hosted.
function relayTokenStorageKey(relayUrl, appId) {
const normalizedRelayUrl = new URL(relayUrl).origin;
return `byok-relay:relay-token:${normalizedRelayUrl}:${appId}`;
}
async function getRelayToken(relayUrl, appId) {
// Keep bearer tokens scoped to one relay/app. Do not reuse one global
// `relay_token` key across products, tenants, or relay URLs.
const storageKey = relayTokenStorageKey(relayUrl, appId);
const stored = localStorage.getItem(storageKey);
if (stored) return stored; // reuse across page loads
const res = await fetch(`${relayUrl}/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ app_id: appId }),
redirect: 'error'
});
const { token } = await res.json();
localStorage.setItem(storageKey, token);
return token;
}
async function storeApiKey(relayUrl, token, provider, apiKey) {
// provider: 'openai' | 'anthropic' | 'google' | 'groq' | 'mistral' | 'openrouter'
const res = await fetch(`${relayUrl}/keys/${provider}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-relay-token': token
},
body: JSON.stringify({ key: apiKey }),
redirect: 'error'
});
return res.ok;
}
// OpenAI via relay
// onDelta is a browser-safe callback — e.g. (text) => { div.textContent += text; }
async function chat(relayUrl, token, messages, onDelta = () => {}) {
const relay = new URL(relayUrl);
const isLocalhost = ['localhost', '127.0.0.1', '[::1]'].includes(relay.hostname);
if (relay.protocol !== 'https:' && !(relay.protocol === 'http:' && isLocalhost)) {
throw new Error('relayUrl must use HTTPS (except localhost during development)');
}
const res = await fetch(new URL('/relay/openai/v1/chat/completions', relay), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-relay-token': token
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages,
stream: true
}),
redirect: 'error'
});
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error((typeof err?.error === 'string' ? err.error : null) || `chat failed: ${res.status}`);
}
if (!res.body) throw new Error('chat: response body is null (ReadableStream not supported)');
const contentType = res.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase();
if (contentType !== 'text/event-stream') {
throw new Error(`chat: expected text/event-stream, got ${contentType ?? 'missing'}`);
}
let currentEvent = 'message';
function handleSseLine(line) {
const normalizedLine = line.replace(/\r$/, '');
if (normalizedLine.startsWith('event: ')) {
currentEvent = normalizedLine.slice(7).trim();
return;
}
if (!normalizedLine.startsWith('data: ')) return;
const data = normalizedLine.slice(6).trim();
if (data === '[DONE]') {
currentEvent = 'message';
return;
}
let json;
try {
json = JSON.parse(data);
} catch {
return; // Ignore malformed SSE data, but let callback errors propagate.
}
if (currentEvent === 'error') {
throw new Error(json.error || 'Relay stream error');
}
const delta = json.choices?.[0]?.delta?.content ?? '';
if (delta) onDelta(delta);
currentEvent = 'message';
}
// SSE stream — buffered across chunk boundaries (ReadableStream, browser-safe)
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
// { stream: true } handles multi-byte UTF-8 characters split across chunks
buffer += decoder.decode(value, { stream: true });
// Process complete lines only; keep any trailing partial line in the buffer
const lines = buffer.split('\n');
buffer = lines.pop(); // last element may be an incomplete line
for (const line of lines) {
handleSseLine(line);
}
}
// Flush the TextDecoder and process any remaining buffered content
buffer += decoder.decode();
if (buffer) handleSseLine(buffer);
} catch (error) {
// Ensure upstream streaming work stops, but preserve the original error.
try {
await reader.cancel(error);
} catch {
// Cancellation is best-effort; the processing error remains authoritative.
}
throw error;
} finally {
reader.releaseLock();
}
}
// Anthropic via relay
async function claudeChat(relayUrl, token, messages) {
const res = await fetch(`${relayUrl}/relay/anthropic/v1/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
'x-relay-token': token
},
body: JSON.stringify({
model: 'claude-3-5-haiku-20241022',
max_tokens: 1024,
messages,
stream: true
}),
redirect: 'error'
});
return res;
}
Use the correct link and wording for each provider. Important: API accounts and API credits are separate from consumer subscriptions (ChatGPT Plus, Claude Pro, Gemini Advanced, Grok Premium). A user who pays for a consumer subscription still needs to create a separate API account and add billing credits to get an API key.
| Provider | Get API key | Key format | Note |
|---|---|---|---|
| OpenAI | https://platform.openai.com/api-keys | sk-... (≥40 chars) | Separate from ChatGPT Plus. Requires billing credits. |
| Anthropic | https://console.anthropic.com/settings/keys | sk-ant-api03-... | Separate from Claude Pro. Requires billing credits. |
| Google Gemini | https://aistudio.google.com/app/apikey | AIza... (39 chars) | Free tier available. Separate from Gemini Advanced. |
| Groq | https://console.groq.com/keys | gsk_... | Free tier available. Fast inference. |
| Mistral | https://console.mistral.ai/api-keys | ... (32 hex chars) | Separate billing from Mistral subscription. |
| OpenRouter | https://openrouter.ai/settings/keys | sk-or-v1-... | Routes to 200+ models. One key for many providers. |
User-facing copy to include near the input: "Your API key is encrypted and stored on the relay. We never log, expose, or transmit your key to third parties. Inference costs go directly to your provider account, not to us."
Subscription disambiguation copy (show when appropriate): "Note: Your [ChatGPT Plus / Claude Pro / Gemini Advanced / Grok Premium] subscription does not include API access. Create a separate account at [link] and add billing credits to get an API key."
When integrating byok-relay, always generate a styled API key input component that matches the app's existing design system. This is a required part of the integration — the relay is useless without a UI for users to enter their keys.
Never prefill the input with a stored key value. The relay never returns stored key material. If a key is already stored, show a masked placeholder (e.g. sk-...••••••••) or a "Key already saved" badge — never the actual key.
What to generate:
<input> for the API keystoreApiKey() and shows feedbackGET /keys to check)aria-describedby for status messagesDesign system detection — use the one already in the project:
bg-gray-50, border, rounded-lg, px-4 py-2, etc.<Input>, <Button>, <Card> from @/components/ui<TextField>, <Button variant="contained">, <Paper>Headless Connect AI component: When @byok-relay/connect (issue #103) is available in the project's dependencies, prefer it over a hand-rolled component. It provides accessible, tested flows for all providers out of the box. Fall back to the pattern below only if it is not installed.
Minimal example (plain HTML / Tailwind):
<div class="rounded-lg border bg-gray-50 p-4 space-y-3" role="region" aria-label="API key settings">
<h3 class="font-medium text-sm text-gray-700">Connect your API key</h3>
<p class="text-xs text-gray-500">
Your key is encrypted at rest and never returned or logged.
Inference costs go directly to your provider account.
<a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener" class="underline">Get an OpenAI key ↗</a>
</p>
<!-- Primary input: used for both initial connect and key rotation -->
<div id="connect-panel" class="flex gap-2">
<input
id="api-key-input"
type="password"
placeholder="sk-..."
autocomplete="off"
aria-label="API key"
aria-describedby="key-status"
class="flex-1 rounded border px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
id="save-btn"
onclick="handleSaveKey()"
class="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
Connect
</button>
</div>
<!-- Rotation panel: shown in place of connect panel when rotating -->
<div id="rotate-panel" class="hidden flex gap-2">
<input
id="rotate-key-input"
type="password"
placeholder="New API key…"
autocomplete="off"
aria-label="New API key for rotation"
aria-describedby="key-status"
class="flex-1 rounded border px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onclick="confirmRotateKey()"
class="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
Confirm
</button>
<button
onclick="cancelRotate()"
class="rounded border px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100"
>
Cancel
</button>
</div>
<p id="key-status" class="text-xs text-gray-500 hidden" aria-live="polite"></p>
<div id="key-actions" class="hidden flex gap-2 pt-1">
<button onclick="handleRotateKey()" class="text-xs text-blue-600 hover:underline">Rotate key</button>
<button onclick="handleRemoveKey()" class="text-xs text-red-500 hover:underline">Disconnect</button>
<button onclick="handleTestKey()" class="text-xs text-gray-500 hover:underline">Test connection</button>
</div>
</div>
<script>
// On load: check whether a key is already stored and restore connected state
async function initKeyState() {
try {
const token = await getRelayToken(RELAY_URL, APP_ID);
const res = await fetch(`${RELAY_URL}/keys`, {
headers: { 'x-relay-token': token },
redirect: 'error'
});
if (!res.ok) {
const state = await responseState(res);
setStatus(state, statusMessages[state]);
return;
}
const data = await res.json();
if (data.providers && data.providers.includes('openai')) {
setStatus('connected', '✓ Connected — key already saved (sk-…••••••••).');
document.getElementById('key-actions').classList.remove('hidden');
}
} catch {
setStatus('network', statusMessages.network);
}
}
document.addEventListener('DOMContentLoaded', initKeyState);
// Map relay/provider responses to distinct UX states. Only the relay-owned
// X-Byok-Relay-Error header identifies relay authentication failures. Provider
// responses are forwarded and may use similar words in their response bodies.
async function responseState(res) {
const relayError = res.headers.get('x-byok-relay-error');
if (relayError === 'missing-relay-token' || relayError === 'invalid-relay-token') {
return 'relay_auth';
}
let detail = '';
try { detail = JSON.stringify(await res.clone().json()).toLowerCase(); } catch { /* plain-text error */ }
if (res.status === 429) return 'rate_limited';
if (res.status >= 500 && res.status < 600) return 'server_error';
if (/\b(expired|revoked)\b/.test(detail)) return 'expired';
if (res.status === 400 || res.status === 401 || res.status === 403 || res.status === 422) return 'invalid'; // provider rejection or key validation
return 'server_error'; // unknown failures are retryable, not bad keys
}
const statusMessages = {
connected: '✓ Connected — your requests use your own API credits.',
invalid: '✗ Key rejected. Check the key format and ensure billing credits are available.',
relay_auth: '✗ Your relay session is invalid or expired. Sign in again and retry.',
rate_limited: '⚠ Too many requests — slow down or try again shortly.',
expired: '⚠ Your key has expired or been revoked. Rotate or enter a new key.',
network: '✗ Could not reach the relay. Check your connection and relay URL.',
server_error: '⚠ Relay or provider is temporarily unavailable. Retry shortly.',
rotating: '↻ Rotating key…',
disconnected: 'No key connected. Add a key to use AI features.',
disconnecting:'Removing key…',
validating: 'Validating key…',
};
async function handleSaveKey() {
const input = document.getElementById('api-key-input');
const key = input.value.trim();
if (!key) return;
setStatus('validating', statusMessages.validating);
try {
const token = await getRelayToken(RELAY_URL, APP_ID);
const res = await fetch(`${RELAY_URL}/keys/openai`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-relay-token': token },
body: JSON.stringify({ key }),
redirect: 'error'
});
input.value = ''; // clear after attempt — never leave key in DOM
if (res.ok) {
setStatus('connected', statusMessages.connected);
document.getElementById('key-actions').classList.remove('hidden');
} else {
const state = await responseState(res);
setStatus(state, statusMessages[state]);
}
} catch {
input.value = '';
setStatus('network', statusMessages.network);
}
}
// handleRotateKey: show the rotation panel with a password input instead of window.prompt()
function handleRotateKey() {
document.getElementById('connect-panel').classList.add('hidden');
document.getElementById('key-actions').classList.add('hidden');
const rotatePanel = document.getElementById('rotate-panel');
rotatePanel.classList.remove('hidden');
document.getElementById('rotate-key-input').focus();
setStatus('rotating', 'Enter the new key and click Confirm.');
}
function cancelRotate() {
document.getElementById('rotate-key-input').value = '';
document.getElementById('rotate-panel').classList.add('hidden');
document.getElementById('connect-panel').classList.add('hidden'); // stays hidden — key still connected
document.getElementById('key-actions').classList.remove('hidden');
setStatus('connected', statusMessages.connected);
}
async function confirmRotateKey() {
const input = document.getElementById('rotate-key-input');
const key = input.value.trim();
if (!key) return;
setStatus('rotating', statusMessages.rotating);
try {
const token = await getRelayToken(RELAY_URL, APP_ID);
const res = await fetch(`${RELAY_URL}/keys/openai/rotate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-relay-token': token },
body: JSON.stringify({ key }),
redirect: 'error'
});
input.value = ''; // clear regardless of outcome
document.getElementById('rotate-panel').classList.add('hidden');
document.getElementById('key-actions').classList.remove('hidden');
if (res.ok) {
setStatus('connected', '✓ Key rotated — live with zero downtime.');
} else {
const state = await responseState(res);
setStatus(state, state === 'invalid'
? `✗ Rotation failed. Old key is unchanged.`
: statusMessages[state]);
}
} catch {
document.getElementById('rotate-key-input').value = '';
setStatus('network', statusMessages.network);
}
}
async function handleRemoveKey() {
if (!confirm('Remove your API key? You will need to reconnect to use AI features.')) return;
setStatus('disconnecting', statusMessages.disconnecting);
try {
const token = await getRelayToken(RELAY_URL, APP_ID);
const res = await fetch(`${RELAY_URL}/keys/openai`, {
method: 'DELETE',
headers: { 'x-relay-token': token },
redirect: 'error'
});
if (res.ok) {
setStatus('disconnected', statusMessages.disconnected);
document.getElementById('key-actions').classList.add('hidden');
document.getElementById('connect-panel').classList.remove('hidden');
} else {
// Deletion failed — keep UI in connected state and report a safe summary.
const state = await responseState(res);
setStatus(state, '✗ Could not remove key. Key may still be stored.');
document.getElementById('key-actions').classList.remove('hidden');
}
} catch {
setStatus('network', statusMessages.network);
document.getElementById('key-actions').classList.remove('hidden');
}
}
async function handleTestKey() {
setStatus('validating', 'Sending test request…');
try {
const token = await getRelayToken(RELAY_URL, APP_ID);
const res = await fetch(`${RELAY_URL}/relay/openai/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-relay-token': token },
body: JSON.stringify({ model: 'gpt-4o-mini', messages: [{ role: 'user', content: 'hi' }], max_tokens: 5 }),
redirect: 'error'
});
if (res.ok) {
setStatus('connected', '✓ Test request succeeded.');
} else {
const state = await responseState(res);
setStatus(state, statusMessages[state]);
}
} catch {
setStatus('network', statusMessages.network);
}
}
function setStatus(state, msg) {
const el = document.getElementById('key-status');
const colors = {
connected:'text-green-600', invalid:'text-red-600',
validating:'text-blue-500', rotating:'text-blue-500',
rate_limited:'text-amber-600', expired:'text-amber-600',
disconnected:'text-gray-500', disconnecting:'text-gray-400',
network:'text-red-600', server_error:'text-amber-600',
};
el.textContent = msg;
el.className = `text-xs ${colors[state] || 'text-gray-500'}`;
el.classList.remove('hidden');
}
</script>
Always place this component on a settings page, in a modal triggered by a "Connect API key" button, or in the app's onboarding flow.
Track and display the correct state at all times. Never leave the user guessing.
| State | Display | User action |
|---|---|---|
unconnected | Empty input, "Connect" CTA prominent | Paste key and click Connect |
validating | Spinner / "Validating…" | None — wait |
connected | Badge "✓ Connected", key actions visible | Rotate, test, or disconnect |
invalid | Error "Key format invalid" or "Key rejected by provider" | Re-enter correct key |
relay_auth | Error "Your relay session is invalid or expired" | Sign in again, then retry |
expired | Warning "Your key has expired or been revoked" | Rotate or enter new key |
rate_limited | Warning "Too many requests — slow down" | Retry later or upgrade plan |
network | Error "Could not reach relay — check your connection." | Check connection and relay URL, then retry |
server_error | Warning "Relay or provider is temporarily unavailable" | Retry shortly; do not ask for a new key |
rotating | Spinner / "Rotating…" | None — wait |
disconnected | "No key connected" + Connect CTA | Connect a new key |
Do not surface raw HTTP status codes to users. Map relay responses to human-readable states. The relay identifies a missing or invalid token with its X-Byok-Relay-Error response header, which maps to relay_auth. A provider-key rejection maps to invalid; a 429 to rate_limited; an expired/revoked provider-key response to expired; a network error to network with "Could not reach relay — check your connection."; and 5xx or unknown failures to retryable server_error.
Response-classification fixtures: Cover these cases in the integration's client tests. The header is intentionally the only signal for relay authentication, so provider error text cannot misclassify a provider rejection.
| Fixture | Expected state |
|---|---|
401 with X-Byok-Relay-Error: invalid-relay-token | relay_auth |
Provider 401 / 403 without that header | invalid |
| Provider error body says expired or revoked | expired |
429 | rate_limited |
5xx | server_error |
| Plain-text or otherwise unrecognized error | server_error |
Individual / personal key flow: Each user connects their own provider API key. The relay token is scoped to that user. Keys are personal and must not be shared.
Organization / company-managed key flow: An org admin registers one relay token per team member via the app's backend (POST /users server-side), then stores the company's provider API key under each member's token. The shared token must not be distributed to client browsers — a relay token grants full access to all stored keys for that token. Never pass a shared relay token to end-user clients. Instead, have the app server proxy relay requests on behalf of the member (server-side x-relay-token header) and issue a session credential to the browser that has no relay privilege by itself.
For the admin UI, add:
GET /keys returns only which providers are stored, not rotation timestamps)Rotation (POST /keys/:provider/rotate):
Deletion (DELETE /keys/:provider):
disconnected and hide key actionsAccount erasure (DELETE /users):
Recovery if key is compromised:
POST /keys/:provider/rotatePOST /tokens/revoke, then re-registerBefore declaring the integration complete, confirm every item:
localStorage/sessionStorage in plain texttype="password" and clears after saveAfter wiring up the integration, run this quick smoke test (Node.js or browser console) to confirm the relay is reachable and the stored key works before closing the PR:
// Paste into browser console or run with node --input-type=module
const RELAY_URL = 'https://relay.byokrelay.com'; // or your self-hosted URL
const APP_ID = 'smoke-test';
async function smokeTest() {
// 1. Health check
const healthRes = await fetch(`${RELAY_URL}/health`);
if (!healthRes.ok) throw new Error(`Health check failed: ${healthRes.status} ${healthRes.statusText}`);
const health = await healthRes.json();
if (health.ok !== true) throw new Error(`Health check failed: ${JSON.stringify(health)}`);
console.log('✓ Health:', health);
// 2. Register
const usersRes = await fetch(`${RELAY_URL}/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ app_id: APP_ID }),
redirect: 'error'
});
if (!usersRes.ok) throw new Error(`Registration failed: ${usersRes.status} ${usersRes.statusText}`);
const { token } = await usersRes.json();
if (!token) throw new Error('Registration failed — no token returned');
console.log('✓ Token obtained');
// 3. List providers (should be empty before storing a key)
const keysRes = await fetch(`${RELAY_URL}/keys`, {
headers: { 'x-relay-token': token },
redirect: 'error'
});
if (!keysRes.ok) throw new Error(`Keys list failed: ${keysRes.status} ${keysRes.statusText}`);
const { providers } = await keysRes.json();
if (!Array.isArray(providers)) throw new Error(`Keys list returned invalid providers: ${JSON.stringify(providers)}`);
if (providers.length !== 0) throw new Error(`Newly registered user unexpectedly has stored providers: ${providers.join(', ')}`);
console.log('✓ Stored providers:', providers);
// 4. (Optional) Store a real key and test a relay call
// await storeApiKey(RELAY_URL, token, 'openai', 'sk-YOUR-KEY');
// const res = await fetch(`${RELAY_URL}/relay/openai/v1/chat/completions`, {
// method: 'POST',
// headers: { 'Content-Type': 'application/json', 'x-relay-token': token },
// body: JSON.stringify({ model: 'gpt-4o-mini', messages: [{ role: 'user', content: 'ping' }] }),
// redirect: 'error'
// });
// if (!res.ok) throw new Error(`Relay call failed: ${res.status} ${res.statusText}`);
// const data = await res.json();
// console.log('✓ Relay response:', data.choices?.[0]?.message?.content);
console.log('✅ Smoke test passed');
}
smokeTest().catch(console.error);
Run this before shipping the integration. If health check fails → relay URL is wrong. If token is missing → check app_id and CORS. If a relay call errors → verify the key was stored correctly.
The relay logs the User-Agent header from every relay call and surfaces it in GET /stats under top_user_agents. This lets operators see which integrations (agents, SDKs, custom apps) are driving usage.
For server-side integrations, set a descriptive User-Agent header on all relay requests so the operator can identify your integration in the stats dashboard. Browser Fetch cannot set a custom User-Agent, so browser-only clients cannot produce the documented byok-relay-client suffix:
// Server-side only: include your app/framework/version so it appears in stats
const UA = 'MyApp/1.0 byok-relay-client';
await fetch(`${RELAY_URL}/relay/openai/v1/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-relay-token': token,
'User-Agent': UA, // <-- add this
},
body: JSON.stringify({ model: 'gpt-4o-mini', messages }),
});
Recommended format: <AppName>/<version> byok-relay-client (e.g. MyChatApp/2.3 byok-relay-client, claude-code byok-relay-skill/1.0). The byok-relay-client suffix makes it easy to filter agent-driven traffic from browser/curl requests in aggregate stats.
| Provider | Relay path | Notes |
|---|---|---|
| OpenAI | /relay/openai/... | Full OpenAI API compatibility |
| Anthropic | /relay/anthropic/... | Claude models, SSE streaming |
/relay/google/... | Gemini API | |
| Groq | /relay/groq/... | Fast inference |
| Mistral | /relay/mistral/... | Mistral models |
| OpenRouter | /relay/openrouter/... | 200+ models |
| Any OpenAI-compatible | /relay/openai-compatible/... | Pass x-relay-base-url header |
If this skill saved you time, consider ⭐ starring the repo — it helps other developers find it.