AIアプリにおけるベンダーロックインは、たいてい一度に起こるものではありません。少しずつ入り込んできます。ここで直接 import openai し、そこでモデル名をハードコードし、別プロバイダーが同じものを返すか確認せずにレスポンスフィールドをパースする。6か月後、プロバイダーを切り替えるにはバックエンドの半分を書き直すことになります。
ロックインが起こる4つのパターン
多くの開発者は、ロックインとは「OpenAI SDK を使っていること」だと考えます。それは最も危険性の低い種類です。本当の落とし穴はもっと微妙です。
| ロックインの種類 | 起こり方 | 結果 |
|---|---|---|
| SDK ロックイン | from openai import OpenAI everywhere | SDK を切り替えるには全ファイルを触る必要がある |
| モデル名ロックイン | model="gpt-4o" hardcoded in business logic | モデル変更のたびにコード変更が必要になる |
| パラメータロックイン | Using logprobs, n>1, or reasoning_effort | Claude や Gemini には存在しない |
| レスポンス形式ロックイン | Parsing provider-specific response fields | プロバイダーごとに返す構造が異なる |
目的は、これらをすべて排除することではありません。一部は許容できるトレードオフです。目的は、自分がどれを引き受けているのかを把握することです。
抽象化レイヤーとして OpenAI 互換エンドポイントを使う
SDK ロックインを避ける最もクリーンな方法は、複数のプロバイダーへルーティングできる単一の OpenAI 互換エンドポイントを使うことです。OpenAI SDK はそのまま使いながら、バックエンドは任意のプロバイダーにできます。
CometAPI はこれを実現します。1つのエンドポイント、1つのキーで、OpenAI、Anthropic、Google、DeepSeek、xAI などにまたがる500以上のモデルを利用できます。
import osfrom openai import OpenAIfrom dotenv import load_dotenvload_dotenv()api_key = os.environ.get("AI_API_KEY")if not api_key: raise ValueError("AI_API_KEY environment variable is not set")client = OpenAI( base_url=os.environ.get("AI_BASE_URL", "https://api.cometapi.com/v1"), api_key=api_key,)
GPT から Claude、Gemini への切り替えは1行の変更です。
# Beforeresponse = client.chat.completions.create(model="gpt-5.4", messages=[...])# After — same code, different modelresponse = client.chat.completions.create(model="claude-sonnet-4-6", messages=[...])
注: gpt-5.4 や claude-sonnet-4-6 のようなモデル名は CometAPI のプラットフォーム識別子です。これらは https://api.cometapi.com/v1 経由でのみ機能し、OpenAI や Anthropic の API で直接使うことはできません。完全なカタログと料金については、モデル一覧全体を参照してください。
モデル名をビジネスロジックから切り離す
コード全体に散らばったモデル名は、最も一般的なロックインの形です。解決策は、環境変数から読み込む中央設定を用意することです。
# config.py — one place to change model assignmentsimport osMODEL_CONFIG = { "summarize": os.environ.get("MODEL_SUMMARIZE", "claude-opus-4-7"), "code": os.environ.get("MODEL_CODE", "gpt-5.4"), "classify": os.environ.get("MODEL_CLASSIFY", "claude-haiku-4-5"), "chat": os.environ.get("MODEL_CHAT", "gpt-5.4-mini"),}# Validate at startup — fail fast rather than getting mysterious API errorsfor task, model in MODEL_CONFIG.items(): if not model: raise ValueError(f"Model config for '{task}' is not set")
ビジネスロジックでは、モデル名を直接参照しません。
from config import MODEL_CONFIGdef summarize(text: str) -> str: response = client.chat.completions.create( model=MODEL_CONFIG["summarize"], messages=[{"role": "user", "content": f"Summarize: {text}"}], max_tokens=300 # move to config in production ) return response.choices[0].message.content
アプリ全体で要約モデルを切り替えるには、環境変数を1つ変更するだけです。grep も検索置換も不要です。
コードがプロバイダー固有フィールドに依存しないようレスポンスをラップする
プロバイダーによってレスポンスの構造は少しずつ異なります。コードベース全体で生の API レスポンスをパースしていると、そのプロバイダーの形式にロックインされます。
正規化された dataclass にラップします。
from dataclasses import dataclassfrom typing import Optionalfrom openai import OpenAI, APIStatusError, APIConnectionError, APITimeoutErrorfrom openai.types.chat import ChatCompletionimport logging@dataclassclass AIResponse: content: str model: str input_tokens: int output_tokens: intdef call_model(task: str, messages: list, **kwargs) -> AIResponse: """ Single entry point for all LLM calls. Returns a normalized AIResponse regardless of which model handled it. Raises on 4xx (client errors). Logs and re-raises on 5xx/network errors. """ model = MODEL_CONFIG.get(task, "gpt-5.4-mini") if not model: raise ValueError(f"No model configured for task '{task}'") try: response: ChatCompletion = client.chat.completions.create( model=model, messages=messages, **kwargs ) except APIStatusError as e: logging.error(f"API error for task={task} model={model}: {e.status_code} {e.message}") raise except (APIConnectionError, APITimeoutError) as e: logging.error(f"Network error for task={task} model={model}: {e}") raise # content is None when the model triggers a tool call instead of returning text content = response.choices[0].message.content or "" # usage is None in streaming mode — default to 0 if not available usage = response.usage input_tokens = usage.prompt_tokens if usage else 0 output_tokens = usage.completion_tokens if usage else 0 logging.info( f"task={task} model={model} " f"input_tokens={input_tokens} output_tokens={output_tokens}" ) return AIResponse( content=content, model=response.model, input_tokens=input_tokens, output_tokens=output_tokens, )
これでビジネスロジックは、生の API レスポンスではなく AIResponse オブジェクトを扱います。プロバイダーがレスポンス形式を変更しても、修正箇所は1か所です。
ラッパーにストリーミング対応を追加する
チャットインターフェースではストリーミングが必要になるでしょう。ラッパーでは別経路として扱います。
from typing import Iteratordef stream_model(task: str, messages: list, **kwargs) -> Iterator[str]: """ Stream tokens from the routed model. Note: streaming doesn't return usage data. Fallback is not supported in streaming mode — you've already started yielding tokens before you know if the full request succeeds. """ model = MODEL_CONFIG.get(task, "gpt-5.4-mini") if not model: raise ValueError(f"No model configured for task '{task}'") stream = client.chat.completions.create( model=model, messages=messages, stream=True, **kwargs ) for chunk in stream: delta = chunk.choices[0].delta.content if delta: yield delta# Usagefor token in stream_model("chat", [{"role": "user", "content": "Hello"}]): print(token, end="", flush=True)
どのパラメータがロックインを生むかを把握する
一部のパラメータは特定のプロバイダーにしか存在しません。使うこと自体は問題ありません。ただし、意図的な選択をしていることを理解しておく必要があります。
| パラメータ | 対応しているもの | ロックインリスク |
|---|---|---|
| logprobs | GPT のみ | 高 — Claude や Gemini に同等機能がない |
| n > 1 | GPT、Gemini(Claude は非対応) | 中 — Claude ではループが必要 |
| reasoning_effort | GPT o-series のみ | 高 — 他には同等機能がない |
| temperature > 1.0 | GPT、Gemini(Claude は非対応) | 低 — Claude は 1.0 が上限 |
| tools | 主要プロバイダーすべて | なし — 安全に使用可能 |
| response_format | 主要プロバイダーすべて | 低 — スキーマ差異は軽微 |
信頼度スコアリングに logprobs を使っているなら、その機能については GPT にロックインされています。それは妥当なトレードオフです。ただし、次の開発者が理由を理解できるように文書化しておきましょう。
プロバイダーエンドポイントを設定可能にする
base_url="https://api.cometapi.com/v1" をハードコードすることも、依然としてロックインの一種です。環境変数にしましょう。
# .env — using CometAPIAI_BASE_URL=https://api.cometapi.com/v1AI_API_KEY=your_cometapi_key# To switch to OpenAI directly, change two lines:# AI_BASE_URL=https://api.openai.com/v1# AI_API_KEY=your_openai_key
Step 1 のクライアント初期化は、すでにこれらの変数から読み込むようになっています。CometAPI とプロバイダーへの直接接続の切り替えは、コード変更ではなく設定変更になります。
Node.js 版
import OpenAI from 'openai';const apiKey = process.env.AI_API_KEY;if (!apiKey) throw new Error('AI_API_KEY is not set');const client = new OpenAI({ baseURL: process.env.AI_BASE_URL ?? 'https://api.cometapi.com/v1', apiKey,});// Model IDs are CometAPI platform identifiers — see cometapi.com/modelsconst MODEL_CONFIG = { summarize: process.env.MODEL_SUMMARIZE ?? 'claude-opus-4-7', code: process.env.MODEL_CODE ?? 'gpt-5.4', classify: process.env.MODEL_CLASSIFY ?? 'claude-haiku-4-5', chat: process.env.MODEL_CHAT ?? 'gpt-5.4-mini',};// Validate at startupfor (const [task, model] of Object.entries(MODEL_CONFIG)) { if (!model) throw new Error(`Model config for '${task}' is not set`);}/** * Single entry point for all LLM calls. * Returns normalized response. Raises on 4xx, logs and re-raises on 5xx/network. */async function callModel(task, messages, options = {}) { const model = MODEL_CONFIG[task] ?? 'gpt-5.4-mini'; let response; try { response = await client.chat.completions.create({ model, messages, ...options, }); } catch (err) { // Don't swallow errors — log and re-raise console.error(`API error task=${task} model=${model}:`, err.message); throw err; } // content is null when model triggers a tool call const content = response.choices[0].message.content ?? ''; // usage may be absent in some configurations const inputTokens = response.usage?.prompt_tokens ?? 0; const outputTokens = response.usage?.completion_tokens ?? 0; console.log(`task=${task} model=${model} input=${inputTokens} output=${outputTokens}`); return { content, model: response.model, inputTokens, outputTokens };}/** * Stream tokens from the routed model. * Usage data is not available in streaming mode. */async function* streamModel(task, messages, options = {}) { const model = MODEL_CONFIG[task] ?? 'gpt-5.4-mini'; const stream = await client.chat.completions.create({ model, messages, stream: true, ...options, }); for await (const chunk of stream) { const delta = chunk.choices[0]?.delta?.content; if (delta) yield delta; }}// Usage — blockingconst result = await callModel('classify', [ { role: 'user', content: 'Positive or negative? "Loved it!"' }]);console.log(result.content);// Usage — streamingfor await (const token of streamModel('chat', [ { role: 'user', content: 'Hello' }])) { process.stdout.write(token);}
許容できるロックイン
すべてのロックインに対抗する価値があるわけではありません。意味のあるトレードオフもあります。
- OpenAI SDK を使うこと — 事実上の標準です。ほとんどのプロバイダーが対応しています。低リスクのロックインです。
- 実際に必要なプロバイダー固有機能 —
logprobsが必要なら使いましょう。そのコードを分離しておけば、後で見つけて置き換えやすくなります。 - ファインチューニング済みモデル — ファインチューニング済みモデルは本質的に1つのプロバイダーに結び付きます。それは想定どおりです。
避けるべきロックインは、偶発的なものです。ビジネスロジック内のモデル名、複数ファイルに散らばった生レスポンスのパース、ソース内にハードコードされた API キーです。
次の内容
これで、プロバイダーの詳細をビジネスロジックから切り離す抽象化レイヤーができました。このシリーズの最後の記事では、問題が起きたときの対処を扱います。失敗した生成のデバッグ方法、エラーコードの解釈、実際に何が壊れたのかを伝えるエラーハンドリングの構築方法です。
次回: 失敗した AI API 生成をデバッグする方法
FAQ
Q: SDK ロックインとモデルロックインの違いは何ですか?
SDK ロックインとは、コードが特定のライブラリをインポートしており、SDK を切り替える場合に変更が必要になることです。モデルロックインとは、モデル名がビジネスロジック全体に散らばっていることです。現在は多くのプロバイダーが OpenAI SDK 形式に対応しているため、SDK ロックインの危険性は比較的低いです。モデルロックインは、見つけて修正するのが難しいため、より厄介です。
Q: CometAPI を使うと、単に OpenAI ロックインを CometAPI ロックインに置き換えているだけではありませんか?
部分的にはそうです。直接プロバイダーへのロックインを、プロキシレイヤーへの依存に置き換えています。利点は、1つのキー、1つのエンドポイント、簡単なモデル切り替えです。リスクは、CometAPI に障害が発生すると、すべてのプロバイダーがまとめて使えなくなることです。緩和策は上のコードにすでに入っています。AI_BASE_URL は環境変数です。CometAPI を迂回してプロバイダーを直接呼び出す必要がある場合、それはコード変更ではなく設定変更です。
Q: このパターンで Claude の extended thinking や OpenAI の reasoning_effort を使えますか?
はい、call_model に **kwargs として渡せます。ただし、そのタスクを別のモデルにルーティングした場合、それらのパラメータは無視されるかエラーになります。どのタスクがプロバイダー固有機能を使っているのかを文書化し、次の開発者が理由を理解できるようにしてください。
Q: Claude と GPT** の間でルーティングする場合、Claude の temperature 上限 1.0 にはどう対応すればよいですか?**
両方で安全な範囲に収めるには、temperature を 1.0 以下に保ちます。創造的なタスクで GPT 専用により高い temperature が必要な場合は、汎用ルーターに任せるのではなく、MODEL_CONFIG でそれらのタスクを明示的に GPT にルーティングしてください。
Q: 画像生成 API や動画生成 API も同じように抽象化すべきですか?
同じ原則が適用されます。中央設定、正規化されたレスポンスラッパー、ビジネスロジックにプロバイダー固有フィールドを持ち込まないことです。画像 API や動画 API は構造上の違いが大きいため(非同期と同期、異なるパラメータセットなど)、抽象化レイヤーにはより多くの作業が必要です。まずテキストから始め、構造が実証されてからパターンを拡張してください。
Q: モデル間の context window の違いはどう扱えばよいですか?
これはルーティング時の実際のリスクです。GPT-5.5 は 1M トークンの context window を持ち、Claude モデルは最大 200K、Gemini 3.5 Flash は最大 1M をサポートします。長文ドキュメントのタスクをより短い context window のモデルにルーティングすると、入力が黙って切り詰められます。タスクが長い入力を扱う場合は、ルーティング前にコンテキスト長チェックを追加してください。あるいは、長いコンテキストを必要とするタスクは、デフォルトに流すのではなく MODEL_CONFIG で特定のモデルに常にルーティングしてください。
