Gioco di Dungeon con IA
Modulo 3: Implementazione dell’API per la storia
Lo StoryApi è composto da una singola API generate_story
che, dati un Game
e una lista di Action
come contesto, farà progredire una storia. Questa API sarà implementata come API streaming in Python/FastAPI e dimostrerà inoltre come modificare il codice generato per adattarlo allo scopo.
Implementazione dell’API
Per creare la nostra API, dobbiamo prima installare alcune dipendenze aggiuntive:
boto3
verrà utilizzato per chiamare Amazon Bedrockuvicorn
verrà usato per avviare la nostra API in combinazione con Lambda Web Adapter (LWA)copyfiles
è una dipendenza npm necessaria per supportare la copia cross-platform dei file durante l’aggiornamento del taskbundle
Per installare queste dipendenze, esegui i seguenti comandi:
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
Sostituiamo ora il contenuto di packages/story_api/story_api/main.py
con quanto segue:
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")
Analizzando il codice sopra:
- Usiamo l’impostazione
x-streaming
per indicare che si tratta di un’API streaming quando genereremo il client SDK. Questo ci permetterà di consumare l’API in modalità streaming mantenendo la type-safety! - Usiamo l’impostazione
x-query
per indicare che, nonostante sia una richiesta POST, la tratteremo come unaquery
invece che unamutation
, sfruttando al meglio la gestione dello stato streaming di TanStack Query - La nostra API restituisce semplicemente un flusso di testo come definito da
media_type="text/plain"
eresponse_class=PlainTextResponse
Infrastruttura
L’infrastruttura configurata precedentemente presuppone che tutte le API abbiano un API Gateway integrato con Lambda. Per la nostra story_api
non vogliamo usare API Gateway perché non supporta risposte streaming. Utilizzeremo invece un Lambda Function URL configurato con response streaming.
Per supportare questo, aggiorniamo prima i costrutti CDK come segue:
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', ], }), ); }}
Aggiorneremo ora lo story_api
per supportare il deployment con Lambda Web Adapter.
#!/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 e testing
Prima di tutto, compiliamo il codice base:
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
Ora puoi deployare l’applicazione eseguendo:
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
Il deployment richiederà circa 2 minuti.
Comando di distribuzione
Puoi distribuire tutti gli stack dell’applicazione CDK eseguendo:
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
Non è raccomandato in quanto potresti voler separare le fasi di deployment in stack diversi (es. infra-prod
). In questo caso il flag --all
tenterà di distribuire tutti gli stack, con il rischio di deployment indesiderati!
Al termine del deployment, dovresti vedere output simili a questi (alcuni valori sono oscurati):
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
Possiamo testare l’API in due modi:
- Avviare un’istanza locale del server FastAPI e invocare le API con
curl
- Chiamare l'API deployata usando curl con Sigv4
Curl con Sigv4 abilitato
Puoi aggiungere questo script al tuo file
.bashrc
(e faresource
) oppure incollare direttamente nel terminale:~/.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)" "$@"}Esempi di richieste autenticate con Sigv4:
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
Avvia il server FastAPI locale con:
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
Esegui poi la chiamata con:
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"
Se il comando va a buon fine, dovresti vedere una risposta in streaming simile a:
UnnamedHero si erge maestoso, il mantello che sventola nel vento....
Congratulazioni. Hai creato e distribuito la tua prima API utilizzando FastAPI! 🎉🎉🎉