extension-openai
此技能为开发者提供便捷的 OpenAI/LLM 集成方案,通过 `openai-client` 连接器,轻松在应用中调用 OpenAI 的聊天和嵌入 API。它简化了 API 密钥管理,支持多种认证模式,并确保安全高效的外部调用,帮助开发者快速构建具备高级 AI 能力的应用,如智能客服、内容生成或语义搜索。
npx skills add https://github.com/caffeinelabs/skills --skill extension-openaiBefore / After 效果对比
1 组开发者需要手动编写 HTTP 请求、处理 API 密钥存储和安全调用逻辑,耗时且易出错,导致集成周期长。
此技能提供标准化的 `openai-client` 连接器和密钥管理,大幅简化集成流程,加速应用开发。
OpenAI integration
OpenAI / LLM extension for Caffeine AI.
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 intent | Platform 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) |
Backend
Use this skill whenever the user wants their canister to call OpenAI. The ingredients are:
- The
openai-clientmops package (curated Motoko bindings for the OpenAI REST API, generated from OpenAPI spec 2.3.0). - 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
#adminpermission check.
- A
Configvalue that pinsis_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.
1. Add openai-client to mops.toml
Use the mops tool, not manual file edits:
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).
2. Auth model — API-key bearer, not 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.
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). | 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.
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
queryorsharedfunction. Never logged. Never sent to the frontend. Never put in a stable variable that another endpoint with a weaker gate could read.
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 ()andisMyOpenAIConfigured : async Bool— both gated onnot caller.isAnonymous(). Optionally alsoclearMyOpenAIApiKey : async (). Do not addgetMyOpenAIApiKey/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-onlysetOpenAIApiKey(key)and unauthenticatedisOpenAIConfigured : query () -> async Bool. Same rule: nogetOpenAIApiKey/getApiKeyendpoint, ever. - Fully anonymous: identical to admin-key (single
var openAIApiKey : ?Text,isOpenAIConfigured : Boolquery, no getter), butsetOpenAIApiKeyis 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.
3. is_replicated = ?false is REQUIRED
This 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
Authorization: Bearer sk-...header. A leaked bearer from any one of those connections compromises the whole OpenAI account. - 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
temperature = 0has 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.
4. Canonical layout
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 +includes 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.
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);
};
import Map "mo:core/Map";
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";
import OpenAI "../lib/openai";
// Per-user OpenAI key endpoints. Mounted by `main.mo` via `include`.
// Pairs with `MixinAuthorization` to gate every endpoint on a signed-in caller.
mixin (openAIKeys : Map.Map<Principal, Text>) {
public query ({ caller }) func isMyOpenAIConfigured() : async Bool {
openAIKeys.containsKey(caller);
};
public shared ({ caller }) func setMyOpenAIApiKey(key : Text) : async () {
if (caller.isAnonymous()) {
Runtime.trap("Sign in to use this feature");
};
openAIKeys.add(caller, key);
};
public shared ({ caller }) func clearMyOpenAIApiKey() : async () {
if (caller.isAnonymous()) {
Runtime.trap("Sign in to use this feature");
};
openAIKeys.remove(caller);
};
public shared ({ caller }) func chat(prompt : Text) : async Text {
if (caller.isAnonymous()) {
Runtime.trap("Sign in to use this feature");
};
let ?key = openAIKeys.get(caller) else {
Runtime.trap("Set your OpenAI API key first");
};
await* OpenAI.runChatCompletion(OpenAI.configForKey(key), prompt);
};
};
import { defaultConfig; type Config } "mo:openai-client/Config";
import ChatApi "mo:openai-client/Apis/ChatApi";
import CreateChatCompletionRequest "mo:openai-client/Models/CreateChatCompletionRequest";
import ChatCompletionRequestUserMessage "mo:openai-client/Models/ChatCompletionRequestUserMessage";
import Runtime "mo:core/Runtime";
module {
// Build a Config bound to a single bearer. `is_replicated = ?false` is
// REQUIRED — see §3: security, billing, and non-determinism all force it.
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)");
};
};
};
Per-user-specific invariants
- Key the map by
caller, never by user-supplied id. ATextuserId from the frontend can be spoofed;Principalfromshared ({ 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 Booland nothing more. Concretely: do not generategetMyOpenAIApiKey,getApiKey,myApiKey, or any other shared / query function whose return type is?Text/Text. Internal reads of the map (insidechat,configFor, etc.) useopenAIKeys.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 anyopenAIKeys.add— otherwise everyone reading the canister via2vxsx-faeshares one key slot. stable var/ migration. TheMap<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.
...
用户评价 (0)
发表评价
暂无评价
统计数据
用户评分
为此 Skill 评分