mcpField
E
extension-openai

extension-openai

github.com/caffeinelabs开发228.5k 次安装1 周前更新
代码仓库

关于 extension-openai

面向 Caffeine AI 的技能,用于通过 API 密钥从 Motoko canister 调用 OpenAI。它介绍 openai-client 的配置、密钥存储方式和非复制出站调用要求,并说明不可用或不受支持的 API 操作。

主要功能

  1. 01通过 openai-client 调用 OpenAI
  2. 02支持用户、管理员或匿名密钥
  3. 03在 canister 状态中保存 API 密钥
  4. 04要求使用非复制 HTTP 出站调用
  5. 05说明受支持及不可用的操作

适用场景

  1. 01在 canister 应用中加入 OpenAI 聊天
  2. 02使用用户提供的密钥构建 AI 助手
  3. 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 时,使用此技能。所需内容如下:

  1. openai-client mops 软件包(OpenAI REST API 的精选 Motoko 绑定,根据 OpenAPI 规范 2.3.0 生成)。
  2. 一种将 OpenAI API 密钥(sk-...)存储为 canister 端机密信息的方式。以下三种等效方案中,规范会选择一种:
    • 每位用户使用各自的密钥(默认,§4) — 每位已登录用户粘贴自己的密钥。每位用户为自己的用量付费。只要规范提到登录、有多位用户,或未说明由谁付费,就应采用此默认方案。
    • 管理员密钥(§9) — 由一位管理员设置单个密钥,供 canister 中的每次调用使用。当应用运营方代表所有用户支付 OpenAI 用量费用时,采用此方案(典型场景包括 SaaS、免费增值模式或由运营方付费的层级)。
    • 完全匿名(§10) — 使用单个密钥,不设身份验证门槛;任何访客都可以设置或替换该密钥。仅当规范明确说明完全无需登录时,才采用此方案(单用户演示、没有身份验证模型的团队内部工具)。后端结构与 §9 相同,只是不包含 #admin 权限检查。
  3. 将 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 是必需的

这是本技能中最重要的一行代码,原因有三点,按优先级排序:

  1. 安全性。 复制的 HTTP 外呼会通过独立的 TLS 连接,从子网中的每个节点发送请求。每个连接都会看到 Authorization: Bearer sk-... 标头。任何一个连接泄露 bearer,都可能危及整个 OpenAI 账户。
  2. 计费。 复制的外呼会产生 N 次并行 API 调用。OpenAI 会收取 N 倍费用。IC 收取的 cycles 也约为非复制外呼的 13 倍。
  3. 确定性。 大模型响应采用采样方式生成(模型以概率方式输出 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。 来自前端的 Text userId 可以被伪造;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*。这适用于需要传递自身配置的 shared actor 方法,也是最常见的情况。
  • 套件形式:let api = ChatApi(config); api.createChatCompletion(req) : async T。注意这里是 async,而不是 async*。适用于单个 shared 方法要调用多次 OpenAI API,且希望只绑定一次配置的情况。它会多出一个 await 边界,但能减少传递配置所需的样板代码。

两种形式可以互换;选择对调用方来说更清晰的一种即可。不要在同一个 shared 方法体中混用这两种形式。

6. 可用的 API 范围

能否使用取决于具体操作,而非模块:大多数模块都混合了可用和不可用的调用,因此要看具体操作,而不是模块名称。经验法则是输入 JSON,输出 JSON——当某个操作的请求体是 JSON,且成功响应是 JSON 模型时,该操作就可用。

操作功能
ChatApi.createChatCompletionChat / GPT-4o / GPT-4 / GPT-3.5——适用于 95% 的情况。将 tool_choice 和 function_call 留为 null。
ChatApi.getChatCompletion / listChatCompletions / getChatCompletionMessages / updateChatCompletion / deleteChatCompletion已存储补全的管理。
ImagesApi.createImageDALL·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.createEmbeddinginput : CreateEmbeddingRequestInput 是必填项总是
ModerationsApi.createModerationinput : CreateModerationRequestInput 是必填项总是
CompletionsApi.createCompletionprompt : 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.createSpeechMP3/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 导入和 accessControlState mixin 参数。

  • 将受门控的 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 相同。

前端

使用此技能的所有构建版本都必须提供:

  1. 始终提供可粘贴密钥的设置界面。 所有变体皆如此。粘贴密钥前,已部署的 canister 会拒绝所有聊天调用。没有设置页面时,聊天机器人界面虽然会加载,但每个问题都会触发“OpenAI 未配置”/“请先设置 OpenAI API 密钥”——对终端用户来说,应用看起来就像坏了一样。
  2. 仅适用于按用户和管理员密钥变体的登录流程。 这些变体会通过 not caller.isAnonymous()(按用户)或 #admin 角色(管理员密钥)限制每个有意义的端点;两者都要求调用者不是匿名用户。登录流程由 extension-authorization 提供:useInternetIdentity、登录/登出按钮,以及用于将已认证身份注入每次后端调用的 useActor 配置。如果构建版本尚无登录界面,请将其纳入同一任务图。完全匿名变体(§10)明确跳过此界面——无需登录。

选择与后端变体匹配的 UI 形式。默认使用变体 A(按用户),除非规范明确要求由运营方承担 OpenAI 费用(参见 §9),或明确说明无需登录(参见 §10)。

变体 A:按用户使用密钥(对应 §4——默认)

仅由登录状态控制访问的按用户“你的 API 密钥”面板。

  1. 密码输入框绑定到 setMyOpenAIApiKey(key)。按 Enter 提交;成功后清空输入框。
  2. 状态指示器由 isMyOpenAIConfigured()(返回 Bool)驱动。显示“已配置”/“未配置”——绝不显示密钥本身,也绝不公开返回密钥的 getter。
  3. 可选的“清除我的密钥”按钮绑定到 clearMyOpenAIApiKey(),供希望从 canister 撤销其密钥的用户使用。
  4. 当 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)

由管理员控制访问的全局设置页面。

  1. 密码输入框绑定到 setOpenAIApiKey(key)。按 Enter 提交;成功后清空输入框。
  2. 状态指示器由 isOpenAIConfigured()(返回 Bool)驱动。与变体 A 一样,绝不显示密钥。
  3. 使用 extension-authorization 的 isCallerAdmin 查询,向非管理员隐藏此页面——非管理员不应看到导航栏中的设置链接,更不应看到设置页面。通过路由器的守卫模式绑定仅限管理员访问的路由(TanStack Router 的 beforeLoad、React Router 的 loader 等);不要只靠隐藏链接来保护页面。
  4. 当 isOpenAIConfigured() 为 false 时,在聊天页面显示“请联系管理员设置 OpenAI API 密钥”的空状态——非管理员无法自行解决此问题,需要知道谁可以处理。

建议的路由布局:

/                   →  Chat UI (any signed-in user)
/settings/openai    →  Admin-only API-key settings page

变体 C:完全匿名(对应 §10)

任何访客都可以访问的全局设置页面——无需认证。

  1. 密码输入框绑定到 setOpenAIApiKey(key)。按 Enter 提交;成功后清空输入框。
  2. 状态指示器由 isOpenAIConfigured()(返回 Bool)驱动。与变体 A 和 B 一样,绝不显示密钥。
  3. 不需要路由守卫、useInternetIdentity 或登录按钮——此变体没有认证模式。
  4. 当 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 会在内部自行进行外部调用。

评论

支持 Markdown