2. 認証とクライアント初期化の移行
クライアントの初期化方法を移行します。SDKを使うか、直接HTTPリクエストを使うかによって方法が変わります。
Python(SDK使用)
# 移行前: OpenAI
from openai import OpenAI
client = OpenAI( api_key = "YOUR_OPENAI_API_KEY" )
# 移行後: Claude(Anthropic SDK)
import anthropic
client = anthropic.Anthropic( api_key = "YOUR_ANTHROPIC_API_KEY" )
# 環境変数 ANTHROPIC_API_KEY が設定されていれば引数不要
client = anthropic.Anthropic()
Node.js/TypeScript(SDK使用)
// 移行前: OpenAI
import OpenAI from 'openai' ;
const openai = new OpenAI ({ apiKey: process.env. OPENAI_API_KEY });
// 移行後: Claude(Anthropic SDK)
import Anthropic from '@anthropic-ai/sdk' ;
const anthropic = new Anthropic ({ apiKey: process.env. ANTHROPIC_API_KEY });
// または
const anthropic = new Anthropic (); // ANTHROPIC_API_KEY 環境変数を自動読み込み
環境変数の設定
# .env ファイル
# 移行前
OPENAI_API_KEY = sk-...
# 移行後(既存環境変数はフォールバックとして残しておく)
ANTHROPIC_API_KEY = sk-ant-...
# OPENAI_API_KEY=sk-... # フォールバック用に残す(段階的移行中)
3. 基本的なテキスト生成の変換
最もよく使われる基本的なテキスト生成パターンの変換を見ていきます。
Python(同期)
import anthropic
def chat_with_claude (user_message: str , system_prompt: str = "" ) -> str :
"""OpenAI の基本的なチャット呼び出しをClaude用に変換したラッパー関数"""
client = anthropic.Anthropic()
kwargs = {
"model" : "claude-sonnet-4-6" ,
"max_tokens" : 2048 ,
"messages" : [
{ "role" : "user" , "content" : user_message}
]
}
# system が空でない場合のみ追加(Claude APIは空文字列を許容しない)
if system_prompt:
kwargs[ "system" ] = system_prompt
response = client.messages.create( ** kwargs)
# OpenAI: response.choices[0].message.content
# Claude: response.content[0].text
return response.content[ 0 ].text
# 使用例
result = chat_with_claude(
user_message = "Pythonのasyncioについて簡単に説明してください。" ,
system_prompt = "あなたは経験豊富なPython開発者です。簡潔に答えてください。"
)
print (result)
Python(非同期)
import asyncio
import anthropic
async def async_chat_with_claude (user_message: str , system_prompt: str = "" ) -> str :
"""非同期バージョン"""
async with anthropic.AsyncAnthropic() as client:
kwargs = {
"model" : "claude-sonnet-4-6" ,
"max_tokens" : 2048 ,
"messages" : [{ "role" : "user" , "content" : user_message}]
}
if system_prompt:
kwargs[ "system" ] = system_prompt
response = await client.messages.create( ** kwargs)
return response.content[ 0 ].text
# 実行例
async def main ():
result = await async_chat_with_claude( "Pythonとは何ですか?" )
print (result)
asyncio.run(main())
マルチターン会話
マルチターン会話はmessages配列に会話履歴を追加していくスタイルで、OpenAIとほぼ同じですが、Claude APIでは会話の最初と最後がuserロールである必要があります。
def multi_turn_conversation ():
"""マルチターン会話の実装例"""
client = anthropic.Anthropic()
conversation_history = []
def chat (user_input: str ) -> str :
# ユーザーメッセージを履歴に追加
conversation_history.append({
"role" : "user" ,
"content" : user_input
})
response = client.messages.create(
model = "claude-sonnet-4-6" ,
max_tokens = 1024 ,
system = "あなたは親切なコーディングアシスタントです。" ,
messages = conversation_history
)
assistant_message = response.content[ 0 ].text
# アシスタントの返答も履歴に追加
conversation_history.append({
"role" : "assistant" ,
"content" : assistant_message
})
return assistant_message
print (chat( "FastAPIとは何ですか?" ))
print (chat( "それを使う主なメリットを3つ教えてください。" ))
print (chat( "簡単なHello Worldのコードを見せてください。" ))
multi_turn_conversation()
4. ストリーミングレスポンスの移行
ストリーミングはOpenAIとClaude APIで最も実装が異なる箇所の一つです。Claude APIではストリーミングイベントの種類が異なります。
Python ストリーミング
import anthropic
def stream_chat_with_claude (user_message: str , system_prompt: str = "" ) -> str :
"""ストリーミングレスポンスの実装"""
client = anthropic.Anthropic()
full_response = ""
kwargs = {
"model" : "claude-sonnet-4-6" ,
"max_tokens" : 2048 ,
"messages" : [{ "role" : "user" , "content" : user_message}]
}
if system_prompt:
kwargs[ "system" ] = system_prompt
# Claude の推奨ストリーミング方式: context manager を使用
with client.messages.stream( ** kwargs) as stream:
for text in stream.text_stream:
print (text, end = "" , flush = True )
full_response += text
print () # 改行
return full_response
# 使用例
stream_chat_with_claude(
user_message = "Pythonのデコレータについて詳しく説明してください。" ,
system_prompt = "技術的な説明をわかりやすく行ってください。"
)
Node.js ストリーミング
import Anthropic from '@anthropic-ai/sdk' ;
const anthropic = new Anthropic ();
async function streamChat ( userMessage : string , systemPrompt ?: string ) : Promise < string > {
let fullResponse = '' ;
const params : Anthropic . MessageCreateParamsStreaming = {
model: 'claude-sonnet-4-6' ,
max_tokens: 2048 ,
stream: true ,
messages: [{ role: 'user' , content: userMessage }],
};
if (systemPrompt) {
params.system = systemPrompt;
}
// Claude SDK のストリーミング
const stream = anthropic.messages. stream (params);
// テキストデルタを処理
stream. on ( 'text' , ( text ) => {
process.stdout. write (text);
fullResponse += text;
});
// 完了を待機
await stream. finalMessage ();
console. log (); // 改行
return fullResponse;
}
// 使用例
streamChat (
'TypeScriptの型システムについて説明してください。' ,
'経験豊富な開発者向けに詳細な技術的説明を提供してください。'
). then ( result => {
console. log ( ' \n 完了:' , result. length , '文字' );
});
5. ツール使用(Function Calling → Tool Use)の完全変換
最も複雑な移行の一つがFunction CallingからTool Useへの変換です。概念は同じですが、形式が異なります。
スキーマ定義の変換
# OpenAI Function Calling 形式
openai_tools = [
{
"type" : "function" ,
"function" : {
"name" : "get_weather" ,
"description" : "指定した都市の現在の天気を取得する" ,
"parameters" : {
"type" : "object" ,
"properties" : {
"location" : {
"type" : "string" ,
"description" : "都市名(例:東京, 大阪)"
},
"unit" : {
"type" : "string" ,
"enum" : [ "celsius" , "fahrenheit" ],
"description" : "温度の単位"
}
},
"required" : [ "location" ]
}
}
}
]
# Claude Tool Use 形式(input_schema に変更)
claude_tools = [
{
"name" : "get_weather" ,
"description" : "指定した都市の現在の天気を取得する" ,
"input_schema" : { # ← "parameters" から "input_schema" に変更
"type" : "object" ,
"properties" : {
"location" : {
"type" : "string" ,
"description" : "都市名(例:東京, 大阪)"
},
"unit" : {
"type" : "string" ,
"enum" : [ "celsius" , "fahrenheit" ],
"description" : "温度の単位"
}
},
"required" : [ "location" ]
}
}
]
ツール呼び出しの処理ループ
import anthropic
import json
from typing import Any
def process_tool_call (tool_name: str , tool_input: dict ) -> Any:
"""実際のツール実行ロジック"""
if tool_name == "get_weather" :
location = tool_input[ "location" ]
unit = tool_input.get( "unit" , "celsius" )
# 実際にはAPIを叩く処理
return { "temperature" : 22 , "condition" : "晴れ" , "humidity" : 60 , "unit" : unit}
return { "error" : f "Unknown tool: { tool_name } " }
def tool_use_agent (user_message: str ) -> str :
"""ツール使用エージェントの実装"""
client = anthropic.Anthropic()
tools = [
{
"name" : "get_weather" ,
"description" : "指定した都市の現在の天気を取得する" ,
"input_schema" : {
"type" : "object" ,
"properties" : {
"location" : { "type" : "string" , "description" : "都市名" },
"unit" : { "type" : "string" , "enum" : [ "celsius" , "fahrenheit" ]}
},
"required" : [ "location" ]
}
}
]
messages = [{ "role" : "user" , "content" : user_message}]
MAX_TURNS = 8 # ツール呼び出しの往復回数に必ず上限を設けます
for _ in range ( MAX_TURNS ):
response = client.messages.create(
model = "claude-sonnet-4-6" ,
max_tokens = 2048 ,
tools = tools,
messages = messages
)
if response.stop_reason == "tool_use" :
# アシスタントの応答をそのまま履歴へ戻します
messages.append({ "role" : "assistant" , "content" : response.content})
tool_results = []
for block in response.content:
if block.type != "tool_use" :
continue
try :
result = process_tool_call(block.name, block.input)
is_error = False
except Exception as exc:
# 例外を握り潰さず、失敗として Claude に返します
result = { "error" : str (exc)}
is_error = True
tool_results.append({
"type" : "tool_result" ,
"tool_use_id" : block.id,
"content" : json.dumps(result, ensure_ascii = False ),
"is_error" : is_error,
})
messages.append({ "role" : "user" , "content" : tool_results})
continue
# tool_use 以外で止まったときも、生成済みのテキストは必ず回収します
text = " " .join(b.text for b in response.content if b.type == "text" )
if response.stop_reason == "max_tokens" :
raise RuntimeError (
f "max_tokens に到達し出力が途中で終わりました(回収済み: { len (text) } 文字)"
)
return text
raise RuntimeError ( f "ツール呼び出しが { MAX_TURNS } 往復を超えました" )
# 使用例
result = tool_use_agent( "東京の今日の天気を教えてください。" )
print (result)
このループで気をつけていただきたいのは、終了条件の書き方です。stop_reason を end_turn かどうかだけで判定すると、max_tokens や stop_sequence で止まったケースがどの分岐にも入りません。私は移行の初期にここを while True と固定文字列の戻り値で書いてしまい、上限に達したリクエストが「処理完了」という空の成功として返ってくる事故を起こしました。上限往復数を持たせ、tool_use 以外はすべて明示的に扱う形にしてから、この種の沈黙は出ていません。
ツールの実行時例外を is_error: true として返している点も意図があります。例外をそのまま送出するとエージェントは何が起きたか分からず、握り潰すと誤った前提で応答を続けます。失敗を失敗として渡せば、Claude 側が引数を修正して再試行する余地が残ります。
6. マルチモーダル(Vision)の移行
画像処理の移行も重要な変換ポイントです。URLと base64 の両方に対応していますが、形式が異なります。
import anthropic
import base64
from pathlib import Path
def analyze_image_with_claude (image_path: str , question: str ) -> str :
"""画像分析のOpenAI→Claude移行実装"""
client = anthropic.Anthropic()
# Base64エンコード
image_data = Path(image_path).read_bytes()
base64_image = base64.standard_b64encode(image_data).decode( "utf-8" )
# 拡張子からMIMEタイプを判定
suffix = Path(image_path).suffix.lower()
media_types = { ".jpg" : "image/jpeg" , ".jpeg" : "image/jpeg" ,
".png" : "image/png" , ".gif" : "image/gif" , ".webp" : "image/webp" }
media_type = media_types.get(suffix, "image/jpeg" )
response = client.messages.create(
model = "claude-sonnet-4-6" ,
max_tokens = 1024 ,
messages = [
{
"role" : "user" ,
"content" : [
{
"type" : "image" ,
"source" : {
"type" : "base64" ,
"media_type" : media_type,
"data" : base64_image,
},
},
{
"type" : "text" ,
"text" : question
}
],
}
],
)
return response.content[ 0 ].text
def analyze_image_from_url (image_url: str , question: str ) -> str :
"""URLから画像を分析"""
client = anthropic.Anthropic()
response = client.messages.create(
model = "claude-sonnet-4-6" ,
max_tokens = 1024 ,
messages = [
{
"role" : "user" ,
"content" : [
{
"type" : "image" ,
"source" : {
"type" : "url" ,
"url" : image_url,
},
},
{ "type" : "text" , "text" : question}
],
}
],
)
return response.content[ 0 ].text
7. エラーハンドリングとリトライ戦略
エラーの種類と対処法もOpenAIとは異なります。本番環境での安定稼働に欠かせないエラーハンドリングの実装を詳しく見ていきます。
import anthropic
import time
import random
from typing import Optional
class ClaudeAPIClient :
"""本番対応のClaude APIクライアント(エラーハンドリング・リトライ付き)"""
def __init__ (self, max_retries: int = 3 , base_delay: float = 1.0 ):
self .client = anthropic.Anthropic()
self .max_retries = max_retries
self .base_delay = base_delay
def chat (
self,
user_message: str ,
system_prompt: Optional[ str ] = None ,
model: str = "claude-sonnet-4-6" ,
max_tokens: int = 2048
) -> str :
"""指数バックオフリトライ付きチャット関数"""
for attempt in range ( self .max_retries + 1 ):
try :
kwargs = {
"model" : model,
"max_tokens" : max_tokens,
"messages" : [{ "role" : "user" , "content" : user_message}]
}
if system_prompt:
kwargs[ "system" ] = system_prompt
response = self .client.messages.create( ** kwargs)
return response.content[ 0 ].text
except anthropic.RateLimitError as e:
# レート制限: Retry-After ヘッダーを尊重
if attempt < self .max_retries:
wait_time = self ._calculate_wait_time(attempt, e)
print ( f "⚠️ レート制限 — { wait_time :.1f } 秒待機 (試行 { attempt + 1 } / { self .max_retries } )" )
time.sleep(wait_time)
else :
raise
except anthropic.APIStatusError as e:
# APIエラー(4xx/5xx)
if e.status_code == 529 and attempt < self .max_retries:
# 529: APIオーバーロード — リトライ可能
wait_time = self .base_delay * ( 2 ** attempt) + random.uniform( 0 , 1 )
print ( f "⚠️ APIオーバーロード — { wait_time :.1f } 秒待機" )
time.sleep(wait_time)
elif e.status_code in ( 400 , 401 , 403 , 404 ):
# クライアントエラー — リトライ不可
raise
elif attempt < self .max_retries:
wait_time = self .base_delay * ( 2 ** attempt)
time.sleep(wait_time)
else :
raise
except anthropic.APIConnectionError as e:
# ネットワークエラー
if attempt < self .max_retries:
wait_time = self .base_delay * ( 2 ** attempt)
print ( f "⚠️ 接続エラー — { wait_time :.1f } 秒待機 ( { str (e) } )" )
time.sleep(wait_time)
else :
raise
raise Exception ( "最大リトライ回数を超えました" )
def _calculate_wait_time (self, attempt: int , error) -> float :
"""Retry-After ヘッダーを優先し、無ければ指数バックオフで待機時間を計算"""
backoff = self .base_delay * ( 2 ** attempt) + random.uniform( 0 , 1 )
response = getattr (error, "response" , None )
if response is None :
return backoff
retry_after = response.headers.get( "retry-after" )
if retry_after is None :
return backoff
try :
# サーバーの指示とバックオフの、長いほうを採用します
return max ( float (retry_after), backoff)
except ValueError :
# HTTP-date 形式で返る場合があります。解釈できなければバックオフに委ねます
return backoff
# 使用例
client = ClaudeAPIClient( max_retries = 3 )
result = client.chat(
user_message = "大規模言語モデルの仕組みを説明してください。" ,
system_prompt = "専門的な解説をわかりやすく提供してください。"
)
print (result)
_calculate_wait_time の書き方には、私が実際に踏んだ落とし穴が反映されています。最初は getattr(error, "retry_after", None) と書いていました。名前として自然に見えたからです。
手元の VM で anthropic Python SDK 1.0.0 を入れ、429 応答(retry-after: 7)から RateLimitError を組み立てて確認したところ、結果は次のとおりでした。
アクセス方法 取得できた値
hasattr(err, "retry_after")False
getattr(err, "retry_after", None)None
err.response.headers.get("retry-after")"7"
例外オブジェクトの公開属性は body / message / request / request_id / response / status_code / type / workspace_id で、retry_after は含まれていません。つまり最初の実装は、サーバーが「7秒待って」と明示しているのに、その値を一度も読まずに毎回バックオフへ落ちていたことになります。例外も出ず、ログにも残りません。ヘッダーは response を経由して読む、と覚えていただくのが確実です。
レート制限まわりの詳細は、Claude API のリトライを書いたらレート制限が悪化した話 でも扱っています。
8. 移行自動化スクリプト:一括変換ツール
既存のOpenAI呼び出しコードを半自動的に変換するスクリプトを作成しました。完全な自動変換は難しいですが、繰り返し作業を大幅に削減できます。
import ast
import re
from pathlib import Path
class OpenAIToClaudeMigrator :
"""OpenAI APIコードをClaude API向けに変換するツール"""
# 後続の .strip() などを巻き込まないよう、置換対象だけを厳密に指定します
RESPONSE_ACCESS = re.compile( r ' \. choices \[ 0 \]\. message \. content' )
# モデル名のマッピング
MODEL_MAP = {
"gpt-4o" : "claude-sonnet-4-6" ,
"gpt-4o-mini" : "claude-haiku-4-5-20251001" ,
"gpt-4-turbo" : "claude-sonnet-4-6" ,
"gpt-4" : "claude-sonnet-4-6" ,
"gpt-3.5-turbo" : "claude-haiku-4-5-20251001" ,
"o1-preview" : "claude-opus-4-6" ,
"o1-mini" : "claude-sonnet-4-6" ,
}
def migrate_file (self, source_path: str , output_path: str ) -> dict :
"""ファイルの移行変換を実行"""
source = Path(source_path).read_text( encoding = "utf-8" )
result = self ._apply_transformations(source)
Path(output_path).write_text(result[ "code" ], encoding = "utf-8" )
return result
def _find_calls_without_max_tokens (self, code: str ):
"""messages.create( のうち max_tokens が無い呼び出しの行番号を返す
文字列の有無で判定すると、1箇所でも max_tokens があるファイルは
残り全部が見逃されます。呼び出し単位で見るには構文木を歩くのが確実です。
"""
try :
tree = ast.parse(code)
except SyntaxError :
return None
missing = []
for node in ast.walk(tree):
if not isinstance (node, ast.Call):
continue
func = node.func
if not ( isinstance (func, ast.Attribute) and func.attr == "create" ):
continue
owner = func.value
if not ( isinstance (owner, ast.Attribute) and owner.attr == "messages" ):
continue
if not any (kw.arg == "max_tokens" for kw in node.keywords):
missing.append(node.lineno)
return sorted (missing)
def _apply_transformations (self, code: str ) -> dict :
"""変換ルールを適用"""
warnings = []
# 1. インポート文の変換
code = re.sub(
r 'from openai import OpenAI' ,
'import anthropic' ,
code
)
code = re.sub(
r 'import openai' ,
'import anthropic' ,
code
)
# 2. クライアント初期化の変換
code = re.sub(
r 'OpenAI \( api_key= ([ ^ ) ] + ) \) ' ,
r 'anthropic . Anthropic ( api_key= \1 ) ' ,
code
)
code = re.sub( r 'OpenAI \(\) ' , 'anthropic.Anthropic()' , code)
# 3. モデル名の変換(クォートで囲まれた完全一致のみ)
for old_model, new_model in self . MODEL_MAP .items():
code = re.sub(
rf "([' \" ]) { re.escape(old_model) } \1" ,
rf "\g<1> { new_model } \g<1>" ,
code
)
# 4. chat.completions.create → messages.create
code = re.sub(
r ' \. chat \. completions \. create \( ' ,
'.messages.create(' ,
code
)
# 5. レスポンスアクセスの変換(後続のメソッド呼び出しを壊さない)
hits = len ( self . RESPONSE_ACCESS .findall(code))
if hits:
code = self . RESPONSE_ACCESS .sub( '.content[0].text' , code)
warnings.append( f "レスポンスアクセスを { hits } 箇所変換しました: .choices[0].message.content → .content[0].text" )
# 6. max_tokens 未指定の呼び出しを AST で行単位に検出
missing = self ._find_calls_without_max_tokens(code)
if missing is None :
warnings.append( "⚠️ 変換後のコードを構文解析できませんでした。手動での確認をお願いします。" )
elif missing:
lines = "、" .join( f " { n } 行目" for n in missing)
warnings.append( f "⚠️ max_tokens 未指定の呼び出しがあります( { lines } )。Claude APIでは省略できません。" )
# 7. function_calling → tool_use の警告
if '"functions"' in code or '"function_call"' in code:
warnings.append( "⚠️ Function Callingが検出されました。Tool Use形式への手動変換が必要です(セクション5参照)。" )
return { "code" : code, "warnings" : warnings}
# 使用例
migrator = OpenAIToClaudeMigrator()
result = migrator.migrate_file( "openai_app.py" , "claude_app.py" )
print ( "変換完了:" )
for warning in result[ "warnings" ]:
print ( f " { warning } " )
この変換ツールが、以前は静かにコードを壊していた話
上のコードには、私が実際に痛い目を見て書き直した箇所が2つあります。せっかくなので、書き直す前の実装が何をしていたかを、手元の VM で走らせた結果とともに残しておきます。同じ罠は、他の一括置換スクリプトでも起こり得るためです。
まず、レスポンスアクセスの変換です。以前は分かりやすさのつもりで、置換と同時に注釈を差し込んでいました。
# 書き直す前の実装
code = code.replace(
'.choices[0].message.content' ,
'.content[0].text # ← Claude形式に変換'
)
これを、次のような何でもない OpenAI コードに掛けてみます。
def summarize (text: str ) -> str :
resp = client.chat.completions.create(
model = "gpt-4o" , max_tokens = 512 ,
messages = [{ "role" : "user" , "content" : text}],
)
return resp.choices[ 0 ].message.content.strip()
変換後は、こうなりました。
return resp.content[ 0 ].text # ← Claude形式に変換.strip()
.strip() が注釈の内側に取り込まれています。厄介なのは、これが構文エラーにならないことです。ast.parse() は通ります。実際に同じ形の式を評価して比べたところ、結果はこうでした。
実行した式 返ってきた値
移行前 r.choices[0].message.content.strip() 'POSITIVE'
変換後 r.content[0].text # ← Claude形式に変換.strip() ' POSITIVE '
前後の空白が残ったまま流れていきます。ラベル比較や json.loads() の手前でこれが起きると、移行そのものではなく数日後の不可解な分岐ミスとして表面化します。注釈は変換後のコードに差し込まず、warnings として呼び出し側へ返す。それだけで防げる事故でした。
もう1つは max_tokens の検出です。以前はファイル全体の文字列判定でした。
# 書き直す前の実装
if 'messages.create(' in code and 'max_tokens' not in code:
warnings.append( "⚠️ max_tokens が必須です。" )
3つの呼び出しのうち1つだけに max_tokens があるファイルを与えると、'max_tokens' not in code が偽になり、残り2つは見逃されます。実際に3呼び出し(うち2つが未指定)のファイルで両実装を比べた結果です。
実装 検出できた未指定の呼び出し
文字列判定(旧) 0件(警告なし)
構文木を歩く(新) 2件(13行目・20行目)
messages.create( の引数は複数行に散るため、行単位の文字列判定でも取りこぼします。呼び出し単位で確実に見るなら、ast.walk() で Call ノードを辿るのが結局いちばん短い道でした。
一括変換スクリプトは、書いた本人がいちばん結果を信用してしまう道具です。移行対象が数十ファイルに及ぶときほど、変換後のコードを一度 ast.parse() に通し、差分を目で追う時間を残しておきたいところです。
9. 段階的移行戦略:ダウンタイムゼロの本番移行
本番サービスを稼働させながら移行するための段階的アプローチを解説します。
フェーズ1:プロキシレイヤーの導入
まず、OpenAI/Claude APIの切り替えを透過的に行うプロキシレイヤーを実装します。
from enum import Enum
import anthropic
from openai import OpenAI
from typing import Optional
import os
class AIProvider ( Enum ):
OPENAI = "openai"
CLAUDE = "claude"
class AIGateway :
"""OpenAIとClaudeを透過的に切り替えるゲートウェイ"""
def __init__ (self, provider: AIProvider = AIProvider. OPENAI , rollout_percentage: float = 0.0 ):
self .primary_provider = provider
self .rollout_percentage = rollout_percentage # Claude への移行率(0.0〜1.0)
self .openai_client = OpenAI( api_key = os.environ.get( "OPENAI_API_KEY" ))
self .claude_client = anthropic.Anthropic( api_key = os.environ.get( "ANTHROPIC_API_KEY" ))
def chat (self, user_message: str , system_prompt: str = "" , ** kwargs) -> dict :
"""プロバイダーを自動選択してチャット"""
import random
# カナリアリリース: rollout_percentage の確率でClaude を使用
use_claude = (
self .primary_provider == AIProvider. CLAUDE or
random.random() < self .rollout_percentage
)
if use_claude:
return self ._chat_with_claude(user_message, system_prompt, ** kwargs)
else :
return self ._chat_with_openai(user_message, system_prompt, ** kwargs)
def _chat_with_claude (self, user_message: str , system_prompt: str , ** kwargs) -> dict :
kwargs_claude = {
"model" : kwargs.get( "model" , "claude-sonnet-4-6" ),
"max_tokens" : kwargs.get( "max_tokens" , 2048 ),
"messages" : [{ "role" : "user" , "content" : user_message}]
}
if system_prompt:
kwargs_claude[ "system" ] = system_prompt
response = self .claude_client.messages.create( ** kwargs_claude)
return {
"text" : response.content[ 0 ].text,
"provider" : "claude" ,
"model" : kwargs_claude[ "model" ],
"input_tokens" : response.usage.input_tokens,
"output_tokens" : response.usage.output_tokens
}
def _chat_with_openai (self, user_message: str , system_prompt: str , ** kwargs) -> dict :
messages = []
if system_prompt:
messages.append({ "role" : "system" , "content" : system_prompt})
messages.append({ "role" : "user" , "content" : user_message})
response = self .openai_client.chat.completions.create(
model = kwargs.get( "model" , "gpt-4o" ),
messages = messages,
max_tokens = kwargs.get( "max_tokens" , 2048 )
)
return {
"text" : response.choices[ 0 ].message.content,
"provider" : "openai" ,
"model" : response.model,
"input_tokens" : response.usage.prompt_tokens,
"output_tokens" : response.usage.completion_tokens
}
# 移行フェーズの管理
# フェーズ1: 10%のトラフィックをClaudeに流す(テスト)
gateway_phase1 = AIGateway( rollout_percentage = 0.1 )
# フェーズ2: 50%(A/Bテスト)
gateway_phase2 = AIGateway( rollout_percentage = 0.5 )
# フェーズ3: 100%(移行完了)
gateway_phase3 = AIGateway( provider = AIProvider. CLAUDE , rollout_percentage = 1.0 )
フェーズ2:パフォーマンス計測とA/Bテスト
import time
import statistics
from dataclasses import dataclass, field
from typing import List
@dataclass
class ResponseMetrics :
provider: str
latency_ms: float
input_tokens: int
output_tokens: int
cost_usd: float
quality_score: float = 0.0 # 人間評価または自動評価
class MigrationAnalyzer :
"""移行前後のパフォーマンスを比較分析"""
# 料金設定(2026年4月時点の概算)
PRICING = {
"claude-sonnet-4-6" : { "input" : 3.0 / 1_000_000 , "output" : 15.0 / 1_000_000 },
"gpt-4o" : { "input" : 5.0 / 1_000_000 , "output" : 15.0 / 1_000_000 },
}
def __init__ (self):
self .metrics: List[ResponseMetrics] = []
def record (self, provider: str , model: str , latency_ms: float ,
input_tokens: int , output_tokens: int ) -> ResponseMetrics:
pricing = self . PRICING .get(model, { "input" : 0 , "output" : 0 })
cost = (input_tokens * pricing[ "input" ]) + (output_tokens * pricing[ "output" ])
metric = ResponseMetrics(
provider = provider,
latency_ms = latency_ms,
input_tokens = input_tokens,
output_tokens = output_tokens,
cost_usd = cost
)
self .metrics.append(metric)
return metric
def compare (self) -> dict :
"""OpenAIとClaudeの比較レポートを生成"""
openai_metrics = [m for m in self .metrics if m.provider == "openai" ]
claude_metrics = [m for m in self .metrics if m.provider == "claude" ]
def summarize (metrics):
if not metrics:
return {}
latencies = [m.latency_ms for m in metrics]
costs = [m.cost_usd for m in metrics]
return {
"count" : len (metrics),
"avg_latency_ms" : statistics.mean(latencies),
"p95_latency_ms" : sorted (latencies)[ int ( len (latencies) * 0.95 )],
"total_cost_usd" : sum (costs),
"avg_cost_usd" : statistics.mean(costs)
}
openai_summary = summarize(openai_metrics)
claude_summary = summarize(claude_metrics)
# コスト削減率を計算
if openai_summary.get( "avg_cost_usd" ) and claude_summary.get( "avg_cost_usd" ):
cost_reduction = (
(openai_summary[ "avg_cost_usd" ] - claude_summary[ "avg_cost_usd" ]) /
openai_summary[ "avg_cost_usd" ] * 100
)
else :
cost_reduction = 0
return {
"openai" : openai_summary,
"claude" : claude_summary,
"cost_reduction_percent" : cost_reduction
}
10. TypeScriptプロジェクトの完全移行パターン
Next.jsやExpressなどのTypeScriptプロジェクトの移行パターンを解説します。
// lib/ai-client.ts
import Anthropic from '@anthropic-ai/sdk' ;
interface ChatOptions {
systemPrompt ?: string ;
maxTokens ?: number ;
model ?: string ;
}
interface ChatResponse {
text : string ;
inputTokens : number ;
outputTokens : number ;
}
export class ClaudeClient {
private client : Anthropic ;
private defaultModel : string ;
constructor ( apiKey ?: string , defaultModel = 'claude-sonnet-4-6' ) {
this .client = new Anthropic ({ apiKey });
this .defaultModel = defaultModel;
}
async chat ( userMessage : string , options : ChatOptions = {}) : Promise < ChatResponse > {
const {
systemPrompt ,
maxTokens = 2048 ,
model = this .defaultModel
} = options;
const params : Anthropic . MessageCreateParamsNonStreaming = {
model,
max_tokens: maxTokens,
messages: [{ role: 'user' , content: userMessage }]
};
if (systemPrompt) {
params.system = systemPrompt;
}
const response = await this .client.messages. create (params);
const textBlock = response.content. find ( block => block.type === 'text' );
if ( ! textBlock || textBlock.type !== 'text' ) {
throw new Error ( 'No text response from Claude' );
}
return {
text: textBlock.text,
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens
};
}
// Next.js API Route 対応のストリーミングレスポンス
async streamToResponse (
userMessage : string ,
options : ChatOptions = {}
) : Promise < ReadableStream > {
const { systemPrompt , maxTokens = 2048 , model = this .defaultModel } = options;
const params : Anthropic . MessageStreamParams = {
model,
max_tokens: maxTokens,
messages: [{ role: 'user' , content: userMessage }]
};
if (systemPrompt) params.system = systemPrompt;
const stream = this .client.messages. stream (params);
// Web Streams API 形式に変換(Next.js App Router 対応)
return new ReadableStream ({
async start ( controller ) {
stream. on ( 'text' , ( text ) => {
controller. enqueue ( new TextEncoder (). encode (text));
});
stream. on ( 'error' , ( error ) => {
controller. error (error);
});
await stream. finalMessage ();
controller. close ();
}
});
}
}
// Next.js API Route での使用例
// app/api/chat/route.ts
export async function POST ( request : Request ) {
const { message , systemPrompt } = await request. json ();
const claude = new ClaudeClient ();
const stream = await claude. streamToResponse (message, { systemPrompt });
return new Response (stream, {
headers: { 'Content-Type' : 'text/plain; charset=utf-8' }
});
}
プロンプト設計そのものの作法は移行後も効いてきます。Claude AIプロンプトエンジニアリング実践テクニック2026 も合わせてご覧ください。
本番移行で見えてきた、ドキュメントに載っていない調整ポイント
ここまでで変換の「型」は揃いました。最後に、私自身が個人開発で運用しているサービスを OpenAI から Claude API へ移したときに、公式ドキュメントだけでは見えてこなかった実務上の気づきを、いくつか共有させてください。
私は Dolice Labs という複数のAI技術ブログを、記事整形の自動化処理とあわせて個人開発で続けています。そのバックエンドには、長らく OpenAI ベースの要約・整形ロジックが組み込まれていました。AdMob で収益化している小さなアプリの説明文生成も、同じ系統の処理です。これらを Claude API へ移したとき、手こずったのはコードの変換そのものよりも、移行後に静かに表面化する挙動の違いでした。
max_tokens の値が、そのまま出力品質に直結する
OpenAI では max_tokens を省略しても破綻しませんが、Claude では必須です。ここは本文でも触れましたが、実務で効いてくるのは「仮置きした上限値が、要約の末尾欠けという形で後から表面化する」点でした。移行直後、整形タスクの末尾が途切れる事象が散発し、原因を辿ると移植時に仮置きした小さな上限でした。
タスク別に、私の環境で安定した目安は次のとおりです。
タスク 移行前の感覚(OpenAI) 推奨 max_tokens(Claude) 末尾欠けの発生
分類・ラベル付け 未指定で十分 256 なし
記事要約(400字前後) 未指定で十分 1,024 512以下で散発
長文整形・章立て 2,048前後 4,096 2,048で末尾欠け
上限を上げても、実際の出力が短ければ課金トークンは増えません。安全側に倒して構わない、というのが移行を終えての実感です。
system を独立させるだけで、指示の追従性が上がった
OpenAI 時代は、役割指示を会話の先頭メッセージに混ぜていました。Claude では system パラメータへ分離するのが作法です。これは単なる形式の違いに見えて、移してみると指示の追従性そのものが変わりました。私の整形パイプラインでは、出力フォーマットの逸脱、たとえば指定した見出し階層を無視する挙動が、体感で目に見えて減りました。トーンや制約を system に集約し、可変の入力だけを user 側へ回す構成にしてからは、温度設定を下げずとも安定するようになっています。
コスト感覚は、トークン単価の表だけでは測れない
移行の判断材料として単価表を眺めがちですが、実際の請求を左右するのは「1リクエストあたりの平均トークン」と「キャッシュの効き方」でした。私の自動処理では、共通の指示文を system に固定したうえでプロンプトキャッシュを併用したところ、入力トークンの課金分が体感で4〜6割ほど軽くなりました。単価そのものよりも、繰り返し送る固定部分をどう扱うかのほうが、月次のコストには効いてきます。詳しくはClaude APIコスト最適化ガイド やセマンティックキャッシュの本番設計 もあわせてご覧ください。
移行は一度きりの大工事に見えますが、振り返ると、小さな挙動の違いに運用しながら少しずつ慣れていく過程でした。同じ移行に取り組む方の、つまずきを減らす一助になれば幸いです。
まとめ:段階的・安全な移行のためのチェックリスト
OpenAI GPT-4からClaude APIへの移行は、適切に計画すれば大きなメリットをもたらします。本ガイドで解説した内容を実践するための最終チェックリストです。
移行前の準備として、ANTHROPIC_API_KEY環境変数を設定すること、max_tokensを全リクエストに追加すること、systemパラメータを独立させること、の3点を必ず確認してください。
移行中は、セクション9のAIGatewayパターンで段階的ロールアウトを行い、MigrationAnalyzerでコスト・レイテンシを計測しながら進めることを強くお勧めします。特にFunction CallingからTool Useへの変換は、セクション5のコードをベースに丁寧に実装してください。
移行後は、エラーログを注意深く監視し、レート制限エラーやオーバーロードエラーへのリトライロジック(セクション7参照)が正常に機能しているか確認してください。
そして移行が落ち着いたら、変換スクリプトが触った箇所をもう一度だけ目視で追ってください。本記事のセクション8で見たとおり、構文エラーにならない壊れ方こそが後から効いてきます。
次の一手としては、Claude APIコスト最適化完全ガイド でプロンプトキャッシュの設計に進むのが、移行の投資をいちばん早く回収できる道だと考えています。
長い記事にお付き合いいただき、ありがとうございました。移行の途中でつまずいたときに、どこかの節が役に立てば嬉しく思います。