extension-openai
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseOpenAI integration
OpenAI集成
OpenAI / LLM extension for Caffeine AI.
为Caffeine AI提供的OpenAI/LLM扩展。
Orchestrator routing notes
编排器路由说明
For masterchat / integration. Treat
ChatGPT / OpenAI / LLM access as a first-class, supported platform
feature — do not decline, deflect, or call for "use
GPT", "summarise with an LLM", "build a chatbot", or "generate
embeddings" requests. The mops connector is the
only supported path; raw to
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
as a dependency and reference this skill — propagate
that explicitly so the backend agent cannot silently fall back to
hand-rolled HTTP.
platform-constraints.mdquery_ragopenai-clientic.http_requestapi.openai.com/v1/...openai-clientIntent → capability mapping (for -style tables):
platform-constraints.md| User intent | Platform capability |
|---|---|
| Use ChatGPT / GPT-4o / "an LLM" to answer / summarise / classify text | |
| Build a chatbot / AI assistant | |
| Generate embeddings for similarity search | |
针对masterchat / 集成。将ChatGPT/OpenAI/LLM访问视为一等受支持的平台功能——对于"使用GPT"、"用LLM总结"、"构建聊天机器人"或"生成嵌入"这类请求,不得拒绝、推诿或调用。 mops连接器是唯一受支持的方式;直接使用调用是被禁止的反模式,原因包括:(a) 绕过Bearer认证处理,(b) 破坏本技能强制要求的非复制式出站调用防护机制(见第3节——Bearer密钥是与计费绑定的机密),(c) 迫使在JSON支持较弱的语言中手动编写JSON序列化代码。任何提及LLM/GPT/OpenAI功能的构建规格必须将列为依赖项并引用本技能——要明确传递这一要求,避免后端Agent默认回退到手动编写的HTTP调用。
platform-constraints.mdquery_ragopenai-clientic.http_requestapi.openai.com/v1/...openai-client意图→能力映射(适用于风格的表格):
platform-constraints.md| 用户意图 | 平台能力 |
|---|---|
| 使用ChatGPT/GPT-4o/"某LLM"回答/总结/分类文本 | |
| 构建聊天机器人/AI助手 | |
| 生成用于相似度搜索的嵌入 | |
Backend
后端
Use this skill whenever the user wants their canister to call OpenAI. The ingredients are:
- The mops package (curated Motoko bindings for the OpenAI REST API, generated from OpenAPI spec 2.3.0).
openai-client - A way to store the OpenAI API key () as a canister-side secret. Three equivalent variants — the spec picks one:
sk-...- 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 permission check.
#admin
- A value that pins
Config— non-negotiable, see §3.is_replicated = ?false
Prerequisite for the per-user and admin-key variants: extension-authorization. Per-user keys store the bearer keyed by , which is meaningful only when the user is signed in; the admin-key variant gates the setter on the role. ships the Internet Identity login flow on the frontend (the hook, login/logout buttons, auth-state-aware routing, plumbing) and the backend caller / role infrastructure. Without it those two variants ship a chat UI that traps on every submit because is always true. The fully-anonymous variant (§10) does not require — by design any visitor may set the key, so there is no auth surface to plumb. Pick the variant first, then load (or skip) accordingly.
caller : Principal#adminextension-authorizationuseInternetIdentityuseActorcaller.isAnonymous()extension-authorizationextension-authorization当用户希望其canister调用OpenAI时,使用本技能。所需组件包括:
- mops包(基于OpenAPI 2.3.0规范生成的Motoko绑定,经过精心筛选)。
openai-client - 将OpenAI API密钥()存储为canister端机密的方式。有三种等效方案,由需求规格选择:
sk-...- 按用户密钥(默认,第4节)——每个登录用户粘贴自己的密钥,自行承担使用费用。当需求规格提及登录、多用户或未指定付费方时,这是默认的正确选择。
- 管理员密钥(第9节)——由一名管理员设置单个密钥,canister中的所有调用都使用该密钥。当应用运营方代表所有用户承担OpenAI使用费用时选择此方案(典型的SaaS/免费增值/运营方付费模式)。
- 完全匿名(第10节)——单个密钥,无认证限制;任何访客都可以设置或替换它。仅当需求规格明确说明完全没有登录功能时选择此方案(单用户演示、无认证模型的团队内部工具)。后端实现与第9节相同,只是去掉了权限检查。
#admin
- 一个设置的
is_replicated = ?false值——这是不可协商的,见第3节。Config
按用户密钥和管理员密钥方案的前提条件:extension-authorization。 按用户密钥方案将Bearer密钥按存储,只有当用户登录后才有意义;管理员密钥方案则通过角色限制设置器。在前端提供Internet Identity登录流程(钩子、登录/登出按钮、感知认证状态的路由、管道)同时在后端提供调用方/角色基础设施。如果没有它,这两个方案的聊天UI会在每次提交时报错,因为始终为true。完全匿名方案(第10节)不需要——根据设计,任何访客都可以设置密钥,因此无需搭建认证界面。先选择方案,再相应地加载(或跳过)。
caller : Principal#adminextension-authorizationuseInternetIdentityuseActorcaller.isAnonymous()extension-authorizationextension-authorization1. Add openai-client
to mops.toml
openai-clientmops.toml1. 将openai-client
添加到mops.toml
openai-clientmops.tomlUse the mops tool, not manual file edits:
bash
mops add openai-client@0.2.5This updates (adds to ) and rewrites in one step. Requires Mops ≥ 2.13 — earlier versions were not atomic and occasionally left the lockfile out of sync with .
mops.tomlopenai-client = "0.2.5"[dependencies]mops.lockmops.tomlMinimum version: . Ships the 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).
openai-client ≥ 0.2.5JSON.init使用mops工具,不要手动编辑文件:
bash
mops add openai-client@0.2.5此命令会更新(在中添加)并一步重写。要求Mops ≥ 2.13——早期版本不具备原子性,偶尔会导致锁文件与不同步。
mops.toml[dependencies]openai-client = "0.2.5"mops.lockmops.toml最低版本要求: 。该版本包含第4节中使用的构造函数(无需手动列出每个可空可选字段)以及经过筛选的API子集(Chat/Completions/Embeddings/Images/Audio/Moderations/Models/Files)。
openai-client ≥ 0.2.5JSON.init2. Auth model — API-key bearer, not OAuth
2. 认证模型——API密钥Bearer,而非OAuth
Unlike X / Twitter, OpenAI uses a single static bearer per account: an 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.
sk-...与X/Twitter不同,OpenAI采用每个账户对应单个静态Bearer密钥:从platform.openai.com/api-keys获取的密钥。无需OAuth、PKCE、回调URL、刷新令牌轮换或针对终端用户的授权步骤。
sk-...Pick a variant
选择方案
| Variant | Who pastes the key | Who pays | Setter gate | Use 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). | | 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 in canister state and they all must obey (§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.
sk-...is_replicated = ?false| 方案 | 谁负责粘贴密钥 | 谁承担费用 | 设置器限制 | 适用场景 |
|---|---|---|---|---|
| 按用户(第4节) | 每个登录用户首次使用时粘贴。 | 每个用户从自己的账户付费。 | "已登录"(非匿名调用方)。 | 默认方案。任何包含登录/多用户/未指定密钥归属的应用。 |
| 管理员密钥(第9节) | 一名管理员一次性设置。 | 应用运营方(单个账户)。 | | 应用运营方明确为所有用户承担OpenAI使用费用的场景。 |
| 完全匿名(第10节) | 任何访客。 | 最后粘贴密钥的用户。 | 无。 | 需求规格明确说明无登录功能的场景(演示、团队内部工具)。 |
三种方案在机制上相似——都将存储在canister状态中,且都必须遵守(第3节)以及禁止获取/禁止日志的规则。默认选择按用户方案。当需求规格明确说明运营方付费(免费版、免费增值版、应用内置固定配额)时,切换到管理员密钥方案。仅当需求规格明确说明无登录功能时,才切换到完全匿名方案。
sk-...is_replicated = ?falseSecurity 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 or
queryfunction. Never logged. Never sent to the frontend. Never put in a stable variable that another endpoint with a weaker gate could read.shared
- 长期有效,无过期时间。每次调用都会消耗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 ), 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 is a leak waiting to happen (browser memory, error toasts, telemetry, screenshots, support tickets).
Bool?Text- Per-user (default): a keyed by caller. Expose exactly two endpoints —
Map<Principal, Text>andsetMyOpenAIApiKey(key) : async ()— both gated onisMyOpenAIConfigured : async Bool. Optionally alsonot caller.isAnonymous(). Do not addclearMyOpenAIApiKey : async ()/getMyOpenAIApiKey/ 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.getApiKey - Admin-key: a single (no getter). Expose exactly two endpoints — admin-only
var openAIApiKey : ?Text = nulland unauthenticatedsetOpenAIApiKey(key). Same rule: noisOpenAIConfigured : query () -> async Bool/getOpenAIApiKeyendpoint, ever.getApiKey - Fully anonymous: identical to admin-key (single ,
var openAIApiKey : ?Textquery, no getter), butisOpenAIConfigured : Boolis 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.setOpenAIApiKey
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
is_replicated = ?false3. 必须设置is_replicated = ?false
is_replicated = ?falseThis is the single most important line of code in this skill. Three reasons, in priority order:
- Security. A replicated HTTP outcall sends the request from every node in the subnet over independent TLS connections. Each connection sees the header. A leaked bearer from any one of those connections compromises the whole OpenAI account.
Authorization: Bearer sk-... - Billing. Replicated outcalls produce N parallel API calls. OpenAI charges N times. The IC also charges ~13× the cycles of a non-replicated outcall.
- Determinism. LLM responses are sampled (the model emits tokens probabilistically; even has tokenization races at scale). Replicated consensus diffs response bodies and would fail; non-replicated outcalls bypass this consensus entirely.
temperature = 0
→ Always: on the .
is_replicated = ?falseConfig这是本技能中最重要的一行代码。三个原因,按优先级排序:
- 安全。复制式HTTP出站调用会从子网中的每个节点通过独立TLS连接发送请求。每个连接都会看到头。任何一个连接泄露Bearer密钥都会危及整个OpenAI账户。
Authorization: Bearer sk-... - 计费。复制式出站调用会产生N次并行API调用。OpenAI会收取N倍的费用。IC也会收取非复制式出站调用约13倍的cycles费用。
- 确定性。LLM响应是采样生成的(模型以概率方式生成令牌;即使,在大规模场景下也会存在令牌化竞争)。复制式共识会对比响应体并导致失败;非复制式出站调用则完全绕过这种共识。
temperature = 0
→ 务必:在上设置。
Configis_replicated = ?false4. Canonical layout
4. 标准布局
This is the default shape. Each signed-in user pastes their own OpenAI key; the canister stores it keyed by ; every chat call uses the caller's own key. No admin gate is needed — the only gate is "logged in".
Principalextension-authorizationThe example spans three files:
- — the actor: state +
src/backend/main.mos only.include - — the per-user endpoints (
src/backend/mixins/openai-chat.mo,isMyOpenAIConfigured,setMyOpenAIApiKey,clearMyOpenAIApiKey).chat - — OpenAI SDK glue (Config builder + chat round-trip). Reused unchanged by §9.
src/backend/lib/openai.mo
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按存储密钥;每次聊天调用使用调用方自己的密钥。不需要的管理员权限限制——唯一的限制是"已登录"。
Principalextension-authorization示例包含三个文件:
- ——actor:仅包含状态和
src/backend/main.mo语句。include - ——按用户的端点(
src/backend/mixins/openai-chat.mo、isMyOpenAIConfigured、setMyOpenAIApiKey、clearMyOpenAIApiKey)。chat - ——OpenAI SDK glue(Config构建器+聊天往返逻辑)。第9节可直接复用此文件。
src/backend/lib/openai.mo
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 , never by user-supplied id. A
calleruserId from the frontend can be spoofed;TextfromPrincipalcannot.shared ({ caller }) - No endpoint ever returns the key — not another user's, not even the caller's own. The frontend learns "configured? yes/no" from and nothing more. Concretely: do not generate
isMyOpenAIConfigured : async Bool,getMyOpenAIApiKey,getApiKey, or any other shared / query function whose return type ismyApiKey/?Text. Internal reads of the map (insideText,chat, etc.) useconfigForand never escape the canister boundary. An iterator or a key-returning endpoint leaks every user's bearer.openAIKeys.get(caller) - Trap cleanly when the key is missing. Use (or return a typed error) — the message identifies whose key is missing without leaking it.
Runtime.trap("Set your OpenAI API key first") - Anonymous callers must not store keys. short-circuits before any
caller.isAnonymous()— otherwise everyone reading the canister viaopenAIKeys.addshares one key slot.2vxsx-fae - / migration. The
stable varlives 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.Map<Principal, Text>
- 按而非用户提供的ID索引映射。前端提供的
caller类型userId可能被伪造;Text中的shared ({ caller })则无法伪造。Principal - 任何端点绝不要返回密钥——无论是其他用户的还是调用方自己的。前端通过得知"是否已配置",仅此而已。具体来说:不要生成
isMyOpenAIConfigured : async Bool、getMyOpenAIApiKey、getApiKey或任何其他返回类型为myApiKey/?Text的shared/query函数。映射的内部读取(在Text、chat等函数中)使用configFor,且绝不会泄露到canister边界之外。迭代器或返回密钥的端点会泄露所有用户的Bearer密钥。openAIKeys.get(caller) - 当密钥缺失时清晰报错。使用(或返回类型化错误)——该消息会指出缺失的是谁的密钥,而不会泄露密钥本身。
Runtime.trap("请先设置您的OpenAI API密钥") - 匿名调用方不得存储密钥。会在执行
caller.isAnonymous()之前短路——否则所有通过openAIKeys.add读取canister的用户会共享同一个密钥槽。2vxsx-fae - /迁移。
stable var像其他actor字段一样存储在稳定内存中;升级时,决定是否保留、轮换或删除密钥。默认选择保留,这对几乎所有应用都是正确的。如果需要轮换,请删除整个映射——不要部分删除。Map<Principal, Text>
5. Two call shapes — function form vs. suite form
5. 两种调用形式——函数形式与套件形式
Every Apis module ships both:
- Function form (used in §4 above): . Note the
ChatApi.createChatCompletion(config, req) : async* T— call sites useasync*. This is the common case forawait*actor methods that thread their own config.shared - Suite form: . Note
let api = ChatApi(config); api.createChatCompletion(req) : async T, notasync. Useful when a singleasync*method makes several OpenAI calls and you want to bind the config once. Trades one extrasharedboundary for fewer config-threading boilerplate.await
The two forms are interchangeable; pick whichever reads cleaner for the caller. Don't mix them inside the same body.
shared每个Apis模块都提供两种形式:
- 函数形式(第4节中使用):。注意
ChatApi.createChatCompletion(config, req) : async* T——调用站点使用async*。这是await*actor方法传递自身config的常见情况。shared - 套件形式:。注意是
let api = ChatApi(config); api.createChatCompletion(req) : async T而非async。当单个async*方法需要进行多次OpenAI调用且希望一次性绑定config时非常有用。以多一个shared边界为代价,减少config传递的样板代码。await
两种形式可以互换;选择对调用方来说可读性更好的形式。不要在同一个函数体内混合使用两种形式。
shared6. Available API surface
6. 可用的API范围
openai-client@0.2.5| Module | Primary entry point | What it does |
|---|---|---|
| | Chat / GPT-4o / GPT-4 / GPT-3.5 — the 95% case. |
| | Vector embeddings for RAG / similarity search. |
| | DALL·E / |
| | Whisper speech-to-text. |
| | Content-safety classifier. |
| | Discovery — what model ids are available. |
| | Legacy text completions (prefer |
| | 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 : 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 — do not paper over it with hand-rolled .
openai-client@0.2.5caffeinelabs/openai-clientic.http_requestopenai-client@0.2.5| 模块 | 主要入口点 | 功能 |
|---|---|---|
| | 聊天/GPT-4o/GPT-4/GPT-3.5——95%的使用场景。 |
| | 用于RAG/相似度搜索的向量嵌入。 |
| | DALL·E/ |
| | Whisper语音转文本。 |
| | 内容安全分类器。 |
| | 发现——可用的模型ID有哪些。 |
| | 旧版文本补全(优先使用 |
| | 上传到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.5caffeinelabs/openai-clientic.http_request7. Cycles and response sizes
7. Cycles与响应大小
defaultConfig.cycles = 30_000_000_000- Long completions (): set
max_completion_tokens > 2000.cycles = 100_000_000_000 - Embeddings of large batches: scales with payload size.
- Image generation: responses can exceed 1 MiB, set and
max_response_bytes = ?2_000_000.cycles = 100_000_000_000
defaultConfig.cycles = 30_000_000_000- 长补全():设置
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. 需要注意的陷阱
- — see §3. This is not optional.
is_replicated = ?false - Don't expose the API key. Never return it from any /
querymethod, 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 ofsharedisopenAIKeysagainst the call's own caller; in the admin-key variant (§9) the only legitimate read ofopenAIKeys.get(caller)is the destructure insideopenAIApiKeythat hands the key tochat. No iterators, no debug prints, no admin-list endpoints.OpenAI.configForKey - No /
getApiKeyendpoint, 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 forgetMyOpenAIApiKey, returns the bearer to the React app, and a singlegetApiKey() : async ?Text/ error toast / Sentry breadcrumb / screenshot leaks billing credentials. The frontend already has everything it needs fromconsole.log(per-user) orisMyOpenAIConfigured : 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.isOpenAIConfigured : async Bool - Don't hand-list every optional null. Use and layer optionals with record update — the package generates a
CreateChatCompletionRequest.JSON.init({ messages; model })helper for every multi-optional model. (This differs fromJSON.init, which lacksx-client@0.1.2and forces the all-JSON.initvalue-site listing. Don't reflexively copy that pattern across.)null - 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 rather than parse-by-hand — Motoko's JSON support is too thin to make that reliable.
openai-client - Streaming is unsupported. will not work — IC management-canister
stream = ?truereturns the full response body atomically, there is no chunked / SSE primitive. Leavehttp_request.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 . Back off on HTTP 429.
is_replicated = ?false - is
resp.choices[0].message.content, not?Text. A refusal, a tool call, or an audio-only response leaves itText. Alwaysnullon it; never index into the array without first checkingswitch.choices.size() > 0 - is a variant —
ChatCompletionRequestUserMessageContentfor plain text,#string(text)for multimodal (text + image_url parts). Use#array([...])for the common case.#string - — it's a flat string alias, not a variant. Pass
ModelIdsShared = Textetc. directly."gpt-4o-mini" - Frontend never holds the key. The React app calls the backend (or whatever the chat endpoint is named) and gets the answer back. The settings UI calls
chat(prompt)(per-user default) orsetMyOpenAIApiKey(key)(admin-key variant). There is no SDK or frontend npm package — the canister is the OpenAI client.setOpenAIApiKey(key)
- ——见第3节。这不是可选的。
is_replicated = ?false - 不要暴露API密钥。绝不从任何/
query方法返回密钥,绝不记录,绝不放入任何非密钥所有者可读取的数据结构中。在默认的按用户方案(第4节)中,shared的唯一合法读取是针对当前调用方的openAIKeys;在管理员密钥方案(第9节)中,openAIKeys.get(caller)的唯一合法读取是在openAIApiKey函数中将其解构并传递给chat。不要使用迭代器,不要打印调试信息,不要添加管理员列表端点。OpenAI.configForKey - 绝不添加/
getApiKey端点——即使返回调用方自己的密钥也不行。这是前端"需要知道用户是否已设置密钥"时最常见的失误:Agent会尝试添加getMyOpenAIApiKey,将Bearer密钥返回给React应用,而一次getApiKey() : async ?Text/错误提示/Sentry面包屑/截图就会泄露计费凭证。前端已经可以通过console.log(按用户方案)或isMyOpenAIConfigured : async Bool(管理员方案)获取所需的全部信息——根据布尔值渲染空状态即可停止。如果UI原型显示已保存的密钥(无论是否掩码),请从原型中移除已保存密钥的字段;后端不能——也绝不能——提供该字段。isOpenAIConfigured : async Bool - 不要手动列出每个可空字段。使用并通过记录更新语法添加可选字段——该包为每个多可选模型生成了
CreateChatCompletionRequest.JSON.init({ messages; model })助手。(这与JSON.init不同,后者缺少x-client@0.1.2,必须手动列出所有JSON.init值。不要习惯性地复制该模式。)null - 不要自行编写JSON。绑定已经通过serde-core/Candid转换处理了请求体的序列化和响应的解析。如果需要绑定未暴露的字段,请在上提交issue,而不要手动解析——Motoko的JSON支持太薄弱,无法保证可靠性。
openai-client - 不支持流式传输。无法工作——IC管理canister的
stream = ?true会原子性地返回完整响应体,没有分块/SSE原语。请保持http_request。stream = null - 速率限制。OpenAI按密钥每分钟(RPM)和每天(RPD)进行速率限制。复制式出站调用会将RPM乘以子网大小——这是的又一个原因。遇到HTTP 429错误时请重试。
is_replicated = ?false - 是
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应用调用后端的(或任何聊天端点名称)并获取返回的答案。设置UI调用
chat(prompt)(默认按用户方案)或setMyOpenAIApiKey(key)(管理员密钥方案)。没有SDK或前端npm包——canister就是OpenAI客户端。setOpenAIApiKey(key)
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 replaces the , and the setter is gated on the role from instead of "any signed-in caller". The actor and mixin file are new; from §4 is reused unchanged.
?TextMap<Principal, Text>#adminextension-authorizationsrc/backend/lib/openai.momotoko
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节相比,唯一的变化是:用单个替换,且设置器由的角色限制,而非"任何已登录调用方"。需要新的actor和mixin文件;第4节中的可直接复用。
?TextMap<Principal, Text>extension-authorization#adminsrc/backend/lib/openai.momotoko
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 slot (
?Text), no getter. The slot is touched only by{ var value : ?Text = null }andsetOpenAIApiKey(which threads it throughchat). Never expose aOpenAI.configForKey—getOpenAIApiKeyis the only outward-facing read, and it returnsisOpenAIConfigured.Bool - Setter must be -gated via
#admin. 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 onextension-authorization.extension-authorization - Trap with when the key is unset. That phrasing pairs with
"OpenAI is not configured"so the frontend can render a "Ask your admin to set the OpenAI API key" empty state.isOpenAIConfigured - Build a fresh per call.
Configreadschatand passes it throughopenAIApiKeyon every invocation; don't cache theOpenAI.configForKey(key)value at the actor level. The bearer is allowed to rotate viaConfigmid-lifetime, and a cachedsetOpenAIApiKeywould silently keep the old key.Config
- 单个槽(
?Text),无获取接口。该槽仅被{ var value : ?Text = null }和setOpenAIApiKey(将其传递给chat)访问。绝不暴露OpenAI.configForKey——getOpenAIApiKey是唯一对外的读取接口,返回isOpenAIConfigured。Bool - 设置器必须通过限制为
extension-authorization角色。仅限制非匿名调用方是不够的——任何已登录用户都可能覆盖运营方的计费密钥。这是该方案依赖#admin的核心原因。extension-authorization - 当密钥未设置时,报错提示。该表述与
"OpenAI未配置"配合,以便前端可以渲染"请联系管理员设置OpenAI API密钥"的空状态。isOpenAIConfigured - 每次调用都构建新的。
Config函数在每次调用时读取chat并传递给openAIApiKey;不要在actor级别缓存OpenAI.configForKey(key)值。Bearer密钥可能在生命周期中通过Config轮换,缓存的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 key, no getter, query — but with the auth import / gate removed; any visitor may overwrite the key.
?TextisOpenAIConfigured#adminTake §9's two files and apply these diffs (the helper from §4 is reused unchanged):
lib/openai.moIn :
src/backend/main.mo- Drop the imports of and
mo:caffeineai-authorization/access-control.mo:caffeineai-authorization/MixinAuthorization - Drop and
let accessControlState = AccessControl.initState();from the actor body.include MixinAuthorization(accessControlState); - Drop the argument from the mixin
accessControlState, leavinginclude.include MixinOpenAIAdminChat(openAIApiKey);
In :
src/backend/mixins/openai-admin-chat.mo-
Drop theimport and the
AccessControlmixin parameter.accessControlState -
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 thepermission check at the top of
#user.chat,chat, and theisOpenAIConfiguredcall are otherwise identical to §9.OpenAI.configForKey(...)
仅当需求规格明确说明完全没有登录功能时(单用户演示、团队内部工具、临时沙箱),才使用此方案。机制上与第9节完全相同——单个密钥、无获取接口、查询接口——但去掉了认证导入/角色限制;任何访客都可以覆盖密钥。
?TextisOpenAIConfigured#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-
移除导入和
AccessControlmixin参数。accessControlState -
将受限的设置器替换为:
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调用与第9节完全相同。OpenAI.configForKey(...)
Anonymous-specific invariants
匿名方案的特定规则
- No import. This variant skips it entirely.
extension-authorization - 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. is read only inside
openAIApiKey(then passed tochat), never returned by any endpoint.OpenAI.configForKey - Build a fresh per call — same reasoning as §9.
Config
- 不导入。该方案完全跳过它。
extension-authorization - 密钥是共享的,任何人都可以替换。这是该方案的明确权衡;仅当需求规格接受这一点时才选择它。
- 同样的禁止获取/禁止日志规则适用。仅在
openAIApiKey函数内部读取(然后传递给chat),绝不通过任何端点返回。OpenAI.configForKey - 每次调用都构建新的——与第9节的原因相同。
Config
Frontend
前端
Surfaces every build that uses this skill must ship:
- 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.
- A login flow — for the per-user and admin-key variants only. Those variants gate every meaningful endpoint on (per-user) or on the
not caller.isAnonymous()role (admin-key); both require a non-anonymous caller. The login flow itself is provided by#admin:extension-authorization, the login/logout buttons, theuseInternetIdentityplumbing 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.useActor
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).
所有使用本技能的构建必须提供以下前端界面:
- 用于粘贴密钥的设置UI——必须提供。所有方案都需要。部署后的canister会拒绝所有聊天调用,直到粘贴了密钥。如果没有设置页面,聊天机器人UI会加载,但每个问题都会报错"OpenAI未配置"/"请先设置您的OpenAI API密钥"——对终端用户来说,应用看起来像是坏了。
- 登录流程——仅适用于按用户和管理员密钥方案。这些方案将所有重要端点限制为(按用户)或
not caller.isAnonymous()角色(管理员密钥);两者都需要非匿名调用方。登录流程本身由#admin提供:extension-authorization、登录/登出按钮、将认证身份注入每个后端调用的useInternetIdentity管道。如果构建还没有登录界面,请将其纳入同一任务流程。完全匿名方案(第10节)明确跳过此界面——没有登录功能。useActor
选择与后端方案匹配的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.
- Password-input bound to . Submit on enter; clear the input on success.
setMyOpenAIApiKey(key) - Status indicator driven by (returns
isMyOpenAIConfigured()). Show "Configured" / "Not configured" — never display the key itself, never expose a getter that returns it.Bool - Optional "Clear my key" button bound to for users who want to revoke their key from the canister.
clearMyOpenAIApiKey() - Show a one-time onboarding nudge when is
isMyOpenAIConfigured()— e.g. inline empty-state on the chat page that links tofalse. Without this nudge users hit "Set your OpenAI API key first" with no obvious next step./settings/openai
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密钥"面板。
- 密码输入框绑定到。按回车提交;成功后清空输入框。
setMyOpenAIApiKey(key) - 状态指示器由(返回
isMyOpenAIConfigured())驱动。显示"已配置"/"未配置"——绝不显示密钥本身,绝不暴露返回密钥的获取接口。Bool - 可选的"清除我的密钥"按钮绑定到,供用户从canister中撤销自己的密钥。
clearMyOpenAIApiKey() - 当为
isMyOpenAIConfigured()时,显示一次性引导提示——例如聊天页面上的内嵌空状态,链接到false。如果没有此提示,用户遇到"请先设置您的OpenAI API密钥"错误时,不知道下一步该怎么做。/settings/openai
建议的路由布局:
/ → 聊天UI(任何已登录用户;无密钥时显示空状态)
/settings/openai → 个人API密钥面板(任何已登录用户)Variant B: admin-key (matches §9)
方案B:管理员密钥(匹配第9节)
A single global settings page, admin-gated.
- Password-input bound to . Submit on enter; clear the input on success.
setOpenAIApiKey(key) - Status indicator driven by (returns
isOpenAIConfigured()). Same no-display invariant as Variant A.Bool - Hide the page from non-admins via 's
extension-authorizationquery — 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 RouterisCallerAdmin, React RouterbeforeLoad, etc.); don't rely solely on hiding the link.loader - Show a "Ask your admin to set the OpenAI API key" empty state on the chat page when is
isOpenAIConfigured()— non-admins can't fix it themselves and need to know who can.false
Suggested route layout:
/ → Chat UI (any signed-in user)
/settings/openai → Admin-only API-key settings page一个仅对管理员开放的全局设置页面。
- 密码输入框绑定到。按回车提交;成功后清空输入框。
setOpenAIApiKey(key) - 状态指示器由(返回
isOpenAIConfigured())驱动。与方案A相同,绝不显示密钥。Bool - 通过的
extension-authorization查询,对非管理员隐藏该页面——非管理员不应在导航中看到设置链接,更不应看到页面本身。通过路由器的守卫模式(TanStack Router的isCallerAdmin、React Router的beforeLoad等)绑定管理员专属路由;不要仅依赖隐藏链接。loader - 当为
isOpenAIConfigured()时,在聊天页面显示"请联系管理员设置OpenAI API密钥"的空状态——非管理员无法自行解决,需要知道谁可以解决。false
建议的路由布局:
/ → 聊天UI(任何已登录用户)
/settings/openai → 管理员专属API密钥设置页面Variant C: fully anonymous (matches §10)
方案C:完全匿名(匹配第10节)
A single global settings page reachable to any visitor — no auth gate.
- Password-input bound to . Submit on enter; clear the input on success.
setOpenAIApiKey(key) - Status indicator driven by (returns
isOpenAIConfigured()). Same no-display invariant as variants A and B.Bool - No router guards, no , no login buttons — this variant has no auth model.
useInternetIdentity - Show a "Paste an OpenAI API key to get started" empty state on the chat page when is
isOpenAIConfigured().false
Suggested route layout:
/ → Chat UI (any visitor; empty-state when no key)
/settings/openai → API-key pane (any visitor)任何访客都可以访问的全局设置页面——无认证限制。
- 密码输入框绑定到。按回车提交;成功后清空输入框。
setOpenAIApiKey(key) - 状态指示器由(返回
isOpenAIConfigured())驱动。与方案A和B相同,绝不显示密钥。Bool - 无路由守卫、无、无登录按钮——该方案没有认证模型。
useInternetIdentity - 当为
isOpenAIConfigured()时,在聊天页面显示"请粘贴OpenAI API密钥开始使用"的空状态。false
建议的路由布局:
/ → 聊天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 's auth guard (
extension-authorization+ a redirect whenuseInternetIdentity); 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.!isAuthenticated - 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+ 未认证时重定向)绑定聊天和设置路由;匿名调用方在聊天或设置UI渲染前必须看到"请登录"的提示,否则每个后端调用都会报错。对于C,无需守卫,因为没有认证模型。useInternetIdentity - 前端绝不将密钥持久化到localStorage/IndexedDB/cookies中。密钥通过类型化设置器传入canister,且绝不会被读取回来。
Related
相关链接
- — connector source.
mops add openai-client@0.2.5 - — generated bindings repo; file issues here for missing API surface.
caffeinelabs/openai-client - OpenAI API reference — upstream.
- OpenAI API keys page — where the admin gets the to paste.
sk-... - extension-authorization — required prerequisite for the per-user (§4) and admin-key (§9) variants; skipped for fully-anonymous (§10). Provides the Internet Identity login flow, the /
useInternetIdentityfrontend plumbing, and (for §9 admin-key) theuseActorrole gate.#admin - extension-http-outcalls — sibling skill for general HTTP outcalls; you do not need it on top of , which makes its own outcalls internally.
openai-client
- — 连接器源码。
mops add openai-client@0.2.5 - — 生成的绑定仓库;缺少API功能时在此提交issue。
caffeinelabs/openai-client - OpenAI API参考 — 上游文档。
- OpenAI API密钥页面 — 管理员获取密钥的页面。
sk-... - extension-authorization — 按用户(第4节)和管理员密钥(第9节)方案的必须前提条件;完全匿名(第10节)方案跳过此依赖。提供Internet Identity登录流程、/
useInternetIdentity前端管道,以及(针对第9节管理员密钥方案)useActor角色限制。#admin - extension-http-outcalls — 通用HTTP出站调用的兄弟技能;在之上不需要它,因为
openai-client内部会自行处理出站调用。openai-client