---
name: extension-openai
description: >-
  MANDATORY recipe for every Caffeine build that calls OpenAI (ChatGPT,
  GPT-4o, an LLM, a chatbot, embeddings). The ONLY supported path is the
  `openai-client` mops package with a canister-side API-key bearer.
  Hand-rolling `ic.http_request` to `api.openai.com/v1/...` is a
  FORBIDDEN anti-pattern — it leaks the bearer across replicated
  outcalls (security + 13× billing impact), bypasses the typed
  request/response bindings, and forces hand-rolled JSON on a language
  with poor JSON support. Load this skill whenever the user, spec, or
  any prior task mentions ChatGPT, GPT (any version), OpenAI, an LLM, a
  chatbot, or embeddings — and BEFORE writing any code that touches
  `api.openai.com`.
version: 0.1.0
compatibility:
  mops:
    openai-client: "~0.2.5"
    caffeineai-authorization: "~0.1.1"
caffeineai-subscription: [none]
---

# OpenAI integration
OpenAI / LLM extension for [Caffeine AI](https://caffeine.ai?utm_source=caffeine-skill&utm_medium=referral).

## 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:

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](../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:

```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).

## 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](https://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 `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.

### 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.

## 3. `is_replicated = ?false` is REQUIRED

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`.

## 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 + `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 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.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 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");
    };
    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.** 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.

## 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 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.

## 6. Available API surface

`openai-client@0.2.5` 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 `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`](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`