Amazon Bedrock Provider Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Route all anthropic/* model calls through Amazon Bedrock when BEDROCK_ENABLED=true, so AWS credits cover Claude inference costs transparently — no change to model IDs or user-facing behavior.

Architecture: Add a callBedrock() function in ai-providers.ts that reuses existing message/tool conversion logic from callAnthropic() but calls the AWS Bedrock Converse API. In callProviderByName(), when provider is "anthropic" and BEDROCK_ENABLED=true, delegate to callBedrock() instead of callAnthropic(). Falls back to direct Anthropic API when env var is absent.

Tech Stack: @aws-sdk/client-bedrock-runtime (Converse API), existing filterOrphanedToolMessages, sanitizeToolId helpers.


Task 1: Install AWS Bedrock SDK

Files:

  • Modify: package.json (via pnpm)

Step 1: Install the SDK

pnpm add @aws-sdk/client-bedrock-runtime

Step 2: Verify install

node -e "require('@aws-sdk/client-bedrock-runtime'); console.log('ok')"

Expected: ok

Step 3: Commit

git add package.json pnpm-lock.yaml
git commit -m "chore: add @aws-sdk/client-bedrock-runtime"

Task 2: Write failing test for callBedrock routes through Bedrock when BEDROCK_ENABLED=true

Files:

  • Modify: src/lib/ai-providers.test.ts (create if missing)

Step 1: Write the failing test

Add to src/lib/ai-providers.test.ts:

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

// Mock AWS SDK
vi.mock("@aws-sdk/client-bedrock-runtime", () => ({
  BedrockRuntimeClient: vi.fn().mockImplementation(() => ({
    send: vi.fn().mockResolvedValue({
      output: {
        message: {
          role: "assistant",
          content: [{ text: "Hello from Bedrock" }],
        },
      },
      usage: { inputTokens: 10, outputTokens: 5 },
      stopReason: "end_turn",
    }),
  })),
  ConverseCommand: vi.fn().mockImplementation((input) => ({ input })),
}));

vi.mock("./network-config", () => ({
  getCurrentNetworkConfig: vi.fn(() => ({
    network: "eip155:8453",
    usdc: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
    usdcDomainName: "USD Coin",
    networkName: "Base",
    apiBaseUrl: "https://blockrun.ai",
  })),
}));

import { callAIProvider } from "./ai-providers";

const MOCK_REQUEST = {
  model: "anthropic/claude-sonnet-4.6",
  messages: [{ role: "user" as const, content: "hello" }],
};

describe("Bedrock transparent routing", () => {
  afterEach(() => {
    delete process.env.BEDROCK_ENABLED;
    delete process.env.AWS_BEDROCK_REGION;
    vi.unstubAllEnvs();
  });

  it("routes anthropic/* calls through Bedrock when BEDROCK_ENABLED=true", async () => {
    process.env.BEDROCK_ENABLED = "true";
    process.env.AWS_BEDROCK_REGION = "us-east-1";
    process.env.AWS_BEDROCK_ACCESS_KEY_ID = "test-key";
    process.env.AWS_BEDROCK_SECRET_ACCESS_KEY = "test-secret";

    const { BedrockRuntimeClient } = await import("@aws-sdk/client-bedrock-runtime");

    const result = await callAIProvider(MOCK_REQUEST);

    // BedrockRuntimeClient should have been instantiated
    expect(BedrockRuntimeClient).toHaveBeenCalled();
    expect(result.response.choices[0].message.content).toBe("Hello from Bedrock");
    expect(result.inputTokens).toBe(10);
    expect(result.outputTokens).toBe(5);
  });

  it("routes anthropic/* calls to direct Anthropic when BEDROCK_ENABLED is not set", async () => {
    // BEDROCK_ENABLED not set — direct Anthropic path
    const { BedrockRuntimeClient } = await import("@aws-sdk/client-bedrock-runtime");
    vi.mocked(BedrockRuntimeClient).mockClear();

    // callAnthropic will throw (no ANTHROPIC_API_KEY in test env) — that's fine
    // We just want to confirm BedrockRuntimeClient was NOT called
    try {
      await callAIProvider(MOCK_REQUEST);
    } catch {
      // expected
    }

    expect(BedrockRuntimeClient).not.toHaveBeenCalled();
  });
});

Step 2: Run test to verify it fails

npx vitest run src/lib/ai-providers.test.ts

Expected: FAIL — callBedrock not implemented, Bedrock client not called.


Task 3: Implement callBedrock() and env-var gate

Files:

  • Modify: src/lib/ai-providers.ts

Step 1: Add Bedrock model ID map (after ANTHROPIC_MODEL_MAP at line ~1100)

// Maps BlockRun model IDs → Bedrock cross-region inference profile IDs
// Cross-region profiles (us.*) provide higher availability across AWS regions
const BEDROCK_MODEL_MAP: Record<string, string> = {
  "claude-haiku-4.5":  "us.anthropic.claude-haiku-4-5-20251001-v1:0",
  "claude-sonnet-4":   "us.anthropic.claude-sonnet-4-5-20251101-v1:0",
  "claude-sonnet-4.6": "us.anthropic.claude-sonnet-4-5-20251101-v1:0",
  "claude-opus-4":     "us.anthropic.claude-opus-4-5-20251101-v1:0",
  "claude-opus-4.5":   "us.anthropic.claude-opus-4-5-20251101-v1:0",
  "claude-opus-4.6":   "us.anthropic.claude-opus-4-5-20251101-v1:0",
};

Note: Bedrock model IDs use -v1:0 suffix and specific dates. As of Mar 2026, Claude 4.6 is not yet on Bedrock — map to 4.5 (closest available). Update when 4.6 is available.

Step 2: Add lazy Bedrock client factory (after the model map)

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

let _bedrockClient: BedrockRuntimeClient | null = null;

function getBedrockClient(): BedrockRuntimeClient {
  if (!_bedrockClient) {
    _bedrockClient = new BedrockRuntimeClient({
      region: process.env.AWS_BEDROCK_REGION || "us-east-1",
      credentials: {
        accessKeyId: process.env.AWS_BEDROCK_ACCESS_KEY_ID!,
        secretAccessKey: process.env.AWS_BEDROCK_SECRET_ACCESS_KEY!,
      },
    });
  }
  return _bedrockClient;
}

export function resetBedrockClient(): void {
  _bedrockClient = null; // for testing
}

Step 3: Add callBedrock() function (add after callAnthropic, before callOpenAICompatible)

export async function callBedrock(
  request: ChatCompletionRequest
): Promise<{ response: ChatCompletionResponse; inputTokens: number; outputTokens: number }> {
  const modelKey = request.model.replace("anthropic/", "");
  const bedrockModelId = BEDROCK_MODEL_MAP[modelKey] || `us.anthropic.${modelKey}-v1:0`;

  // Reuse Anthropic message prep: filter orphans, strip thinking, sanitize tool IDs
  const validRoles = new Set(["user", "assistant", "system", "tool"]);
  const filteredMessages = filterOrphanedToolMessages(
    filterOrphanedToolMessages(request.messages).filter((m) => validRoles.has(m.role))
  );

  const systemMessage = filteredMessages.find((m) => m.role === "system");
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const otherMessages = filteredMessages.filter((m) => m.role !== "system").map((m: any) => {
    const clean = { ...m };
    delete clean.reasoning_content;
    delete clean.thinking;
    if (Array.isArray(clean.content)) {
      clean.content = clean.content.filter(
        (b: { type?: string }) => b.type !== "thinking" && b.type !== "reasoning"
      );
    }
    return clean;
  });

  // Build tool ID map for sanitization consistency
  const toolIdMap = new Map<string, string>();
  for (const m of otherMessages) {
    if (m.role === "assistant" && m.tool_calls) {
      for (const tc of m.tool_calls) {
        if (tc.id && !toolIdMap.has(tc.id)) toolIdMap.set(tc.id, sanitizeToolId(tc.id));
      }
    }
    if (m.role === "tool" && m.tool_call_id && !toolIdMap.has(m.tool_call_id)) {
      toolIdMap.set(m.tool_call_id, sanitizeToolId(m.tool_call_id));
    }
  }

  // Convert messages to Bedrock Converse format
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const bedrockMessages: any[] = [];
  for (const m of otherMessages) {
    if (m.role === "tool") {
      const sanitizedId = toolIdMap.get(m.tool_call_id!) || sanitizeToolId(m.tool_call_id!);
      bedrockMessages.push({
        role: "user",
        content: [{ toolResult: { toolUseId: sanitizedId, content: [{ text: m.content || "" }] } }],
      });
    } else if (m.role === "assistant" && m.tool_calls?.length > 0) {
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      const content: any[] = [];
      if (m.content) content.push({ text: m.content });
      for (const tc of m.tool_calls) {
        const sanitizedId = toolIdMap.get(tc.id) || sanitizeToolId(tc.id);
        content.push({
          toolUse: {
            toolUseId: sanitizedId,
            name: tc.function.name,
            input: JSON.parse(tc.function.arguments || "{}"),
          },
        });
      }
      bedrockMessages.push({ role: "assistant", content });
    } else {
      // Regular text message
      const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
      bedrockMessages.push({ role: m.role === "assistant" ? "assistant" : "user", content: [{ text }] });
    }
  }

  // Build Converse request
  const converseInput: ConverseCommandInput = {
    modelId: bedrockModelId,
    messages: bedrockMessages,
    inferenceConfig: {
      maxTokens: request.max_tokens || 1024,
      ...(request.temperature !== undefined && { temperature: request.temperature }),
      ...(request.top_p !== undefined && { topP: request.top_p }),
    },
    ...(systemMessage && {
      system: [{
        text: typeof systemMessage.content === "string"
          ? systemMessage.content
          : JSON.stringify(systemMessage.content),
      }],
    }),
    ...(request.tools?.length && {
      toolConfig: {
        tools: request.tools.map((t) => ({
          toolSpec: {
            name: t.function.name,
            description: t.function.description || "",
            inputSchema: { json: t.function.parameters || { type: "object", properties: {} } },
          },
        })),
      },
    }),
  };

  const command = new ConverseCommand(converseInput);
  const result = await getBedrockClient().send(command);

  // Parse response
  let textContent = "";
  const toolCalls: Array<{ id: string; type: "function"; function: { name: string; arguments: string } }> = [];

  for (const block of result.output?.message?.content || []) {
    if ("text" in block && block.text) {
      textContent += block.text;
    } else if ("toolUse" in block && block.toolUse) {
      toolCalls.push({
        id: block.toolUse.toolUseId || `tool_${Date.now()}`,
        type: "function",
        function: {
          name: block.toolUse.name || "",
          arguments: JSON.stringify(block.toolUse.input || {}),
        },
      });
    }
  }

  const stopReason = result.stopReason;
  const finishReason: "stop" | "length" | "tool_calls" =
    stopReason === "tool_use" ? "tool_calls" :
    stopReason === "max_tokens" ? "length" : "stop";

  const response: ChatCompletionResponse = {
    id: `bedrock-${Date.now()}`,
    object: "chat.completion",
    created: Math.floor(Date.now() / 1000),
    model: request.model,
    choices: [{
      index: 0,
      message: {
        role: "assistant",
        content: textContent,
        ...(toolCalls.length > 0 && { tool_calls: toolCalls }),
      },
      finish_reason: finishReason,
    }],
    usage: {
      prompt_tokens: result.usage?.inputTokens || 0,
      completion_tokens: result.usage?.outputTokens || 0,
      total_tokens: (result.usage?.inputTokens || 0) + (result.usage?.outputTokens || 0),
    },
  };

  return {
    response,
    inputTokens: result.usage?.inputTokens || 0,
    outputTokens: result.usage?.outputTokens || 0,
  };
}

Step 4: Add env-var gate in callProviderByName()

Change the "anthropic" case:

case "anthropic":
  return process.env.BEDROCK_ENABLED === "true"
    ? callBedrock(request)
    : callAnthropic(request);

Step 5: Run tests to verify GREEN

npx vitest run src/lib/ai-providers.test.ts

Expected: 2 tests pass.

Step 6: Run full suite

npx vitest run

Expected: all tests pass.

Step 7: Commit

git add src/lib/ai-providers.ts src/lib/ai-providers.test.ts
git commit -m "feat: add Amazon Bedrock transparent routing for anthropic/* models"

Task 4: Add env vars to Cloud Run

Step 1: Add to .env.local for local dev

Add to .env.local:

AWS_BEDROCK_ACCESS_KEY_ID=<your-iam-access-key>
AWS_BEDROCK_SECRET_ACCESS_KEY=<your-iam-secret>
AWS_BEDROCK_REGION=us-east-1
BEDROCK_ENABLED=true

Step 2: Add to Cloud Run production service

gcloud run services update blockrun-web \
  --region=us-central1 \
  --project=blockrun-prod-2026 \
  --set-env-vars="BEDROCK_ENABLED=true,AWS_BEDROCK_REGION=us-east-1,AWS_BEDROCK_ACCESS_KEY_ID=<key>,AWS_BEDROCK_SECRET_ACCESS_KEY=<secret>"

Step 3: Verify routing in logs

gcloud logging read \
  'resource.type="cloud_run_revision" AND resource.labels.service_name="blockrun-web" AND textPayload=~"bedrock"' \
  --project=blockrun-prod-2026 --freshness=1h --limit=5

Expected: no errors, bedrock calls succeeding.


Task 5: Add model redirect for amazon-bedrock/eu.* IDs

Users are already requesting amazon-bedrock/eu.anthropic.claude-sonnet-4-6 — add a redirect so it resolves to anthropic/claude-sonnet-4.6 (which then goes through Bedrock transparently).

Files:

  • Modify: src/app/api/v1/chat/completions/route.ts (MODEL_REDIRECTS at line ~427)

Step 1: The test already exists (written earlier in this session — redirects amazon-bedrock/eu.anthropic.claude-sonnet-4-6 to anthropic/claude-sonnet-4.6)

Step 2: Run to confirm it still fails

npx vitest run src/app/api/v1/chat/completions/route.test.ts --reporter=verbose 2>&1 | grep "amazon-bedrock"

Expected: FAIL

Step 3: Add redirects

In route.ts MODEL_REDIRECTS block, add:

// Amazon Bedrock regional prefixes → direct Anthropic equivalents
// (Bedrock routing handled transparently via BEDROCK_ENABLED env var)
"amazon-bedrock/eu.anthropic.claude-sonnet-4-6": "anthropic/claude-sonnet-4.6",
"amazon-bedrock/eu.anthropic.claude-sonnet-4-5": "anthropic/claude-sonnet-4.5",
"amazon-bedrock/eu.anthropic.claude-haiku-4-5":  "anthropic/claude-haiku-4.5",
"amazon-bedrock/eu.anthropic.claude-opus-4":     "anthropic/claude-opus-4.6",
"amazon-bedrock/anthropic.claude-sonnet-4-6":    "anthropic/claude-sonnet-4.6",
"amazon-bedrock/anthropic.claude-sonnet-4-5":    "anthropic/claude-sonnet-4.5",
"amazon-bedrock/anthropic.claude-haiku-4-5":     "anthropic/claude-haiku-4.5",
"amazon-bedrock/anthropic.claude-opus-4":        "anthropic/claude-opus-4.6",

Step 4: Run tests

npx vitest run

Expected: all pass, including the amazon-bedrock redirect test.

Step 5: Commit

git add src/app/api/v1/chat/completions/route.ts src/app/api/v1/chat/completions/route.test.ts
git commit -m "feat: redirect amazon-bedrock/* model IDs to anthropic/* equivalents"

To Enable in Production

Once AWS credentials are ready:

gcloud run services update blockrun-web \
  --region=us-central1 \
  --project=blockrun-prod-2026 \
  --set-env-vars="BEDROCK_ENABLED=true,AWS_BEDROCK_REGION=us-east-1,AWS_BEDROCK_ACCESS_KEY_ID=YOUR_KEY,AWS_BEDROCK_SECRET_ACCESS_KEY=YOUR_SECRET"

To Disable (credits exhausted)

gcloud run services update blockrun-web \
  --region=us-central1 \
  --project=blockrun-prod-2026 \
  --remove-env-vars="BEDROCK_ENABLED"

Falls back to direct Anthropic API immediately, no redeploy.