cd ../back to blog
$Guide//June 4, 2026//8 min read

التوافق مع OpenAI: ما الذي يجب أن يتطابق فعلاً (وما الذي ينكسر)

دليل عملي ومتشكّك للتوافقية مع OpenAI في الإنتاج — شكل المحادثة، وبثّ SSE، والأدوات، والرؤية، ووضع JSON، والـ embeddings: ما الذي ينتقل بنظافة وما الذي يتسرّب حين يكون النموذج خلف الشكل هو Claude أو Gemini.

"OpenAI-compatible" هي أكثر العبارات تحميلاً بالمعاني في سوق بنية الذكاء الاصطناعي التحتية. فقد تعني "يمكنك توجيه الـ OpenAI SDK إلى رابطنا فيعود نداء محادثة بسيط" — وهذا هو الـ 80% السهل — وقد تعني "كل حقل، وكل حدث streaming، وكل جولة استدعاء أداة، وكل رقم usage يتصرّف تماماً كما تتوقّعه شيفرتك أصلاً." والفجوة بين هذين المعنيين هي حيث تعيش حوادث الإنتاج. هذه التدوينة دليل ميداني: ما الذي يجب فعلاً أن يتطابق ليعمل كودك القائم دون تغيير، وما الذي يتصرّف بشكل متطابق عبر المصادر الأعلى، وما الذي يختلف بصمت حين يكون النموذج خلف شكل OpenAI هو فعلاً Claude (من Anthropic) أو Gemini (من Google) بدلاً من GPT.

Brievio هو gateway متوافق مع OpenAI أمام النماذج الأصلية من مصدرها الأول، لذا فهذا مكتوب من مقعد المترجم — الطبقة التي عليها أن تجعل واجهة Messages من Anthropic وواجهة Vertex من Google تخرجان معاً من الأنبوب المشكّل على هيئة OpenAI. وسأكون دقيقاً بشأن المواضع التي يكون فيها التجريد نظيفاً وتلك التي يتسرّب فيها، لأن التظاهر بأنه لا يتسرّب أبداً هو ما يجعلك تتلقّى تنبيهاً في الثانية صباحاً.

ماذا يجب أن تعني «التوافقية» فعلاً

التوافقية ليست خانة تأشير تسويقية؛ إنها عقد مع الـ SDK الذي استوردته أصلاً. فمكتبتا OpenAI لـ Python وNode تضعان افتراضات صارمة حول صيغة الإرسال. والـ gateway لا يكون متوافقاً إلا إذا احترمها جميعاً:

  • مخطط الطلب. POST /v1/chat/completions مع model وmessages (قائمة من كائنات الدور/المحتوى)، والمقابض الاختيارية — temperature، max_tokens، top_p، stop، tools، response_format. والمعاملات المجهولة ينبغي أن تُقبل وتُتجاهل، لا أن تُرفض برمز 400.
  • غلاف الاستجابة. كائن يحوي id وobject: "chat.completion" و model وchoices[] (لكلٍّ منها message وindex وfinish_reason)، وكتلة usage. فالـ SDKs تفكّ التسلسل إلى كائنات مُنمّطة؛ فإذا غاب حقل فإن resp.choices[0].message.content يرمي استثناءً على جهاز شخص آخر.
  • بروتوكول الـ streaming. Server-Sent Events مع علامة data: [DONE] الفاصلة وكائنات delta لكل توكن. وهذا هو الشيء الأكثر شيوعاً الذي تخطئ فيه الـ gateways "المتوافقة" على نحو خفيّ.
  • أشكال الأخطاء ورموز HTTP. فرمز 429 يجب أن يبدو كحدّ معدّل، ورمز 400 يجب أن يحمل كائن error فيه type وmessage. فمنطق إعادة المحاولة والتراجع في الـ SDK يستند إلى هذه.

وهذا هو خط الأساس — الجزء الذي يصيبه الجميع. سطران يتغيّران فيعيد النداء كائن إكمال عادياً:

chat.py
# الفكرة كلها: غيّر سطرين، واحتفظ بشيفرتك كما هي.
# نفس الـ SDK، نفس شكل الطلب، نفس كائن الاستجابة — لكن بنموذج مختلف خلفه.
from openai import OpenAI

client = OpenAI(
    api_key="sk-brievio-...",
    base_url="https://api.brievio.com/v1",   # كان https://api.openai.com/v1
)

resp = client.chat.completions.create(
    model="claude-sonnet-4-6",               # كان gpt-4o
    messages=[
        {"role": "system", "content": "You are concise."},
        {"role": "user", "content": "Summarize the CAP theorem in two sentences."},
    ],
    temperature=0.2,
    max_tokens=300,
)

print(resp.choices[0].message.content)
print(resp.usage)        # prompt_tokens / completion_tokens / total_tokens — نفس الحقول
# resp.id و resp.model و resp.choices[0].finish_reason كلها حاضرة ومشكّلة مثل OpenAI.

إذا لم يستطع gateway أن يفعل هذا الحدّ الأدنى على الأقل، فابتعد عنه. لكن هذا هو الحدّ الأدنى للمشاركة، لا خط النهاية. والسؤال المثير هو ما الذي يحدث حين تشغّل الميزات التي يستخدمها تطبيقك الحقيقي.

الـ streaming: حيث تتسرّب التوافقية بهدوء

الـ streaming هو الميزة الأكثر احتمالاً لأن تكون حاضرة تقنياً ومعطّلة عملياً. فمكرّر الـ streaming في الـ SDK يتوقّع ثلاثة أمور: نوع محتوى text/event-stream، ووصول الـ deltas تدريجياً على choices[0].delta.content، وسطراً حرفياً data: [DONE] لإغلاق الـ stream. أخطئ في أيٍّ منها فيكون العَرَض مجنوناً — يعمل في اختبار curl، ويتعلّق في الإنتاج.

stream.py
# الـ streaming هو الموضع الذي تتسرّب فيه «التوافقية» الساذجة. العقد الذي تعتمد عليه:
# - Content-Type: text/event-stream
# - كل حدث هو "data: {json}\n\n"، وتصل الـ deltas على choices[0].delta.content
# - وينتهي الـ stream بعلامة حرفية فاصلة "data: [DONE]\n\n"
stream = client.chat.completions.create(
    model="gemini-2-5-pro",
    messages=[{"role": "user", "content": "Explain B-trees in one paragraph."}],
    stream=True,
)

for chunk in stream:
    delta = chunk.choices[0].delta
    if delta.content:
        print(delta.content, end="", flush=True)

# أمور تكسر العملاء إذا أخطأ فيها الـ gateway:
#   - تخزين الاستجابة كاملة ثم دفعها دفعة واحدة (ليس streaming حقيقياً)
#   - حذف علامة [DONE] الفاصلة (بعض الـ SDKs تتعلّق في انتظارها)
#   - الـ usage في النهاية فقط — مرّر stream_options={"include_usage": True} للحصول عليه.

أكثر إخفاقات "الـ streaming المزيّف" شيوعاً هو gateway ينادي المصدر الأعلى، فينتظر الاستجابة كاملة، ثم يبعثها على هيئة كتلة أو كتلتين كبيرتين. والـ SDK لا يرمي خطأً — أنت فقط تخسر الهدف كله من الـ streaming (يبقى زمن أول توكن سيئاً). أما الـ gateway الحقيقي فيُبقي الاتصال مفتوحاً إلى المصدر الأعلى ويمرّر كل توكن لحظة وصوله. وبالنسبة إلى Claude يعني ذلك ترجمة أحداث content_block_delta الخاصة بـ Anthropic إلى أحداث chat.completion.chunk الخاصة بـ OpenAI على الطاير؛ ولـ Gemini المهمة نفسها مقابل صيغة الـ streaming الخاصة بـ Vertex. والمخرَج يبدو متطابقاً لشيفرتك، لكن الآلة تحته تؤدي ترجمة حقيقية لكل حدث.

فرق حقيقي واحد يجدر معرفته: الـ usage في الاستجابات المتدفّقة. فـ OpenAI لا يضمّن كتلة usage على الكتلة الأخيرة إلا إذا مرّرت stream_options={"include_usage": true}. والـ gateway الجيد يحترم هذه الراية مقابل كل مصدر أعلى كي لا تضطر شيفرة محاسبة التوكنات لديك إلى معالجة كل نموذج كحالة خاصة. انظر عقد الـ streaming الكامل في وثائق إكمالات المحادثة.

الأدوات واستدعاء الدوال: نفس الشكل، محرّك مختلف

استدعاء الأدوات هو الميزة التي يثبت فيها تجريد OpenAI جدارته — لأن المزوّدين الثلاثة لديهم صيغ أصلية مختلفة كلياً، والـ gateway يخفيها جميعاً. أنت ترسل مصفوفة tools الخاصة بـ OpenAI؛ فتستعيد tool_calls على الرسالة. وما يحدث بينهما ترجمة حقيقية:

tools.py
# استدعاء الأدوات / الدوال: جانب الطلب يطابق OpenAI تماماً.
tools = [{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "Get current weather for a city",
        "parameters": {
            "type": "object",
            "properties": {"city": {"type": "string"}},
            "required": ["city"],
        },
    },
}]

resp = client.chat.completions.create(
    model="claude-opus-4-7",
    messages=[{"role": "user", "content": "What's the weather in Tokyo?"}],
    tools=tools,
    tool_choice="auto",
)

# يعيد النموذج choices[0].message.tool_calls — قائمة، كل عنصر له id
# و .function.name و .function.arguments (وهي سلسلة JSON نصية يجب تمريرها على json.loads).
for call in resp.choices[0].message.tool_calls or []:
    print(call.id, call.function.name, call.function.arguments)

# ثم تُلحِق رسالة {"role": "tool", "tool_call_id": call.id, "content": result}
# وتنادي مجدداً. هذه الحلقة متطابقة سواء كان المصدر الأعلى
# Claude أو Gemini — فالـ gateway يحوّل صيغة الأدوات الأصلية لكل مزوّد
# إلى tool_calls الخاصة بـ OpenAI في طريق الخروج، ويعكسها في طريق الدخول.

تحت الغطاء، يعيد Anthropic كتل محتوى tool_use مع كائن input؛ ويعيد Gemini أجزاء functionCall مع args. والـ gateway يحوّل كليهما إلى شكل tool_calls[] الخاص بـ OpenAI — بما في ذلك التفصيلة التي يسلّم بها OpenAI arguments كسلسلة JSON نصية يجب أن تمرّرها على json.loads، لا ككائن محلّل مسبقاً. وحلقة تنفيذ الأدوات لديك — اقرأ الاستدعاءات، شغّل الدوال، ألحِق رسائل role: "tool"، ونادِ مجدداً — هي ذاتها حرفاً بحرف بغضّ النظر عن العائلة التي تستهدفها. وهذه هي القيمة المقترحة كلها: اكتب الوكيل مرة واحدة، وبدّل النماذج بسلسلة نصية.

التحفّظات الصادقة، لأنها موجودة:

  • استدعاءات الأدوات المتوازية. العائلات الثلاث جميعها تستطيع طلب أدوات متعددة في دور واحد، لكنها تختلف في مدى جرأتها في فعل ذلك لـ prompt معيّن. لا تفترض أن العدد الدقيق أو الترتيب ينتقل عبر النماذج — تعامل مع قائمة، لا مع عدد ثابت.
  • مخططات الأدوات الصارمة / المهيكلة. فرض مخطط JSON عبر strict: true الخاص بـ OpenAI هو ميزة لنماذج OpenAI. وعلى Claude وGemini يمرّر الـ gateway مخططك كتعريف للأداة فيلتزم النموذج به بدقة، لكن الضمان هو ضمان المصدر الأعلى، لا سحراً يستطيع الـ gateway اختلاقه.
  • دقائق tool_choice. فـ auto وفرض دالة بعينها مدعومان جيداً في كل مكان؛ أما التركيبات الغريبة فتستحق اختباراً سريعاً على كل نموذج تشحنه فعلاً.

الرؤية ووضع JSON: تمرير، مع حوافّ

الرؤية تستخدم صيغة أجزاء المحتوى متعددة الوسائط الخاصة بـ OpenAI — قائمة تمزج بين عناصر text وimage_url. ومقابل نموذج يرى الصور أصلاً (Gemini 2.5 Pro/Flash، وعائلة Claude)، يمرّر الـ gateway الصورة فيعمل النداء متعدد الوسائط ببساطة. ووضع JSON — response_format: { type: "json_object" } — يقيّد المخرَج إلى كائن قابل للتحليل:

vision.py
# الرؤية: صيغة أجزاء المحتوى متعددة الوسائط الخاصة بـ OpenAI، تُمرَّر إلى
# نموذج يدعم الصور أصلاً. الرابط أو الـ base64 data URI كلاهما يعمل.
resp = client.chat.completions.create(
    model="gemini-2-5-pro",
    messages=[{
        "role": "user",
        "content": [
            {"type": "text", "text": "What's in this chart? Give me the trend."},
            {"type": "image_url", "image_url": {
                "url": "https://example.com/q3-revenue.png",
            }},
        ],
    }],
)
print(resp.choices[0].message.content)

# وضع JSON — اطلب كائناً مضموناً قابلاً للتحليل:
resp = client.chat.completions.create(
    model="claude-sonnet-4-6",
    messages=[{"role": "user", "content": "Extract name and email as JSON."}],
    response_format={"type": "json_object"},
)
import json
data = json.loads(resp.choices[0].message.content)  # يحلّل في كل مرة.

أما الحوافّ فهي: حدود مدخلات الصور (الأبعاد القصوى، والعدد الأقصى للصور في الطلب الواحد، وأنواع MIME المقبولة) تحدّدها كل مصدر أعلى، لا يخترعها الـ gateway — فملف TIFF بحجم 50 ميغابايت يرفضه Gemini سيُرفَض خلف شكل OpenAI أيضاً، بخطأ مترجَم. ووضع json_object يضمن JSON صالحاً، لا JSON يطابق مخططك المحدّد؛ فإن احتجت بنية بعينها، فصِفها في الـ prompt وتحقّق منها بعد التحليل. وهذه ليست عللاً في الـ gateway — إنها عقد النموذج الكامن يظهر من خلاله، وهو تماماً ما تريد من مترجم أمين أن يحفظه.

الـ embeddings، والأشياء التي لا تنتقل فعلاً

سطحان آخران يستحقان تسميةً صادقة. الـ embeddings (/v1/embeddings) بسيطة ومستقرّة — لكن المتّجهات غير قابلة للتبادل بين النماذج. فـ embedding من Gemini وembedding من OpenAI يعيشان في فضاءين مختلفين بأبعاد مختلفة؛ ولا يمكنك خلطهما في فهرس واحد ولا مقارنة تشابهات جيب التمام بينهما. اختر نموذج embedding واحداً وأعِد ترميز مجموعتك النصية كاملةً إن بدّلت. فالواجهة متوافقة؛ أما الرياضيات فلا.

وأما التسرّبات التي لا يستطيع أي قدر من حشو التوافقية أن يغطّيها — الميزات الخاصة بمزوّد بعينه التي ببساطة لا يوجد لها حقل في OpenAI يحملها:

  • الـ prompt caching من Anthropic. فنقاط فصل cache_control الأصلية تعيش على واجهة Messages من Anthropic. وعبر شكل OpenAI تحصل بدلاً منها على caching بادئة تلقائي بأسلوب OpenAI؛ ولقيادة الـ caching صراحةً تستخدم نقطة النهاية الأصلية /v1/messages. (وكلاهما يعمل على Brievio — انظر وثائق الواجهة.)
  • الـ tokenizers تختلف لكل عائلة. فـ "1000 توكن" ليست نفس طول السلسلة عبر GPT وClaude وGemini — لكلٍّ tokenizer خاص به. ولذلك تتغيّر ميزانيات max_tokens وتقديرات تكلفتك حين تبدّل النماذج، رغم أن اسم الحقل لم يتغيّر. والـ gateway الجيد يبلّغ عن أعداد التوكنات الصادقة لكل مصدر أعلى في usage؛ فهو لا يستطيع أن يجعل ثلاثة tokenizers تتفق، وعليك ألا تثق بمن يتظاهر بأنها تتفق.
  • التفكير الموسّع / الاستدلال. فالتفكير الموسّع لدى Claude وأوضاع التفكير لدى Gemini تظهر بشكل مختلف عن استدلال OpenAI. والمحتوى يمرّ؛ أما تمديد الحقول الدقيق فخاص بكل نموذج، لذا لا تثبّت في الكود شكل استدلال مزوّد واحد عبرها جميعاً.
  • دلالات الـ system prompt. الثلاثة جميعها تقبل رسالة system، لكنها تزنها وتقتطعها بشكل مختلف قليلاً. والسلوك ينتقل؛ لكنه ليس متطابقاً بتاً ببت. اختبر prompts الخاصة بك لكل نموذج.

كيف يطبّع gateway جيد كل هذا

مهمة طبقة التوافقية أن تكون مترجماً أميناً بلا فقدان في المسار الشائع، وصادقاً عند الحوافّ. وملموساً، يعني ذلك: تحويل مخطط الطلب في الاتجاهين؛ وترجمة أحداث الـ streaming توكناً بتوكن، مع العلامة الفاصلة؛ وتحويل صيغة الأدوات الأصلية لكل مزوّد من tool_calls وإليها؛ وحفظ دلالات finish_reason؛ وتمرير صور حقيقية إلى النماذج القادرة على الرؤية؛ و — الجزء الذي يسهل الغش فيه — التبليغ عن أعداد التوكنات الفعلية للمصدر الأعلى بدلاً من رقم محشوّ. وعلى Brievio فإن النماذج خلف الشكل هي الأصلية من مصدرها الأول، قابلة للتتبّع إلى AWS Bedrock وGoogle Vertex، فيكون السلوك الذي تطبّعه هو سلوك النموذج الحقيقي، لا بديل أرخص. وإن أردت أن تتأكد من ذلك بنفسك، فالاختبارات الأربعة في هل Claude الذي تستخدمه هو Claude حقاً تستغرق نحو دقيقة.

مبدآن يتولّدان من كل هذا لكل من يبني على نقطة نهاية "متوافقة". أولاً، اختبر الميزات التي تستخدمها فعلاً — فنداء محادثة ناجح لا يخبرك بشيء عمّا إذا كان الـ streaming يتدفّق تدريجياً أو عمّا إذا كانت معرّفات استدعاء الأدوات تكمل الجولة. ثانياً، احترم التسرّبات: الـ tokenizers، وفضاءات الـ embeddings، وصياغة الـ caching، وأشكال الاستدلال هي خصائص المصدر الأعلى، والـ gateway الصادق بشأنها هو الذي يمكنك الوثوق به في الإنتاج. فالتوافقية طيف، والجزء المفيد منه هو الجزء الذي ينجو من عبء عملك الحقيقي — لا الجزء الذي ينجو من عرض توضيحي.

الخلاصة الملموسة

وجّه الـ OpenAI SDK إلى https://api.brievio.com/v1، وغيّر سلسلة النموذج، وشغّل مجموعة اختباراتك القائمة — لا برنامج hello-world، بل مجموعتك. مارِس الـ streaming مع include_usage، وشغّل جولة استدعاء أداة واحدة، وأرسل صورة واحدة، واطلب json_object واحداً. فإن نجحت الأربعة جميعها على النموذج الذي تنوي شحنه، فالانتقال سطران بحقّ. وحيثما احتجت ميزة خاصة بمزوّد — caching صريح من Anthropic، أو ضوابط استدلال أصلية — فانزل إلى نقاط النهاية الأصلية endpoints لذلك المسار واحتفظ بشكل OpenAI في كل ما عداه. أتريد الانتقال خطوةً بخطوة من قاعدة شيفرة OpenAI قائمة؟ ابدأ بـ استدعاء Claude باستخدام الـ OpenAI SDK، ثم تصفّح قائمة النماذج لتختار ما يعمل خلف الشكل.