Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 49 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,15 +120,59 @@ curl -N -X POST http://localhost:3456/v1/chat/completions \

## Configuration with Popular Tools

### Clawdbot
### OpenClaw / Clawdbot

Clawdbot has **built-in support** for Claude CLI OAuth! Check your config:
Add the following to your `~/.openclaw/openclaw.json` (or equivalent config file):

```bash
clawdbot models status
```json
{
"models": {
"providers": {
"claude-max-proxy": {
"baseUrl": "http://localhost:3456/v1",
"apiKey": "not-needed",
"api": "openai-completions",
"models": [
{
"id": "claude-opus-4",
"name": "Claude Opus 4 (via Max Proxy)",
"reasoning": true,
"input": ["text"],
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
"contextWindow": 200000,
"maxTokens": 32000
},
{
"id": "claude-sonnet-4",
"name": "Claude Sonnet 4 (via Max Proxy)",
"reasoning": true,
"input": ["text"],
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
"contextWindow": 200000,
"maxTokens": 32000
}
]
}
}
},
"agents": {
"defaults": {
"model": {
"primary": "claude-max-proxy/claude-opus-4"
},
"models": {
"claude-max-proxy/claude-opus-4": { "alias": "opus4" },
"claude-max-proxy/claude-sonnet-4": { "alias": "sonnet4" }
}
}
}
}
```

If you see `anthropic:claude-cli=OAuth`, you're already using your Max subscription.
**Important:** Make sure the proxy server is running before starting OpenClaw:
```bash
claude-max-api # or: node dist/server/standalone.js
```

### Continue.dev

Expand Down
32 changes: 31 additions & 1 deletion src/adapter/cli-to-openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,33 @@ export function createDoneChunk(requestId: string, model: string): OpenAIChatChu
};
}

/**
* Ensure content is always a string (defensive against unexpected types)
*/
function ensureString(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (value === null || value === undefined) {
return "";
}
// If it's an object, try to extract text content or stringify it
if (typeof value === "object") {
// Handle potential content array format
if (Array.isArray(value)) {
return value
.filter((item): item is { type: string; text: string } =>
item && typeof item === "object" && item.type === "text" && typeof item.text === "string"
)
.map((item) => item.text)
.join("");
}
// Last resort: stringify the object
return JSON.stringify(value);
}
return String(value);
}

/**
* Convert Claude CLI result to OpenAI non-streaming response
*/
Expand All @@ -74,6 +101,9 @@ export function cliResultToOpenai(
? Object.keys(result.modelUsage)[0]
: "claude-sonnet-4";

// Ensure content is always a string to prevent [object Object] issues
const content = ensureString(result.result);

return {
id: `chatcmpl-${requestId}`,
object: "chat.completion",
Expand All @@ -84,7 +114,7 @@ export function cliResultToOpenai(
index: 0,
message: {
role: "assistant",
content: result.result,
content,
},
finish_reason: "stop",
},
Expand Down
46 changes: 43 additions & 3 deletions src/adapter/openai-to-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,44 @@ export function extractModel(model: string): ClaudeModel {
return "opus";
}

/**
* Extract text from message content (handles both string and array formats)
*
* OpenAI API allows content to be:
* - A string: "Hello"
* - An array of content parts: [{"type": "text", "text": "Hello"}]
*/
function extractContentText(content: unknown): string {
// Simple string content
if (typeof content === "string") {
return content;
}

// Array of content parts (multi-modal format)
if (Array.isArray(content)) {
return content
.filter((part): part is { type: string; text: string } =>
part && typeof part === "object" && part.type === "text" && typeof part.text === "string"
)
.map((part) => part.text)
.join("");
}

// Null/undefined
if (content === null || content === undefined) {
return "";
}

// Unknown object - try to stringify as last resort
if (typeof content === "object") {
console.error("[extractContentText] Unexpected object content:", JSON.stringify(content).slice(0, 200));
return JSON.stringify(content);
}

// Other types - convert to string
return String(content);
}

/**
* Convert OpenAI messages array to a single prompt string for Claude CLI
*
Expand All @@ -56,20 +94,22 @@ export function messagesToPrompt(messages: OpenAIChatRequest["messages"]): strin
const parts: string[] = [];

for (const msg of messages) {
const textContent = extractContentText(msg.content);

switch (msg.role) {
case "system":
// System messages become context instructions
parts.push(`<system>\n${msg.content}\n</system>\n`);
parts.push(`<system>\n${textContent}\n</system>\n`);
break;

case "user":
// User messages are the main prompt
parts.push(msg.content);
parts.push(textContent);
break;

case "assistant":
// Previous assistant responses for context
parts.push(`<previous_response>\n${msg.content}\n</previous_response>\n`);
parts.push(`<previous_response>\n${textContent}\n</previous_response>\n`);
break;
}
}
Expand Down
25 changes: 23 additions & 2 deletions src/server/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,21 @@ export async function handleChatCompletions(
const body = req.body as OpenAIChatRequest;
const stream = body.stream === true;

// Debug: Log incoming request for diagnostics
console.error(`[Request ${requestId}] Incoming request:`, JSON.stringify({
model: body.model,
stream: body.stream,
messageCount: body.messages?.length,
messages: body.messages?.map((m, i) => ({
index: i,
role: m.role,
contentType: typeof m.content,
contentPreview: typeof m.content === 'string'
? m.content.slice(0, 100)
: JSON.stringify(m.content).slice(0, 100)
}))
}));

try {
// Validate request
if (!body.messages || !Array.isArray(body.messages) || body.messages.length === 0) {
Expand Down Expand Up @@ -109,7 +124,9 @@ async function handleStreamingResponse(

// Handle streaming content deltas
subprocess.on("content_delta", (event: ClaudeCliStreamEvent) => {
const text = event.event.delta?.text || "";
// Defensive: ensure text is always a string
const rawText = event.event.delta?.text;
const text = typeof rawText === "string" ? rawText : (rawText ? String(rawText) : "");
if (text && !res.writableEnded) {
const chunk = {
id: `chatcmpl-${requestId}`,
Expand All @@ -135,8 +152,12 @@ async function handleStreamingResponse(
lastModel = message.message.model;
});

subprocess.on("result", (_result: ClaudeCliResult) => {
subprocess.on("result", (result: ClaudeCliResult) => {
isComplete = true;
// Debug: log result type for diagnostics
if (typeof result.result !== "string") {
console.error(`[Streaming] WARNING: result.result is not a string, type: ${typeof result.result}`);
}
if (!res.writableEnded) {
// Send final done chunk with finish_reason
const doneChunk = createDoneChunk(requestId, lastModel);
Expand Down
11 changes: 10 additions & 1 deletion src/types/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,18 @@
* Used for Clawdbot integration
*/

// Content can be a string or an array of content parts (multi-modal)
export interface OpenAIContentPart {
type: "text" | "image_url";
text?: string;
image_url?: { url: string };
}

export type OpenAIMessageContent = string | OpenAIContentPart[];

export interface OpenAIChatMessage {
role: "system" | "user" | "assistant";
content: string;
content: OpenAIMessageContent;
}

export interface OpenAIChatRequest {
Expand Down