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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ Thumbs.db

# Test coverage
coverage/
pnpm-lock.yaml
182 changes: 168 additions & 14 deletions src/adapter/cli-to-openai.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,29 @@
/**
* Converts Claude CLI output to OpenAI-compatible response format
* Converts Claude CLI output to OpenAI-compatible response format.
*
* Tool-calling extraction:
* When the request had `tools`, the openai-to-cli encoder instructs
* Claude to emit tool calls wrapped in <tool_call>…</tool_call> tags
* (see {@link ../adapter/openai-to-cli.ts}). This decoder looks for those
* tags in the response text, parses the inner JSON, and returns the
* result as OpenAI-format `tool_calls` so clients (Hermes, Clawdbot,
* anything speaking OpenAI) can handle them natively.
*
* If parsing fails (malformed JSON, missing fields), we fall through to
* plain-text content so the client still gets something useful.
*/

import type { ClaudeCliAssistant, ClaudeCliResult } from "../types/claude-cli.js";
import type { OpenAIChatResponse, OpenAIChatChunk } from "../types/openai.js";
import type {
OpenAIChatResponse,
OpenAIChatChunk,
OpenAIToolCall,
OpenAIFinishReason,
} from "../types/openai.js";
import {
TOOL_CALL_OPEN_TAG,
TOOL_CALL_CLOSE_TAG,
} from "./openai-to-cli.js";

/**
* Extract text content from Claude CLI assistant message
Expand All @@ -15,15 +35,120 @@ export function extractTextContent(message: ClaudeCliAssistant): string {
.join("");
}

// ---------------------------------------------------------------------------
// Tool-call extraction from Claude's text output
// ---------------------------------------------------------------------------

export interface ExtractedToolCalls {
/** Text with tool_call blocks stripped. May be empty. */
text: string;
/** Tool calls parsed out, in order. Empty array when none present. */
toolCalls: OpenAIToolCall[];
}

let toolCallCounter = 0;
function nextToolCallId(): string {
toolCallCounter = (toolCallCounter + 1) % 1_000_000;
return `call_${Date.now().toString(36)}_${toolCallCounter}`;
}

/**
* Find all <tool_call>…</tool_call> blocks in `text`, parse each as JSON,
* and return OpenAI-format tool_calls plus the remaining text with the
* blocks stripped.
*
* Robust against:
* - Extra whitespace / newlines inside the tags
* - Missing `id` (we generate one)
* - Missing or non-object `arguments` (normalized to empty object)
* - Malformed JSON (skipped silently, text kept as-is with tags removed)
*/
export function extractToolCalls(text: string): ExtractedToolCalls {
const calls: OpenAIToolCall[] = [];
const open = TOOL_CALL_OPEN_TAG;
const close = TOOL_CALL_CLOSE_TAG;

const out: string[] = [];
let cursor = 0;
while (cursor < text.length) {
const start = text.indexOf(open, cursor);
if (start === -1) {
out.push(text.slice(cursor));
break;
}
// Keep any text before the block
out.push(text.slice(cursor, start));
const end = text.indexOf(close, start + open.length);
if (end === -1) {
// Unterminated block: keep raw text, stop scanning
out.push(text.slice(start));
break;
}
const payload = text.slice(start + open.length, end).trim();
cursor = end + close.length;

try {
const parsed = JSON.parse(payload);
if (!parsed || typeof parsed !== "object") continue;
const name = typeof parsed.name === "string" ? parsed.name : null;
if (!name) continue;
const argsObj =
parsed.arguments && typeof parsed.arguments === "object"
? parsed.arguments
: {};
const id =
typeof parsed.id === "string" && parsed.id.length > 0
? parsed.id
: nextToolCallId();
calls.push({
id,
type: "function",
function: {
name,
arguments: JSON.stringify(argsObj),
},
});
} catch {
// Malformed JSON inside block — drop silently; the surrounding text
// is preserved via `out` so the client still sees the assistant's
// prose (minus the broken block).
continue;
}
}

return {
text: out.join("").trim(),
toolCalls: calls,
};
}

// ---------------------------------------------------------------------------
// Streaming chunk conversion
// ---------------------------------------------------------------------------

/**
* Convert Claude CLI assistant message to OpenAI streaming chunk
* Convert Claude CLI assistant message to OpenAI streaming chunk.
*
* When `extractTools` is true, the chunk text is inspected for tool_call
* blocks; if found, an OpenAI tool_calls delta is emitted and the block
* is stripped from the text content.
*/
export function cliToOpenaiChunk(
message: ClaudeCliAssistant,
requestId: string,
isFirst: boolean = false
isFirst: boolean = false,
extractTools: boolean = false,
): OpenAIChatChunk {
const text = extractTextContent(message);
const rawText = extractTextContent(message);
const { text, toolCalls } = extractTools
? extractToolCalls(rawText)
: { text: rawText, toolCalls: [] as OpenAIToolCall[] };

const finishReason: OpenAIFinishReason = message.message.stop_reason
? toolCalls.length > 0
? "tool_calls"
: "stop"
: null;

return {
id: `chatcmpl-${requestId}`,
Expand All @@ -35,9 +160,21 @@ export function cliToOpenaiChunk(
index: 0,
delta: {
role: isFirst ? "assistant" : undefined,
content: text,
content: text ? text : undefined,
tool_calls:
toolCalls.length > 0
? toolCalls.map((c, index) => ({
index,
id: c.id,
type: "function",
function: {
name: c.function.name,
arguments: c.function.arguments,
},
}))
: undefined,
},
finish_reason: message.message.stop_reason ? "stop" : null,
finish_reason: finishReason,
},
],
};
Expand All @@ -46,7 +183,11 @@ export function cliToOpenaiChunk(
/**
* Create a final "done" chunk for streaming
*/
export function createDoneChunk(requestId: string, model: string): OpenAIChatChunk {
export function createDoneChunk(
requestId: string,
model: string,
finishReason: OpenAIFinishReason = "stop",
): OpenAIChatChunk {
return {
id: `chatcmpl-${requestId}`,
object: "chat.completion.chunk",
Expand All @@ -56,24 +197,36 @@ export function createDoneChunk(requestId: string, model: string): OpenAIChatChu
{
index: 0,
delta: {},
finish_reason: "stop",
finish_reason: finishReason,
},
],
};
}

// ---------------------------------------------------------------------------
// Non-streaming response
// ---------------------------------------------------------------------------

/**
* Convert Claude CLI result to OpenAI non-streaming response
* Convert Claude CLI result to OpenAI non-streaming response.
*
* When `extractTools` is true, the result text is scanned for tool_call
* blocks and returned in `message.tool_calls` with content stripped; the
* finish_reason becomes "tool_calls" to signal the client.
*/
export function cliResultToOpenai(
result: ClaudeCliResult,
requestId: string
requestId: string,
extractTools: boolean = false,
): OpenAIChatResponse {
// Get model from modelUsage or default
const modelName = result.modelUsage
? Object.keys(result.modelUsage)[0]
: "claude-sonnet-4";

const { text, toolCalls } = extractTools
? extractToolCalls(result.result)
: { text: result.result, toolCalls: [] as OpenAIToolCall[] };

return {
id: `chatcmpl-${requestId}`,
object: "chat.completion",
Expand All @@ -84,9 +237,10 @@ export function cliResultToOpenai(
index: 0,
message: {
role: "assistant",
content: result.result,
content: toolCalls.length > 0 ? (text || null) : text,
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
},
finish_reason: "stop",
finish_reason: toolCalls.length > 0 ? "tool_calls" : "stop",
},
],
usage: {
Expand Down
Loading