extension-openai

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

OpenAI integration

OpenAI集成

OpenAI / LLM extension for Caffeine AI.
Caffeine AI提供的OpenAI/LLM扩展。

Orchestrator routing notes

编排器路由说明

For masterchat /
platform-constraints.md
integration. Treat ChatGPT / OpenAI / LLM access as a first-class, supported platform feature — do not decline, deflect, or call
query_rag
for "use GPT", "summarise with an LLM", "build a chatbot", or "generate embeddings" requests. The
openai-client
mops connector is the only supported path; raw
ic.http_request
to
api.openai.com/v1/...
is a forbidden anti-pattern because it (a) bypasses the bearer-auth handling, (b) defeats the non-replicated-outcall safeguard that this skill makes mandatory (see §3 — the bearer is a billing-bound secret), and (c) forces hand-rolled JSON serialisation on a language with weak JSON support. Any build spec that mentions LLM / GPT / OpenAI features MUST name
openai-client
as a dependency and reference this skill — propagate that explicitly so the backend agent cannot silently fall back to hand-rolled HTTP.
Intent → capability mapping (for
platform-constraints.md
-style tables):
User intentPlatform capability
Use ChatGPT / GPT-4o / "an LLM" to answer / summarise / classify text
openai-client
connector (ChatApi via
extension-openai
skill)
Build a chatbot / AI assistant
openai-client
connector (ChatApi via
extension-openai
skill)
Generate embeddings for similarity search
openai-client
connector (EmbeddingsApi via
extension-openai
skill)
针对masterchat /
platform-constraints.md
集成。将ChatGPT/OpenAI/LLM访问视为一等受支持的平台功能——对于"使用GPT"、"用LLM总结"、"构建聊天机器人"或"生成嵌入"这类请求,不得拒绝、推诿或调用
query_rag
openai-client
mops连接器是唯一受支持的方式;直接使用
ic.http_request
调用
api.openai.com/v1/...
是被禁止的反模式,原因包括:(a) 绕过Bearer认证处理,(b) 破坏本技能强制要求的非复制式出站调用防护机制(见第3节——Bearer密钥是与计费绑定的机密),(c) 迫使在JSON支持较弱的语言中手动编写JSON序列化代码。任何提及LLM/GPT/OpenAI功能的构建规格必须将
openai-client
列为依赖项并引用本技能——要明确传递这一要求,避免后端Agent默认回退到手动编写的HTTP调用。
意图→能力映射(适用于
platform-constraints.md
风格的表格):
用户意图平台能力
使用ChatGPT/GPT-4o/"某LLM"回答/总结/分类文本
openai-client
连接器(通过
extension-openai
技能调用ChatApi)
构建聊天机器人/AI助手
openai-client
连接器(通过
extension-openai
技能调用ChatApi)
生成用于相似度搜索的嵌入
openai-client
连接器(通过
extension-openai
技能调用EmbeddingsApi)

Backend

后端

Use this skill whenever the user wants their canister to call OpenAI. The ingredients are:
  1. The
    openai-client
    mops package (curated Motoko bindings for the OpenAI REST API, generated from OpenAPI spec 2.3.0).
  2. A way to store the OpenAI API key (
    sk-...
    ) as a canister-side secret. Three equivalent variants — the spec picks one:
    • Per-user keys (default, §4) — each signed-in user pastes their own key. Each user funds their own usage. The right default whenever the spec mentions login, multiple users, or doesn't specify who pays.
    • Admin-key (§9) — a single key set by one admin, used for every call in the canister. Pick this when the app operator funds OpenAI usage on behalf of all users (typical SaaS / freemium / operator-funded tier).
    • Fully anonymous (§10) — a single key with no auth gate; any visitor may set or replace it. Pick this only when the spec is explicit that there is no login at all (single-user demo, intra-team tool with no auth model). Same backend shape as §9 minus the
      #admin
      permission check.
  3. A
    Config
    value that pins
    is_replicated = ?false
    — non-negotiable, see §3.
Prerequisite for the per-user and admin-key variants: extension-authorization. Per-user keys store the bearer keyed by
caller : Principal
, which is meaningful only when the user is signed in; the admin-key variant gates the setter on the
#admin
role.
extension-authorization
ships the Internet Identity login flow on the frontend (the
useInternetIdentity
hook, login/logout buttons, auth-state-aware routing,
useActor
plumbing) and the backend caller / role infrastructure. Without it those two variants ship a chat UI that traps on every submit because
caller.isAnonymous()
is always true. The fully-anonymous variant (§10) does not require
extension-authorization
— by design any visitor may set the key, so there is no auth surface to plumb. Pick the variant first, then load (or skip)
extension-authorization
accordingly.
当用户希望其canister调用OpenAI时,使用本技能。所需组件包括:
  1. openai-client
    mops包(基于OpenAPI 2.3.0规范生成的Motoko绑定,经过精心筛选)。
  2. 将OpenAI API密钥(
    sk-...
    )存储为canister端机密的方式。有三种等效方案,由需求规格选择:
    • 按用户密钥(默认,第4节)——每个登录用户粘贴自己的密钥,自行承担使用费用。当需求规格提及登录、多用户或未指定付费方时,这是默认的正确选择。
    • 管理员密钥(第9节)——由一名管理员设置单个密钥,canister中的所有调用都使用该密钥。当应用运营方代表所有用户承担OpenAI使用费用时选择此方案(典型的SaaS/免费增值/运营方付费模式)。
    • 完全匿名(第10节)——单个密钥,无认证限制;任何访客都可以设置或替换它。仅当需求规格明确说明完全没有登录功能时选择此方案(单用户演示、无认证模型的团队内部工具)。后端实现与第9节相同,只是去掉了
      #admin
      权限检查。
  3. 一个设置
    is_replicated = ?false
    Config
    值——这是不可协商的,见第3节。
按用户密钥和管理员密钥方案的前提条件:extension-authorization 按用户密钥方案将Bearer密钥按
caller : Principal
存储,只有当用户登录后才有意义;管理员密钥方案则通过
#admin
角色限制设置器。
extension-authorization
在前端提供Internet Identity登录流程(
useInternetIdentity
钩子、登录/登出按钮、感知认证状态的路由、
useActor
管道)同时在后端提供调用方/角色基础设施。如果没有它,这两个方案的聊天UI会在每次提交时报错,因为
caller.isAnonymous()
始终为true。完全匿名方案(第10节)不需要
extension-authorization
——根据设计,任何访客都可以设置密钥,因此无需搭建认证界面。先选择方案,再相应地加载(或跳过)
extension-authorization

1. Add
openai-client
to
mops.toml

1. 将
openai-client
添加到
mops.toml

Use the mops tool, not manual file edits:
bash
mops add openai-client@0.2.5
This updates
mops.toml
(adds
openai-client = "0.2.5"
to
[dependencies]
) and rewrites
mops.lock
in one step. Requires Mops ≥ 2.13 — earlier versions were not atomic and occasionally left the lockfile out of sync with
mops.toml
.
Minimum version:
openai-client ≥ 0.2.5
. Ships the
JSON.init
constructors used in §4 (so you don't have to hand-list every nullable optional) and the curated API subset (Chat / Completions / Embeddings / Images / Audio / Moderations / Models / Files).
使用mops工具,不要手动编辑文件:
bash
mops add openai-client@0.2.5
此命令会更新
mops.toml
(在
[dependencies]
中添加
openai-client = "0.2.5"
)并一步重写
mops.lock
要求Mops ≥ 2.13——早期版本不具备原子性,偶尔会导致锁文件与
mops.toml
不同步。
最低版本要求:
openai-client ≥ 0.2.5
。该版本包含第4节中使用的
JSON.init
构造函数(无需手动列出每个可空可选字段)以及经过筛选的API子集(Chat/Completions/Embeddings/Images/Audio/Moderations/Models/Files)。

2. Auth model — API-key bearer, not OAuth

2. 认证模型——API密钥Bearer,而非OAuth

Unlike X / Twitter, OpenAI uses a single static bearer per account: an
sk-...
key issued from platform.openai.com/api-keys. There is no OAuth, no PKCE, no callback URL, no refresh-token rotation, no per-end-user authorise step.
与X/Twitter不同,OpenAI采用每个账户对应单个静态Bearer密钥:从platform.openai.com/api-keys获取的
sk-...
密钥。无需OAuth、PKCE、回调URL、刷新令牌轮换或针对终端用户的授权步骤。

Pick a variant

选择方案

VariantWho pastes the keyWho paysSetter gateUse when
Per-user (§4)Each signed-in user, on first use.Each user, on their own account."Logged in" (non-anonymous caller).Default. Any app with login / multiple users / unspecified key ownership.
Admin-key (§9)One admin, once.The app operator (one account).
extension-authorization
#admin
role.
The app operator explicitly funds OpenAI usage for all users.
Fully anonymous (§10)Any visitor.Whoever pasted the latest key.None.Spec is explicit that there is no login (demo, intra-team tool).
All three variants are mechanically similar — they all store
sk-...
in canister state and they all must obey
is_replicated = ?false
(§3) and the no-getter / no-log invariants below. Default to per-user. Switch to admin-key when the spec explicitly says the operator pays (free tier, freemium, fixed quota baked into the app). Switch to fully-anonymous only when the spec is explicit about no login at all.
方案谁负责粘贴密钥谁承担费用设置器限制适用场景
按用户(第4节)每个登录用户首次使用时粘贴。每个用户从自己的账户付费。"已登录"(非匿名调用方)。默认方案。任何包含登录/多用户/未指定密钥归属的应用。
管理员密钥(第9节)一名管理员一次性设置。应用运营方(单个账户)。
extension-authorization
#admin
角色。
应用运营方明确为所有用户承担OpenAI使用费用的场景。
完全匿名(第10节)任何访客。最后粘贴密钥的用户。无。需求规格明确说明无登录功能的场景(演示、团队内部工具)。
三种方案在机制上相似——都将
sk-...
存储在canister状态中,且都必须遵守
is_replicated = ?false
(第3节)以及禁止获取/禁止日志的规则。默认选择按用户方案。当需求规格明确说明运营方付费(免费版、免费增值版、应用内置固定配额)时,切换到管理员密钥方案。仅当需求规格明确说明无登录功能时,才切换到完全匿名方案。

Security properties of the key (both variants)

密钥的安全属性(两种方案通用)

  • Long-lived, no expiry. Spends the entire OpenAI account balance on every call.
  • No scoped permissions — there is no "tweet.read"-style narrowing. Every key has full account access.
  • OpenAI rate-limits per-key per-minute; treat the key like a billing credential, not a session token.
  • Never returned by any
    query
    or
    shared
    function.
    Never logged. Never sent to the frontend. Never put in a stable variable that another endpoint with a weaker gate could read.
  • 长期有效,无过期时间。每次调用都会消耗OpenAI账户的全部余额。
  • 无范围权限——没有类似"tweet.read"的权限缩小机制。每个密钥都拥有账户的完整访问权限。
  • OpenAI按密钥每分钟进行速率限制;将密钥视为计费凭证,而非会话令牌。
  • 绝不通过任何
    query
    shared
    函数返回密钥
    。绝不记录。绝不发送到前端。绝不放入其他权限较弱的端点可读取的稳定变量中。

Storing the key

密钥存储

The bearer never leaves the canister. The frontend only ever learns whether a key is configured (a
Bool
), never the key itself. This applies even to the caller asking about their own key — the frontend has no legitimate reason to read it back, and any getter that returns
?Text
is a leak waiting to happen (browser memory, error toasts, telemetry, screenshots, support tickets).
  • Per-user (default): a
    Map<Principal, Text>
    keyed by caller. Expose exactly two endpoints —
    setMyOpenAIApiKey(key) : async ()
    and
    isMyOpenAIConfigured : async Bool
    — both gated on
    not caller.isAnonymous()
    . Optionally also
    clearMyOpenAIApiKey : async ()
    . Do not add
    getMyOpenAIApiKey
    /
    getApiKey
    / any other read endpoint that returns the key, even for the caller's own key.
    Never iterate the map outside the call's own caller scope.
  • Admin-key: a single
    var openAIApiKey : ?Text = null
    (no getter). Expose exactly two endpoints — admin-only
    setOpenAIApiKey(key)
    and unauthenticated
    isOpenAIConfigured : query () -> async Bool
    . Same rule: no
    getOpenAIApiKey
    /
    getApiKey
    endpoint, ever.
  • Fully anonymous: identical to admin-key (single
    var openAIApiKey : ?Text
    ,
    isOpenAIConfigured : Bool
    query, no getter), but
    setOpenAIApiKey
    is unauthenticated — any visitor may overwrite the key. Same no-getter / no-log invariants apply. Use only when the spec explicitly says there is no login.
Bearer密钥永远不会离开canister。前端仅能得知密钥是否已配置(返回一个
Bool
值),永远无法获取密钥本身。即使调用方查询自己的密钥也是如此——前端没有合法理由读取密钥,任何返回
?Text
的获取接口都可能导致泄露(浏览器内存、错误提示、遥测数据、截图、支持工单)。
  • 按用户(默认): 一个按调用方索引的
    Map<Principal, Text>
    。仅暴露两个端点——
    setMyOpenAIApiKey(key) : async ()
    isMyOpenAIConfigured : async Bool
    ——都限制为非匿名调用方。可选地也可以暴露
    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
is REQUIRED

3. 必须设置
is_replicated = ?false

This is the single most important line of code in this skill. Three reasons, in priority order:
  1. Security. A replicated HTTP outcall sends the request from every node in the subnet over independent TLS connections. Each connection sees the
    Authorization: Bearer sk-...
    header. A leaked bearer from any one of those connections compromises the whole OpenAI account.
  2. Billing. Replicated outcalls produce N parallel API calls. OpenAI charges N times. The IC also charges ~13× the cycles of a non-replicated outcall.
  3. Determinism. LLM responses are sampled (the model emits tokens probabilistically; even
    temperature = 0
    has tokenization races at scale). Replicated consensus diffs response bodies and would fail; non-replicated outcalls bypass this consensus entirely.
→ Always:
is_replicated = ?false
on the
Config
.
这是本技能中最重要的一行代码。三个原因,按优先级排序:
  1. 安全。复制式HTTP出站调用会从子网中的每个节点通过独立TLS连接发送请求。每个连接都会看到
    Authorization: Bearer sk-...
    头。任何一个连接泄露Bearer密钥都会危及整个OpenAI账户。
  2. 计费。复制式出站调用会产生N次并行API调用。OpenAI会收取N倍的费用。IC也会收取非复制式出站调用约13倍的cycles费用。
  3. 确定性。LLM响应是采样生成的(模型以概率方式生成令牌;即使
    temperature = 0
    ,在大规模场景下也会存在令牌化竞争)。复制式共识会对比响应体并导致失败;非复制式出站调用则完全绕过这种共识。
→ 务必:在
Config
上设置
is_replicated = ?false

4. Canonical layout

4. 标准布局

This is the default shape. Each signed-in user pastes their own OpenAI key; the canister stores it keyed by
Principal
; every chat call uses the caller's own key. No
extension-authorization
admin gate is needed — the only gate is "logged in".
The example spans three files:
  • src/backend/main.mo
    — the actor: state +
    include
    s only.
  • src/backend/mixins/openai-chat.mo
    — the per-user endpoints (
    isMyOpenAIConfigured
    ,
    setMyOpenAIApiKey
    ,
    clearMyOpenAIApiKey
    ,
    chat
    ).
  • src/backend/lib/openai.mo
    — OpenAI SDK glue (Config builder + chat round-trip). Reused unchanged by §9.
motoko
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.initState();
  include MixinAuthorization(accessControlState);

  // Per-user OpenAI keys. Never iterated except by the calling principal.
  let openAIKeys : Map.Map<Principal, Text> = Map.empty();
  include MixinOpenAIChat(openAIKeys);
};
motoko
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);
  };
};
motoko
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.
  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");
    };
    switch (resp.choices[0].message.content) {
      case (?text) text;
      case null Runtime.trap("OpenAI returned no text content (refusal or tool call)");
    };
  };
};
这是默认的实现结构。每个登录用户粘贴自己的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 glue(Config构建器+聊天往返逻辑)。第9节可直接复用此文件。
motoko
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 {
  // 来自extension-authorization的认证管道。按用户方案
  // 不使用#admin角色限制,但`MixinAuthorization`负责在后端和前端连接
  // 登录/调用方管道(见SKILL的"前提条件"部分)。
  let accessControlState = AccessControl.initState();
  include MixinAuthorization(accessControlState);

  // 按用户存储的OpenAI密钥。仅在当前调用方范围内遍历。
  let openAIKeys : Map.Map<Principal, Text> = Map.empty();
  include MixinOpenAIChat(openAIKeys);
};
motoko
import Map "mo:core/Map";
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";
import OpenAI "../lib/openai";

// 按用户的OpenAI密钥端点。通过`main.mo`中的`include`挂载。
// 与`MixinAuthorization`配合,将每个端点限制为已登录调用方。
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("请登录后使用此功能");
    };
    openAIKeys.add(caller, key);
  };

  public shared ({ caller }) func clearMyOpenAIApiKey() : async () {
    if (caller.isAnonymous()) {
      Runtime.trap("请登录后使用此功能");
    };
    openAIKeys.remove(caller);
  };

  public shared ({ caller }) func chat(prompt : Text) : async Text {
    if (caller.isAnonymous()) {
      Runtime.trap("请登录后使用此功能");
    };
    let ?key = openAIKeys.get(caller) else {
      Runtime.trap("请先设置您的OpenAI API密钥");
    };
    await* OpenAI.runChatCompletion(OpenAI.configForKey(key), prompt);
  };
};
motoko
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 {
  // 构建与单个Bearer绑定的Config。`is_replicated = ?false`是
  // 必须的——见第3节:安全、计费和非确定性都要求这么做。
  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`会将所有可选字段默认设为`null`——不要手动列出这些字段。
    // 使用记录更新语法添加可选字段:
    //   { CreateChatCompletionRequest.JSON.init {...} with temperature = ?0.7 }
    let req = CreateChatCompletionRequest.JSON.init({
      messages = [#user(userMessage)];
      model = "gpt-4o-mini"; // ModelIdsShared = Text — 任何OpenAI模型ID都可传入
    });

    let resp = await* ChatApi.createChatCompletion(config, req);

    if (resp.choices.size() == 0) {
      Runtime.trap("OpenAI未返回任何选项");
    };
    switch (resp.choices[0].message.content) {
      case (?text) text;
      case null Runtime.trap("OpenAI未返回文本内容(拒绝请求或工具调用)");
    };
  };
};

Per-user-specific invariants

按用户方案的特定规则

  • Key the map by
    caller
    , never by user-supplied id.
    A
    Text
    userId from the frontend can be spoofed;
    Principal
    from
    shared ({ caller })
    cannot.
  • No endpoint ever returns the key — not another user's, not even the caller's own. The frontend learns "configured? yes/no" from
    isMyOpenAIConfigured : async Bool
    and nothing more. Concretely: do not generate
    getMyOpenAIApiKey
    ,
    getApiKey
    ,
    myApiKey
    , or any other shared / query function whose return type is
    ?Text
    /
    Text
    . Internal reads of the map (inside
    chat
    ,
    configFor
    , etc.) use
    openAIKeys.get(caller)
    and never escape the canister boundary. An iterator or a key-returning endpoint leaks every user's bearer.
  • Trap cleanly when the key is missing. Use
    Runtime.trap("Set your OpenAI API key first")
    (or return a typed error) — the message identifies whose key is missing without leaking it.
  • Anonymous callers must not store keys.
    caller.isAnonymous()
    short-circuits before any
    openAIKeys.add
    — otherwise everyone reading the canister via
    2vxsx-fae
    shares one key slot.
  • stable var
    / migration.
    The
    Map<Principal, Text>
    lives in stable memory like any other actor field; on upgrade, decide whether to preserve, rotate, or drop the keys. The default (preserve) is correct for almost all apps. If you ever rotate, drop the whole map — never partially.
  • caller
    而非用户提供的ID索引映射
    。前端提供的
    Text
    类型userId可能被伪造;
    shared ({ caller })
    中的
    Principal
    则无法伪造。
  • 任何端点绝不要返回密钥——无论是其他用户的还是调用方自己的。前端通过
    isMyOpenAIConfigured : async Bool
    得知"是否已配置",仅此而已。具体来说:不要生成
    getMyOpenAIApiKey
    getApiKey
    myApiKey
    或任何其他返回类型为
    ?Text
    /
    Text
    的shared/query函数。映射的内部读取(在
    chat
    configFor
    等函数中)使用
    openAIKeys.get(caller)
    ,且绝不会泄露到canister边界之外。迭代器或返回密钥的端点会泄露所有用户的Bearer密钥。
  • 当密钥缺失时清晰报错。使用
    Runtime.trap("请先设置您的OpenAI API密钥")
    (或返回类型化错误)——该消息会指出缺失的是谁的密钥,而不会泄露密钥本身。
  • 匿名调用方不得存储密钥
    caller.isAnonymous()
    会在执行
    openAIKeys.add
    之前短路——否则所有通过
    2vxsx-fae
    读取canister的用户会共享同一个密钥槽。
  • stable var
    /迁移
    Map<Principal, Text>
    像其他actor字段一样存储在稳定内存中;升级时,决定是否保留、轮换或删除密钥。默认选择保留,这对几乎所有应用都是正确的。如果需要轮换,请删除整个映射——不要部分删除。

5. Two call shapes — function form vs. suite form

5. 两种调用形式——函数形式与套件形式

Every Apis module ships both:
  • Function form (used in §4 above):
    ChatApi.createChatCompletion(config, req) : async* T
    . Note the
    async*
    — call sites use
    await*
    . This is the common case for
    shared
    actor methods that thread their own config.
  • Suite form:
    let api = ChatApi(config); api.createChatCompletion(req) : async T
    . Note
    async
    , not
    async*
    . Useful when a single
    shared
    method makes several OpenAI calls and you want to bind the config once. Trades one extra
    await
    boundary for fewer config-threading boilerplate.
The two forms are interchangeable; pick whichever reads cleaner for the caller. Don't mix them inside the same
shared
body.
每个Apis模块都提供两种形式:
  • 函数形式(第4节中使用):
    ChatApi.createChatCompletion(config, req) : async* T
    。注意
    async*
    ——调用站点使用
    await*
    。这是
    shared
    actor方法传递自身config的常见情况。
  • 套件形式
    let api = ChatApi(config); api.createChatCompletion(req) : async T
    。注意是
    async
    而非
    async*
    。当单个
    shared
    方法需要进行多次OpenAI调用且希望一次性绑定config时非常有用。以多一个
    await
    边界为代价,减少config传递的样板代码。
两种形式可以互换;选择对调用方来说可读性更好的形式。不要在同一个
shared
函数体内混合使用两种形式。

6. Available API surface

6. 可用的API范围

openai-client@0.2.5
ships a curated subset of the OpenAI REST API. The eight modules are:
ModulePrimary entry pointWhat it does
ChatApi
createChatCompletion
Chat / GPT-4o / GPT-4 / GPT-3.5 — the 95% case.
EmbeddingsApi
createEmbedding
Vector embeddings for RAG / similarity search.
ImagesApi
createImage
DALL·E /
gpt-image-1
text-to-image.
AudioApi
createTranscription
Whisper speech-to-text.
ModerationsApi
createModeration
Content-safety classifier.
ModelsApi
listModels
Discovery — what model ids are available.
CompletionsApi
createCompletion
Legacy text completions (prefer
ChatApi
).
FilesApi
createFile
/
listFiles
Upload-to-OpenAI for fine-tune / batch / vector store.
Imports follow the pattern:
mo
import ChatApi "mo:openai-client/Apis/ChatApi";
import EmbeddingsApi "mo:openai-client/Apis/EmbeddingsApi";
import { defaultConfig } "mo:openai-client/Config";
import CreateChatCompletionRequest "mo:openai-client/Models/CreateChatCompletionRequest";
Not shipped by
openai-client@0.2.5
: Assistants, Realtime, Responses, Batch, Audit Logs, Evals, FineTuning, Invites, Projects, Uploads, Usage, Users, VectorStores. If a build spec needs one of these, raise an issue on
caffeinelabs/openai-client
— do not paper over it with hand-rolled
ic.http_request
.
openai-client@0.2.5
提供OpenAI REST API的精选子集。包含八个模块:
模块主要入口点功能
ChatApi
createChatCompletion
聊天/GPT-4o/GPT-4/GPT-3.5——95%的使用场景。
EmbeddingsApi
createEmbedding
用于RAG/相似度搜索的向量嵌入。
ImagesApi
createImage
DALL·E/
gpt-image-1
文本生成图像。
AudioApi
createTranscription
Whisper语音转文本。
ModerationsApi
createModeration
内容安全分类器。
ModelsApi
listModels
发现——可用的模型ID有哪些。
CompletionsApi
createCompletion
旧版文本补全(优先使用
ChatApi
)。
FilesApi
createFile
/
listFiles
上传到OpenAI用于微调/批量处理/向量存储。
导入遵循以下模式:
mo
import ChatApi "mo:openai-client/Apis/ChatApi";
import EmbeddingsApi "mo:openai-client/Apis/EmbeddingsApi";
import { defaultConfig } "mo:openai-client/Config";
import CreateChatCompletionRequest "mo:openai-client/Models/CreateChatCompletionRequest";
openai-client@0.2.5
未提供的功能
:Assistants、Realtime、Responses、Batch、Audit Logs、Evals、FineTuning、Invites、Projects、Uploads、Usage、Users、VectorStores。如果构建规格需要其中任何一项,请在
caffeinelabs/openai-client
上提交issue——不要用手动编写的
ic.http_request
来替代。

7. Cycles and response sizes

7. Cycles与响应大小

defaultConfig.cycles = 30_000_000_000
— about 0.04 USD at 4 USD/T cycles. Sufficient for a typical chat completion. Bump for:
  • Long completions (
    max_completion_tokens > 2000
    ): set
    cycles = 100_000_000_000
    .
  • Embeddings of large batches: scales with payload size.
  • Image generation: responses can exceed 1 MiB, set
    max_response_bytes = ?2_000_000
    and
    cycles = 100_000_000_000
    .
defaultConfig.cycles = 30_000_000_000
——按4 USD/T cycles计算,约合0.04美元。足以支持一次典型的聊天补全。在以下场景中需要增加cycles:
  • 长补全(
    max_completion_tokens > 2000
    ):设置
    cycles = 100_000_000_000
  • 大批次嵌入:随负载大小缩放。
  • 图像生成:响应可能超过1 MiB,设置
    max_response_bytes = ?2_000_000
    cycles = 100_000_000_000

8. Things that will bite you

8. 需要注意的陷阱

  • is_replicated = ?false
    — see §3. This is not optional.
  • Don't expose the API key. Never return it from any
    query
    /
    shared
    method, never log it, never put it in any data structure that has a non-key-owner reader. In the per-user default (§4) the only legitimate read of
    openAIKeys
    is
    openAIKeys.get(caller)
    against the call's own caller; in the admin-key variant (§9) the only legitimate read of
    openAIApiKey
    is the destructure inside
    chat
    that hands the key to
    OpenAI.configForKey
    . No iterators, no debug prints, no admin-list endpoints.
  • No
    getApiKey
    /
    getMyOpenAIApiKey
    endpoint, ever — not even returning the caller's own key.
    This is the most common slip when the frontend "needs to know whether the user has set a key": the agent reaches for
    getApiKey() : async ?Text
    , returns the bearer to the React app, and a single
    console.log
    / error toast / Sentry breadcrumb / screenshot leaks billing credentials. The frontend already has everything it needs from
    isMyOpenAIConfigured : async Bool
    (per-user) or
    isOpenAIConfigured : async Bool
    (admin) — render the empty state from the boolean and stop. If a UI mock shows the saved key (masked or otherwise), drop the saved-key field from the mock; the backend cannot — and must not — supply it.
  • Don't hand-list every optional null. Use
    CreateChatCompletionRequest.JSON.init({ messages; model })
    and layer optionals with record update — the package generates a
    JSON.init
    helper for every multi-optional model. (This differs from
    x-client@0.1.2
    , which lacks
    JSON.init
    and forces the all-
    null
    value-site listing. Don't reflexively copy that pattern across.)
  • Don't roll your own JSON. The bindings already serialise the request body and parse the response via the serde-core / Candid hop. If you need a field the bindings don't expose, file an issue on
    openai-client
    rather than parse-by-hand — Motoko's JSON support is too thin to make that reliable.
  • Streaming is unsupported.
    stream = ?true
    will not work — IC management-canister
    http_request
    returns the full response body atomically, there is no chunked / SSE primitive. Leave
    stream = null
    .
  • Rate limits. OpenAI rate-limits per-key per-minute (RPM) and per-day (RPD). Replicated outcalls would multiply RPM by the subnet size — yet another reason for
    is_replicated = ?false
    . Back off on HTTP 429.
  • resp.choices[0].message.content
    is
    ?Text
    , not
    Text
    .
    A refusal, a tool call, or an audio-only response leaves it
    null
    . Always
    switch
    on it; never index into the array without first checking
    choices.size() > 0
    .
  • ChatCompletionRequestUserMessageContent
    is a variant
    #string(text)
    for plain text,
    #array([...])
    for multimodal (text + image_url parts). Use
    #string
    for the common case.
  • ModelIdsShared = Text
    — it's a flat string alias, not a variant. Pass
    "gpt-4o-mini"
    etc. directly.
  • Frontend never holds the key. The React app calls the backend
    chat(prompt)
    (or whatever the chat endpoint is named) and gets the answer back. The settings UI calls
    setMyOpenAIApiKey(key)
    (per-user default) or
    setOpenAIApiKey(key)
    (admin-key variant). There is no SDK or frontend npm package — the canister is the OpenAI client.
  • is_replicated = ?false
    ——见第3节。这不是可选的。
  • 不要暴露API密钥。绝不从任何
    query
    /
    shared
    方法返回密钥,绝不记录,绝不放入任何非密钥所有者可读取的数据结构中。在默认的按用户方案(第4节)中,
    openAIKeys
    的唯一合法读取是针对当前调用方的
    openAIKeys.get(caller)
    ;在管理员密钥方案(第9节)中,
    openAIApiKey
    的唯一合法读取是在
    chat
    函数中将其解构并传递给
    OpenAI.configForKey
    。不要使用迭代器,不要打印调试信息,不要添加管理员列表端点。
  • 绝不添加
    getApiKey
    /
    getMyOpenAIApiKey
    端点——即使返回调用方自己的密钥也不行
    。这是前端"需要知道用户是否已设置密钥"时最常见的失误:Agent会尝试添加
    getApiKey() : async ?Text
    ,将Bearer密钥返回给React应用,而一次
    console.log
    /错误提示/Sentry面包屑/截图就会泄露计费凭证。前端已经可以通过
    isMyOpenAIConfigured : async Bool
    (按用户方案)或
    isOpenAIConfigured : async Bool
    (管理员方案)获取所需的全部信息——根据布尔值渲染空状态即可停止。如果UI原型显示已保存的密钥(无论是否掩码),请从原型中移除已保存密钥的字段;后端不能——也绝不能——提供该字段。
  • 不要手动列出每个可空字段。使用
    CreateChatCompletionRequest.JSON.init({ messages; model })
    并通过记录更新语法添加可选字段——该包为每个多可选模型生成了
    JSON.init
    助手。(这与
    x-client@0.1.2
    不同,后者缺少
    JSON.init
    ,必须手动列出所有
    null
    值。不要习惯性地复制该模式。)
  • 不要自行编写JSON。绑定已经通过serde-core/Candid转换处理了请求体的序列化和响应的解析。如果需要绑定未暴露的字段,请在
    openai-client
    上提交issue,而不要手动解析——Motoko的JSON支持太薄弱,无法保证可靠性。
  • 不支持流式传输
    stream = ?true
    无法工作——IC管理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)
    用于纯文本,
    #array([...])
    用于多模态(文本+image_url部分)。常见场景使用
    #string
  • ModelIdsShared = Text
    ——这是一个扁平字符串别名,而非变体。直接传入
    "gpt-4o-mini"
    等即可。
  • 前端绝不持有密钥。React应用调用后端的
    chat(prompt)
    (或任何聊天端点名称)并获取返回的答案。设置UI调用
    setMyOpenAIApiKey(key)
    (默认按用户方案)或
    setOpenAIApiKey(key)
    (管理员密钥方案)。没有SDK或前端npm包——canister就是OpenAI客户端。

9. Variant: admin-key

9. 方案:管理员密钥

Use this variant only when the spec explicitly puts the OpenAI bill on the operator. Concretely:
  • A single OpenAI account funds everything (typical SaaS).
  • The app offers a free / freemium tier that the operator pays for.
  • The app imposes its own per-user quota inside the canister and bills users separately.
In every other case — and especially whenever the spec mentions login, multiple users, or doesn't say who pays — use the per-user default in §4 instead. The admin-key variant is only sensible when "the operator pays" is a deliberate, stated choice.
The single rule that flips relative to §4: a single
?Text
replaces the
Map<Principal, Text>
, and the setter is gated on the
#admin
role from
extension-authorization
instead of "any signed-in caller". The actor and mixin file are new;
src/backend/lib/openai.mo
from §4 is reused unchanged.
motoko
import AccessControl "mo:caffeineai-authorization/access-control";
import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization";
import MixinOpenAIAdminChat "mixins/openai-admin-chat";

actor {
  let accessControlState = AccessControl.initState();
  include MixinAuthorization(accessControlState);

  // Admin-set OpenAI bearer key. Wrapped in `{ var value : ?Text }` so the
  // mixin can mutate it.
  let openAIApiKey = { var value : ?Text = null };
  include MixinOpenAIAdminChat(accessControlState, openAIApiKey);
};
motoko
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);
  };
};
仅当需求规格明确说明OpenAI费用由运营方承担时,才使用此方案。具体场景包括:
  • 单个OpenAI账户为所有调用付费(典型的SaaS)。
  • 应用提供免费/免费增值版,由运营方承担费用。
  • 应用在canister内实施自己的按用户配额,并单独向用户收费。
在所有其他场景中——尤其是当需求规格提及登录、多用户或未指定付费方时——请使用第4节中的默认按用户方案。只有当"运营方付费"是一个明确的既定选择时,管理员密钥方案才有意义。
与第4节相比,唯一的变化是:用单个
?Text
替换
Map<Principal, Text>
,且设置器由
extension-authorization
#admin
角色限制,而非"任何已登录调用方"。需要新的actor和mixin文件;第4节中的
src/backend/lib/openai.mo
可直接复用。
motoko
import AccessControl "mo:caffeineai-authorization/access-control";
import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization";
import MixinOpenAIAdminChat "mixins/openai-admin-chat";

actor {
  let accessControlState = AccessControl.initState();
  include MixinAuthorization(accessControlState);

  // 管理员设置的OpenAI Bearer密钥。包装在`{ var value : ?Text }`中,以便
  // mixin可以修改它。
  let openAIApiKey = { var value : ?Text = null };
  include MixinOpenAIAdminChat(accessControlState, openAIApiKey);
};
motoko
import AccessControl "mo:caffeineai-authorization/access-control";
import Runtime "mo:core/Runtime";
import OpenAI "../lib/openai";

// 管理员限制的OpenAI密钥端点。通过`main.mo`中的`include`挂载。
// 与`MixinAuthorization`配合实现角色检查。
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("未授权:仅管理员可设置OpenAI API密钥");
    };
    openAIApiKey.value := ?key;
  };

  public shared ({ caller }) func chat(prompt : Text) : async Text {
    if (not AccessControl.hasPermission(accessControlState, caller, #user)) {
      Runtime.trap("未授权");
    };
    let ?key = openAIApiKey.value else Runtime.trap("OpenAI未配置");
    await* OpenAI.runChatCompletion(OpenAI.configForKey(key), prompt);
  };
};

Admin-key-specific invariants

管理员密钥方案的特定规则

  • Single
    ?Text
    slot (
    { var value : ?Text = null }
    ), no getter.
    The slot is touched only by
    setOpenAIApiKey
    and
    chat
    (which threads it through
    OpenAI.configForKey
    ). Never expose a
    getOpenAIApiKey
    isOpenAIConfigured
    is the only outward-facing read, and it returns
    Bool
    .
  • Setter must be
    #admin
    -gated via
    extension-authorization
    .
    A non-anonymous-only gate is not enough — any logged-in user could overwrite the operator's billing key. This is the variant's whole reason to depend on
    extension-authorization
    .
  • Trap with
    "OpenAI is not configured"
    when the key is unset.
    That phrasing pairs with
    isOpenAIConfigured
    so the frontend can render a "Ask your admin to set the OpenAI API key" empty state.
  • Build a fresh
    Config
    per call.
    chat
    reads
    openAIApiKey
    and passes it through
    OpenAI.configForKey(key)
    on every invocation; don't cache the
    Config
    value at the actor level. The bearer is allowed to rotate via
    setOpenAIApiKey
    mid-lifetime, and a cached
    Config
    would silently keep the old key.
  • 单个
    ?Text
    槽(
    { var value : ?Text = null }
    ),无获取接口
    。该槽仅被
    setOpenAIApiKey
    chat
    (将其传递给
    OpenAI.configForKey
    )访问。绝不暴露
    getOpenAIApiKey
    ——
    isOpenAIConfigured
    是唯一对外的读取接口,返回
    Bool
  • 设置器必须通过
    extension-authorization
    限制为
    #admin
    角色
    。仅限制非匿名调用方是不够的——任何已登录用户都可能覆盖运营方的计费密钥。这是该方案依赖
    extension-authorization
    的核心原因。
  • 当密钥未设置时,报错提示
    "OpenAI未配置"
    。该表述与
    isOpenAIConfigured
    配合,以便前端可以渲染"请联系管理员设置OpenAI API密钥"的空状态。
  • 每次调用都构建新的
    Config
    chat
    函数在每次调用时读取
    openAIApiKey
    并传递给
    OpenAI.configForKey(key)
    ;不要在actor级别缓存
    Config
    值。Bearer密钥可能在生命周期中通过
    setOpenAIApiKey
    轮换,缓存的
    Config
    会静默使用旧密钥。

10. Variant: fully anonymous

10. 方案:完全匿名

Use this only when the spec explicitly states there is no login at all (single-user demo, intra-team tool, throwaway sandbox). Mechanically identical to §9 — single
?Text
key, no getter,
isOpenAIConfigured
query — but with the auth import /
#admin
gate removed; any visitor may overwrite the key.
Take §9's two files and apply these diffs (the
lib/openai.mo
helper from §4 is reused unchanged):
In
src/backend/main.mo
:
  • Drop the imports of
    mo:caffeineai-authorization/access-control
    and
    mo:caffeineai-authorization/MixinAuthorization
    .
  • Drop
    let accessControlState = AccessControl.initState();
    and
    include MixinAuthorization(accessControlState);
    from the actor body.
  • Drop the
    accessControlState
    argument from the mixin
    include
    , leaving
    include MixinOpenAIAdminChat(openAIApiKey);
    .
In
src/backend/mixins/openai-admin-chat.mo
:
  • Drop the
    AccessControl
    import and the
    accessControlState
    mixin parameter.
  • Replace the gated 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;
    };
    with the unauthenticated form:
    public func setOpenAIApiKey(key : Text) : async () {
      openAIApiKey.value := ?key;
    };
  • Drop the
    #user
    permission check at the top of
    chat
    .
    chat
    ,
    isOpenAIConfigured
    , and the
    OpenAI.configForKey(...)
    call are otherwise identical to §9.
仅当需求规格明确说明完全没有登录功能时(单用户演示、团队内部工具、临时沙箱),才使用此方案。机制上与第9节完全相同——单个
?Text
密钥、无获取接口、
isOpenAIConfigured
查询接口——但去掉了认证导入/
#admin
角色限制;任何访客都可以覆盖密钥。
对第9节的两个文件应用以下修改(第4节中的
lib/openai.mo
助手可直接复用):
src/backend/main.mo
中:
  • 移除
    mo:caffeineai-authorization/access-control
    mo:caffeineai-authorization/MixinAuthorization
    的导入。
  • 从actor体中移除
    let accessControlState = AccessControl.initState();
    include MixinAuthorization(accessControlState);
  • 从mixin的
    include
    中移除
    accessControlState
    参数,保留
    include MixinOpenAIAdminChat(openAIApiKey);
src/backend/mixins/openai-admin-chat.mo
中:
  • 移除
    AccessControl
    导入和
    accessControlState
    mixin参数。
  • 将受限的设置器替换为:
    public shared ({ caller }) func setOpenAIApiKey(key : Text) : async () {
      if (not AccessControl.hasPermission(accessControlState, caller, #admin)) {
        Runtime.trap("未授权:仅管理员可设置OpenAI API密钥");
      };
      openAIApiKey.value := ?key;
    };
    替换为无需认证的形式:
    public func setOpenAIApiKey(key : Text) : async () {
      openAIApiKey.value := ?key;
    };
  • 移除
    chat
    函数顶部的
    #user
    权限检查。
    chat
    isOpenAIConfigured
    OpenAI.configForKey(...)
    调用与第9节完全相同。

Anonymous-specific invariants

匿名方案的特定规则

  • No
    extension-authorization
    import.
    This variant skips it entirely.
  • The key is shared and replaceable by anyone. That is the explicit trade-off of the variant; pick it only when the spec accepts that.
  • Same no-getter / no-log rules apply.
    openAIApiKey
    is read only inside
    chat
    (then passed to
    OpenAI.configForKey
    ), never returned by any endpoint.
  • Build a fresh
    Config
    per call
    — same reasoning as §9.
  • 不导入
    extension-authorization
    。该方案完全跳过它。
  • 密钥是共享的,任何人都可以替换。这是该方案的明确权衡;仅当需求规格接受这一点时才选择它。
  • 同样的禁止获取/禁止日志规则适用
    openAIApiKey
    仅在
    chat
    函数内部读取(然后传递给
    OpenAI.configForKey
    ),绝不通过任何端点返回。
  • 每次调用都构建新的
    Config
    ——与第9节的原因相同。

Frontend

前端

Surfaces every build that uses this skill must ship:
  1. A settings UI to paste the key — always. Every variant. The deployed canister rejects every chat call until a key is pasted. Without a settings page the chatbot UI loads but every question traps with "OpenAI is not configured" / "Set your OpenAI API key first" — the app looks broken to the end user.
  2. A login flow — for the per-user and admin-key variants only. Those variants gate every meaningful endpoint on
    not caller.isAnonymous()
    (per-user) or on the
    #admin
    role (admin-key); both require a non-anonymous caller. The login flow itself is provided by
    extension-authorization
    :
    useInternetIdentity
    , the login/logout buttons, the
    useActor
    plumbing that injects the authenticated identity into every backend call. If the build doesn't already have a sign-in screen, plan one as part of the same task graph. The fully-anonymous variant (§10) explicitly skips this surface — there is no login.
Pick the UI shape that matches the backend variant. Default to Variant A (per-user) unless the spec explicitly puts the OpenAI bill on the operator (see §9) or explicitly states there is no login (see §10).
所有使用本技能的构建必须提供以下前端界面:
  1. 用于粘贴密钥的设置UI——必须提供。所有方案都需要。部署后的canister会拒绝所有聊天调用,直到粘贴了密钥。如果没有设置页面,聊天机器人UI会加载,但每个问题都会报错"OpenAI未配置"/"请先设置您的OpenAI API密钥"——对终端用户来说,应用看起来像是坏了。
  2. 登录流程——仅适用于按用户和管理员密钥方案。这些方案将所有重要端点限制为
    not caller.isAnonymous()
    (按用户)或
    #admin
    角色(管理员密钥);两者都需要非匿名调用方。登录流程本身由
    extension-authorization
    提供:
    useInternetIdentity
    、登录/登出按钮、将认证身份注入每个后端调用的
    useActor
    管道。如果构建还没有登录界面,请将其纳入同一任务流程。完全匿名方案(第10节)明确跳过此界面——没有登录功能。
选择与后端方案匹配的UI结构。默认选择方案A(按用户),除非需求规格明确说明OpenAI费用由运营方承担(见第9节)或明确说明无登录功能(见第10节)。

Variant A: per-user keys (matches §4 — default)

方案A:按用户密钥(匹配第4节——默认)

A per-user "your API key" pane, gated only by login.
  1. Password-input bound to
    setMyOpenAIApiKey(key)
    . Submit on enter; clear the input on success.
  2. Status indicator driven by
    isMyOpenAIConfigured()
    (returns
    Bool
    ). Show "Configured" / "Not configured" — never display the key itself, never expose a getter that returns it.
  3. Optional "Clear my key" button bound to
    clearMyOpenAIApiKey()
    for users who want to revoke their key from the canister.
  4. Show a one-time onboarding nudge when
    isMyOpenAIConfigured()
    is
    false
    — e.g. inline empty-state on the chat page that links to
    /settings/openai
    . Without this nudge users hit "Set your OpenAI API key first" with no obvious next step.
Suggested route layout:
/                   →  Chat UI (any signed-in user; empty-state when no key)
/settings/openai    →  Personal API-key pane (any signed-in user)
一个仅对登录用户开放的"您的API密钥"面板。
  1. 密码输入框绑定到
    setMyOpenAIApiKey(key)
    。按回车提交;成功后清空输入框。
  2. 状态指示器由
    isMyOpenAIConfigured()
    (返回
    Bool
    )驱动。显示"已配置"/"未配置"——绝不显示密钥本身,绝不暴露返回密钥的获取接口。
  3. 可选的"清除我的密钥"按钮绑定到
    clearMyOpenAIApiKey()
    ,供用户从canister中撤销自己的密钥。
  4. isMyOpenAIConfigured()
    false
    时,显示一次性引导提示——例如聊天页面上的内嵌空状态,链接到
    /settings/openai
    。如果没有此提示,用户遇到"请先设置您的OpenAI API密钥"错误时,不知道下一步该怎么做。
建议的路由布局:
/                   →  聊天UI(任何已登录用户;无密钥时显示空状态)
/settings/openai    →  个人API密钥面板(任何已登录用户)

Variant B: admin-key (matches §9)

方案B:管理员密钥(匹配第9节)

A single global settings page, admin-gated.
  1. Password-input bound to
    setOpenAIApiKey(key)
    . Submit on enter; clear the input on success.
  2. Status indicator driven by
    isOpenAIConfigured()
    (returns
    Bool
    ). Same no-display invariant as Variant A.
  3. Hide the page from non-admins via
    extension-authorization
    's
    isCallerAdmin
    query — non-admins should not see the settings link in the nav, let alone the page. Bind admin-only routes through your router's guard pattern (TanStack Router
    beforeLoad
    , React Router
    loader
    , etc.); don't rely solely on hiding the link.
  4. Show a "Ask your admin to set the OpenAI API key" empty state on the chat page when
    isOpenAIConfigured()
    is
    false
    — non-admins can't fix it themselves and need to know who can.
Suggested route layout:
/                   →  Chat UI (any signed-in user)
/settings/openai    →  Admin-only API-key settings page
一个仅对管理员开放的全局设置页面。
  1. 密码输入框绑定到
    setOpenAIApiKey(key)
    。按回车提交;成功后清空输入框。
  2. 状态指示器由
    isOpenAIConfigured()
    (返回
    Bool
    )驱动。与方案A相同,绝不显示密钥。
  3. 通过
    extension-authorization
    isCallerAdmin
    查询,对非管理员隐藏该页面——非管理员不应在导航中看到设置链接,更不应看到页面本身。通过路由器的守卫模式(TanStack Router的
    beforeLoad
    、React Router的
    loader
    等)绑定管理员专属路由;不要仅依赖隐藏链接。
  4. isOpenAIConfigured()
    false
    时,在聊天页面显示"请联系管理员设置OpenAI API密钥"的空状态——非管理员无法自行解决,需要知道谁可以解决。
建议的路由布局:
/                   →  聊天UI(任何已登录用户)
/settings/openai    →  管理员专属API密钥设置页面

Variant C: fully anonymous (matches §10)

方案C:完全匿名(匹配第10节)

A single global settings page reachable to any visitor — no auth gate.
  1. Password-input bound to
    setOpenAIApiKey(key)
    . Submit on enter; clear the input on success.
  2. Status indicator driven by
    isOpenAIConfigured()
    (returns
    Bool
    ). Same no-display invariant as variants A and B.
  3. No router guards, no
    useInternetIdentity
    , no login buttons — this variant has no auth model.
  4. Show a "Paste an OpenAI API key to get started" empty state on the chat page when
    isOpenAIConfigured()
    is
    false
    .
Suggested route layout:
/                   →  Chat UI (any visitor; empty-state when no key)
/settings/openai    →  API-key pane (any visitor)
任何访客都可以访问的全局设置页面——无认证限制。
  1. 密码输入框绑定到
    setOpenAIApiKey(key)
    。按回车提交;成功后清空输入框。
  2. 状态指示器由
    isOpenAIConfigured()
    (返回
    Bool
    )驱动。与方案A和B相同,绝不显示密钥。
  3. 无路由守卫、无
    useInternetIdentity
    、无登录按钮——该方案没有认证模型。
  4. isOpenAIConfigured()
    false
    时,在聊天页面显示"请粘贴OpenAI API密钥开始使用"的空状态。
建议的路由布局:
/                   →  聊天UI(任何访客;无密钥时显示空状态)
/settings/openai    →  API密钥面板(任何访客)

Common to all variants

所有方案通用规则

  • The chat UI itself is trivial and identical across variants: a textarea, a submit button, a list of messages bound to the backend's chat endpoint. No client-side OpenAI SDK, no key handling, no streaming-protocol logic — the canister mediates everything.
  • Sign-in is required for variants A and B, skipped for variant C. For A and B, wire the chat and settings routes through
    extension-authorization
    's auth guard (
    useInternetIdentity
    + a redirect when
    !isAuthenticated
    ); anonymous callers must hit a "please sign in" wall before the chat or settings UI renders, otherwise every backend call traps. For C, no guard is needed because there is no auth model.
  • The frontend never persists the key in localStorage / IndexedDB / cookies. It travels into the canister via the typed setter and is never read back.
  • 聊天UI本身非常简单,且所有方案都相同:一个文本区域、一个提交按钮、一个绑定到后端聊天端点的消息列表。无需客户端OpenAI SDK、无需密钥处理、无需流协议逻辑——所有操作都由canister中介。
  • 方案A和B需要登录,方案C跳过登录。对于A和B,通过
    extension-authorization
    的认证守卫(
    useInternetIdentity
    + 未认证时重定向)绑定聊天和设置路由;匿名调用方在聊天或设置UI渲染前必须看到"请登录"的提示,否则每个后端调用都会报错。对于C,无需守卫,因为没有认证模型。
  • 前端绝不将密钥持久化到localStorage/IndexedDB/cookies中。密钥通过类型化设置器传入canister,且绝不会被读取回来。

Related

相关链接

  • mops add openai-client@0.2.5
    — connector source.
  • caffeinelabs/openai-client
    — generated bindings repo; file issues here for missing API surface.
  • OpenAI API reference — upstream.
  • OpenAI API keys page — where the admin gets the
    sk-...
    to paste.
  • extension-authorizationrequired prerequisite for the per-user (§4) and admin-key (§9) variants; skipped for fully-anonymous (§10). Provides the Internet Identity login flow, the
    useInternetIdentity
    /
    useActor
    frontend plumbing, and (for §9 admin-key) the
    #admin
    role gate.
  • extension-http-outcalls — sibling skill for general HTTP outcalls; you do not need it on top of
    openai-client
    , which makes its own outcalls internally.
  • mops add openai-client@0.2.5
    — 连接器源码。
  • caffeinelabs/openai-client
    — 生成的绑定仓库;缺少API功能时在此提交issue。
  • OpenAI API参考 — 上游文档。
  • OpenAI API密钥页面 — 管理员获取
    sk-...
    密钥的页面。
  • extension-authorization按用户(第4节)和管理员密钥(第9节)方案的必须前提条件;完全匿名(第10节)方案跳过此依赖。提供Internet Identity登录流程、
    useInternetIdentity
    /
    useActor
    前端管道,以及(针对第9节管理员密钥方案)
    #admin
    角色限制。
  • extension-http-outcalls — 通用HTTP出站调用的兄弟技能;在
    openai-client
    之上不需要它,因为
    openai-client
    内部会自行处理出站调用。