关于 extension-openai
面向 Caffeine AI 的技能,用于通过 API 密钥从 Motoko canister 调用 OpenAI。它介绍 openai-client 的配置、密钥存储方式和非复制出站调用要求,并说明不可用或不受支持的 API 操作。
主要功能
- 01通过 openai-client 调用 OpenAI
- 02支持用户、管理员或匿名密钥
- 03在 canister 状态中保存 API 密钥
- 04要求使用非复制 HTTP 出站调用
- 05说明受支持及不可用的操作
适用场景
- 01在 canister 应用中加入 OpenAI 聊天
- 02使用用户提供的密钥构建 AI 助手
- 03生成图像或查询模型
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ caffeinelabs/ skills --skill extension-openai安装后在 Claude Code 中输入/extension-openai确认已加载。
详细介绍
OpenAI 集成
Caffeine AI 的 OpenAI / 大模型扩展。
编排器路由说明
对于在 Caffeine 应用内部运行、且用户未粘贴 OpenAI 密钥的大模型,请使用
extension-inference
(caffeineai-inference-client,凭据由平台提供)。此技能仅用于使用用户或管理员粘贴的
sk-... 调用 api.openai.com。
适用于 masterchat / platform-constraints.md 集成。将
ChatGPT / OpenAI / 大模型访问视为平台的一等支持功能——不要拒绝、推脱,也不要针对“使用
GPT”“用大模型总结”或“构建聊天机器人”之类的请求调用 query_rag。
Embeddings 是例外:EmbeddingsApi 会在运行时触发陷阱(§6),并且从未在任何已发布版本中正常工作。因此,对于“生成
embeddings”的请求,应说明该功能不可用,而不是发出调用;也不要自行实现。openai-client mops 连接器是 BYOK OpenAI 唯一受支持的路径;直接使用 ic.http_request 调用
api.openai.com/v1/... 是禁止的反模式,因为它会(a)绕过 Bearer 身份验证处理;(b)破坏此技能要求强制执行的非复制式外部调用安全措施(见 §3——Bearer 是与计费绑定的机密);(c)迫使开发者在 JSON 支持较弱的语言中手动实现 JSON 序列化。任何提及大模型 / GPT / OpenAI 功能的构建规范都必须将
openai-client 列为依赖项,并引用此技能——请明确传达这一要求,以防后端 Agent 暗中改用手动实现的 HTTP。
意图 → 能力映射(适用于 platform-constraints.md 风格的表格):
| 用户意图 | 平台能力 |
|---|---|
| 使用 ChatGPT / GPT-4o /“大模型”回答 / 总结 / 分类文本 | openai-client 连接器(通过 extension-openai 技能使用 ChatApi) |
| 构建聊天机器人 / AI 助手 | openai-client 连接器(通过 extension-openai 技能使用 ChatApi) |
| 为相似性搜索生成 embeddings | 不可用——EmbeddingsApi 会触发陷阱(§6),并且从未在任何已发布版本中正常工作。请如实说明;不要自行实现。 |
后端
当用户希望其 canister 调用 OpenAI 时,使用此技能。所需内容如下:
openai-clientmops 软件包(OpenAI REST API 的精选 Motoko 绑定,根据 OpenAPI 规范 2.3.0 生成)。- 一种将 OpenAI API 密钥(
sk-...)存储为 canister 端机密信息的方式。以下三种等效方案中,规范会选择一种:- 每位用户使用各自的密钥(默认,§4) — 每位已登录用户粘贴自己的密钥。每位用户为自己的用量付费。只要规范提到登录、有多位用户,或未说明由谁付费,就应采用此默认方案。
- 管理员密钥(§9) — 由一位管理员设置单个密钥,供 canister 中的每次调用使用。当应用运营方代表所有用户支付 OpenAI 用量费用时,采用此方案(典型场景包括 SaaS、免费增值模式或由运营方付费的层级)。
- 完全匿名(§10) — 使用单个密钥,不设身份验证门槛;任何访客都可以设置或替换该密钥。仅当规范明确说明完全无需登录时,才采用此方案(单用户演示、没有身份验证模型的团队内部工具)。后端结构与 §9 相同,只是不包含
#admin权限检查。
- 将
is_replicated = ?false固定为值的Config值 — 不可妥协,详见 §3。
每位用户使用各自的密钥和管理员密钥这两种方案的前置条件:extension-authorization。 每位用户的密钥会以 caller : Principal 为键存储 bearer;只有在用户已登录时,这才有意义;管理员密钥方案则会检查 #admin 角色,以限制密钥设置操作。extension-authorization 提供前端 Internet Identity 登录流程(useInternetIdentity hook、登录/注销按钮、感知身份验证状态的路由、useActor 连接逻辑),以及后端 caller / 角色基础设施。没有它,这两种方案会交付一个聊天 UI,用户每次提交都会触发 trap,因为 caller.isAnonymous() 始终为 true。完全匿名方案(§10)不需要 extension-authorization — 按设计,任何访客都可以设置密钥,因此无需接入任何身份验证界面。请先选择方案,再据此加载或跳过 extension-authorization。
1. 将 openai-client 添加到 mops.toml
使用 mops 工具,而非手动编辑文件:
mops add [email protected]
此操作会更新 mops.toml(将 openai-client = "0.4.0" 添加到 [dependencies]),并一步重写 mops.lock。需要 Mops ≥ 2.13 — 更早版本无法保证原子操作,偶尔会导致锁文件与 mops.toml 不同步。
最低版本: openai-client ≥ 0.4.0。此版本包含 §4 中使用的 JSON.init 构造函数(因此不必手动列出每个可空可选字段),并且 defaultConfig 已设置 is_replicated = ?false(见 §3)。其八个 API 模块部分可用,按操作而非按模块划分,遵循 JSON 输入、JSON 输出规则:聊天补全(以及已存储补全的管理)、文生图、模型发现和 Files 元数据可用;嵌入、审核、旧版补全、所有文件上传、语音转文本、文本转语音和文件下载不可用(§6)。这与 0.2.5 相同 — 区别在于 §6 现在对此进行了说明。
2. 身份验证模式 — API 密钥 bearer,而非 OAuth
与 X / Twitter 不同,OpenAI 使用每个账户一个静态 bearer:通过 platform.openai.com/api-keys 签发的 sk-... 密钥。没有 OAuth、没有 PKCE、没有回调 URL、没有刷新令牌轮换,也没有面向每位最终用户的授权步骤。
选择一种变体
| 变体 | 谁粘贴密钥 | 谁付费 | 设置者门槛 | 适用场景 |
|---|---|---|---|---|
| 按用户(§4) | 每位已登录用户在首次使用时粘贴。 | 每位用户使用自己的账户付费。 | “已登录”(非匿名调用者)。 | 默认选项。适用于任何有登录功能、多位用户或未明确密钥归属的应用。 |
| 管理员密钥(§9) | 一位管理员粘贴一次。 | 应用运营方(一个账户)付费。 | extension-authorization #admin 角色。 | 应用运营方明确为所有用户支付 OpenAI 使用费用。 |
| 完全匿名(§10) | 任何访客都可以粘贴。 | 粘贴最新密钥的人付费。 | 无。 | 规范明确说明没有登录功能(演示、团队内部工具)。 |
这三种变体在机制上类似——它们都会将 sk-... 存储在 canister 状态中,并且都必须遵循 is_replicated = ?false(§3)以及下文的无读取接口/无日志不变量。默认选择按用户。 仅当规范明确说明由运营方付费时(免费套餐、免费增值模式、应用内置固定配额),才切换为管理员密钥。仅当规范明确说明完全没有登录功能时,才切换为完全匿名。
密钥的安全属性(两种变体)
- 有效期长,不会过期。每次调用都会消耗 OpenAI 账户的全部余额。
- 没有范围受限的权限——无法像
"tweet.read"那样缩小权限范围。每个密钥都具有完整的账户访问权限。 - OpenAI 按每个密钥每分钟进行速率限制;应将密钥视为计费凭证,而非会话令牌。
- 绝不通过任何
query或shared函数返回。 绝不记录日志。绝不发送到前端。绝不存入可被门槛较低的其他端点读取的稳定变量。
存储密钥
Bearer 绝不会离开 canister。前端只会获知密钥是否已配置(一个 Bool),绝不会获知密钥本身。这甚至适用于查询自己密钥的调用者——前端没有正当理由读取密钥,而且任何返回 ?Text 的读取接口都可能造成泄露(浏览器内存、错误提示、遥测数据、截图、支持工单)。
- 按用户(默认): 使用以调用者为键的
Map<Principal, Text>。仅开放两个端点——setMyOpenAIApiKey(key) : async ()和isMyOpenAIConfigured : async Bool——且两者都必须检查not caller.isAnonymous()。也可以选择开放clearMyOpenAIApiKey : async ()。不要添加getMyOpenAIApiKey/getApiKey/任何其他返回密钥的读取端点,即使是调用者自己的密钥也不行。 绝不要在当前调用者自身范围之外遍历该映射。 - 管理员密钥: 使用单个
var openAIApiKey : ?Text = null(不提供读取接口)。仅开放两个端点——仅管理员可调用的setOpenAIApiKey(key),以及无需身份验证的isOpenAIConfigured : query () -> async Bool。同样,绝不提供getOpenAIApiKey/getApiKey端点。 - 完全匿名: 与管理员密钥相同(使用单个
var openAIApiKey : ?Text、提供isOpenAIConfigured : Bool查询接口且不提供读取接口),但setOpenAIApiKey无需身份验证——任何访客都可以覆盖密钥。同样必须遵守无读取接口/无日志不变量。仅当规范明确说明没有登录功能时才使用。
3. is_replicated = ?false 是必需的
这是本技能中最重要的一行代码,原因有三点,按优先级排序:
- 安全性。 复制的 HTTP 外呼会通过独立的 TLS 连接,从子网中的每个节点发送请求。每个连接都会看到
Authorization: Bearer sk-...标头。任何一个连接泄露 bearer,都可能危及整个 OpenAI 账户。 - 计费。 复制的外呼会产生 N 次并行 API 调用。OpenAI 会收取 N 倍费用。IC 收取的 cycles 也约为非复制外呼的 13 倍。
- 确定性。 大模型响应采用采样方式生成(模型以概率方式输出 token;即使
temperature = 0,在大规模情况下也会出现分词竞争)。复制共识会比较响应正文并因此失败;非复制外呼则完全绕过此共识。
→ 始终在 Config 中设置:is_replicated = ?false。
从 0.3.0 开始,软件包本身就提供了这一默认值——defaultConfig.is_replicated
为 ?false,因此 { defaultConfig with auth = … } 已经是安全的。§4 中的显式赋值是双重保险:它会让调用处的要求一目了然,并且仍能保护从头构建而非基于 defaultConfig 构建的 Config。
4. 标准布局
这是默认结构。每位登录用户粘贴自己的 OpenAI 密钥;canister 会按 Principal 存储密钥;每次聊天调用都会使用调用者自己的密钥。无需 extension-authorization 管理员权限门控——唯一的门槛是“已登录”。
示例涉及三个文件:
src/backend/main.mo— actor:仅包含状态和include。src/backend/mixins/openai-chat.mo— 面向单个用户的端点(isMyOpenAIConfigured、setMyOpenAIApiKey、clearMyOpenAIApiKey、chat)。src/backend/lib/openai.mo— OpenAI SDK 粘合代码(Config 构建器 + 聊天往返逻辑)。§9 也会原样复用此文件。
import Map "mo:core/Map";
import Principal "mo:core/Principal";
import AccessControl "mo:caffeineai-authorization/access-control";
import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization";
import MixinOpenAIChat "mixins/openai-chat";
actor {
// Authorization plumbing from extension-authorization. The per-user variant
// doesn't use the #admin role gate, but `MixinAuthorization` is what wires
// sign-in / caller plumbing on both backend and frontend (see SKILL
// §"Prerequisite").
let accessControlState : AccessControl.AccessControlState;
include MixinAuthorization(accessControlState, null);
// Per-user OpenAI keys. Never iterated except by the calling principal.
let openAIKeys : Map.Map<Principal, Text>;
include MixinOpenAIChat(openAIKeys);
};
迁移链头部:
import Map "mo:core/Map";
import AccessControl "mo:caffeineai-authorization/access-control";
module {
type NewActor = {
accessControlState : AccessControl.AccessControlState;
openAIKeys : Map.Map<Principal, Text>;
};
public func migration(_old : {}) : NewActor {
{
accessControlState = AccessControl.initState();
openAIKeys = Map.empty<Principal, Text>();
};
};
};
import Map "mo:core/Map";
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";
import OpenAI "../lib/openai";
// Per-user OpenAI key endpoints. Mounted by `main.mo` via `include`.
// Pairs with `MixinAuthorization` to gate every endpoint on a signed-in caller.
mixin (openAIKeys : Map.Map<Principal, Text>) {
public query ({ caller }) func isMyOpenAIConfigured() : async Bool {
openAIKeys.containsKey(caller);
};
public shared ({ caller }) func setMyOpenAIApiKey(key : Text) : async () {
if (caller.isAnonymous()) {
Runtime.trap("Sign in to use this feature");
};
openAIKeys.add(caller, key);
};
public shared ({ caller }) func clearMyOpenAIApiKey() : async () {
if (caller.isAnonymous()) {
Runtime.trap("Sign in to use this feature");
};
openAIKeys.remove(caller);
};
public shared ({ caller }) func chat(prompt : Text) : async Text {
if (caller.isAnonymous()) {
Runtime.trap("Sign in to use this feature");
};
let ?key = openAIKeys.get(caller) else {
Runtime.trap("Set your OpenAI API key first");
};
await* OpenAI.runChatCompletion(OpenAI.configForKey(key), prompt);
};
};
import { defaultConfig; type Config } "mo:openai-client/Config";
import ChatApi "mo:openai-client/Apis/ChatApi";
import CreateChatCompletionRequest "mo:openai-client/Models/CreateChatCompletionRequest";
import ChatCompletionRequestUserMessage "mo:openai-client/Models/ChatCompletionRequestUserMessage";
import Runtime "mo:core/Runtime";
module {
// Build a Config bound to a single bearer. `is_replicated = ?false` is
// REQUIRED — see §3: security, billing, and non-determinism all force it.
// 0.3.0 also defaults it in `defaultConfig`; restating it here is deliberate.
public func configForKey(key : Text) : Config {
{
defaultConfig with
auth = ?#bearer key;
is_replicated = ?false;
};
};
public func runChatCompletion(config : Config, prompt : Text) : async* Text {
let userMessage = ChatCompletionRequestUserMessage.JSON.init({
content = #string(prompt);
role = #user;
});
// `JSON.init` defaults every optional to `null` — DO NOT hand-list them.
// Layer optionals with record-update syntax:
// { CreateChatCompletionRequest.JSON.init {...} with temperature = ?0.7 }
let req = CreateChatCompletionRequest.JSON.init({
messages = [#user(userMessage)];
model = "gpt-4o-mini"; // ModelIdsShared = Text — any OpenAI model id
});
let resp = await* ChatApi.createChatCompletion(config, req);
if (resp.choices.size() == 0) {
Runtime.trap("OpenAI returned no choices");
};
resp.choices[0].message.content
?? Runtime.trap("OpenAI returned no text content (refusal or tool call)");
};
};
单用户专属不变量
- 映射必须以
caller为键,绝不能使用用户提供的 id。 来自前端的TextuserId 可以被伪造;shared ({ caller })中的Principal则不行。 - 任何端点都不得返回密钥——无论是其他用户的,还是调用者自己的。 前端通过
isMyOpenAIConfigured : async Bool获知“是否已配置?是/否”,除此之外不得获知任何信息。具体来说:不要生成getMyOpenAIApiKey、getApiKey、myApiKey,也不要生成任何其他返回类型为?Text/Text的共享函数或查询函数。内部读取映射时(在chat、configFor等函数中)使用openAIKeys.get(caller),绝不让密钥离开 canister 边界。迭代器或返回密钥的端点会泄露每位用户的 bearer。 - 缺少密钥时应妥善触发 trap。 使用
Runtime.trap("Set your OpenAI API key first")(或返回类型化错误)——消息应指出缺少密钥,但不得泄露密钥。 - 匿名调用者不得存储密钥。 在任何
openAIKeys.add操作之前,都应通过caller.isAnonymous()直接短路;否则,所有通过2vxsx-fae读取 canister 的人都会共用一个密钥槽。 stable var/ 迁移。Map<Principal, Text>与其他 actor 字段一样存储在稳定内存中;升级时,决定是保留、轮换还是丢弃这些密钥。默认(保留)对几乎所有应用来说都是正确的。如果要轮换,就丢弃整个映射——绝不能只丢弃一部分。
5. 两种调用形式——函数形式与套件形式
每个 Apis 模块都提供以下两种形式:
- 函数形式(在上文 §4 中使用):
ChatApi.createChatCompletion(config, req) : async* T。注意这里是async*——调用处使用await*。这适用于需要传递自身配置的sharedactor 方法,也是最常见的情况。 - 套件形式:
let api = ChatApi(config); api.createChatCompletion(req) : async T。注意这里是async,而不是async*。适用于单个shared方法要调用多次 OpenAI API,且希望只绑定一次配置的情况。它会多出一个await边界,但能减少传递配置所需的样板代码。
两种形式可以互换;选择对调用方来说更清晰的一种即可。不要在同一个 shared 方法体中混用这两种形式。
6. 可用的 API 范围
能否使用取决于具体操作,而非模块:大多数模块都混合了可用和不可用的调用,因此要看具体操作,而不是模块名称。经验法则是输入 JSON,输出 JSON——当某个操作的请求体是 JSON,且成功响应是 JSON 模型时,该操作就可用。
| 操作 | 功能 |
|---|---|
ChatApi.createChatCompletion | Chat / GPT-4o / GPT-4 / GPT-3.5——适用于 95% 的情况。将 tool_choice 和 function_call 留为 null。 |
ChatApi.getChatCompletion / listChatCompletions / getChatCompletionMessages / updateChatCompletion / deleteChatCompletion | 已存储补全的管理。 |
ImagesApi.createImage | DALL·E / gpt-image-1 文本生成图像(JSON 响应:url 或 b64_json)。 |
ModelsApi.listModels / retrieveModel / deleteModel | 发现和模型管理。 |
FilesApi.listFiles / retrieveFile / deleteFile | 文件元数据和删除。 |
AudioApi、EmbeddingsApi、ModerationsApi 或 CompletionsApi 中没有可用操作,而 FilesApi / ImagesApi 仅支持上面列出的操作。
不可用的操作——切勿将构建路由到这些操作
生成器为通用 oneOf 架构生成的转换器是桩实现(混合基元、数组和数组嵌套数组的分支无法通过单个模块分派),因此 toCandidValue / fromCandidValue 会调用 Runtime.unreachable()。这些文件可以通过类型检查,所以仍会随包发布;但首次实际请求时就会触发陷阱。
有两种不同的原因。通用 oneOf 转换器是桩实现——toCandidValue / fromCandidValue 会调用 Runtime.unreachable(),因此调用会触发陷阱:
| 操作 | 原因 | 会触发吗? |
|---|---|---|
EmbeddingsApi.createEmbedding | input : CreateEmbeddingRequestInput 是必填项 | 总是 |
ModerationsApi.createModeration | input : CreateModerationRequestInput 是必填项 | 总是 |
CompletionsApi.createCompletion | prompt : CreateCompletionRequestPrompt | 设置了 prompt 时 |
ChatApi 中使用 tool_choice 或 function_call | 两者都是桩实现的 oneOf 模型 | 仅在设置它们时——保持为 null |
不支持 multipart——这些操作将文件作为 Blob 参数,然后发送 body = null,并使用 JSON 内容类型,因此上传会悄无声息地变成空内容。它们不会触发陷阱,而是在 API 处失败:
| 操作 |
|---|
FilesApi.createFile |
ImagesApi.createImageEdit / createImageVariation |
AudioApi.createTranscription / createTranslation(响应解码同样是桩实现) |
非 JSON 响应体被强制交给 JSON 解码器处理——成功路径始终是 Text.decodeUtf8 → JSON.toCandid → 期望得到一个基元;当端点返回字节或原始文件内容时,这样做行不通。每次调用都会抛出 Error.reject:
| 操作 | 实际返回的响应 |
|---|---|
AudioApi.createSpeech | MP3/Opus/AAC 音频——不是有效的 UTF-8,更不是 JSON |
FilesApi.downloadFile | 原始文件内容(微调文件为 JSONL),不是 JSON 字符串 |
这两种失败都不是回归问题:0.2.5 的行为完全相同——有相同的桩实现,也有相同的 body = null,因此这些操作在任何已发布版本中都从未可用。0.3.0 的变化是现在本节明确说明了这一点。
ChatApi 本身是安全的,因为这两个字段都是可选的,序列化器会跳过 null(§4 中不会包含它们)。如果某个规范需要 embeddings、moderation、legacy completions、speech-to-text 或 tool-calling,请说明这些功能在 0.4.0 中不可用,并在 caffeinelabs/skills-internal 中提出。不要用手写的 ic.http_request 替代,也不要试图“绕过”这个陷阱——调用点没有任何变通方法。
导入遵循以下模式:
import ChatApi "mo:openai-client/Apis/ChatApi";
import { defaultConfig } "mo:openai-client/Config";
import CreateChatCompletionRequest "mo:openai-client/Models/CreateChatCompletionRequest";
[email protected] 未随包发布:Assistants、Realtime、Responses、Batch、Audit Logs、Evals、FineTuning、Invites、Projects、Uploads、Usage、Users、VectorStores。如果构建规范需要其中某项,请在 caffeinelabs/skills-internal 中提出问题——不要用手写的 ic.http_request 来敷衍。
7. Cycles 和响应大小
将 cycles 留空(null):每次调用都会附带 IC 对该请求收取的费用;在出站调用免费的子网上,费用为零,因此长文本补全和图像生成无需手动设置预算。?n 会附带恰好 n 个 cycles:如果 n 低于费用,IC 会拒绝该调用;如果 canister 持有的 cycles 少于 n,则调用会触发陷阱。
- 图像生成:响应可能超过 1 MiB,请设置
max_response_bytes = ?2_000_000。
8. 容易踩坑的事项
is_replicated = ?false——参见 §3。这不是可选项。- 不要暴露 API 密钥。 永远不要从任何
query/shared方法中返回密钥,不要记录密钥,也不要将密钥放入任何非密钥所有者可读取的数据结构中。在按用户存储的默认方案(§4)中,唯一合理的openAIKeys读取方式是针对本次调用者自身调用openAIKeys.get(caller);在管理员密钥方案(§9)中,唯一合理的openAIApiKey读取方式是在chat中解构该值并将密钥传给OpenAI.configForKey。不要使用迭代器、不要输出调试信息、不要提供管理员列表端点。 - 绝不要创建
getApiKey/getMyOpenAIApiKey端点——即使只返回调用者自己的密钥也不行。 前端“需要知道用户是否已设置密钥”时,这是最常见的疏漏:开发者会想到getApiKey() : async ?Text,把持有者令牌返回给 React 应用,随后一次console.log/ 错误提示 / Sentry 面包屑 / 屏幕截图就会泄露计费凭据。前端已经可以通过isMyOpenAIConfigured : async Bool(按用户存储)或isOpenAIConfigured : async Bool(管理员方案)获取所需的全部信息——根据布尔值显示空状态即可。如果 UI 模型中展示了已保存的密钥(无论是否掩码),请从模型中移除已保存密钥字段;后端既不能也不应提供该字段。 - 不要逐一列出所有可选值的
null。 使用CreateChatCompletionRequest.JSON.init({ messages; model }),再通过记录更新添加可选项——该包会为每个包含多个可选项的模型生成JSON.init辅助函数。(这与缺少JSON.init、必须在值处列出所有null的[email protected]不同。不要不加思考地照搬那种模式。) - 不要自行处理 JSON。 绑定已经通过 serde-core / Candid 中转序列化请求正文并解析响应。如果需要绑定未公开的字段,请在
skills-internal提交问题,而不是手动解析——Motoko 的 JSON 支持过于有限,无法保证可靠。 - 不支持流式传输。
stream = ?true无法正常工作——IC management-canister 的http_request会以原子方式返回完整响应正文,没有分块 / SSE 原语。将stream = null。 - 速率限制。 OpenAI 会按密钥限制每分钟请求数(RPM)和每日请求数(RPD)。复制式出站调用会按子网大小成倍增加 RPM——这也是应设置
is_replicated = ?false的又一个理由。遇到 HTTP 429 时应退避。 resp.choices[0].message.content的类型是?Text,而非Text。 拒绝回答、工具调用或仅音频响应都会使其为null。务必对其使用switch;在检查choices.size() > 0前,切勿索引数组。ChatCompletionRequestUserMessageContent是一个变体类型——纯文本使用#string(text),多模态(文本 + image_url 部分)使用#array([...])。常见情况使用#string。ModelIdsShared = Text——这是一个普通字符串别名,而不是变体类型。直接传入"gpt-4o-mini"等值。- 前端绝不持有密钥。 React 应用调用后端的
chat(prompt)(或聊天端点的其他名称),并获取回答。设置界面调用setMyOpenAIApiKey(key)(按用户存储的默认方案)或setOpenAIApiKey(key)(管理员密钥方案)。这里没有 SDK 或前端 npm 包——canister 就是 OpenAI 客户端。
9. 变体:管理员密钥
仅当规范明确规定由运营方承担 OpenAI 费用时,才使用此变体。具体而言:
- 一个 OpenAI 账户承担所有费用(典型的 SaaS 模式)。
- 应用提供免费 / 免费增值层,由运营方付费。
- 应用在 canister 内设置自己的每用户配额,并另行向用户收费。
其他所有情况——尤其是规范提到登录、多个用户,或未说明由谁付费时——都应使用 §4 中的默认每用户方案。只有当“由运营方付费”是明确声明的选择时,管理员密钥变体才合理。
相较于 §4,唯一改变的规则是:用单个 ?Text 取代 Map<Principal, Text>,并且 setter 通过 extension-authorization 中的 #admin 角色进行门控,而不是允许“任何已登录的调用者”。Actor 和 mixin 文件是新增的;§4 中的 src/backend/lib/openai.mo 保持不变。此处的 main.mo 替代 §4 中的版本,其迁移链头提供 AccessControl.initState() 和 { var value = null }。
import AccessControl "mo:caffeineai-authorization/access-control";
import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization";
import MixinOpenAIAdminChat "mixins/openai-admin-chat";
actor {
let accessControlState : AccessControl.AccessControlState;
include MixinAuthorization(accessControlState, null);
// Admin-set OpenAI bearer key. Wrapped in `{ var value : ?Text }` so the
// mixin can mutate it.
let openAIApiKey : { var value : ?Text };
include MixinOpenAIAdminChat(accessControlState, openAIApiKey);
};
import AccessControl "mo:caffeineai-authorization/access-control";
import Runtime "mo:core/Runtime";
import OpenAI "../lib/openai";
// Admin-gated OpenAI key endpoints. Mounted by `main.mo` via `include`.
// Pairs with `MixinAuthorization` to power role checks.
mixin (
accessControlState : AccessControl.AccessControlState,
openAIApiKey : { var value : ?Text },
) {
public query func isOpenAIConfigured() : async Bool {
openAIApiKey.value != null;
};
public shared ({ caller }) func setOpenAIApiKey(key : Text) : async () {
if (not AccessControl.hasPermission(accessControlState, caller, #admin)) {
Runtime.trap("Unauthorized: Only admins can set the OpenAI API key");
};
openAIApiKey.value := ?key;
};
public shared ({ caller }) func chat(prompt : Text) : async Text {
if (not AccessControl.hasPermission(accessControlState, caller, #user)) {
Runtime.trap("Unauthorized");
};
let ?key = openAIApiKey.value else Runtime.trap("OpenAI is not configured");
await* OpenAI.runChatCompletion(OpenAI.configForKey(key), prompt);
};
};
管理员密钥变体专属的不变量
- 单个
?Text槽位({ var value : ?Text = null }),不提供 getter。 此槽位仅由setOpenAIApiKey和chat(后者通过OpenAI.configForKey传入该值)访问。绝不暴露getOpenAIApiKey——isOpenAIConfigured是唯一对外提供的读取接口,且只返回Bool。 - Setter 必须通过
extension-authorization进行#admin门控。 仅限制非匿名用户是不够的——任何已登录用户都可能覆盖运营方的计费密钥。这正是此变体依赖extension-authorization的根本原因。 - 密钥未设置时,使用
"OpenAI is not configured"触发 trap。 该措辞与isOpenAIConfigured相配合,使前端可以呈现“请联系管理员设置 OpenAI API 密钥”的空状态。 - 每次调用都创建新的
Config。chat每次执行时都会读取openAIApiKey,并通过OpenAI.configForKey(key)传入;不要在 actor 层缓存Config值。Bearer 密钥可以在生命周期内通过setOpenAIApiKey轮换,而缓存的Config会悄悄继续使用旧密钥。
10. 变体:完全匿名
仅当规范明确说明完全不需要登录时,才使用此变体(单用户演示、团队内部工具、一次性沙盒)。在实现机制上,它与 §9 相同——使用单个 ?Text 密钥、不提供 getter、提供 isOpenAIConfigured 查询——但移除授权导入和 #admin 门控;任何访客都可以覆盖密钥。
使用 §9 中的两个文件,并应用以下差异(§4 中的 lib/openai.mo 辅助文件保持不变):
在 src/backend/main.mo 中:
- 移除对
mo:caffeineai-authorization/access-control和mo:caffeineai-authorization/MixinAuthorization的导入。 - 从 actor 主体中移除
let accessControlState : AccessControl.AccessControlState;和include MixinAuthorization(accessControlState, null);(并从迁移链头中移除accessControlState条目)。 - 从 mixin 的
include中移除accessControlState参数,仅保留include MixinOpenAIAdminChat(openAIApiKey);。
在 src/backend/mixins/openai-admin-chat.mo 中:
-
移除
AccessControl导入和accessControlStatemixin 参数。 -
将受门控的 setter 替换为:
public shared ({ caller }) func setOpenAIApiKey(key : Text) : async () { if (not AccessControl.hasPermission(accessControlState, caller, #admin)) { Runtime.trap("Unauthorized: Only admins can set the OpenAI API key"); }; openAIApiKey.value := ?key; };改为未认证形式:
public func setOpenAIApiKey(key : Text) : async () { openAIApiKey.value := ?key; }; -
移除
chat开头的#user权限检查。除此之外,chat、isOpenAIConfigured和OpenAI.configForKey(...)调用均与 §9 相同。
匿名变体专属的不变量
- 不导入
extension-authorization。 此变体完全跳过它。 - 密钥由所有人共享,且任何人都可替换。 这是此变体明确接受的权衡;只有在规范认可这一点时才选择它。
- 同样适用不提供 getter / 不记录日志的规则。
openAIApiKey仅在chat中读取(随后传入OpenAI.configForKey),绝不由任何端点返回。 - 每次调用都创建新的
Config——理由与 §9 相同。
前端
使用此技能的所有构建版本都必须提供:
- 始终提供可粘贴密钥的设置界面。 所有变体皆如此。粘贴密钥前,已部署的 canister 会拒绝所有聊天调用。没有设置页面时,聊天机器人界面虽然会加载,但每个问题都会触发“OpenAI 未配置”/“请先设置 OpenAI API 密钥”——对终端用户来说,应用看起来就像坏了一样。
- 仅适用于按用户和管理员密钥变体的登录流程。 这些变体会通过
not caller.isAnonymous()(按用户)或#admin角色(管理员密钥)限制每个有意义的端点;两者都要求调用者不是匿名用户。登录流程由extension-authorization提供:useInternetIdentity、登录/登出按钮,以及用于将已认证身份注入每次后端调用的useActor配置。如果构建版本尚无登录界面,请将其纳入同一任务图。完全匿名变体(§10)明确跳过此界面——无需登录。
选择与后端变体匹配的 UI 形式。默认使用变体 A(按用户),除非规范明确要求由运营方承担 OpenAI 费用(参见 §9),或明确说明无需登录(参见 §10)。
变体 A:按用户使用密钥(对应 §4——默认)
仅由登录状态控制访问的按用户“你的 API 密钥”面板。
- 密码输入框绑定到
setMyOpenAIApiKey(key)。按 Enter 提交;成功后清空输入框。 - 状态指示器由
isMyOpenAIConfigured()(返回Bool)驱动。显示“已配置”/“未配置”——绝不显示密钥本身,也绝不公开返回密钥的 getter。 - 可选的“清除我的密钥”按钮绑定到
clearMyOpenAIApiKey(),供希望从 canister 撤销其密钥的用户使用。 - 当
isMyOpenAIConfigured()为false时,显示一次性引导提示——例如,在聊天页面显示内联空状态,并链接到/settings/openai。没有此提示时,用户会遇到“请先设置 OpenAI API 密钥”,却不知道下一步该怎么做。
建议的路由布局:
/ → Chat UI (any signed-in user; empty-state when no key)
/settings/openai → Personal API-key pane (any signed-in user)
变体 B:管理员密钥(对应 §9)
由管理员控制访问的全局设置页面。
- 密码输入框绑定到
setOpenAIApiKey(key)。按 Enter 提交;成功后清空输入框。 - 状态指示器由
isOpenAIConfigured()(返回Bool)驱动。与变体 A 一样,绝不显示密钥。 - 使用
extension-authorization的isCallerAdmin查询,向非管理员隐藏此页面——非管理员不应看到导航栏中的设置链接,更不应看到设置页面。通过路由器的守卫模式绑定仅限管理员访问的路由(TanStack Router 的beforeLoad、React Router 的loader等);不要只靠隐藏链接来保护页面。 - 当
isOpenAIConfigured()为false时,在聊天页面显示“请联系管理员设置 OpenAI API 密钥”的空状态——非管理员无法自行解决此问题,需要知道谁可以处理。
建议的路由布局:
/ → Chat UI (any signed-in user)
/settings/openai → Admin-only API-key settings page
变体 C:完全匿名(对应 §10)
任何访客都可以访问的全局设置页面——无需认证。
- 密码输入框绑定到
setOpenAIApiKey(key)。按 Enter 提交;成功后清空输入框。 - 状态指示器由
isOpenAIConfigured()(返回Bool)驱动。与变体 A 和 B 一样,绝不显示密钥。 - 不需要路由守卫、
useInternetIdentity或登录按钮——此变体没有认证模式。 - 当
isOpenAIConfigured()为false时,在聊天页面显示“粘贴 OpenAI API 密钥以开始使用”的空状态。
建议的路由布局:
/ → Chat UI (any visitor; empty-state when no key)
/settings/openai → API-key pane (any visitor)
所有变体通用
- 聊天 UI 本身非常简单,而且在所有变体中都相同:一个文本区域、一个提交按钮,以及一个绑定到后端聊天端点的消息列表。没有客户端 OpenAI SDK、密钥处理或流式协议逻辑——一切都由 canister 处理。
- 变体 A 和 B 需要登录,变体 C 则跳过登录。 对于 A 和 B,请通过
extension-authorization的身份验证守卫将聊天和设置路由接入身份验证(useInternetIdentity+ 在!isAuthenticated时重定向);匿名调用者必须先看到“请登录”提示,才能渲染聊天或设置 UI,否则每次后端调用都会 trap。对于 C,由于没有身份验证模型,因此无需守卫。 - 前端绝不会在 localStorage / IndexedDB / cookies 中持久化密钥。密钥会通过类型化的 setter 传入 canister,之后永远不会被读回。
相关内容
mops add [email protected]— 连接器源码。caffeinelabs/skills-internal— 生成的绑定所在仓库(packages/connectors/openai);如果 API 表面缺失或会 trap,请在此处提交 issue。独立的caffeinelabs/openai-client仓库已停止维护。- OpenAI API reference — 上游文档。
- OpenAI API keys page — 管理员获取要粘贴的
sk-...密钥的页面。 - extension-authorization — 单用户(§4)和管理员密钥(§9)变体的必需前置条件;完全匿名(§10)变体跳过此项。 提供 Internet Identity 登录流程、
useInternetIdentity/useActor前端接入,以及(针对 §9 管理员密钥)#admin角色门禁。 - extension-http-outcalls — 用于通用 HTTP 外部调用的配套技能;在
openai-client之外不需要再使用它,因为openai-client会在内部自行进行外部调用。