commit b5da096676e63ac900518677d29b20376d2a39c6 Author: Cristiano Hoshikawa Date: Thu Feb 12 10:29:44 2026 -0300 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..027cf03 --- /dev/null +++ b/README.md @@ -0,0 +1,306 @@ +# Integrating OpenClaw with Oracle Cloud Generative AI (OCI) + +## Overview + +This tutorial explains how to integrate **OpenClaw** with **Oracle Cloud +Infrastructure (OCI) Generative AI** by building an OpenAI-compatible +API gateway using FastAPI. + +Instead of modifying OpenClaw's core, we expose an **OpenAI-compatible +endpoint** (`/v1/chat/completions`) that internally routes requests to +OCI Generative AI. + +This approach provides: + +- ✅ Full OpenClaw compatibility +- ✅ Control over OCI model mapping +- ✅ Support for streaming responses +- ✅ Enterprise-grade OCI infrastructure +- ✅ Secure request signing via OCI SDK + +------------------------------------------------------------------------ + +# Why Use OCI Generative AI? + +Oracle Cloud Infrastructure provides: + +- Enterprise security (IAM, compartments, VCN) +- Flexible model serving (ON_DEMAND, Dedicated) +- High scalability +- Cost control +- Regional deployment control +- Native integration with Oracle ecosystem + +By building an OpenAI-compatible proxy, we combine: + +OpenClaw flexibility + OCI enterprise power + +------------------------------------------------------------------------ + +# Architecture + +OpenClaw ↓ OpenAI-Compatible Gateway (FastAPI) ↓ OCI Generative AI REST +API (20231130) ↓ OCI Hosted LLM + +------------------------------------------------------------------------ + +# Project Structure + + project/ + ├── oci_openai_proxy.py + ├── README.md + +------------------------------------------------------------------------ + +# Key Code Sections Explained + +## 1️⃣ Configuration Section + +``` python +OCI_CONFIG_FILE = os.getenv("OCI_CONFIG_FILE", os.path.expanduser("~/.oci/config")) +OCI_PROFILE = os.getenv("OCI_PROFILE", "DEFAULT") +OCI_COMPARTMENT_ID = os.getenv("OCI_COMPARTMENT_ID", "...") +OCI_GENAI_ENDPOINT = os.getenv( + "OCI_GENAI_ENDPOINT", + "https://inference.generativeai.us-chicago-1.oci.oraclecloud.com" +) +``` + +### What it does: + +- Reads OCI authentication config +- Defines target compartment +- Defines the OCI inference endpoint + +------------------------------------------------------------------------ + +## 2️⃣ Model Mapping + +``` python +MODEL_MAP = { + "gpt-4o-mini": "openai.gpt-4.1", + "text-embedding-3-small": "cohere.embed-multilingual-v3.0", +} +``` + +### Why this is important: + +OpenClaw expects OpenAI model names.\ +OCI uses different model IDs. + +This dictionary translates between them. + +------------------------------------------------------------------------ + +## 3️⃣ Pydantic OpenAI-Compatible Request Model + +``` python +class ChatRequest(BaseModel): + model: str + messages: List[Message] + temperature: Optional[float] = None + max_tokens: Optional[int] = None + stream: Optional[bool] = False +``` + +### Purpose: + +Defines a request format fully compatible with OpenAI's API. + +------------------------------------------------------------------------ + +## 4️⃣ OCI Signer + +``` python +def get_signer(): + config = oci.config.from_file(OCI_CONFIG_FILE, OCI_PROFILE) + signer = oci.signer.Signer( + tenancy=config["tenancy"], + user=config["user"], + fingerprint=config["fingerprint"], + private_key_file_location=config["key_file"], + pass_phrase=config.get("pass_phrase"), + ) + return signer +``` + +### Purpose: + +Creates a signed request for OCI REST calls. + +Without this, OCI rejects the request. + +------------------------------------------------------------------------ + +## 5️⃣ Message Conversion (OpenAI → OCI Format) + +``` python +def openai_to_oci_messages(messages: list, model_id: str) -> list: +``` + +OCI expects: + + { + "role": "USER", + "content": [ + {"type": "TEXT", "text": "..."} + ] + } + +OpenAI sends: + + { "role": "user", "content": "..." } + +This function converts formats. + +------------------------------------------------------------------------ + +## 6️⃣ OCI REST Call + +``` python +url = f"{OCI_GENAI_ENDPOINT}/20231130/actions/chat" +``` + +We use OCI's REST endpoint: + + POST /20231130/actions/chat + +Payload structure: + + { + "compartmentId": "...", + "servingMode": { + "servingType": "ON_DEMAND", + "modelId": "openai.gpt-4.1" + }, + "chatRequest": { + "apiFormat": "GENERIC", + "messages": [...], + "maxTokens": 512 + } + } + +------------------------------------------------------------------------ + +## 7️⃣ Streaming Implementation + +``` python +def fake_stream(text: str, model: str): +``` + +Since OCI GENERIC mode returns full response (not streaming), we +simulate OpenAI streaming by splitting the response into chunks. + +This keeps OpenClaw fully compatible. + +------------------------------------------------------------------------ + +## 8️⃣ OpenAI-Compatible Response Builder + +``` python +def build_openai_response(model: str, text: str) +``` + +Formats the OCI response to match OpenAI's schema: + + { + "id": "...", + "object": "chat.completion", + "choices": [...] + } + +------------------------------------------------------------------------ + +# Running the Server + +Install dependencies: + + pip install fastapi uvicorn requests oci pydantic + +Run: + + uvicorn oci_openai_proxy:app --host 0.0.0.0 --port 8050 + +------------------------------------------------------------------------ + +# Testing with curl + + curl http://127.0.0.1:8050/v1/chat/completions -H "Content-Type: application/json" -d '{ + "model": "gpt-4o-mini", + "messages": [ + {"role": "user", "content": "Hello"} + ] + }' + +------------------------------------------------------------------------ + +# OpenClaw Configuration (openclaw.json) + +Edit your **openclaw.json** configuration file (normaly it's in ~/.openclaw/openclaw.json) and replace models and agents definitions with: + +```json +{ + "models":{ + "providers":{ + "openai-compatible":{ + "baseUrl":"http://127.0.0.1:8050/v1", + "apiKey":"sk-test", + "api":"openai-completions", + "models":[ + { + "id":"gpt-4o-mini", + "name":"gpt-4o-mini", + "reasoning":false, + "input":[ + "text" + ], + "contextWindow":200000, + "maxTokens":8192 + } + ] + } + } + }, + "agents":{ + "defaults":{ + "model":{ + "primary":"openai-compatible/gpt-4o-mini" + } + } + }, + "gateway":{ + "port":18789, + "mode":"local", + "bind":"loopback" + } +} +``` + +### Important Fields + +```table + Field Purpose + --------------- -------------------------------- + baseUrl Points OpenClaw to our gateway + api Must be openai-completions + model id Must match MODEL_MAP key + contextWindow Model context size + maxTokens Max response tokens +``` + +------------------------------------------------------------------------ + +# Final Notes + +You now have: + +✔ OpenClaw fully integrated\ +✔ OCI Generative AI backend\ +✔ Streaming compatibility\ +✔ Enterprise-ready architecture + +------------------------------------------------------------------------ + +# Acknowledgments + +- **Author** - Cristiano Hoshikawa (Oracle LAD A-Team Solution Engineer) diff --git a/oci_openapi_proxy.py b/oci_openapi_proxy.py new file mode 100644 index 0000000..91d34df --- /dev/null +++ b/oci_openapi_proxy.py @@ -0,0 +1,316 @@ +import os +import time +import json +import uuid +from typing import List, Optional, Dict, Any, Iterable + +import requests +import oci +from fastapi import FastAPI, HTTPException +from fastapi import Request +from fastapi.responses import JSONResponse, StreamingResponse +from pydantic import BaseModel, ConfigDict + +# ============================================================ +# CONFIG +# ============================================================ + +OCI_CONFIG_FILE = os.getenv("OCI_CONFIG_FILE", os.path.expanduser("~/.oci/config")) +OCI_PROFILE = os.getenv("OCI_PROFILE", "DEFAULT") +OCI_COMPARTMENT_ID = os.getenv("OCI_COMPARTMENT_ID", "") +OCI_GENAI_ENDPOINT = os.getenv( + "OCI_GENAI_ENDPOINT", + "https://inference.generativeai..oci.oraclecloud.com" +) + +MODEL_MAP = { + "gpt-4o-mini": "openai.gpt-4.1", + "text-embedding-3-small": "cohere.embed-multilingual-v3.0", +} + +app = FastAPI(title="OCI OpenAI-Compatible Gateway") + +# ============================================================ +# Pydantic Models (OpenAI-compatible) +# ============================================================ + +class Message(BaseModel): + role: str + content: str + +class EmbeddingRequest(BaseModel): + model: str + input: List[str] | str + +class ChatRequest(BaseModel): + model: str + messages: List[Message] + temperature: Optional[float] = None + max_tokens: Optional[int] = None + max_completion_tokens: Optional[int] = None + top_p: Optional[float] = None + stream: Optional[bool] = False + tools: Optional[Any] = None + tool_choice: Optional[Any] = None + + model_config = ConfigDict(extra="allow") + +# ============================================================ +# OCI SIGNER +# ============================================================ + +def get_signer(): + config = oci.config.from_file(OCI_CONFIG_FILE, OCI_PROFILE) + signer = oci.signer.Signer( + tenancy=config["tenancy"], + user=config["user"], + fingerprint=config["fingerprint"], + private_key_file_location=config["key_file"], + pass_phrase=config.get("pass_phrase"), + ) + return signer + + +# ============================================================ +# CONVERSION HELPERS +# ============================================================ + +def openai_to_oci_messages(messages: list, model_id: str) -> list: + oci_messages = [] + + for m in messages: + role = m.get("role", "").upper() + + if role == "SYSTEM": + role = "SYSTEM" + elif role == "ASSISTANT": + role = "ASSISTANT" + else: + role = "USER" + + oci_messages.append({ + "role": role, + "content": [ + { + "type": "TEXT", + "text": m.get("content", "") + } + ] + }) + + return oci_messages + +def build_openai_response(model: str, text: str) -> Dict[str, Any]: + return { + "id": f"chatcmpl-{uuid.uuid4().hex}", + "object": "chat.completion", + "created": int(time.time()), + "model": model, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": text, + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": None, + "completion_tokens": None, + "total_tokens": None, + }, + } + +def normalize_messages(messages: list) -> list: + normalized = [] + + for m in messages: + content = m.get("content") + + # Caso OpenClaw envie array [{type:"text", text:"..."}] + if isinstance(content, list): + text_parts = [] + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + text_parts.append(item.get("text", "")) + content = "\n".join(text_parts) + + normalized.append({ + "role": m.get("role"), + "content": content + }) + + return normalized + +def fake_stream(text: str, model: str): + completion_id = f"chatcmpl-{uuid.uuid4().hex}" + created = int(time.time()) + + yield f"data: {json.dumps({ + 'id': completion_id, + 'object': 'chat.completion.chunk', + 'created': created, + 'model': model, + 'choices': [{ + 'index': 0, + 'delta': {'role': 'assistant'}, + 'finish_reason': None + }] + })}\n\n" + + for i in range(0, len(text), 40): + chunk = text[i:i+40] + yield f"data: {json.dumps({ + 'id': completion_id, + 'object': 'chat.completion.chunk', + 'created': created, + 'model': model, + 'choices': [{ + 'index': 0, + 'delta': {'content': chunk}, + 'finish_reason': None + }] + })}\n\n" + + yield "data: [DONE]\n\n" + +# ============================================================ +# OCI CHAT CALL (REST 20231130) +# ============================================================ + +def call_oci_chat(request: dict) -> str: + signer = get_signer() + + model = request.get("model") + oci_model = MODEL_MAP.get(model, model) + + url = f"{OCI_GENAI_ENDPOINT}/20231130/actions/chat" + + oci_messages = [] + for m in request.get("messages", []): + oci_messages.append({ + "role": m["role"].upper(), + "content": [ + { + "type": "TEXT", + "text": m["content"] + } + ] + }) + + payload = { + "compartmentId": OCI_COMPARTMENT_ID, + "servingMode": { + "servingType": "ON_DEMAND", + "modelId": oci_model + }, + "chatRequest": { + "apiFormat": "GENERIC", + "messages": oci_messages, + "maxTokens": request.get("max_tokens", 512), + "temperature": request.get("temperature", 0.7), + "topP": request.get("top_p", 0.9) + } + } + + print("\n================ OCI PAYLOAD ================") + print(json.dumps(payload, indent=2, ensure_ascii=False)) + print("=============================================\n") + + response = requests.post( + url, + json=payload, + auth=signer, + headers={"Content-Type": "application/json"}, + ) + + if response.status_code != 200: + print("\n================ OCI ERROR =================") + print(response.text) + print("===========================================\n") + raise HTTPException(status_code=500, detail=response.text) + + data = response.json() + + # Caminho correto da resposta GENERIC + choices = data["chatResponse"]["choices"] + message = choices[0]["message"] + content = message["content"] + + return content[0]["text"] + +# ============================================================ +# ENDPOINTS +# ============================================================ + +@app.get("/v1/models") +def list_models(): + return { + "object": "list", + "data": [ + {"id": k, "object": "model", "owned_by": "oci"} + for k in MODEL_MAP.keys() + ], + } + +@app.post("/v1/chat/completions") +async def chat_completions(request: Request): + body = await request.json() + + print("\n=== OPENCLAW BODY ===") + print(json.dumps(body, indent=2)) + print("=====================\n") + + body["messages"] = normalize_messages(body["messages"]) + text = call_oci_chat(body) + + if body.get("stream"): + return StreamingResponse( + fake_stream(text, body["model"]), + media_type="text/event-stream" + ) + + return build_openai_response(body["model"], text) + +# ============================================================ +# HEALTHCHECK +# ============================================================ + +@app.get("/health") +def health(): + return {"status": "ok"} + +@app.middleware("http") +async def log_requests(request: Request, call_next): + body = await request.body() + + try: + body_json = json.loads(body.decode()) + except: + body_json = body.decode() + + print("\n>>> HIT:", request.method, request.url.path) + print(">>> BODY:", json.dumps(body_json, indent=2, ensure_ascii=False)) + + # NÃO mexe no request._receive + response = await call_next(request) + return response + + +@app.post("/v1/responses") +async def responses_passthrough(request: Request): + body = await request.json() + + body["messages"] = normalize_messages(body.get("messages", [])) + text = call_oci_chat(body) + + if body.get("stream"): + return StreamingResponse( + fake_stream(text, body["model"]), + media_type="text/event-stream" + ) + + return build_openai_response(body["model"], text) + diff --git a/openclaw.json b/openclaw.json new file mode 100644 index 0000000..7a92349 --- /dev/null +++ b/openclaw.json @@ -0,0 +1,66 @@ +{ + "meta": { + "lastTouchedVersion": "2026.2.6-3", + "lastTouchedAt": "2026-02-12T10:50:18.766Z" + }, + "wizard": { + "lastRunAt": "2026-02-12T10:50:18.763Z", + "lastRunVersion": "2026.2.6-3", + "lastRunCommand": "onboard", + "lastRunMode": "local" + }, + "models": { + "providers": { + "openai-compatible": { + "baseUrl": "http://127.0.0.1:8050/v1", + "apiKey": "sk-test", + "api": "openai-completions", + "models": [ + { + "id": "gpt-4o-mini", + "name": "gpt-4o-mini", + "reasoning": false, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 200000, + "maxTokens": 8192 + } + ] + } + } + }, + "agents": { + "defaults": { + "model": { + "primary": "openai-compatible/gpt-4o-mini" + } + } + }, + "messages": { + "ackReactionScope": "group-mentions" + }, + "commands": { + "native": "auto", + "nativeSkills": "auto" + }, + "gateway": { + "port": 18789, + "mode": "local", + "bind": "loopback", + "auth": { + "mode": "token", + "token": "3c758e93a4e6bbdd823795a4e40a70409099823f49b89b93" + }, + "tailscale": { + "mode": "off", + "resetOnExit": false + } + }, + "plugins": { + "entries": { + "whatsapp": { + "enabled": true + } + } + } +}