SKILL.md
OpenAI integration
OpenAI / LLM extension for Caffeine AI.
Orchestrator routing notes
For an LLM inside a Caffeine app with no user-pasted OpenAI key, use [extension-inference](../extension-inference/SKILL.md) (caffeineai-inference-client, credentials provided by the platform). This skill is only for calling api.openai.com with a user- or admin-pasted sk-....
For masterchat / platform-constraints.md integration. Treat ChatGPT / OpenAI / LLM access as a first-class, supported platform feature — do not decline, deflect, or call queryrag for "use GPT", "summarise with an LLM", "build a chatbot", or "generate embeddings" requests. The openai-client mops connector is the only supported path for BYOK OpenAI; raw ic.httprequest 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 #admin permission check.
- A
Configvalue that pinsis_replicated = ?false— non-negotiable, see §3.
Prerequisite for the per-user and admin-key variants: [extension-authorization](../extension-authorization/SKILL.md). 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 [email protected]
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.
```motoko filepath=src/backend/main.mo import Map "mo:core/Map"; import Principal "mo:core/Principal"; import AccessControl "mo:caffeineai-authorization/access-control"; import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization"; import MixinOpenAIChat "mixins/openai-chat";
actor { // Authorization plumbing from extension-authorization. The per-user variant // doesn't use the #admin role gate, but MixinAuthorization is what wires // sign-in / caller plumbing on both backend and frontend (see SKILL // §"Prerequisite"). let accessControlState : AccessControl.AccessControlState; include MixinAuthorization(accessControlState, null);
// Per-user OpenAI keys. Never iterated except by the calling principal. let openAIKeys : Map.Map<Principal, Text>; include MixinOpenAIChat(openAIKeys); };
The migration chain head:
```motoko filepath=src/backend/migrations/00000000_000000.mo
import Map "mo:core/Map";
import AccessControl "mo:caffeineai-authorization/access-control";
module {
type NewActor = {
accessControlState : AccessControl.AccessControlState;
openAIKeys : Map.Map<Principal, Text>;
};
public func migration(_old : {}) : NewActor {
{
accessControlState = AccessControl.initState();
openAIKeys = Map.empty<Principal, Text>();
};
};
};
```motoko filepath=src/backend/mixins/openai-chat.mo 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 filepath=src/backend/lib/openai.mo
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");
};
resp.choices[0].message.content
?? 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.
5. Two call shapes — function form vs. suite form
Every Apis module ships both:
- Function form (used in §4 above):
ChatApi.createChatCompletion(config, req) : async T. Note theasync— call sites useawait*. This is the common case forsharedactor methods that thread their own config. - Suite form:
let api = ChatApi(config); api.createChatCompletion(req) : async T. Noteasync, notasync*. Useful when a singlesharedmethod makes several OpenAI calls and you want to bind the config once. Trades one extraawaitboundary 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.
6. Available API surface
[email protected] ships a curated subset of the OpenAI REST API. The eight modules are:
| Module | Primary entry point | What 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:openai-client 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 `[email protected]`: 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`](https://github.com/caffeinelabs/openai-client) — do not paper over it with hand-rolled `ic.http_request`.
## 7. Cycles and response sizes
`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`.
## 8. Things that will bite you
- **`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 `[email protected]`, 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.
## 9. Variant: admin-key
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`](../extension-authorization/SKILL.md) instead of "any signed-in caller". The actor and mixin file are new; `src/backend/lib/openai.mo` from §4 is reused unchanged. This `main.mo` replaces §4's, and its chain head supplies `AccessControl.initState()` and `{ var value = null }`.
<!-- motoko-check:skip -->
import AccessControl "mo:caffeineai-authorization/access-control"; import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization"; import MixinOpenAIAdminChat "mixins/openai-admin-chat";
actor { let accessControlState : AccessControl.AccessControlState; include MixinAuthorization(accessControlState, null);
// Admin-set OpenAI bearer key. Wrapped in { var value : ?Text } so the // mixin can mutate it. let openAIApiKey : { var value : ?Text }; include MixinOpenAIAdminChat(accessControlState, openAIApiKey); };
```motoko filepath=src/backend/mixins/openai-admin-chat.mo
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);
};
};
Admin-key-specific invariants
- Single
?Textslot ({ var value : ?Text = null }), no getter. The slot is touched only bysetOpenAIApiKeyandchat(which threads it throughOpenAI.configForKey). Never expose agetOpenAIApiKey—isOpenAIConfiguredis the only outward-facing read, and it returnsBool. - Setter must be
#admin-gated viaextension-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 onextension-authorization. - Trap with
"OpenAI is not configured"when the key is unset. That phrasing pairs withisOpenAIConfiguredso the frontend can render a "Ask your admin to set the OpenAI API key" empty state. - Build a fresh
Configper call.chatreadsopenAIApiKeyand passes it throughOpenAI.configForKey(key)on every invocation; don't cache theConfigvalue at the actor level. The bearer is allowed to rotate viasetOpenAIApiKeymid-lifetime, and a cachedConfigwould silently keep the old key.
10. Variant: fully anonymous
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-controlandmo:caffeineai-authorization/MixinAuthorization. - Drop
let accessControlState : AccessControl.AccessControlState;andinclude MixinAuthorization(accessControlState, null);from the actor body (and theaccessControlStateentry from the migration chain head). - Drop the
accessControlStateargument from the mixininclude, leavinginclude MixinOpenAIAdminChat(openAIApiKey);.
In src/backend/mixins/openai-admin-chat.mo:
- Drop the
AccessControlimport and theaccessControlStatemixin 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
#userpermission check at the top ofchat.chat,isOpenAIConfigured, and theOpenAI.configForKey(...)call are otherwise identical to §9.
Anonymous-specific invariants
- No
extension-authorizationimport. 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.
openAIApiKeyis read only insidechat(then passed toOpenAI.configForKey), never returned by any endpoint. - Build a fresh
Configper call — same reasoning as §9.
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
not caller.isAnonymous()(per-user) or on the#adminrole (admin-key); both require a non-anonymous caller. The login flow itself is provided by [extension-authorization](../extension-authorization/SKILL.md):useInternetIdentity, the login/logout buttons, theuseActorplumbing 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).
Variant A: per-user keys (matches §4 — default)
A per-user "your API key" pane, gated only by login.
- Password-input bound to
setMyOpenAIApiKey(key). Submit on enter; clear the input on success. - Status indicator driven by
isMyOpenAIConfigured()(returnsBool). Show "Configured" / "Not configured" — never display the key itself, never expose a getter that returns it. - Optional "Clear my key" button bound to
clearMyOpenAIApiKey()for users who want to revoke their key from the canister. - Show a one-time onboarding nudge when
isMyOpenAIConfigured()isfalse— 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)
Variant B: admin-key (matches §9)
A single global settings page, admin-gated.
- Password-input bound to
setOpenAIApiKey(key). Submit on enter; clear the input on success. - Status indicator driven by
isOpenAIConfigured()(returnsBool). Same no-display invariant as Variant A. - Hide the page from non-admins via [
extension-authorization](../extension-authorization/SKILL.md)'sisCallerAdminquery — 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 RouterbeforeLoad, React Routerloader, etc.); don't rely solely on hiding the link. - Show a "Ask your admin to set the OpenAI API key" empty state on the chat page when
isOpenAIConfigured()isfalse— 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
Variant C: fully anonymous (matches §10)
A single global settings page reachable to any visitor — no auth gate.
- Password-input bound to
setOpenAIApiKey(key). Submit on enter; clear the input on success. - Status indicator driven by
isOpenAIConfigured()(returnsBool). Same no-display invariant as variants A and B. - No router guards, no
useInternetIdentity, no login buttons — this variant has no auth model. - Show a "Paste an OpenAI API key to get started" empty state on the chat page when
isOpenAIConfigured()isfalse.
Suggested route layout:
/ → Chat UI (any visitor; empty-state when no key)
/settings/openai → API-key pane (any visitor)
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.
Related
mops add [email protected]— 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-authorization](../extension-authorization/SKILL.md) — required prerequisite for the per-user (§4) and admin-key (§9) variants; skipped for fully-anonymous (§10). Provides the Internet Identity login flow, the
useInternetIdentity/useActorfrontend plumbing, and (for §9 admin-key) the#adminrole gate. - [extension-http-outcalls](../extension-http-outcalls/SKILL.md) — sibling skill for general HTTP outcalls; you do not need it on top of
openai-client, which makes its own outcalls internally.