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
167 changes: 167 additions & 0 deletions src/adapter/anthropic-to-cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* Converts Anthropic Messages API request to Claude CLI input
*/

import fs from "fs";
import path from "path";
import os from "os";
import crypto from "crypto";
import type {
AnthropicMessagesRequest,
AnthropicContentBlock,
AnthropicImageContent,
} from "../types/anthropic.js";
import type { ClaudeModel } from "./openai-to-cli.js";

export interface CliInput {
prompt: string;
model: ClaudeModel;
sessionId?: string;
tempFiles: string[]; // temp files to clean up after request
}

const MODEL_MAP: Record<string, ClaudeModel> = {
"claude-opus-4": "opus",
"claude-opus-4-20250514": "opus",
"claude-opus-4-6": "opus",
"claude-opus-4-6-20250725": "opus",
"claude-sonnet-4": "sonnet",
"claude-sonnet-4-20250514": "sonnet",
"claude-sonnet-4-6": "sonnet",
"claude-sonnet-4-6-20250725": "sonnet",
"claude-haiku-4": "haiku",
"claude-haiku-4-5-20251001": "haiku",
"opus": "opus",
"sonnet": "sonnet",
"haiku": "haiku",
};

/**
* Extract Claude model alias from Anthropic model string
*/
export function extractModel(model: string): ClaudeModel {
if (MODEL_MAP[model]) {
return MODEL_MAP[model];
}
// Fuzzy match
if (model.includes("opus")) return "opus";
if (model.includes("sonnet")) return "sonnet";
if (model.includes("haiku")) return "haiku";
return "sonnet";
}

const MIME_TO_EXT: Record<string, string> = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
};

/**
* Save a base64 image to a temp file, return the file path
*/
function saveImageToTempFile(image: AnthropicImageContent): string | null {
if (image.source.type === "base64" && image.source.data) {
const ext = MIME_TO_EXT[image.source.media_type || ""] || ".png";
const id = crypto.randomBytes(8).toString("hex");
const filePath = path.join(os.tmpdir(), `claude-proxy-img-${id}${ext}`);
fs.writeFileSync(filePath, Buffer.from(image.source.data, "base64"));
return filePath;
}
if (image.source.type === "url" && image.source.url) {
return image.source.url;
}
return null;
}

/**
* Serialize content blocks to text, handling images by saving to temp files
*/
function contentToText(
content: string | AnthropicContentBlock[],
tempFiles: string[]
): string {
if (typeof content === "string") return content;

const parts: string[] = [];
for (const block of content) {
if (block.type === "text") {
parts.push((block as { text: string }).text);
} else if (block.type === "image") {
const ref = saveImageToTempFile(block as AnthropicImageContent);
if (ref) {
if (ref.startsWith("/") || ref.startsWith(os.tmpdir())) {
tempFiles.push(ref);
parts.push(`[User sent an image. View it with the Read tool at: ${ref}]`);
} else {
// URL-based image
parts.push(`[User sent an image from URL: ${ref}]`);
}
}
} else if (block.type === "tool_use") {
const tu = block as { name: string; input: unknown };
parts.push(`[Tool call: ${tu.name}(${JSON.stringify(tu.input)})]`);
} else if (block.type === "tool_result") {
const tr = block as { content: string | { text: string }[] };
const text = typeof tr.content === "string"
? tr.content
: tr.content.map((c) => c.text).join("");
parts.push(`[Tool result: ${text}]`);
}
}
return parts.join("\n");
}

/**
* Convert Anthropic Messages request to a single prompt for Claude CLI
*
* Claude Code CLI in --print mode expects a single prompt.
* We format system + messages into a readable prompt.
*/
export function anthropicToCli(request: AnthropicMessagesRequest): CliInput {
const parts: string[] = [];
const tempFiles: string[] = [];

// System prompt
if (request.system) {
const systemText = typeof request.system === "string"
? request.system
: request.system
.filter((b) => b.type === "text")
.map((b) => b.text)
.join("\n");
if (systemText) {
parts.push(`<system>\n${systemText}\n</system>\n`);
}
}

// Messages
for (const msg of request.messages) {
const text = contentToText(msg.content, tempFiles);
if (msg.role === "user") {
parts.push(text);
} else if (msg.role === "assistant") {
parts.push(`<previous_response>\n${text}\n</previous_response>\n`);
}
}

return {
prompt: parts.join("\n").trim(),
model: extractModel(request.model),
sessionId: request.metadata?.user_id,
tempFiles,
};
}

/**
* Clean up temp files created during request processing
*/
export function cleanupTempFiles(files: string[]): void {
for (const f of files) {
try {
if (fs.existsSync(f)) fs.unlinkSync(f);
} catch {
// ignore cleanup errors
}
}
}
191 changes: 191 additions & 0 deletions src/adapter/cli-to-anthropic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
/**
* Converts Claude CLI output to Anthropic Messages API response format
*
* Key insight: the CLI's stream-json events already use the same event types
* as the Anthropic streaming API (message_start, content_block_delta, etc.),
* so the conversion is mostly passthrough with minor reshaping.
*/

import type { ClaudeCliAssistant, ClaudeCliResult, ClaudeCliStreamEvent } from "../types/claude-cli.js";
import type {
AnthropicMessagesResponse,
AnthropicMessageStartEvent,
AnthropicContentBlockStartEvent,
AnthropicContentBlockDeltaEvent,
AnthropicContentBlockStopEvent,
AnthropicMessageDeltaEvent,
AnthropicMessageStopEvent,
AnthropicStreamEvent,
AnthropicUsage,
} from "../types/anthropic.js";

/**
* Map CLI stop_reason to Anthropic stop_reason
*/
function mapStopReason(
reason: string | null
): AnthropicMessagesResponse["stop_reason"] {
if (!reason) return null;
if (reason === "end_turn" || reason === "stop_sequence" || reason === "max_tokens" || reason === "tool_use") {
return reason;
}
return "end_turn";
}

/**
* Convert CLI assistant message to full Anthropic Messages response (non-streaming)
*/
export function cliAssistantToAnthropic(
message: ClaudeCliAssistant,
requestId: string
): AnthropicMessagesResponse {
return {
id: requestId,
type: "message",
role: "assistant",
model: message.message.model,
content: message.message.content.map((c) => ({
type: "text" as const,
text: c.text,
})),
stop_reason: mapStopReason(message.message.stop_reason) || "end_turn",
stop_sequence: null,
usage: {
input_tokens: message.message.usage.input_tokens,
output_tokens: message.message.usage.output_tokens,
cache_creation_input_tokens: message.message.usage.cache_creation_input_tokens,
cache_read_input_tokens: message.message.usage.cache_read_input_tokens,
},
};
}

/**
* Convert CLI result to Anthropic Messages response (non-streaming fallback)
*/
export function cliResultToAnthropic(
result: ClaudeCliResult,
requestId: string
): AnthropicMessagesResponse {
const modelName = result.modelUsage
? Object.keys(result.modelUsage)[0]
: "claude-sonnet-4";

return {
id: requestId,
type: "message",
role: "assistant",
model: modelName,
content: [
{
type: "text",
text: result.result,
},
],
stop_reason: result.is_error ? null : "end_turn",
stop_sequence: null,
usage: {
input_tokens: result.usage?.input_tokens || 0,
output_tokens: result.usage?.output_tokens || 0,
cache_creation_input_tokens: result.usage?.cache_creation_input_tokens,
cache_read_input_tokens: result.usage?.cache_read_input_tokens,
},
};
}

/**
* Create message_start SSE event
*/
export function createMessageStartEvent(
requestId: string,
model: string
): AnthropicMessageStartEvent {
return {
type: "message_start",
message: {
id: requestId,
type: "message",
role: "assistant",
model,
content: [],
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: 0,
output_tokens: 0,
},
},
};
}

/**
* Create content_block_start SSE event
*/
export function createContentBlockStartEvent(
index: number
): AnthropicContentBlockStartEvent {
return {
type: "content_block_start",
index,
content_block: {
type: "text",
text: "",
},
};
}

/**
* Convert CLI content_delta to Anthropic content_block_delta
*/
export function cliDeltaToAnthropic(
event: ClaudeCliStreamEvent,
index: number
): AnthropicContentBlockDeltaEvent {
return {
type: "content_block_delta",
index,
delta: {
type: "text_delta",
text: event.event.delta?.text || "",
},
};
}

/**
* Create content_block_stop SSE event
*/
export function createContentBlockStopEvent(
index: number
): AnthropicContentBlockStopEvent {
return {
type: "content_block_stop",
index,
};
}

/**
* Create message_delta SSE event
*/
export function createMessageDeltaEvent(
stopReason: AnthropicMessagesResponse["stop_reason"],
outputTokens: number
): AnthropicMessageDeltaEvent {
return {
type: "message_delta",
delta: {
stop_reason: stopReason,
stop_sequence: null,
},
usage: {
output_tokens: outputTokens,
},
};
}

/**
* Create message_stop SSE event
*/
export function createMessageStopEvent(): AnthropicMessageStopEvent {
return {
type: "message_stop",
};
}
4 changes: 3 additions & 1 deletion src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import express, { Express, Request, Response, NextFunction } from "express";
import { createServer, Server } from "http";
import { handleChatCompletions, handleModels, handleHealth } from "./routes.js";
import { handleChatCompletions, handleAnthropicMessages, handleModels, handleHealth } from "./routes.js";

export interface ServerConfig {
port: number;
Expand Down Expand Up @@ -49,6 +49,7 @@ function createApp(): Express {
app.get("/health", handleHealth);
app.get("/v1/models", handleModels);
app.post("/v1/chat/completions", handleChatCompletions);
app.post("/v1/messages", handleAnthropicMessages);

// 404 handler
app.use((_req: Request, res: Response) => {
Expand Down Expand Up @@ -103,6 +104,7 @@ export async function startServer(config: ServerConfig): Promise<Server> {
serverInstance.listen(port, host, () => {
console.log(`[Server] Claude Code CLI provider running at http://${host}:${port}`);
console.log(`[Server] OpenAI-compatible endpoint: http://${host}:${port}/v1/chat/completions`);
console.log(`[Server] Anthropic Messages endpoint: http://${host}:${port}/v1/messages`);
resolve(serverInstance!);
});
});
Expand Down
Loading