惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

B
Blog RSS Feed
Spread Privacy
Spread Privacy
T
Threatpost
C
Cisco Blogs
P
Palo Alto Networks Blog
AI
AI
Cyberwarzone
Cyberwarzone
NISL@THU
NISL@THU
P
Privacy & Cybersecurity Law Blog
G
GRAHAM CLULEY
Simon Willison's Weblog
Simon Willison's Weblog
T
Tor Project blog
Latest news
Latest news
AWS News Blog
AWS News Blog
D
Docker
S
SegmentFault 最新的问题
博客园 - 聂微东
WordPress大学
WordPress大学
Vercel News
Vercel News
S
Securelist
爱范儿
爱范儿
J
Java Code Geeks
Know Your Adversary
Know Your Adversary
S
Schneier on Security
Hugging Face - Blog
Hugging Face - Blog
F
Fortinet All Blogs
Last Week in AI
Last Week in AI
D
DataBreaches.Net
宝玉的分享
宝玉的分享
D
Darknet – Hacking Tools, Hacker News & Cyber Security
MongoDB | Blog
MongoDB | Blog
Engineering at Meta
Engineering at Meta
K
Kaspersky official blog
美团技术团队
博客园 - 叶小钗
阮一峰的网络日志
阮一峰的网络日志
量子位
博客园_首页
Attack and Defense Labs
Attack and Defense Labs
S
Secure Thoughts
Google Online Security Blog
Google Online Security Blog
Application and Cybersecurity Blog
Application and Cybersecurity Blog
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
腾讯CDC
T
Threat Research - Cisco Blogs
雷峰网
雷峰网
有赞技术团队
有赞技术团队
www.infosecurity-magazine.com
www.infosecurity-magazine.com
P
Privacy International News Feed
S
Security Affairs

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Putting an OpenAI-Compatible Gateway in Front of AWS Bedrock
Azeem Siddiqui · 2026-06-22 · via DEV Community

The request that kicked this off was simple enough: the developers wanted an AI coding assistant in their editors. The constraints were where it got interesting. No AWS credentials on laptops. No third-party SaaS that meant a data-handling review. And whoever set it up had to be able to answer "who spent what" when the bill showed up.

AWS Bedrock solved the backend question. The models are good, inference stays inside our account, and there's nothing to sign off on legally. The trouble is everything sitting in front of Bedrock.

Here's the mismatch. Bedrock has its own API and authenticates with AWS SigV4. The tools developers actually reach for — Continue.dev, Cursor, aider, a big chunk of the LangChain ecosystem — all speak the OpenAI API instead: POST /v1/chat/completions, an Authorization: Bearer header, done. So if you want your team using their preferred tools against Bedrock, something has to bridge those two worlds.

The naive bridge is to mint an IAM user per developer so their editor can sign requests. Don't do this. You'll have AWS credentials scattered across a dozen machines, no per-person attribution, and no way to throttle or cut off one person without going and editing IAM policies. I went a different way: a small gateway that speaks OpenAI on the front, Bedrock on the back, and owns auth, rate limiting, audit logging, and cost tracking in between.

I've been running a version of this in production for a team of about a dozen developers. What follows is a clean, self-contained build of it — you can have it answering requests in an afternoon.

What we're building

Developer tools (Continue.dev, Cursor, aider, curl)
        │   OpenAI format + Bearer key (sk-...)
        ▼
┌──────────────────────────────────────┐
│              Gateway                  │
│   auth → rate limit → translate →     │
│   call Bedrock → translate back →     │
│   audit + cost log                    │
└──────────────────────────────────────┘
        │   SigV4, one IAM role
        ▼
        AWS Bedrock (Converse API)

One IAM role lives on the gateway and nowhere else. Developers hold a sk-... key that I issue and can kill from one place. Every request passes through a single service, which is exactly where you want auth, throttling, and accounting to sit. That last point is the whole argument for doing this, so I'll keep coming back to it.

Before any code: should you even build this? LiteLLM has a proxy that covers a lot of the same ground, and AWS publishes a bedrock-access-gateway sample. Look at both. I went custom because I wanted auth tied to our own notion of identity, audit logs in a shape our existing tooling already understood, and per-developer cost numbers rather than a per-key blob — and because the translation layer turned out to be about 80 lines. If your needs are generic, use the off-the-shelf option and save yourself the maintenance. If you want to own the four things in the title, it's a weekend, and you'll know every line.

Prerequisites

  • Node.js 18+ and an AWS account with Bedrock model access turned on. (You enable it per-model in the Bedrock console. For most models it's effectively instant.)
  • An IAM role or user the gateway can assume, with bedrock:InvokeModel and bedrock:InvokeModelWithResponseStream on the model ARNs you plan to serve.
  • Comfort with Express. Nothing fancy.

I'm using SQLite here so the whole thing runs with zero external services. In production I back it with a managed database instead, but the schema doesn't change — you swap the driver and move on.

Step 1: Project setup

mkdir bedrock-gateway && cd bedrock-gateway
npm init -y
npm install express @aws-sdk/client-bedrock-runtime express-rate-limit better-sqlite3

Add "type": "module" to package.json so the ES module imports work.

Give the gateway its AWS credentials however you normally would — an instance role if it's on EC2 or ECS, environment variables if you're running it locally to test. The one rule: these credentials live on the gateway and only on the gateway.

export AWS_REGION=us-east-1

Step 2: The data layer (keys + usage)

Two tables. One maps an issued key to a developer. One records every call so you can audit it and bill it back.

db.js:

import Database from "better-sqlite3";

export const db = new Database("gateway.db");

db.exec(`
  CREATE TABLE IF NOT EXISTS api_keys (
    id          TEXT PRIMARY KEY,
    name        TEXT NOT NULL,
    key_hash    TEXT NOT NULL UNIQUE,
    active      INTEGER NOT NULL DEFAULT 1,
    created_at  TEXT NOT NULL DEFAULT (datetime('now'))
  );

  CREATE TABLE IF NOT EXISTS usage (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    developer_id  TEXT NOT NULL,
    ts            TEXT NOT NULL DEFAULT (datetime('now')),
    model         TEXT NOT NULL,
    input_tokens  INTEGER NOT NULL,
    output_tokens INTEGER NOT NULL,
    cost_usd      REAL NOT NULL,
    latency_ms    INTEGER NOT NULL,
    streamed      INTEGER NOT NULL,
    status        INTEGER NOT NULL
  );
`);

The keys table stores a hash of the key, never the key itself. You see the raw value once, at creation, and it's unrecoverable after that — same deal as a password. If gateway.db ever leaks, no usable key leaks with it.

Step 3: Issuing keys

A throwaway CLI so you can hand a developer a key without building a UI first:

create-key.js:

import crypto from "node:crypto";
import { db } from "./db.js";

const name = process.argv[2];
if (!name) {
  console.error("usage: node create-key.js \"developer name\"");
  process.exit(1);
}

const raw = "sk-" + crypto.randomBytes(24).toString("hex");
const id = crypto.randomUUID();
const keyHash = crypto.createHash("sha256").update(raw).digest("hex");

db.prepare(
  "INSERT INTO api_keys (id, name, key_hash) VALUES (?, ?, ?)"
).run(id, name, keyHash);

console.log(`Created key for ${name}`);
console.log(`Key (shown once, store it now): ${raw}`);

node create-key.js "Jordan"
# Key (shown once, store it now): sk-1f3c...e9a2

The sk- prefix is cosmetic, but it earns its keep. Some OpenAI-compatible clients sanity-check the shape of the key, and it tells the developer this drops into the same field where an OpenAI key would go. Small thing, fewer support pings.

Step 4: Auth middleware

Pull the bearer token, hash it, look it up. Attach the developer to the request so everything downstream — the rate limiter, the audit log — knows who's calling.

auth.js:

import crypto from "node:crypto";
import { db } from "./db.js";

const lookup = db.prepare(
  "SELECT id, name FROM api_keys WHERE key_hash = ? AND active = 1"
);

export function authenticate(req, res, next) {
  const header = req.get("authorization") || "";
  const match = header.match(/^Bearer\s+(.+)$/i);
  if (!match) {
    return res.status(401).json({
      error: { message: "Missing bearer token", type: "invalid_request_error" },
    });
  }

  const keyHash = crypto.createHash("sha256").update(match[1]).digest("hex");
  const dev = lookup.get(keyHash);
  if (!dev) {
    return res.status(401).json({
      error: { message: "Invalid API key", type: "invalid_request_error" },
    });
  }

  req.developer = dev;
  next();
}

I'm matching OpenAI's error envelope — { error: { message, type } } — on purpose. Client tools parse that shape. Return a bare string and some of them surface a vague generic failure instead of your message, and then you're debugging "the gateway is down" tickets that were really just expired keys. Ask me how I know.

Step 5: The translation layer

This is the part that does the actual work. OpenAI's chat format and Bedrock's Converse format are close, with three differences that'll trip you up if you don't watch for them:

  1. Converse roles are only user and assistant. The system prompt goes in its own top-level system field, not in the messages array.
  2. Converse message content is an array of blocks — [{ text: "..." }] — not a plain string.
  3. Inference params live under inferenceConfig, and the keys are renamed (maxTokens, not max_tokens).

bedrock.js:

import {
  BedrockRuntimeClient,
  ConverseCommand,
  ConverseStreamCommand,
} from "@aws-sdk/client-bedrock-runtime";

const client = new BedrockRuntimeClient({
  region: process.env.AWS_REGION || "us-east-1",
});

// Friendly names the clients send -> real Bedrock model IDs.
// Check current IDs with: aws bedrock list-foundation-models
// IDs and regional availability move around. If you need a model in a
// region that doesn't host it directly, look at cross-region inference
// profiles (the "us." / "eu." prefixed IDs).
export const MODELS = {
  "claude-3-5-sonnet": "anthropic.claude-3-5-sonnet-20240620-v1:0",
  "claude-3-5-haiku": "anthropic.claude-3-5-haiku-20241022-v1:0",
};

// OpenAI content can be a string or an array of parts. Normalize to text.
function extractText(content) {
  if (typeof content === "string") return content;
  if (Array.isArray(content)) {
    return content
      .filter((p) => p.type === "text")
      .map((p) => p.text)
      .join("");
  }
  return "";
}

// OpenAI request body -> Converse arguments
export function toBedrock(body) {
  const modelId = MODELS[body.model];
  if (!modelId) throw new ModelNotFound(body.model);

  const system = [];
  const messages = [];

  for (const m of body.messages) {
    if (m.role === "system") {
      system.push({ text: extractText(m.content) });
    } else {
      messages.push({
        role: m.role, // "user" | "assistant"
        content: [{ text: extractText(m.content) }],
      });
    }
  }

  const inferenceConfig = {};
  if (body.max_tokens != null) inferenceConfig.maxTokens = body.max_tokens;
  if (body.temperature != null) inferenceConfig.temperature = body.temperature;
  if (body.top_p != null) inferenceConfig.topP = body.top_p;
  if (body.stop != null) {
    inferenceConfig.stopSequences = Array.isArray(body.stop) ? body.stop : [body.stop];
  }

  return {
    modelId,
    ...(system.length ? { system } : {}),
    messages,
    ...(Object.keys(inferenceConfig).length ? { inferenceConfig } : {}),
  };
}

export class ModelNotFound extends Error {}

const FINISH = {
  end_turn: "stop",
  stop_sequence: "stop",
  max_tokens: "length",
  tool_use: "tool_calls",
  content_filtered: "content_filter",
};

export function finishReason(stopReason) {
  return FINISH[stopReason] || "stop";
}

export async function converse(args) {
  return client.send(new ConverseCommand(args));
}

export async function converseStream(args) {
  return client.send(new ConverseStreamCommand(args));
}

Step 6: Cost calculation

Bedrock bills per token, at a rate that depends on the model. Keep a price table and compute cost from the usage Bedrock hands back. Prices change, so I keep this in one file and treat it as config, not as numbers buried in the request path.

pricing.js:

// USD per 1M tokens. Confirm against current Bedrock pricing before trusting it.
const PRICES = {
  "claude-3-5-sonnet": { input: 3.0, output: 15.0 },
  "claude-3-5-haiku":  { input: 0.8, output: 4.0 },
};

export function costUsd(model, inputTokens, outputTokens) {
  const p = PRICES[model];
  if (!p) return 0;
  return (inputTokens / 1e6) * p.input + (outputTokens / 1e6) * p.output;
}

Step 7: Wiring it together

Now the server pulls it all in: auth, a per-developer rate limiter, the two routes, and the audit-and-cost write that fires on every request.

server.js:

import express from "express";
import crypto from "node:crypto";
import rateLimit from "express-rate-limit";
import { db } from "./db.js";
import { authenticate } from "./auth.js";
import {
  toBedrock,
  converse,
  converseStream,
  finishReason,
  MODELS,
  ModelNotFound,
} from "./bedrock.js";
import { costUsd } from "./pricing.js";

const app = express();
app.use(express.json({ limit: "2mb" }));

const recordUsage = db.prepare(`
  INSERT INTO usage
    (developer_id, model, input_tokens, output_tokens, cost_usd, latency_ms, streamed, status)
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);

// One audit + cost write per request. Lives in a finally block so it runs
// even when a streaming client hangs up mid-response.
function audit(req, { model, usage, latencyMs, streamed, status }) {
  const input = usage?.inputTokens ?? 0;
  const output = usage?.outputTokens ?? 0;
  const cost = costUsd(model, input, output);

  recordUsage.run(
    req.developer.id, model, input, output, cost, latencyMs, streamed ? 1 : 0, status
  );

  // Structured line for log shipping. Metadata only — no prompt or
  // completion text. Logging content is a privacy/compliance call you
  // make on purpose, not a default you back into.
  console.log(JSON.stringify({
    evt: "llm_request",
    developer: req.developer.name,
    developer_id: req.developer.id,
    model, input_tokens: input, output_tokens: output,
    cost_usd: Number(cost.toFixed(6)), latency_ms: latencyMs,
    streamed, status,
  }));
}

app.use("/v1", authenticate);

// Per-developer rate limit. The keyGenerator is the whole trick — it makes
// the limit per-person instead of per-IP. Run more than one instance and
// you'll want a shared store (rate-limit-redis), or each instance counts
// its own window and the real limit is N times what you set.
app.use("/v1", rateLimit({
  windowMs: 60_000,
  limit: 60,
  keyGenerator: (req) => req.developer?.id || req.ip,
  standardHeaders: true,
  legacyHeaders: false,
  handler: (req, res) => res.status(429).json({
    error: { message: "Rate limit exceeded", type: "rate_limit_error" },
  }),
}));

app.get("/v1/models", (req, res) => {
  res.json({
    object: "list",
    data: Object.keys(MODELS).map((id) => ({
      id, object: "model", owned_by: "bedrock-gateway",
    })),
  });
});

app.post("/v1/chat/completions", async (req, res) => {
  const started = Date.now();
  const model = req.body.model;
  const streamed = req.body.stream === true;
  let usage = null;
  let status = 200;

  try {
    const args = toBedrock(req.body);

    if (!streamed) {
      const out = await converse(args);
      usage = out.usage;
      res.json(toCompletion(model, out));
    } else {
      usage = await streamCompletion(res, model, args);
    }
  } catch (err) {
    status = err instanceof ModelNotFound ? 400 : 502;
    const payload = {
      error: {
        message: err instanceof ModelNotFound
          ? `Unknown model: ${model}`
          : "Upstream model error",
        type: err instanceof ModelNotFound ? "invalid_request_error" : "api_error",
      },
    };
    if (res.headersSent) res.end();
    else res.status(status).json(payload);
  } finally {
    audit(req, { model, usage, latencyMs: Date.now() - started, streamed, status });
  }
});

function toCompletion(model, out) {
  return {
    id: "chatcmpl-" + crypto.randomBytes(12).toString("hex"),
    object: "chat.completion",
    created: Math.floor(Date.now() / 1000),
    model,
    choices: [{
      index: 0,
      message: {
        role: "assistant",
        content: out.output.message.content.map((b) => b.text || "").join(""),
      },
      finish_reason: finishReason(out.stopReason),
    }],
    usage: {
      prompt_tokens: out.usage.inputTokens,
      completion_tokens: out.usage.outputTokens,
      total_tokens: out.usage.totalTokens,
    },
  };
}

app.listen(3000, () => console.log("Gateway listening on :3000"));

Step 8: Streaming

Streaming is where gateways tend to get sloppy, and it's also where two of the four concerns get tested at once. Bedrock's ConverseStream gives you an async iterable of events; you translate each text delta into an OpenAI SSE chunk and flush it right away. The catch that got me the first time: token counts only show up in the final metadata event. If you care about cost tracking — and the entire point here is that you do — you have to let the stream finish, or at least catch that event, even when the client has already walked away.

Add this to server.js:

async function streamCompletion(res, model, args) {
  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    Connection: "keep-alive",
  });

  const id = "chatcmpl-" + crypto.randomBytes(12).toString("hex");
  const created = Math.floor(Date.now() / 1000);
  const send = (delta, finish = null) => {
    const chunk = {
      id, object: "chat.completion.chunk", created, model,
      choices: [{ index: 0, delta, finish_reason: finish }],
    };
    res.write(`data: ${JSON.stringify(chunk)}\n\n`);
  };

  let usage = null;
  const out = await converseStream(args);

  send({ role: "assistant" }); // first chunk carries the role, no content

  for await (const event of out.stream) {
    if (event.contentBlockDelta?.delta?.text) {
      send({ content: event.contentBlockDelta.delta.text });
    }
    if (event.messageStop) {
      send({}, finishReason(event.messageStop.stopReason));
    }
    if (event.metadata?.usage) {
      usage = event.metadata.usage; // this is the part you can't skip
    }
  }

  res.write("data: [DONE]\n\n");
  res.end();
  return usage;
}

Two details that matter. The first chunk announces { role: "assistant" } with no content — clients expect the role up front, once. And the terminating data: [DONE] line is part of the OpenAI contract; tools wait for it to know the stream closed cleanly, and they'll hang if it never comes.

The reason the usage capture lives where it does: that function returns usage, and the route's finally block writes it. So a developer who cancels a long generation halfway through still gets billed for the tokens Bedrock actually produced. That's the difference between cost numbers that tie out at the end of the month and cost numbers that quietly run low and make you look like you don't know what your platform costs.

Testing it

Plain curl, no streaming:

curl http://localhost:3000/v1/chat/completions \
  -H "Authorization: Bearer sk-YOUR-KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "claude-3-5-sonnet",
    "messages": [{"role": "user", "content": "Say hello in five words."}]
  }'

Streaming — note the -N so curl doesn't buffer:

curl -N http://localhost:3000/v1/chat/completions \
  -H "Authorization: Bearer sk-YOUR-KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"claude-3-5-haiku","stream":true,
       "messages":[{"role":"user","content":"Count to five."}]}'

And the test that actually matters — pointing a real editor at it. In Continue.dev, the gateway is just an "openai" provider with a custom base URL:

{
  "models": [
    {
      "title": "Bedrock Gateway",
      "provider": "openai",
      "model": "claude-3-5-sonnet",
      "apiBase": "http://your-gateway-host:3000/v1",
      "apiKey": "sk-YOUR-KEY"
    }
  ]
}

The developer never has to know Bedrock is back there. They get chat and autocomplete in their editor. You get a key you can revoke, a limit you can tune, and a usage row for every single call.

Checking the books

Cost tracking is only worth anything if you can read it back. The reason every usage row carries a developer id is so questions like "who's driving the bill this month" are one query and not an afternoon:

SELECT
  k.name,
  COUNT(*)                  AS requests,
  SUM(u.input_tokens)       AS input_tokens,
  SUM(u.output_tokens)      AS output_tokens,
  ROUND(SUM(u.cost_usd), 2) AS cost_usd
FROM usage u
JOIN api_keys k ON k.id = u.developer_id
WHERE u.ts >= datetime('now', '-30 days')
GROUP BY u.developer_id
ORDER BY cost_usd DESC;

I wire this into a dashboard and a monthly summary, but even the raw query does the job — it catches a runaway script the day it starts instead of the day the invoice lands.

What I'd add before calling it done

The build above is honest about what it is: a clean core, not a hardened production deployment. A few things I'd want in place before I trusted it with a real team:

  • Shared rate-limit state. The in-memory limiter resets per process. Two instances behind a load balancer means a developer's real limit is double what you configured. Move the window into Redis with rate-limit-redis.
  • A real database. SQLite is great for the tutorial and fine for one small instance. For anything multi-instance, point db.js at managed Postgres or MySQL. The schema is identical; it's a driver swap.
  • Content logging is a decision. This gateway logs metadata only. The moment you start logging prompts and completions, you've created a store of possibly-sensitive text with its own retention, access, and compliance questions hanging off it. Choose that deliberately, write down why, and tell your developers either way.
  • Budget guardrails. Usage rows let you see spend. A hard stop is a separate thing — add a per-developer monthly cap checked in the auth path, and set an account-level AWS budget alert as a backstop. When the app's number and AWS's number disagree, something in your accounting is wrong, and you want to find out from a graph, not a finance meeting.
  • Key lifecycle. You've already got active = 0 for revocation. Add rotation and an expiry column so contractor keys age out on their own instead of living forever.
  • Health and timeouts. A /healthz route for your load balancer, and a request timeout so one stuck upstream call doesn't pin a connection open indefinitely.

Wrapping up

The whole thing is around 200 lines, and it converts "every developer holds AWS credentials and spend is a mystery" into "every developer holds a key I issued, every request is attributed to a person, and I can throttle or cut off anyone from one place." The translation layer — OpenAI in, Converse out — is small and it stays put. The four behaviors in the title aren't bolt-ons; they're the reason the service exists. Auth is one middleware. Rate limiting is one more. Audit and cost are a single write per request. You get all of it precisely because every call funnels through one service you control.

If your requirements are generic, save yourself the upkeep and try LiteLLM or the AWS sample first. But if you want to own these four things end to end, it's a weekend of work — and the first time something goes sideways in production, knowing exactly why each piece is there pays for itself.