Skip to content

Commit 42dfef3

Browse files
authored
Bedrock modular api (#2526)
Modular API didn't support Bedrock because a Bedrock relies on the aws-sdk for constructing and signing requests - HTTP requests aren't naturally exposed to the user with that SDK. To get around this, we take an intermediate object and serialize that to JSON to produce something resembling an HTTP request that users can modify. To send the request after modification, it must be signed again. We have new docs describing this process.
1 parent 8a2dc4f commit 42dfef3

18 files changed

Lines changed: 857 additions & 331 deletions

File tree

engine/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

engine/baml-runtime/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ log.workspace = true
6666
minijinja.workspace = true
6767
pin-project-lite.workspace = true
6868
pretty.workspace = true
69+
percent-encoding = "2.3.1"
6970
regex.workspace = true
7071
reqwest-eventsource = "0.6.0"
7172
scopeguard.workspace = true

engine/baml-runtime/src/internal/llm_client/primitive/aws/aws_client.rs

Lines changed: 187 additions & 160 deletions
Large diffs are not rendered by default.

engine/baml-runtime/src/lib.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1044,6 +1044,20 @@ impl BamlRuntime {
10441044
.await
10451045
.map(|(prompt, ..)| prompt)?;
10461046

1047+
let mut request_id = HttpRequestId::new();
1048+
1049+
if let RenderedPrompt::Chat(chat) = &prompt {
1050+
if let LLMProvider::Primitive(primitive) = provider.as_ref() {
1051+
if let internal::llm_client::primitive::LLMPrimitiveProvider::Aws(aws_client) =
1052+
primitive.as_ref()
1053+
{
1054+
return aws_client
1055+
.build_modular_http_request(&ctx, chat, stream, request_id)
1056+
.await;
1057+
}
1058+
}
1059+
}
1060+
10471061
let request = match prompt {
10481062
RenderedPrompt::Chat(chat) => provider
10491063
.build_request(either::Either::Right(&chat), true, stream, &ctx, self)
@@ -1064,7 +1078,7 @@ impl BamlRuntime {
10641078
// Would also be nice if RequestBuilder had getters so we didn't have to
10651079
// call .build()? above.
10661080
Ok(HTTPRequest::new(
1067-
HttpRequestId::new(),
1081+
std::mem::take(&mut request_id),
10681082
request.url().to_string(),
10691083
request.method().to_string(),
10701084
json_headers(request.headers()),

fern/01-guide/05-baml-advanced/modular-api.mdx

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,172 @@ async function run() {
448448
```
449449
</CodeBlocks>
450450

451+
### AWS Bedrock
452+
453+
The modular API now returns requests for Bedrock's Converse API. You can
454+
modify it, sign it and forward the request with any HTTP client. A signature
455+
with the SignatureV4 SDK is required, we provide examples of how to do this
456+
below.
457+
458+
```baml BAML {2}
459+
function ExtractResume(resume: string) -> Resume {
460+
client Bedrock
461+
// Prompt here...
462+
}
463+
```
464+
465+
<CodeBlocks>
466+
```python Python
467+
import asyncio
468+
import json
469+
import os
470+
import httpx
471+
from botocore.auth import SigV4Auth
472+
from botocore.awsrequest import AWSRequest
473+
import boto3
474+
from baml_client import b
475+
from urllib.parse import urlsplit
476+
477+
async def run():
478+
req = await b.request.ExtractResume("John Doe | Software Engineer | BSc in CS")
479+
480+
body = req.body.json()
481+
# Optional: append your own messages before signing.
482+
body["messages"].append({
483+
"role": "system",
484+
"content": [{"text": "You must respond in JSON."}],
485+
})
486+
body_string = json.dumps(body)
487+
body_bytes = body_string.encode("utf-8")
488+
489+
session = boto3.Session()
490+
credentials = session.get_credentials().get_frozen_credentials()
491+
region = (
492+
req.client_details.options.get("region")
493+
or os.environ.get("AWS_REGION")
494+
or os.environ.get("AWS_DEFAULT_REGION")
495+
or session.region_name
496+
or "us-east-1"
497+
)
498+
499+
url = urlsplit(req.url)
500+
501+
base_headers = {
502+
key: value
503+
for key, value in dict(req.headers).items()
504+
if value is not None
505+
}
506+
507+
headers = {
508+
**base_headers,
509+
"content-type": "application/json",
510+
"accept": "application/json",
511+
"host": url.netloc,
512+
}
513+
514+
aws_request = AWSRequest(
515+
method=req.method,
516+
url=req.url,
517+
data=body_bytes,
518+
headers=headers,
519+
)
520+
SigV4Auth(credentials, "bedrock", region).add_auth(aws_request)
521+
522+
async with httpx.AsyncClient() as client:
523+
response = await client.post(
524+
req.url,
525+
headers={key: str(value) for key, value in aws_request.headers.items()},
526+
content=body_bytes,
527+
)
528+
if not response.is_success:
529+
raise RuntimeError(
530+
f"Bedrock request failed: {response.status_code} {response.text}"
531+
)
532+
533+
payload = response.json()
534+
message = payload["output"]["message"]["content"][0]["text"]
535+
parsed = b.parse.ExtractResume(message)
536+
print(parsed)
537+
538+
asyncio.run(run())
539+
```
540+
541+
```typescript TypeScript
542+
import { SignatureV4 } from "@smithy/signature-v4"
543+
import { fromEnv } from "@aws-sdk/credential-providers"
544+
import { HttpRequest } from "@smithy/protocol-http"
545+
import { Sha256 } from "@aws-crypto/sha256-js"
546+
import { b } from 'baml_client'
547+
548+
async function run() {
549+
const req = await b.request.ExtractResume("John Doe | Software Engineer | BSc in CS")
550+
551+
const body = req.body.json() as any
552+
body.messages.push({
553+
role: "user",
554+
content: [{ text: "Add a short TL;DR." }],
555+
})
556+
const bodyString = JSON.stringify(body)
557+
558+
const url = new URL(req.url)
559+
const region = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1"
560+
561+
const signer = new SignatureV4({
562+
service: "bedrock",
563+
region,
564+
credentials: fromEnv(),
565+
sha256: Sha256,
566+
})
567+
568+
const baseHeaders = Object.fromEntries(
569+
Object.entries(req.headers as Record<string, string | undefined>).filter(
570+
([, value]) => value !== undefined,
571+
),
572+
) as Record<string, string>
573+
574+
const headers = {
575+
...baseHeaders,
576+
host: url.host,
577+
"content-type": "application/json",
578+
accept: "application/json",
579+
}
580+
581+
const unsigned = new HttpRequest({
582+
protocol: url.protocol,
583+
hostname: url.hostname,
584+
path: url.pathname,
585+
method: req.method,
586+
headers,
587+
body: bodyString,
588+
})
589+
590+
const signed = await signer.sign(unsigned)
591+
const signedHeaders = Object.fromEntries(
592+
Object.entries(signed.headers).map(([key, value]) => [key, String(value)]),
593+
) as Record<string, string>
594+
595+
const res = await fetch(req.url, {
596+
method: req.method,
597+
headers: signedHeaders,
598+
body: bodyString,
599+
})
600+
601+
if (!res.ok) {
602+
throw new Error(`Bedrock request failed: ${res.status} ${await res.text()}`)
603+
}
604+
605+
const payload = await res.json()
606+
const message = payload.output.message.content.find((block: any) => block.text)?.text ?? ''
607+
const parsed = b.parse.ExtractResume(message)
608+
console.log(parsed)
609+
}
610+
```
611+
</CodeBlocks>
612+
613+
> ℹ️ Streaming modular requests are not yet supported for Bedrock. Call
614+
> `b.request` (non-streaming) when targeting AWS, and re-sign after any
615+
> modifications to the body or headers.
616+
451617
## Type Checking
452618

453619
### Python

fern/03-reference/baml/clients/providers/aws-bedrock.mdx

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,106 @@ You can use this to modify the `region`, `access_key_id`, `secret_access_key`, a
484484
<Markdown src="/snippets/supports-streaming.mdx" />
485485
<Markdown src="/snippets/finish-reason.mdx" />
486486

487+
## Modular API
488+
489+
- `b.request` returns a fully signed SigV4 `HTTPRequest` pointing at the
490+
Converse API.
491+
- Forward the request as-is. Do not mutate the headers; they already include
492+
`Authorization`, `X-Amz-Date`, and (if needed) `X-Amz-Security-Token`.
493+
- Send the request immediately after building it. The signature is computed at
494+
request time, so rebuilding gives you a fresh signature.
495+
- Streaming modular calls are not yet supported for Bedrock.
496+
497+
```typescript TypeScript
498+
import { SignatureV4 } from "@aws-sdk/signature-v4"
499+
import { defaultProvider } from "@aws-sdk/credential-provider-node"
500+
import { HttpRequest } from "@aws-sdk/protocol-http"
501+
import { b } from 'baml_client'
502+
503+
async function callBedrock() {
504+
const req = await b.request.ExtractResume("John Doe | Software Engineer | BSc in CS")
505+
506+
const body = req.body.json() as any
507+
const bodyString = JSON.stringify(body)
508+
const url = new URL(req.url)
509+
const region = (req.client_details.options?.region as string) ?? process.env.AWS_REGION ?? "us-east-1"
510+
511+
const signer = new SignatureV4({
512+
service: "bedrock",
513+
region,
514+
credentials: defaultProvider(),
515+
})
516+
517+
const unsigned = new HttpRequest({
518+
protocol: url.protocol,
519+
hostname: url.hostname,
520+
path: url.pathname,
521+
method: req.method,
522+
headers: {
523+
...req.headers,
524+
host: url.host,
525+
"content-type": "application/json",
526+
},
527+
body: bodyString,
528+
})
529+
530+
const signed = await signer.sign(unsigned)
531+
532+
const res = await fetch(req.url, {
533+
method: req.method,
534+
headers: signed.headers as Record<string, string>,
535+
body: bodyString,
536+
})
537+
538+
if (!res.ok) {
539+
throw new Error(`Bedrock request failed: ${res.status}`)
540+
}
541+
542+
const payload = await res.json()
543+
const message = payload.output.message.content.find((block: any) => block.text)?.text ?? ''
544+
545+
return b.parse.ExtractResume(message)
546+
}
547+
```
548+
549+
```python Python
550+
import json
551+
import requests
552+
from botocore.auth import SigV4Auth
553+
from botocore.awsrequest import AWSRequest
554+
import boto3
555+
from baml_client import b
556+
557+
def call_bedrock():
558+
req = b.request.ExtractResume("John Doe | Software Engineer | BSc in CS")
559+
560+
body = req.body.json()
561+
body_bytes = json.dumps(body).encode("utf-8")
562+
563+
session = boto3.Session()
564+
credentials = session.get_credentials().get_frozen_credentials()
565+
region = req.client_details.options.get("region") or session.region_name or "us-east-1"
566+
567+
aws_request = AWSRequest(
568+
method=req.method,
569+
url=req.url,
570+
data=body_bytes,
571+
headers=dict(req.headers),
572+
)
573+
SigV4Auth(credentials, "bedrock", region).add_auth(aws_request)
574+
575+
response = requests.post(
576+
req.url,
577+
headers=dict(aws_request.headers.items()),
578+
data=body_bytes,
579+
)
580+
response.raise_for_status()
581+
582+
payload = response.json()
583+
message = payload["output"]["message"]["content"][0]["text"]
584+
return b.parse.ExtractResume(message)
585+
```
586+
487587
## Provider request parameters
488588
These are other `options` that are passed through to the provider, without modification by BAML. For example if the request has a `temperature` field, you can define it in the client here so every call has that set.
489589

@@ -600,4 +700,3 @@ client<llm> MyClient {
600700
- Use `AWS_PROFILE` to manage multiple profiles
601701
- Run `aws configure list` to verify configuration
602702
</Accordion>
603-

flake.nix

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,9 @@
4646
buildInputs = (with pkgs; [
4747
cmake
4848
git
49-
go
50-
gotools
49+
go
50+
gotools
51+
mise
5152
openssl
5253
pkg-config
5354
lld_17

integ-tests/baml_src/clients.baml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ client<llm> AwsBedrock {
219219
// max_completion_tokens 100000
220220
// model "anthropic.claude-3-5-sonnet-20240620-v1:0"
221221
// model_id "anthropic.claude-3-haiku-20240307-v1:0"
222-
model "meta.llama3-8b-instruct-v1:0"
222+
model "arn:aws:bedrock:us-east-1:404337120808:inference-profile/us.anthropic.claude-3-7-sonnet-20250219-v1:0"
223223
// region "us-east-1"
224224
// access_key_id env.AWS_ACCESS_KEY_ID
225225
// secret_access_key env.AWS_SECRET_ACCESS_KEY
@@ -432,4 +432,4 @@ client<llm> AzureO3WithMaxCompletionTokens {
432432
api_key env.AZURE_OPENAI_API_KEY
433433
max_completion_tokens 1000
434434
}
435-
}
435+
}

integ-tests/go/baml_client/baml_source_map.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

integ-tests/python-v1/baml_client/inlinedbaml.py

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)