AI Dungeon Game
Module 3: Story API implementation
The StoryApi comprises of a single API generate_story
which given a Game
and a list of Action
’s for context, will progress a story. This API will be implemented as a streaming API in Python/FastAPI and will additionally demonstrate how changes can be made to the generated code to be fit for purpose.
API implementation
To create our API, we first need to install a couple of additional dependencies.
boto3
will be used to call Amazon Bedrock;uvicorn
will be used to start our API when used in conjunction with the Lambda Web Adapter (LWA).copyfiles
is an npm dependency that we will need to support cross-platform copying of files when updating ourbundle
task.
To install these dependencies, run the following commands:
pnpm nx run dungeon_adventure.story_api:add --args boto3 uvicorn
yarn nx run dungeon_adventure.story_api:add --args boto3 uvicorn
npx nx run dungeon_adventure.story_api:add --args boto3 uvicorn
bunx nx run dungeon_adventure.story_api:add --args boto3 uvicorn
pnpm add -Dw copyfiles
yarn add -D copyfiles
npm install --legacy-peer-deps -D copyfiles
bun install -D copyfiles
Now let’s replace the contents of packages/story_api/story_api/main.py
as follows:
import json
from boto3 import clientfrom fastapi.responses import PlainTextResponse, StreamingResponsefrom pydantic import BaseModel
from .init import app, lambda_handler
handler = lambda_handler
bedrock = client('bedrock-runtime')
class Action(BaseModel): role: str content: str
class StoryRequest(BaseModel): genre: str playerName: str actions: list[Action]
async def bedrock_stream(request: StoryRequest): messages = [ {"role": "user", "content": "Continue or create a new story..."} ]
for action in request.actions: messages.append({"role": action.role, "content": action.content})
response = bedrock.invoke_model_with_response_stream( modelId='anthropic.claude-3-sonnet-20240229-v1:0', body=json.dumps({ "system":f""" You are running an AI text adventure game in the {request.genre} genre. Player: {request.playerName}. Return less than 200 characters of text. """, "messages": messages, "max_tokens": 1000, "temperature": 0.7, "anthropic_version": "bedrock-2023-05-31" }) )
stream = response.get('body') if stream: for event in stream: chunk = event.get('chunk') if chunk: message = json.loads(chunk.get("bytes").decode()) if message['type'] == "content_block_delta": yield message['delta']['text'] or "" elif message['type'] == "message_stop": yield "\n"
@app.post("/story/generate", openapi_extra={'x-streaming': True, 'x-query': True}, response_class=PlainTextResponse)def generate_story(request: StoryRequest) -> str: return StreamingResponse(bedrock_stream(request), media_type="text/plain")
Analyzing the code above:
- We use the
x-streaming
setting to indicate that this is a streaming API when we eventually generate our client SDK. This will allow us to consume this API in a streaming manner whilst maintaining type-safety! - We use the
x-query
setting to indicate that while this is a POST request, we will treat it as aquery
instead of amutation
, allowing us to take full advantage of TanStack Query managing our streaming state. - Our API simply returns a stream of text as defined by both the
media_type="text/plain"
and theresponse_class=PlainTextResponse
Infrastructure
The Infrastructure we set up previously assumes that all APIs have an API Gateway integrating with a Lambda. For our story_api
we actually don’t want to use API Gateway as this does not support streaming repsonses. Instead, we will use a Lambda Function URL configured with response streaming.
To support this, we are going to first update our CDK constructs as follows:
import { Construct } from 'constructs';import { CfnOutput, Duration, Stack } from 'aws-cdk-lib';import { CorsHttpMethod, HttpApi as _HttpApi, HttpMethod, IHttpRouteAuthorizer,} from 'aws-cdk-lib/aws-apigatewayv2';import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';import { Code, Function, FunctionUrl, FunctionUrlAuthType, InvokeMode, LayerVersion, Runtime, Tracing,} from 'aws-cdk-lib/aws-lambda';import { Grant, IGrantable } from 'aws-cdk-lib/aws-iam';import { RuntimeConfig } from './runtime-config.js';
export interface HttpApiProps { readonly apiName: string; readonly handler: string; readonly handlerFilePath: string; readonly runtime: Runtime; readonly defaultAuthorizer: IHttpRouteAuthorizer; readonly apiType?: 'api-gateway' | 'function-url-streaming'; readonly allowedOrigins?: string[];}
export class HttpApi extends Construct { public readonly api?: _HttpApi; public readonly routerFunctionUrl?: FunctionUrl; public readonly routerFunction: Function;
constructor(scope: Construct, id: string, props: HttpApiProps) { super(scope, id);
this.routerFunction = new Function(this, `${id}Handler`, { timeout: Duration.seconds(30), runtime: props.runtime, handler: props.handler, code: Code.fromAsset(props.handlerFilePath), tracing: Tracing.ACTIVE, environment: { AWS_CONNECTION_REUSE_ENABLED: '1', }, });
let apiUrl; if (props.apiType === 'function-url-streaming') { const stack = Stack.of(this); this.routerFunction.addLayers( LayerVersion.fromLayerVersionArn( this, 'LWALayer', `arn:aws:lambda:${stack.region}:753240598075:layer:LambdaAdapterLayerX86:24`, ), ); this.routerFunction.addEnvironment('PORT', '8000'); this.routerFunction.addEnvironment( 'AWS_LWA_INVOKE_MODE', 'response_stream', ); this.routerFunction.addEnvironment( 'AWS_LAMBDA_EXEC_WRAPPER', '/opt/bootstrap', ); this.routerFunctionUrl = this.routerFunction.addFunctionUrl({ authType: FunctionUrlAuthType.AWS_IAM, invokeMode: InvokeMode.RESPONSE_STREAM, cors: { allowedOrigins: props.allowedOrigins ?? ['*'], allowedHeaders: [ 'authorization', 'content-type', 'x-amz-content-sha256', 'x-amz-date', 'x-amz-security-token', ], }, }); apiUrl = this.routerFunctionUrl.url; } else { this.api = new _HttpApi(this, id, { corsPreflight: { allowOrigins: props.allowedOrigins ?? ['*'], allowMethods: [CorsHttpMethod.ANY], allowHeaders: [ 'authorization', 'content-type', 'x-amz-content-sha256', 'x-amz-date', 'x-amz-security-token', ], }, defaultAuthorizer: props.defaultAuthorizer, });
this.api.addRoutes({ path: '/{proxy+}', methods: [ HttpMethod.GET, HttpMethod.DELETE, HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH, HttpMethod.HEAD, ], integration: new HttpLambdaIntegration( 'RouterIntegration', this.routerFunction, ), }); apiUrl = this.api.url; }
new CfnOutput(this, `${props.apiName}Url`, { value: apiUrl! });
RuntimeConfig.ensure(this).config.httpApis = { ...RuntimeConfig.ensure(this).config.httpApis!, [props.apiName]: apiUrl, }; }
public grantInvokeAccess(grantee: IGrantable) { if (this.api) { Grant.addToPrincipal({ grantee, actions: ['execute-api:Invoke'], resourceArns: [this.api.arnForExecuteApi('*', '/*', '*')], }); } else if (this.routerFunction) { Grant.addToPrincipal({ grantee, actions: ['lambda:InvokeFunctionUrl'], resourceArns: [this.routerFunction.functionArn], conditions: { StringEquals: { 'lambda:FunctionUrlAuthType': 'AWS_IAM', }, }, }); } }}
import { Construct } from 'constructs';import * as url from 'url';import { HttpApi } from '../../core/http-api.js';import { HttpIamAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2-authorizers';import { Runtime } from 'aws-cdk-lib/aws-lambda';import { Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam';
export class StoryApi extends HttpApi { constructor(scope: Construct, id: string) { super(scope, id, { defaultAuthorizer: new HttpIamAuthorizer(), apiName: 'StoryApi', runtime: Runtime.PYTHON_3_12, handler: 'story_api.main.handler', apiType: 'function-url-streaming', handler: 'run.sh', handlerFilePath: url.fileURLToPath( new URL( '../../../../../../dist/packages/story_api/bundle', import.meta.url, ), ), });
this.routerFunction.addToRolePolicy( new PolicyStatement({ effect: Effect.ALLOW, actions: ['bedrock:InvokeModelWithResponseStream'], resources: [ 'arn:aws:bedrock:*::foundation-model/anthropic.claude-3-sonnet-20240229-v1:0', ], }), ); }}
Now we will update the story_api
to support the Lambda Web Adapter deployment.
#!/bin/bash
PATH=$PATH:$LAMBDA_TASK_ROOT/bin \ PYTHONPATH=$PYTHONPATH:/opt/python:$LAMBDA_RUNTIME_DIR \ exec python -m uvicorn --port=$PORT story_api.main:app
{ "name": "dungeon_adventure.story_api", "$schema": "../../node_modules/nx/schemas/project-schema.json", "projectType": "application", "sourceRoot": "packages/story_api/story_api", "targets": { ... "bundle": { "cache": true, "executor": "nx:run-commands", "outputs": ["{workspaceRoot}/dist/packages/story_api/bundle"], "options": { "commands": [ "uv export --frozen --no-dev --no-editable --project story_api -o dist/packages/story_api/bundle/requirements.txt", "uv pip install -n --no-installer-metadata --no-compile-bytecode --python-platform x86_64-manylinux2014 --python `uv python pin` --target dist/packages/story_api/bundle -r dist/packages/story_api/bundle/requirements.txt", "copyfiles -f packages/story_api/run.sh dist/packages/story_api/bundle" ], "parallel": false }, "dependsOn": ["compile"] }, ... }}
Deployment and testing
First, lets build the codebase:
pnpm nx run-many --target build --all
yarn nx run-many --target build --all
npx nx run-many --target build --all
bunx nx run-many --target build --all
Your application can now be deployed by running the following command:
pnpm nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox
yarn nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox
npx nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox
bunx nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox
This deployment will take around 2 minutes to complete.
Deployment command
You can also deploy all stacks contained in the CDK application by running:
pnpm nx run @dungeon-adventure/infra:deploy --all
yarn nx run @dungeon-adventure/infra:deploy --all
npx nx run @dungeon-adventure/infra:deploy --all
bunx nx run @dungeon-adventure/infra:deploy --all
This is not recommended given that you may choose to seperate out your deployment stages as seperate stacks i.e. infra-prod
. In this case the --all
flag will attempt to deploy all stacks which can result in unwanted deployments!
Once the deployment completes, you should see some outputs similar to the following (some values have been redacted):
dungeon-adventure-infra-sandboxdungeon-adventure-infra-sandbox: deploying... [2/2]
✅ dungeon-adventure-infra-sandbox
✨ Deployment time: 354s
Outputs:dungeon-adventure-infra-sandbox.ElectroDbTableTableNameXXX = dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYYdungeon-adventure-infra-sandbox.GameApiGameApiUrlXXX = https://xxx.region.amazonaws.com/dungeon-adventure-infra-sandbox.GameUIDistributionDomainNameXXX = xxx.cloudfront.netdungeon-adventure-infra-sandbox.StoryApiStoryApiUrlXXX = https://xxx.lambda-url.ap-southeast-2.on.aws/dungeon-adventure-infra-sandbox.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxxdungeon-adventure-infra-sandbox.UserIdentityUserIdentityUserPoolIdXXX = region_xxx
We can test our API by either:
- Starting a local instance of the FastApi server and invoke the API’s using
curl
. - Calling the deployed API using sigv4 enabled curl directly
Sigv4 enabled curl
You can either add the following script to your
.bashrc
file (andsource
it) or simply paste the following into the same terminal you wish to run the command in.~/.bashrc acurl () {REGION=$1SERVICE=$2shift; shift;curl --aws-sigv4 "aws:amz:$REGION:$SERVICE" --user "$(aws configure get aws_access_key_id):$(aws configure get aws_secret_access_key)" -H "X-Amz-Security-Token: $(aws configure get aws_session_token)" "$@"}Then to make a sigv4 authenticated curl request, you can simply invoke
acurl
like the following examples:API Gateway
Terminal window acurl ap-southeast-2 execute-api -X GET https://xxxStreaming Lambda function url
Terminal window acurl ap-southeast-2 lambda -N -X POST https://xxx
Start your local FastAPI server by running the following command:
pnpm nx run dungeon_adventure.story_api:serve
yarn nx run dungeon_adventure.story_api:serve
npx nx run dungeon_adventure.story_api:serve
bunx nx run dungeon_adventure.story_api:serve
Once the FastAPI server is up and running, call it by running the following command:
curl -N -X POST http://127.0.0.1:8000/story/generate \ -d '{"genre":"superhero", "actions":[], "playerName":"UnnamedHero"}' \ -H "Content-Type: application/json"
acurl ap-southeast-2 lambda -N -X POST \ https://xxx.lambda-url.ap-southeast-2.on.aws/story/generate \ -d '{"genre":"superhero", "actions":[], "playerName":"UnnamedHero"}' \ -H "Content-Type: application/json"
If the command executes successfully, you should see a response being streamed similar to:
UnnamedHero stood tall, his cape billowing in the wind....
Congratulations. You have built and deployed your first API using FastAPI! 🎉🎉🎉