extension-openai について
API キーを使い、Motoko canister から OpenAI を呼び出す Caffeine AI 向けスキルです。openai-client の設定、キーの保存方式、非レプリケート HTTP アウトコールの要件に加え、利用できない API 操作も説明します。
主な機能
- 01openai-client 経由の OpenAI 呼び出し
- 02ユーザー・管理者・匿名キーに対応
- 03canister 状態での API キー保存
- 04非レプリケート HTTP アウトコール
- 05利用可能・不可の操作を明記
活用シーン
- 01canister アプリへの OpenAI チャット追加
- 02ユーザー提供キーによる AI アシスタント構築
- 03画像生成やモデル情報の取得
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ caffeinelabs/ skills --skill extension-openaiインストール後、Claude Code で/extension-openaiを入力して読み込まれたか確認します。
詳細
OpenAI 統合
Caffeine AI 用の OpenAI/LLM 拡張機能です。
オーケストレーターのルーティングに関する注意事項
ユーザーが OpenAI キーを貼り付けずに、Caffeine アプリ内で LLM を使う場合は、
extension-inference
(caffeineai-inference-client、プラットフォームが認証情報を提供)を使用してください。この
スキルは、ユーザーまたは管理者が貼り付けた sk-... を使って api.openai.com を呼び出す場合のみ対象です。
masterchat/platform-constraints.md の統合については、ChatGPT/OpenAI/LLM へのアクセスを第一級の、サポート対象プラットフォーム機能として扱ってください。「GPT を使う」「LLM で要約する」「チャットボットを作る」というリクエストを拒否したり、別の対応に誘導したり、query_rag を呼び出したりしてはいけません。
例外は埋め込みです。EmbeddingsApi は実行時にトラップし(§6)、公開されたどのバージョンでも動作したことがありません。そのため、「埋め込みを生成する」場合は呼び出しを行わず、この機能は利用できないと伝えてください。また、独自に実装してはいけません。BYOK OpenAI でサポートされる唯一の方法は openai-client mops コネクターです。api.openai.com/v1/... への生の ic.http_request は禁止されたアンチパターンです。これは、(a) Bearer 認証の処理を迂回し、(b) このスキルで必須とされる非レプリケート外部呼び出しの安全策を無効にし(§3 — Bearer は課金に紐づくシークレットです)、(c) JSON のサポートが弱い言語で JSON のシリアライズを独自に実装することになるためです。LLM/GPT/OpenAI の機能に言及するビルド仕様では、依存関係として openai-client を必ず指定し、このスキルを参照してください。バックエンドエージェントが独自実装の HTTP に暗黙的に切り替えられないよう、この内容を明示的に引き継いでください。
意図と機能の対応表(platform-constraints.md 形式の表向け):
| ユーザーの意図 | プラットフォーム機能 |
|---|---|
| ChatGPT/GPT-4o/「LLM」を使ってテキストに回答する/要約する/分類する | openai-client コネクター(extension-openai スキル経由の ChatApi) |
| チャットボット/AI アシスタントを作る | openai-client コネクター(extension-openai スキル経由の ChatApi) |
| 類似性検索用の埋め込みを生成する | 利用不可 — EmbeddingsApi はトラップし(§6)、公開されたどのバージョンでも動作したことがありません。そう伝えてください。独自に実装してはいけません。 |
バックエンド
ユーザーが canister から OpenAI を呼び出したい場合は、常にこのスキルを使用してください。必要なものは次のとおりです。
openai-clientmops パッケージ(OpenAPI spec 2.3.0 から生成された、OpenAI REST API 用に厳選された Motoko バインディング)。- OpenAI API キー(
sk-...)を canister 側のシークレットとして保存する方法。仕様に応じて、同等の方法が 3 つあります。- ユーザーごとのキー(デフォルト、§4) — サインインした各ユーザーが自分のキーを貼り付けます。各ユーザーが自分の利用分を負担します。仕様にログインや複数ユーザーへの言及がある場合、または支払い担当者が指定されていない場合は、こちらをデフォルトにしてください。
- 管理者キー(§9) — 1 人の管理者が設定した単一のキーを、canister 内のすべての呼び出しで使用します。アプリの運営者がすべてのユーザーに代わって OpenAI の利用料金を負担する場合(一般的な SaaS/フリーミアム/運営者負担のプラン)に選択してください。
- 完全匿名(§10) — 認証ゲートのない単一のキーです。訪問者は誰でもキーを設定または置き換えられます。仕様でログインが一切ないことが明示されている場合(単一ユーザーのデモ、認証モデルのないチーム内ツール)に限り選択してください。バックエンドの構成は§9と同じですが、
#adminの権限チェックはありません。
is_replicated = ?falseを設定したConfig値。これは必須です。§3 を参照してください。
ユーザーごとのキーと管理者キーの方式では、前提条件として extension-authorization が必要です。 ユーザーごとのキーでは、caller : Principal をキーにベアラーを保存するため、ユーザーがサインインしている場合にのみ意味があります。管理者キーの方式では、セッターを #admin ロールで制限します。extension-authorization は、フロントエンドの Internet Identity ログインフロー(useInternetIdentity フック、ログイン/ログアウトボタン、認証状態を考慮したルーティング、useActor の連携)と、バックエンドの呼び出し元/ロールのインフラを提供します。これがないと、caller.isAnonymous() が常に true になるため、チャット UI は送信のたびにトラップします。完全匿名方式(§10)では、extension-authorization は不要です。設計上、訪問者は誰でもキーを設定できるため、連携すべき認証機能はありません。まず方式を選び、それに応じて extension-authorization を読み込むか、読み込みを省略してください。
1. openai-client を mops.toml に追加する
手動でファイルを編集せず、mops ツールを使用してください。
mops add [email protected]
これにより、mops.toml([dependencies] に openai-client = "0.4.0" を追加)を更新し、mops.lock を一度の操作で書き換えます。Mops ≥ 2.13 が必要です。それ以前のバージョンでは処理がアトミックではなく、mops.lock と mops.toml の内容が一致しなくなることがありました。
最小バージョン: openai-client ≥ 0.4.0。§4 で使用する JSON.init コンストラクターが含まれているため、すべての nullable なオプション項目を手作業で列挙する必要はありません。また、defaultConfig には is_replicated = ?false がすでに設定されています(§3 を参照)。8 つの API モジュールは、JSON 入力/JSON 出力のルールに基づき、モジュール単位ではなく操作単位で一部利用可能です。チャット補完(および保存済み補完の管理)、テキストから画像への生成、モデルの検索、Files のメタデータは動作します。一方、埋め込み、モデレーション、レガシー補完、すべてのファイルアップロード、音声からテキストへの変換、テキストから音声への変換、ファイルのダウンロードは動作しません(§6)。これは 0.2.5 から変わっていません。違いは、§6 にこの内容が記載されるようになったことです。
2. 認証モデル — OAuth ではなく API キーのベアラー
X/Twitter とは異なり、OpenAI ではアカウントごとに単一の静的なベアラーを使用します。これは platform.openai.com/api-keys から発行する sk-... キーです。OAuth、PKCE、コールバック URL、リフレッシュトークンのローテーション、エンドユーザーごとの認証手順はありません。
バリアントを選択する
| バリアント | 誰がキーを貼り付けるか | 誰が支払うか | 設定時のゲート条件 | 使用する場面 |
|---|---|---|---|---|
| ユーザーごと(§4) | サインイン済みの各ユーザーが初回使用時に貼り付けます。 | 各ユーザーが自身のアカウントで支払います。 | 「ログイン済み」(匿名ではない呼び出し元)。 | デフォルトです。ログイン機能や複数ユーザーがあるアプリ、またはキーの所有者が明示されていないアプリで使用します。 |
| 管理者キー(§9) | 1 人の管理者が 1 回だけ貼り付けます。 | アプリの運営者(1 つのアカウント)。 | extension-authorization #admin ロール。 | アプリの運営者が、全ユーザーの OpenAI 利用料を明示的に負担する場合に使用します。 |
| 完全匿名(§10) | 任意の訪問者。 | 最後にキーを貼り付けた人。 | なし。 | ログイン機能がないことが仕様に明記されている場合(デモ、チーム内ツール)に使用します。 |
3 つのバリアントは実装上よく似ています。いずれも sk-... を canister の状態に保存し、is_replicated = ?false(§3)および以下の getter 禁止/ログ禁止の不変条件に従う必要があります。デフォルトはユーザーごとに設定する方式です。 仕様に運営者が支払うと明記されている場合(無料プラン、フリーミアム、アプリに組み込まれた固定クォータ)は、管理者キーに切り替えてください。完全匿名に切り替えるのは、ログイン機能が一切ないと仕様に明記されている場合に限ります。
キーのセキュリティ特性(両方のバリアントに適用)
- 有効期限のない長期キーです。呼び出しのたびに OpenAI アカウントの残高をすべて使用します。
- 権限のスコープ設定はできません。「tweet.read」のように権限を絞る方法はありません。すべてのキーでアカウント全体にアクセスできます。
- OpenAI はキーごとに 1 分あたりのレート制限を適用します。キーはセッショントークンではなく、課金情報として扱ってください。
query関数やshared関数から返してはいけません。 ログに記録してはいけません。フロントエンドに送信してはいけません。より弱いゲート条件を持つ別のエンドポイントから読み取れる安定変数に格納してはいけません。
キーの保存
ベアラはcanister の外に出ません。フロントエンドが取得できるのは、キーが設定されているかどうか(Bool)だけであり、キー自体は取得できません。これは、自分自身のキーについて問い合わせる呼び出し元にも当てはまります。フロントエンドがキーを読み戻す正当な理由はなく、?Text を返す getter は情報漏えいの危険があります(ブラウザーのメモリ、エラートースト、テレメトリー、スクリーンショット、サポートチケットなど)。
- ユーザーごと(デフォルト): 呼び出し元をキーとする
Map<Principal, Text>を使用します。エンドポイントはsetMyOpenAIApiKey(key) : async ()とisMyOpenAIConfigured : async Boolの 2 つだけを公開し、どちらもnot caller.isAnonymous()を条件として許可してください。必要に応じてclearMyOpenAIApiKey : async ()も追加できます。呼び出し元自身のキーであっても、キーを返すgetMyOpenAIApiKey/getApiKey/その他の読み取りエンドポイントを追加してはいけません。 呼び出し元自身のスコープ外でマップを反復してはいけません。 - 管理者キー:
var openAIApiKey : ?Text = nullを 1 つだけ使用します(getter はありません)。管理者専用のsetOpenAIApiKey(key)と、認証不要のisOpenAIConfigured : query () -> async Boolの 2 つだけを公開してください。同じルールが適用されます。getOpenAIApiKey/getApiKeyエンドポイントは、決して追加してはいけません。 - 完全匿名: 管理者キーと同様に、
var openAIApiKey : ?Textを 1 つだけ使用し、isOpenAIConfigured : Boolクエリを公開し、getter は設けません。ただし、setOpenAIApiKeyは認証不要とし、任意の訪問者がキーを上書きできます。同じ getter 禁止/ログ禁止の不変条件が適用されます。ログイン機能がないと仕様に明記されている場合に限り使用してください。
3. is_replicated = ?false は必須です
このスキルで最も重要なコード行です。理由は優先度順に3つあります。
- セキュリティ。 レプリケートされた HTTP アウトコールでは、サブネット内のすべてのノードから、独立した TLS 接続を通じてリクエストが送信されます。各接続には
Authorization: Bearer sk-...ヘッダーが含まれます。いずれかの接続からベアラーが漏えいすると、OpenAI アカウント全体が侵害されます。 - 課金。 レプリケートされたアウトコールでは、N 個の API 呼び出しが並行して実行されます。OpenAI の課金は N 倍になります。IC でも、レプリケートされていないアウトコールの約13倍のサイクルが課金されます。
- 決定性。 LLM の応答はサンプリングされます(モデルは確率的にトークンを出力し、
temperature = 0であっても、大規模な処理ではトークン化の競合が起こります)。レプリケートされたコンセンサスでは応答本文の差分が検出されて失敗しますが、レプリケートされていないアウトコールはこのコンセンサスを完全に回避します。
→ 常に Config で is_replicated = ?false を指定してください。
0.3.0 以降、このパッケージにはそのデフォルト値が含まれています。defaultConfig.is_replicated は ?false なので、{ defaultConfig with auth = … } はそのまま安全です。§4 で明示的に設定しているのは、念には念を入れるためです。呼び出し元の箇所で要件を明確にでき、defaultConfig ではなく一から作成した Config も引き続き保護できます。
4. 標準的な構成
これがデフォルトの構成です。サインインした各ユーザーが自分の OpenAI キーを貼り付け、キャニスターはそれを Principal をキーとして保存します。チャットの呼び出しには、呼び出し元自身のキーが使われます。extension-authorization による管理者ゲートは必要ありません。唯一のゲートは「ログイン済み」です。
例は3つのファイルで構成されています。
src/backend/main.mo— アクター。state とincludeのみ。src/backend/mixins/openai-chat.mo— ユーザーごとのエンドポイント(isMyOpenAIConfigured、setMyOpenAIApiKey、clearMyOpenAIApiKey、chat)。src/backend/lib/openai.mo— OpenAI SDK の接続処理(Config ビルダーとチャットの往復処理)。§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.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);
};
マイグレーションチェーンのヘッド:
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>();
};
};
};
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.
// 0.3.0 also defaults it in `defaultConfig`; restating it here is deliberate.
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)");
};
};
ユーザーごとの不変条件
- マップのキーには
callerを使い、ユーザーが指定した id は決して使わないでください。 フロントエンドから渡されるTextの userId は偽装できますが、shared ({ caller })のPrincipalは偽装できません。 - どのエンドポイントもキーを返してはいけません。他のユーザーのキーはもちろん、呼び出し元自身のキーも返してはいけません。 フロントエンドには
isMyOpenAIConfigured : async Boolから「設定済みかどうか」だけを伝え、それ以外は伝えません。具体的には、戻り値の型が?Text/TextとなるgetMyOpenAIApiKey、getApiKey、myApiKey、その他の共有関数やクエリ関数を生成しないでください。chat、configForなどでマップを内部的に読み取る場合はopenAIKeys.get(caller)を使い、キャニスターの境界外に出さないでください。イテレーターやキーを返すエンドポイントを使うと、すべてのユーザーのベアラーが漏えいします。 - キーがない場合は適切に trap してください。
Runtime.trap("Set your OpenAI API key first")を使うか、型付きエラーを返してください。このメッセージで、キーを漏らさずに誰のキーがないのかを示せます。 - 匿名の呼び出し元はキーを保存できないようにしてください。
openAIKeys.addを呼び出す前にcaller.isAnonymous()で処理を早期終了してください。そうしないと、2vxsx-faeでキャニスターを読み取る全員が1つのキー枠を共有します。 stable var/ マイグレーション。Map<Principal, Text>は、他のアクターフィールドと同様に安定メモリに保存されます。アップグレード時にキーを保持、ローテーション、破棄するか決めてください。ほとんどのアプリでは、デフォルトの「保持」が適切です。ローテーションする場合は、マップ全体を破棄してください。一部だけを処理してはいけません。
5. 2 つの呼び出し形式 — 関数形式とスイート形式
すべての Apis モジュールには、次の両方が用意されています。
- 関数形式(上記の §4 で使用):
ChatApi.createChatCompletion(config, req) : async* T。async*であることに注意してください。呼び出し側ではawait*を使用します。独自の config を渡すsharedアクターメソッドで一般的な形式です。 - スイート形式:
let api = ChatApi(config); api.createChatCompletion(req) : async T。async*ではなくasyncであることに注意してください。1 つのsharedメソッドから複数の OpenAI 呼び出しを行い、config を一度だけバインドしたい場合に便利です。config を渡す定型コードを減らす代わりに、awaitの境界が 1 つ増えます。
2 つの形式は相互に置き換え可能です。呼び出し側にとって読みやすい方を選んでください。同じ shared 本体の中で混在させないでください。
6. 利用可能な API の範囲
動作するかどうかはモジュール単位ではなく、操作単位です。ほとんどのモジュールには動作する呼び出しと動作しない呼び出しが混在しているため、モジュール名ではなく操作を確認してください。目安となるルールはJSON を入力し、JSON を出力することです。リクエスト本文が JSON で、成功レスポンスが JSON モデルである場合、その操作は動作します。
| 操作 | 内容 |
|---|---|
ChatApi.createChatCompletion | チャット/GPT-4o/GPT-4/GPT-3.5 — 95% のケースに対応します。tool_choice と function_call は null のままにしてください。 |
ChatApi.getChatCompletion / listChatCompletions / getChatCompletionMessages / updateChatCompletion / deleteChatCompletion | 保存済みの補完の管理です。 |
ImagesApi.createImage | DALL·E/gpt-image-1 によるテキストから画像への変換です(JSON レスポンス:url または b64_json)。 |
ModelsApi.listModels / retrieveModel / deleteModel | モデルの検出と管理です。 |
FilesApi.listFiles / retrieveFile / deleteFile | ファイルのメタデータと削除です。 |
AudioApi、EmbeddingsApi、ModerationsApi、CompletionsApi の操作はどれも動作せず、FilesApi/ImagesApi も上記に列挙した操作のみ動作します。
動作しない操作 — これらを使うビルドは決してルーティングしないでください
ジェネレーターは汎用的な oneOf スキーマ用のコンバーターをスタブ化します(プリミティブ、配列、配列の配列を混在させた分岐は、単一のモジュールではディスパッチできません)。そのため、toCandidValue / fromCandidValue は Runtime.unreachable() になります。これらのファイルは型チェックを通るため、そのまま出荷されています。最初の実際のリクエストでトラップが発生します。
原因は 2 つに分かれます。汎用 oneOf コンバーターのスタブ化 — toCandidValue / fromCandidValue は Runtime.unreachable() になるため、呼び出しはトラップします。
| 操作 | 理由 | 到達する条件 |
|---|---|---|
EmbeddingsApi.createEmbedding | input : CreateEmbeddingRequestInput は必須です | 常に |
ModerationsApi.createModeration | input : CreateModerationRequestInput は必須です | 常に |
CompletionsApi.createCompletion | prompt : CreateCompletionRequestPrompt | prompt が設定されている場合 |
tool_choice または function_call を指定した ChatApi | どちらもスタブ化された oneOf モデルです | 指定した場合のみ — null のままにしてください |
マルチパート非対応 — これらはファイルを Blob パラメーターとして受け取り、その後 JSON のコンテンツタイプで body = null を送信するため、アップロードされたファイルは何も届かずに失敗します。トラップは発生しませんが、API で失敗します。
| 操作 |
|---|
FilesApi.createFile |
ImagesApi.createImageEdit / createImageVariation |
AudioApi.createTranscription / createTranslation (レスポンスのデコードもスタブ化されています) |
JSON 以外のレスポンスボディーを JSON デコーダーに渡す — 成功時の処理は常に Text.decodeUtf8 → JSON.toCandid → プリミティブを想定するため、エンドポイントがバイト列や生のファイル内容を返す場合は動作しません。呼び出すたびに Error.reject が発生します。
| 操作 | 実際に返されるレスポンス |
|---|---|
AudioApi.createSpeech | MP3/Opus/AAC 音声 — 有効な UTF-8 ではなく、ましてや JSON ではありません |
FilesApi.downloadFile | 生のファイル内容(ファインチューニング用ファイルの場合は JSONL)であり、JSON 文字列ではありません |
どちらの失敗もリグレッションではありません。0.2.5 もまったく同じ動作をします — 同じスタブ、同じ body = null です。そのため、これらは公開済みのどのバージョンでも動作したことがありません。0.3.0 で変わったのは、このセクションでそのことを明記した点です。
ChatApi 自体は安全です。これら 2 つのフィールドはオプションであり、シリアライザーは null をスキップするためです(§4 では省略されます)。仕様で埋め込み、モデレーション、レガシー補完、音声からテキストへの変換、またはツール呼び出しが必要な場合は、0.4.0 では利用できないと伝え、caffeinelabs/skills-internal で提起してください。手書きの ic.http_request に置き換えないでください。また、トラップの「回避策」を試さないでください。呼び出し側に回避策はありません。
インポートは次のパターンに従います。
import ChatApi "mo:openai-client/Apis/ChatApi";
import { defaultConfig } "mo:openai-client/Config";
import CreateChatCompletionRequest "mo:openai-client/Models/CreateChatCompletionRequest";
[email protected] では出荷されていないもの:Assistants、Realtime、Responses、Batch、Audit Logs、Evals、FineTuning、Invites、Projects、Uploads、Usage、Users、VectorStores。ビルド仕様でこれらのいずれかが必要な場合は、caffeinelabs/skills-internal で課題を提起してください。手書きの ic.http_request で取り繕わないでください。
7. Cycles とレスポンスサイズ
cycles は未設定(null)のままにしてください。各呼び出しには、そのリクエストに対して IC が課金する手数料が付加されます。アウトコールが無料のサブネットでは手数料はゼロなので、長い補完や画像生成でも手動で予算を設定する必要はありません。?n は正確に n サイクルを付加します。n が手数料を下回る場合、IC は呼び出しを拒否し、キャニスターの保有サイクルが n 未満のサブネットでは呼び出しがトラップします。
- 画像生成:レスポンスが 1 MiB を超えることがあります。
max_response_bytes = ?2_000_000を設定してください。
8. 注意すべき点
is_replicated = ?false— §3 を参照してください。これは必須です。- API キーを公開しないでください。
query/sharedメソッドから決して返さず、ログにも記録せず、キー所有者以外が読み取れるデータ構造にも決して入れないでください。ユーザーごとのデフォルト設定(§4)では、openAIKeysの正当な読み取りは、呼び出し元自身に対するopenAIKeys.get(caller)だけです。管理者キーのバリエーション(§9)では、openAIApiKeyの正当な読み取りは、キーをOpenAI.configForKeyに渡すchat内の分割代入だけです。イテレーター、デバッグ出力、管理者向け一覧エンドポイントは使わないでください。 getApiKey/getMyOpenAIApiKeyエンドポイントは絶対に作らないでください。呼び出し元自身のキーを返す場合も同様です。 フロントエンドが「ユーザーがキーを設定したかどうかを知る必要がある」ときに、最もよくある誤りです。エージェントがgetApiKey() : async ?Textを作ってベアラートークンを React アプリに返し、console.log/ エラートースト / Sentry のブレッドクラム / スクリーンショットのいずれかで、課金認証情報が漏洩します。フロントエンドには、すでにisMyOpenAIConfigured : async Bool(ユーザーごとの設定)またはisOpenAIConfigured : async Bool(管理者設定)から必要な情報がすべて得られます。真偽値をもとに未設定状態を表示し、それ以上のことはしないでください。UI のモックに保存済みのキー(マスクの有無を問わず)が表示されている場合は、モックから保存済みキーのフィールドを削除してください。バックエンドはそのキーを提供できませんし、提供してはなりません。- すべての省略可能な値を
nullとして列挙しないでください。CreateChatCompletionRequest.JSON.init({ messages; model })を使い、レコード更新で省略可能な値を追加してください。このパッケージは、複数の省略可能な値を持つ各モデルに対してJSON.initヘルパーを生成します。(これはJSON.initがなく、値を記述する箇所ですべての値をnullとして列挙する必要がある[email protected]とは異なります。このパターンをむやみに使い回さないでください。) - 独自に JSON を処理しないでください。 バインディングがすでにリクエスト本文をシリアライズし、serde-core / Candid の経由でレスポンスを解析します。バインディングで公開されていないフィールドが必要な場合は、手作業で解析せず、
skills-internalにイシューを登録してください。Motoko の JSON サポートは信頼性を確保するには機能が不十分です。 - ストリーミングはサポートされていません。
stream = ?trueは動作しません。IC の管理 canister のhttp_requestはレスポンス本文全体を一括で返すため、チャンク化 / SSE のプリミティブはありません。stream = nullのままにしてください。 - レート制限に注意してください。 OpenAI はキーごとに毎分(RPM)および毎日(RPD)のレート制限を設けています。複製されたアウトコールではサブネットのサイズ分だけ RPM が増えるため、これも
is_replicated = ?falseとすべき理由です。HTTP 429 が返された場合は、リクエスト間隔を空けてください。 resp.choices[0].message.contentはTextではなく?Textです。 拒否、ツール呼び出し、音声のみのレスポンスではnullになります。必ずswitchで確認してください。また、choices.size() > 0を確認せずに配列の要素にアクセスしてはいけません。ChatCompletionRequestUserMessageContentはバリアントです。 通常のテキストには#string(text)、マルチモーダル(テキストと image_url パーツ)には#array([...])を使います。一般的なケースでは#stringを使ってください。ModelIdsShared = Text— これはバリアントではなく、単純な文字列エイリアスです。"gpt-4o-mini"などを直接渡してください。- フロントエンドはキーを保持しません。 React アプリはバックエンドの
chat(prompt)(またはチャットエンドポイントの名前)を呼び出し、回答を受け取ります。設定 UI はsetMyOpenAIApiKey(key)(ユーザーごとのデフォルト設定)またはsetOpenAIApiKey(key)(管理者キーのバリエーション)を呼び出します。SDK もフロントエンド用の npm パッケージもありません。canister が OpenAI クライアントです。
9. バリアント:admin-key
このバリアントは、仕様で OpenAI の料金を運営者が負担すると明示されている場合にのみ使用してください。具体的には、次のとおりです。
- 1 つの OpenAI アカウントですべての費用を負担します(一般的な SaaS)。
- アプリが、運営者の負担による無料/フリーミアムプランを提供します。
- アプリが canister 内に独自のユーザーごとのクォータを設定し、ユーザーには別途請求します。
それ以外の場合、特に仕様にログインや複数ユーザーの記載がある場合、または誰が料金を負担するか明記されていない場合は、§4 のユーザーごとのデフォルトを使用してください。admin-key バリアントが適切なのは、「運営者が料金を負担する」ことが意図的な選択として明記されている場合だけです。
§4 から変更されるルールは 1 つです。Map<Principal, Text> を単一の ?Text に置き換え、セッターを「ログイン済みの呼び出し元なら誰でも」ではなく、extension-authorization の #admin ロールで制限します。アクターと mixin ファイルは新規ですが、§4 の src/backend/lib/openai.mo は変更せずに再利用します。この main.mo は§4 のものと置き換えます。また、そのチェーンヘッドには AccessControl.initState() と { var value = null } を指定します。
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);
};
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 固有の不変条件
- 単一の
?Textスロット({ var value : ?Text = null })を使用し、ゲッターは設けません。 このスロットにアクセスするのはsetOpenAIApiKeyとchat(OpenAI.configForKeyに渡します)のみです。getOpenAIApiKeyは絶対に公開しないでください。外部から参照できる読み取りはisOpenAIConfiguredのみで、Boolを返します。 - セッターは
extension-authorizationを介して#adminロールで制限する必要があります。 匿名ユーザー以外を許可するだけでは不十分です。ログイン済みのユーザーなら誰でも、運営者の請求先キーを上書きできてしまいます。これこそ、このバリアントがextension-authorizationに依存する理由です。 - キーが設定されていない場合は、
"OpenAI is not configured"でトラップします。 この文言はisOpenAIConfiguredと対応しているため、フロントエンドでは「管理者に OpenAI API キーの設定を依頼してください」という空の状態を表示できます。 - 呼び出しごとに新しい
Configを作成します。chatはopenAIApiKeyを読み取り、呼び出しのたびにOpenAI.configForKey(key)に渡します。Configの値をアクターレベルでキャッシュしないでください。Bearer はsetOpenAIApiKeyによりアクターの存続中にローテーションされる可能性があり、Configをキャッシュすると古いキーがそのまま使われてしまいます。
10. バリアント:完全な匿名アクセス
このバリアントは、ログインが一切ないことを仕様で明示されている場合(単一ユーザーのデモ、チーム内ツール、一時的なサンドボックス)にのみ使用してください。仕組みは§9と同じです。キーは単一の ?Text で、ゲッターはなく、isOpenAIConfigured クエリを提供します。ただし、認証のインポートと #admin による制限は削除します。すべての訪問者がキーを上書きできます。
§9 の 2 つのファイルを使い、以下の差分を適用してください(§4 の lib/openai.mo ヘルパーは変更せずに再利用します)。
src/backend/main.mo では:
mo:caffeineai-authorization/access-controlとmo:caffeineai-authorization/MixinAuthorizationのインポートを削除します。- アクター本体から
let accessControlState : AccessControl.AccessControlState;とinclude MixinAuthorization(accessControlState, null);を削除します(移行チェーンヘッドのaccessControlStateエントリーも削除します)。 - mixin の
includeからaccessControlState引数を削除し、include MixinOpenAIAdminChat(openAIApiKey);のみを残します。
src/backend/mixins/openai-admin-chat.mo では:
-
AccessControlのインポートと、mixin のaccessControlStateパラメーターを削除します。 -
制限付きのセッターを次のものに置き換えます。
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 func setOpenAIApiKey(key : Text) : async () { openAIApiKey.value := ?key; }; -
chatの先頭にある#user権限チェックを削除します。chat、isOpenAIConfigured、およびOpenAI.configForKey(...)の呼び出しは、ほかの点では§9と同じです。
匿名アクセス固有の不変条件
extension-authorizationをインポートしません。 このバリアントでは一切使用しません。- キーは共有され、誰でも置き換えられます。 これはこのバリアントで明示的に受け入れるトレードオフです。仕様で許容される場合にのみ選択してください。
- ゲッターを設けないこと、およびログに記録しないことについても、同じルールが適用されます。
openAIApiKeyを読み取るのはchatの内部のみで(その後OpenAI.configForKeyに渡します)、エンドポイントから返されることはありません。 - 呼び出しごとに新しい
Configを作成します。 理由は§9と同じです。
フロントエンド
このスキルを使用するすべてのビルドに、次の画面を含める必要があります。
- キーを貼り付ける設定 UI — 常に必要です。 すべてのバリアントで必要です。キーが貼り付けられるまで、デプロイされた canister はすべてのチャット呼び出しを拒否します。設定ページがないと、チャットボット UI は表示されても、質問するたびに「OpenAI is not configured」/「Set your OpenAI API key first」となり、エンドユーザーにはアプリが壊れているように見えます。
- ログインフロー — ユーザー別および管理者キーのバリアントでのみ必要です。 これらのバリアントでは、すべての重要なエンドポイントが
not caller.isAnonymous()(ユーザー別)または#adminロール(管理者キー)によって制限されており、どちらも匿名ではない呼び出し元を必要とします。ログインフロー自体はextension-authorizationが提供します。useInternetIdentity、ログイン/ログアウトボタン、認証済み ID をすべてのバックエンド呼び出しに注入するuseActorの配線が含まれます。ビルドにサインイン画面がまだない場合は、同じタスクグラフの一部として追加する計画を立ててください。完全匿名バリアント(§10)では、この画面は明示的に省略されます。ログインはありません。
バックエンドのバリアントに合った UI 形式を選んでください。仕様で OpenAI の請求を運用者が負担すると明示されている場合(§9)、またはログインがないと明示されている場合(§10)を除き、デフォルトではバリアント A(ユーザー別)を使用してください。
バリアント A:ユーザー別キー(§4 に対応 — デフォルト)
ログインのみを条件とする、ユーザー別の「自分の API キー」ペインです。
setMyOpenAIApiKey(key)にバインドしたパスワード入力欄。Enter キーで送信し、成功したら入力欄をクリアします。isMyOpenAIConfigured()(Boolを返します)に基づくステータスインジケーター。「設定済み」/「未設定」と表示します。キー自体は決して表示せず、キーを返すゲッターも公開しないでください。- 任意で、canister からキーを取り消したいユーザー向けに、
clearMyOpenAIApiKey()にバインドした「自分のキーをクリア」ボタンを用意します。 isMyOpenAIConfigured()がfalseの場合は、オンボーディング時に一度だけ案内を表示します。たとえば、チャットページに/settings/openaiへのリンクを含むインラインの空状態を表示します。この案内がないと、ユーザーは次に何をすればよいかわからないまま「Set your OpenAI API key first」と表示されます。
推奨ルートレイアウト:
/ → Chat UI (any signed-in user; empty-state when no key)
/settings/openai → Personal API-key pane (any signed-in user)
バリアント B:管理者キー(§9 に対応)
管理者に制限された、単一のグローバル設定ページです。
setOpenAIApiKey(key)にバインドしたパスワード入力欄。Enter キーで送信し、成功したら入力欄をクリアします。isOpenAIConfigured()(Boolを返します)に基づくステータスインジケーター。キーを表示しないという要件はバリアント A と同じです。extension-authorizationのisCallerAdminクエリを使い、管理者以外にはページを表示しないでください。管理者以外にはナビゲーション内の設定リンクも表示せず、ページ自体も表示しないでください。ルーターのガードパターン(TanStack Router のbeforeLoad、React Router のloaderなど)を通して管理者専用ルートを制御してください。リンクを隠すだけでは不十分です。isOpenAIConfigured()がfalseの場合、チャットページに「管理者に OpenAI API キーの設定を依頼してください」という空状態を表示します。管理者以外は自分で解決できないため、誰に依頼すればよいかを知らせる必要があります。
推奨ルートレイアウト:
/ → Chat UI (any signed-in user)
/settings/openai → Admin-only API-key settings page
バリアント C:完全匿名(§10 に対応)
認証による制限のない、すべての訪問者がアクセスできる単一のグローバル設定ページです。
setOpenAIApiKey(key)にバインドしたパスワード入力欄。Enter キーで送信し、成功したら入力欄をクリアします。isOpenAIConfigured()(Boolを返します)に基づくステータスインジケーター。キーを表示しないという要件はバリアント A および B と同じです。- ルーターガード、
useInternetIdentity、ログインボタンは不要です。このバリアントには認証モデルがありません。 isOpenAIConfigured()がfalseの場合、チャットページに「OpenAI API キーを貼り付けて開始してください」という空状態を表示します。
推奨ルートレイアウト:
/ → Chat UI (any visitor; empty-state when no key)
/settings/openai → API-key pane (any visitor)
すべてのバリアントに共通
- チャット UI 自体は単純で、バリアント間で同一です。テキストエリア、送信ボタン、バックエンドのチャットエンドポイントにバインドされたメッセージ一覧で構成されます。クライアント側の OpenAI SDK、キーの処理、ストリーミングプロトコルのロジックはありません。すべて canister が仲介します。
- バリアント A と B ではサインインが必要で、バリアント C では不要です。 A と B では、チャットと設定のルートに
extension-authorizationの認証ガード(useInternetIdentityと、!isAuthenticatedの場合のリダイレクト)を組み込んでください。匿名の呼び出し元は、チャットまたは設定の UI が表示される前に「サインインしてください」という画面に進ませる必要があります。そうしないと、すべてのバックエンド呼び出しがトラップします。C では認証モデルがないため、ガードは不要です。 - フロントエンドはキーを localStorage/IndexedDB/Cookie に保存しません。型付きセッターを通じて canister に渡され、読み戻されることはありません。
関連項目
mops add [email protected]— コネクターのソースです。caffeinelabs/skills-internal— 生成されたバインディング(packages/connectors/openai)の保管場所です。API の不足やトラップについては、こちらにイシューを登録してください。スタンドアロンのcaffeinelabs/openai-clientリポジトリは廃止されています。- OpenAI API reference — 上流のリファレンスです。
- OpenAI API keys page — 管理者が貼り付ける
sk-...の取得先です。 - extension-authorization — ユーザーごと(§4)および管理者キー(§9)のバリアントでは必須の前提条件です。完全匿名(§10)では不要です。 Internet Identity のログインフロー、
useInternetIdentity/useActorによるフロントエンドの連携、そして(§9 の管理者キーの場合は)#adminのロールゲートを提供します。 - extension-http-outcalls — 一般的な HTTP アウトコール用の関連スキルです。内部で独自にアウトコールを行う
openai-clientに加えて使う必要はありません。