mirror of
https://github.com/hoshikawa2/openclaw-oci.git
synced 2026-03-06 10:11:06 +00:00
first commit
This commit is contained in:
306
README.md
Normal file
306
README.md
Normal file
@@ -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)
|
||||
316
oci_openapi_proxy.py
Normal file
316
oci_openapi_proxy.py
Normal file
@@ -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", "<YOUR_COMPARTMENT_ID>")
|
||||
OCI_GENAI_ENDPOINT = os.getenv(
|
||||
"OCI_GENAI_ENDPOINT",
|
||||
"https://inference.generativeai.<region>.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)
|
||||
|
||||
66
openclaw.json
Normal file
66
openclaw.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user