将GPT Actions与AWS AppSync Cognito受保护的API集成
太长;不想读 (TL;DR)
许多AWS客户已经开发了AppSync GraphQL API来为他们的网站和移动应用程序提供动力。根据最近的GPT商店公告,一些人现在希望将这些API与GPT Actions集成,以提供基于聊天的体验来使用他们经过试验和验证的功能。
在本篇文章中,我们将探讨如何实现这一目标。我们将重点介绍如何使用亚马逊 Cognito 来保护 AppSync APIs,它是事实上的标准,同时还将探索 GPT Actions 与 OAuth2 受保护的 APIs 的兼容性如何与 Cognito 的功能相对应。
这些是我在路上遇到的主要陷阱:
- GPT Actions 需要一个 OpenAPI 规范,这与 AppSync 的 GraphQL 不兼容。解决方案:使用一个通用的 OpenAPI 模式,并提供包含 GraphQL 模式甚至一些示例查询的 GPT 指令。
- 如果您正在使用Amplify,您的登录界面很可能与OAuth2不兼容。解决方案:使用Cognito的内置“托管界面”。
- GPT操作要求OAuth端点和API在相同的根域下。解决方案:使用AppSync、Cognito和API Gateway的自定义域名。
- 您的AppSync实施可能需要仅在ID令牌中可用的特定声明,而标准OAuth流程使用访问令牌。解决方案:代理令牌终端点以将访问令牌替换为ID令牌。
- 您的Cognito用户池默认情况下可能不会生成ID令牌。解决方案:在GPT设置中配置"openid"范围。
- 在更新操作后,我们在OAuth重定向URL中得到一个“未定义”错误。解决方案:重新配置操作设置并重新输入应用程序客户端ID/密钥。
- Cognito在更新操作后抛出了一个配置错误。解决方案:每次更改操作时,更新Cognito应用程序客户端回调URL以匹配新的GPT OAuth重定向URL。
- GPT可能会生成无效的请求,因为整个GQL查询是一个单个的JSON字符串。解决方案:在GraphQL API上创建一个REST API层,以获取更清晰的OpenAPI规范和简化的API,以适应您的GPT的使用。
- 如果您的REST API路径参数(例如ID)包含特殊字符,GPT可能会生成无效请求。 解决方案:在将数据返回给GPT之前,在REST API层中转义特殊字符,并在发送回AppSync时取消转义。
- 您可能希望有一个高度定制的登录界面,但Cognito托管的用户界面只允许一些基本的徽标和CSS调整。解决方案:这个有些困难-我们需要创建一个自定义的Web应用程序,重新实现整个OAuth授权代码流程,并使用专有的Cognito用户池API。我推荐查看这篇AWS博客文章:我应该使用托管的用户界面还是在Amazon Cognito中创建自定义用户界面?
背景信息
让我们首先了解所涉及的关键组成部分:
-
GPT Actions:作为OpenAI生态系统的一部分,GPT Actions允许对GPT模型进行定制,以智能地与各种API交互。它们可以根据特定用例进行定制,使得GPT模型可以访问第三方应用并根据用户的需求执行功能。
- AWS AppSync:AWS的托管服务,使用GraphQL,AppSync允许通过安全地访问、操作和组合来自多个来源的数据来开发可扩展的应用程序。
- 亚马逊 Cognito:这是一个全面的身份管理服务,提供用户注册、登录和访问控制。它广泛用于保护API,包括使用AWS AppSync创建的API,并符合OAuth2身份验证标准。
让我们以一个实际的用例来探讨这个问题:一个问答网站。想象一下,在LLMs广泛可用之前,我们已经运行了这个网站,并且现在我们希望利用它来构建一个或多个GPT行动。主要目标是让一个GPT行动能够与网站的API进行交互,执行诸如以下功能的功能:
- 浏览最新问题:GPT可以帮助用户按日期排序,浏览问答网站上最近的问题。
- 回答和提交答案:它使GPT不仅能够回答问题,还能够将这些答案提交到问答网站,并为不断增长的知识库做出贡献。
- 审核内容:GPT可以帮助审核网站上发布的问题。它可以识别并删除粗鲁、令人讨厌、冒犯或其他应受谴责的内容。
前提:Cognito和AppSync域名
GPT Actions 要求用于用户登录的 OAuth 终端点和调用 API 的终端点位于同一个根域下。如果您的 AppSync API 和 Cognito 用户池尚未满足此要求,那就是我们需要首先解决的问题。
让我们来看一下如何设置Cognito域。它必须位于我们控制的根域上,以便我们也可以将AppSync API放在同一域下。以下是使用AWS CDK实现的方法:
const domain = userPool.addDomain("CognitoDomain", {
customDomain: {
domainName: `qanda-auth.${this.props.domainName}`,
certificate: Certificate.fromCertificateArn(
this,
"CognitoCertificate",
this.props.certificateArn
),
},
});
const hostedZone = HostedZone.fromLookup(this, "CognitoHostedZone", {
domainName: this.props.domainName,
});
new CnameRecord(this, "CognitoCnameRecord", {
zone: hostedZone,
recordName: "qanda-auth",
domainName: domain.cloudFrontDomainName,
});
- 首先,我们添加了一个“Cognito自定义域名”,它会在后台创建一个CloudFront分发,用于提供托管的界面。
- 然后,我们添加了一个DNS记录,将我们定制的域名别名到CloudFront分发域名。
将AppSync API放置在自定义域下遵循相同的基本结构:
const api = new GraphqlApi(this, "Api", {
domainName: {
domainName: `qanda-api.${this.props.domainName}`,
certificate: Certificate.fromCertificateArn(
this,
"AppSyncCertificate",
this.props.certificateArn
),
},
// ...
});
const hostedZone = HostedZone.fromLookup(this, "AppSyncHostedZone", {
domainName: this.props.domainName,
});
new CnameRecord(this, "AppSyncCnameRecord", {
zone: hostedZone,
recordName: "qanda-api",
domainName: Fn.select(2, Fn.split('/', api.graphqlUrl)),
});
版本1:使用AppSync与访问令牌
在不需要自定义令牌声明的情况下,将GPT Actions与AWS AppSync集成可以更加简单,因为GPT传递的访问令牌是足够的。
第一步是为GPT集成设置Cognito应用程序客户端,以便进行身份验证和令牌管理。CDK示例:
userPool.addClient("AppClient", {
authFlows: {
userPassword: true,
userSrp: true,
},
generateSecret: true,
oAuth: {
flows: {
implicitCodeGrant: true,
authorizationCodeGrant: true,
},
scopes: [OAuthScope.OPENID],
callbackUrls: this.props.callbackUrls,
},
});
一旦我们创建完毕,复制客户端ID和密钥。
然后,我们可以创建GPT和Action。由于GraphQL的模式定义语言不同,并且与OpenAPI不直接兼容,我们必须使用通用的OpenAPI模式。为了确保GPT能够正确使用API,我们必须配置包含GraphQL模式甚至一些示例查询的指令。
配置:
- 验证 = OAuth — 客户端 ID =
— 客户端密钥 = — 授权 URL = https:// /oauth2/authorize(“authorize” 端点)— Token URL = https:// /oauth2/token(“token” 端点)— 范围 = openid — Token 交换方法 = 基本授权头部 - 方案 = 通用的GraphQL API的OpenAPI架构。我决定不包括“variables”参数-这似乎经常导致GPT无法正常使用API。
一旦动作创建完成,主要的GPT编辑页面将在底部显示一个“回调URL”。我们需要将此URL配置为之前创建的Cognito App Client的回调URL。
完成后,我们可以测试GPT以确保其正常工作。示例。以下是用户体验的样子:
- 首先,在用户发送第一条信息后,他们被要求登录到我们的API。
- 然后,他们被重定向到Cognito托管的用户界面。
- 登录后,他们将被重定向回ChatGPT用户界面,并调用API。
- ChatGPT将在进行“写”操作(即除了HTTP GET请求之外的任何操作;所有GraphQL调用都是POST)之前要求用户确认。为了简化用户体验并减少确认提示的频率,您可以在OpenAPI规范中指定自定义属性。
版本2:使用AppSync与ID令牌
如果我们的API依赖于自定义声明,并且需要使用Cognito ID令牌进行调用,我们需要“欺骗”GPT,将ID令牌发送到AppSync API而不是访问令牌。
首先,我们需要了解 GPT 使用的 OAuth 授权码流程。起初,当用户试图访问一个服务时,他们会被重定向到一个身份验证服务器(Cognito)。在这里,他们登录,然后服务器会为应用程序生成一个唯一的授权码。这个代码是一个临时令牌,并且应用程序会使用客户端凭据来交换这个代码以获取访问令牌和 ID 令牌。然后,应用程序可以使用这些令牌代表用户来进行 API 请求。
因此,将GPT“欺骗”为使用ID令牌的一种方法是拦截交换API调用,将“访问令牌”值替换为“ID令牌”。我们可以通过构建一个小型的REST API来实现这一点。与之前一样,这个API必须具有与Cognito托管UI和AppSync API相同的根域。
const api = new LambdaRestApi(this, "LambdaRestApi", {
handler,
domainName: {
certificate,
domainName: `qanda-gpt-api.${props.domainName}`,
},
});
const hostedZone = HostedZone.fromLookup(this, "ApiHostedZone", {
domainName: props.domainName,
});
new ARecord(this, "ApiARecord", {
zone: hostedZone,
recordName: "qanda-gpt-api",
target: RecordTarget.fromAlias(new ApiGateway(api)),
});
这个 REST API 可以由单个 Lambda 函数提供支持,该函数只是将请求转发到 Cognito 并操作响应。
async function handleTokenExchange(
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> {
const response = await axios.post(
`https://${process.env.COGNITO_DOMAIN}/oauth2/token`,
event.body,
{
headers: {
Authorization: event.headers["Authorization"],
"Content-Type": event.headers["content-type"],
},
}
);
const data = response.data ?? {};
return {
statusCode: response.status,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...data, access_token: data.id_token }),
};
}
最后,我们必须更新GPT行动中的“Token URL”设置。在撰写本博客时,ChatGPT存在一个错误,需要我们重新输入客户端ID/密钥,否则该行动将无法正常工作。此外,一旦我们更改该行动,其回调URL将被重新生成,因此需要在Cognito应用程序客户端上重新配置。
一旦完成这些更改,GPT应该可以正常工作,并向我们的AppSync API发送ID令牌。
版本3:使用一个轻量级封装的REST API
在尝试使用由AppSync驱动的Action时,我注意到GPT在处理GraphQL查询时偶尔会出现错误。每次重试,它都会变得更加混乱,因为它无法理解实际的错误。作为用户,我会给出一些"提示"来使用API和恢复,但在大多数面向用户的应用程序中,这是绝对不可接受的。
我发现使用REST API时,GPT是不太容易出现这种问题的 - 这很可能是因为REST API可以使用OpenAPI规范进行清晰描述,明确覆盖每个操作,而对于GraphQL API,我们只有一个通用的“执行查询”操作,其中GPT需要将所有不同的访问模式塞进去。
在GraphQL API之上构建一个REST API是微不足道的。甚至有一些库可以为我们做到这一点。为了我们的演示目的,我们可以通过扩展我们用于交换令牌的REST API来处理这个用例。
例如,这是我们将代理调用列出问题的方法:
async function listQuestions(
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> {
const today = new Date().toISOString().split("T")[0];
const authHeader =
event.headers.Authorization || event.headers.authorization || "";
const questions = await callAppSync<{
data: { listQuestions: { items: any[] } };
}>(
`query ListQuestions($date: String!, $limit: Int!) {
listQuestions(date: $date, limit: $limit) {
items {
id
content
createdAt
answers {
content
}
}
}
}`,
authHeader,
{
date: today,
limit: 5,
}
);
return {
statusCode: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(questions.data.listQuestions.items),
};
}
这种方法的一个重要优势在于能够精简API,使其特别适用于我们的GPT所需的用例。我们可以添加默认参数值,组合API调用,映射错误,调整数据结构等等,从而使API更加适合GPT的使用。
最终结构和代码
这是最终的架构。
... 这个演示实现的完整AWS CDK代码在这里:https://github.com/serban-petrescu/aws-proto-cognito-appsync-gpt
- 问题:GPT操作需要OAuth端点和API在相同的根域下。 解决方案:使用AppSync、Cognito和API Gateway的自定义域名。 处理Cognito群组与ID令牌一起使用:
- 问题:GPT需要ID令牌,但OAuth通常提供访问令牌。解决方案:代理令牌端点,以ID令牌替换访问令牌。生成ID令牌:
- 问题:Cognito默认不会生成ID令牌。解决方案:在GPT设置中配置"openid"范围。在OAuth重定向之后的动作更新中存在问题:
- 问题:在更新操作后,URL中出现“未定义”。解决方案:重新配置操作身份验证设置并重新输入客户端ID/秘钥。Cognito配置错误后更新后:
- 问题:在登录重定向后的操作更新后,Cognito声称存在配置错误。解决方案:每次更改操作时,更新Cognito应用程序客户端回调URL以与新的GPT OAuth重定向URL匹配。