diff --git a/README.md b/README.md index 27729ef03..3474d0b42 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,54 @@ ⚠️ **EDUCATIONAL PURPOSE ONLY** ⚠️ This project is a reverse-engineered implementation of the GitHub Copilot API created for educational purposes only. It is not officially supported by GitHub and should not be used in production environments. -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/E1E519XS7W) +## API Endpoints + +This project implements multiple API endpoints to provide compatibility with different AI tools and frameworks: + +### OpenAI-Compatible Endpoints + +These endpoints follow the OpenAI API specification and are compatible with tools that expect OpenAI's API format: + +- **`POST /chat/completions`** - Chat completion endpoint for conversational AI +- **`POST /v1/chat/completions`** - Same as above with v1 prefix for tool compatibility +- **`GET /models`** - List available models from GitHub Copilot +- **`GET /v1/models`** - Same as above with v1 prefix +- **`POST /embeddings`** - Generate text embeddings +- **`POST /v1/embeddings`** - Same as above with v1 prefix + +### Anthropic-Compatible Endpoints + +These endpoints follow the Anthropic API specification and are compatible with tools that expect Claude's API format: + +- **`POST /v1/messages`** - Main Anthropic messages endpoint for chat completion +- **`POST /v1/messages/count_tokens`** - Token counting endpoint for input estimation + +### Server Status + +- **`GET /`** - Server health check endpoint + +All endpoints proxy requests to GitHub Copilot's API while maintaining compatibility with the respective API formats. The server automatically handles authentication, request/response translation, and model selection. + +## Claude Code Integration + +This fork includes Anthropic-compatible endpoints that make it work seamlessly with [Claude Code](https://claude.ai/code), Anthropic's official CLI for Claude. The server provides `/v1/messages` endpoints that translate between Anthropic's API format and GitHub Copilot's OpenAI-compatible interface. + +### Using with Claude Code + +1. Start the server: + ```sh + bun run dev start --port 4143 --business --verbose + ``` + +2. Configure Claude Code environment variables: + ```sh + export ANTHROPIC_API_KEY="dummy-key" + export ANTHROPIC_BASE_URL="http://localhost:4143" + export ANTHROPIC_MODEL="claude-sonnet-4" + set -e CLAUDE_CODE_USE_BEDROCK + ``` + +3. Use Claude Code normally - it will route through GitHub Copilot while maintaining full compatibility with Anthropic's API format. ## Project Overview @@ -40,26 +87,6 @@ Run the container docker run -p 4141:4141 copilot-api ``` -## Using with npx - -You can run the project directly using npx: - -```sh -npx copilot-api@latest start -``` - -With options: - -```sh -npx copilot-api@latest start --port 8080 -``` - -For authentication only: - -```sh -npx copilot-api@latest auth -``` - ## Command Structure Copilot API now uses a subcommand structure with two main commands: @@ -92,38 +119,36 @@ The following command line options are available for the `start` command: ## Example Usage -Using with npx: - ```sh # Basic usage with start command -npx copilot-api@latest start +bun run dev start # Run on custom port with verbose logging -npx copilot-api@latest start --port 8080 --verbose +bun run dev start --port 4143 --verbose # Use with a business plan GitHub account -npx copilot-api@latest start --business +bun run dev start --business # Use with an enterprise plan GitHub account -npx copilot-api@latest start --enterprise +bun run dev start --enterprise # Enable manual approval for each request -npx copilot-api@latest start --manual +bun run dev start --manual # Set rate limit to 30 seconds between requests -npx copilot-api@latest start --rate-limit 30 +bun run dev start --rate-limit 30 # Wait instead of error when rate limit is hit -npx copilot-api@latest start --rate-limit 30 --wait +bun run dev start --rate-limit 30 --wait # Provide GitHub token directly -npx copilot-api@latest start --github-token ghp_YOUR_TOKEN_HERE +bun run dev start --github-token ghp_YOUR_TOKEN_HERE # Run only the auth flow -npx copilot-api@latest auth +bun run dev auth # Run auth flow with verbose logging -npx copilot-api@latest auth --verbose +bun run dev auth --verbose ``` ## Running from Source diff --git a/package.json b/package.json index 3e1a4952c..2899992ae 100644 --- a/package.json +++ b/package.json @@ -31,12 +31,6 @@ "release": "bumpp && bun publish --access public", "start": "NODE_ENV=production bun run ./src/main.ts" }, - "simple-git-hooks": { - "pre-commit": "bunx lint-staged" - }, - "lint-staged": { - "*": "bunx eslint --fix" - }, "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", diff --git a/src/lib/api-config.ts b/src/lib/api-config.ts index 8075145c9..13e13051d 100644 --- a/src/lib/api-config.ts +++ b/src/lib/api-config.ts @@ -15,7 +15,7 @@ const API_VERSION = "2025-04-01" export const copilotBaseUrl = (state: State) => `https://api.${state.accountType}.githubcopilot.com` -export const copilotHeaders = (state: State, vision: boolean = false) => { +export const copilotHeaders = (state: State, vision = false) => { const headers: Record = { Authorization: `Bearer ${state.copilotToken}`, "content-type": standardHeaders()["content-type"], diff --git a/src/lib/token.ts b/src/lib/token.ts index aa669676d..3d13665a3 100644 --- a/src/lib/token.ts +++ b/src/lib/token.ts @@ -15,20 +15,59 @@ const readGithubToken = () => fs.readFile(PATHS.GITHUB_TOKEN_PATH, "utf8") const writeGithubToken = (token: string) => fs.writeFile(PATHS.GITHUB_TOKEN_PATH, token) +// Utility function to format timestamp as "2001-01-01 13:00:00" +const formatTimestamp = () => { + const now = new Date() + const year = now.getFullYear() + const month = String(now.getMonth() + 1).padStart(2, '0') + const day = String(now.getDate()).padStart(2, '0') + const hours = String(now.getHours()).padStart(2, '0') + const minutes = String(now.getMinutes()).padStart(2, '0') + const seconds = String(now.getSeconds()).padStart(2, '0') + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` +} + +// Track the refresh interval to prevent multiple intervals +let refreshIntervalId: NodeJS.Timeout | null = null +let refreshFailureCount = 0 +const MAX_REFRESH_FAILURES = 3 + export const setupCopilotToken = async () => { + // Clear any existing interval + if (refreshIntervalId) { + clearInterval(refreshIntervalId) + refreshIntervalId = null + } + const { token, refresh_in } = await getCopilotToken() state.copilotToken = token + refreshFailureCount = 0 // Reset failure count on successful setup const refreshInterval = (refresh_in - 60) * 1000 + consola.info(`[${formatTimestamp()}] Copilot token will refresh in ${Math.round(refreshInterval / 1000)} seconds`) - setInterval(async () => { - consola.start("Refreshing Copilot token") + refreshIntervalId = setInterval(async () => { + consola.start(`[${formatTimestamp()}] Refreshing Copilot token`) try { - const { token } = await getCopilotToken() + const { token, refresh_in: newRefreshIn } = await getCopilotToken() state.copilotToken = token + refreshFailureCount = 0 // Reset failure count on success + consola.success(`[${formatTimestamp()}] Copilot token refreshed successfully`) + consola.debug(`[${formatTimestamp()}] Next refresh in ${newRefreshIn - 60} seconds`) } catch (error) { - consola.error("Failed to refresh Copilot token:", error) - throw error + refreshFailureCount++ + consola.error(`[${formatTimestamp()}] Failed to refresh Copilot token (attempt ${refreshFailureCount}/${MAX_REFRESH_FAILURES}):`, error) + + // If we've failed too many times, check if GitHub token is still valid + if (refreshFailureCount >= MAX_REFRESH_FAILURES) { + consola.error(`[${formatTimestamp()}] Multiple refresh failures detected. This might indicate an expired GitHub token.`) + consola.info(`[${formatTimestamp()}] Consider running the 'auth' command to refresh your GitHub token`) + refreshFailureCount = 0 // Reset to prevent spam + } + + // Don't throw the error - this would stop the interval + // Instead, log the error and let the interval continue + // The next refresh attempt will try again } }, refreshInterval) } diff --git a/src/lib/tokenizer.ts b/src/lib/tokenizer.ts index 98797c6b6..b6a4ec80b 100644 --- a/src/lib/tokenizer.ts +++ b/src/lib/tokenizer.ts @@ -1,12 +1,84 @@ import { countTokens } from "gpt-tokenizer/model/gpt-4o" -import type { Message } from "~/services/copilot/create-chat-completions" +import type { Message, ContentPart, ToolCall } from "~/services/copilot/create-chat-completions" + +// Convert Message to gpt-tokenizer compatible format +interface ChatMessage { + role: "user" | "assistant" | "system" + content: string +} + +const convertToTokenizerFormat = (message: Message): ChatMessage | null => { + // Handle tool role messages - convert to assistant for token counting + const role = message.role === "tool" ? "assistant" : message.role + + // Handle string content + if (typeof message.content === "string") { + return { + role: role as "user" | "assistant" | "system", + content: message.content, + } + } + + // Handle null content (can happen with tool calls) + if (message.content === null) { + // If there are tool calls, convert them to text for token counting + if (message.tool_calls && message.tool_calls.length > 0) { + const toolCallsText = message.tool_calls + .map((toolCall: ToolCall) => { + return `Function call: ${toolCall.function.name}(${toolCall.function.arguments})` + }) + .join(" ") + + return { + role: role as "user" | "assistant" | "system", + content: toolCallsText, + } + } + + // If it's a tool response, use the tool_call_id and name for context + if (message.role === "tool" && message.name) { + return { + role: "assistant", + content: `Tool response from ${message.name}`, + } + } + + return null + } + + // Handle ContentPart array - extract text content + const textContent = message.content + .map((part: ContentPart) => { + if (part.type === "input_text" && part.text) { + return part.text + } + // For image parts, we can't count tokens meaningfully, so we'll skip them + // or provide a placeholder. For now, we'll skip them. + return "" + }) + .filter(Boolean) + .join(" ") + + // Only return a message if we have actual text content + if (textContent.trim()) { + return { + role: role as "user" | "assistant" | "system", + content: textContent, + } + } + + return null +} export const getTokenCount = (messages: Array) => { - const input = messages.filter( - (m) => m.role !== "assistant" && typeof m.content === "string", - ) - const output = messages.filter((m) => m.role === "assistant") + // Convert messages to tokenizer-compatible format + const convertedMessages = messages + .map(convertToTokenizerFormat) + .filter((m): m is ChatMessage => m !== null) + + const input = convertedMessages.filter((m) => m.role !== "assistant") + const output = convertedMessages.filter((m) => m.role === "assistant") const inputTokens = countTokens(input) const outputTokens = countTokens(output) diff --git a/src/routes/anthropic/handler.ts b/src/routes/anthropic/handler.ts new file mode 100644 index 000000000..9894c4ce3 --- /dev/null +++ b/src/routes/anthropic/handler.ts @@ -0,0 +1,264 @@ +import type { Context } from "hono" +import { randomUUID } from "node:crypto" +import { streamSSE } from "hono/streaming" +import consola from "consola" + +import { awaitApproval } from "~/lib/approval" +import { checkRateLimit } from "~/lib/rate-limit" +import { state } from "~/lib/state" +import { getTokenCount } from "~/lib/tokenizer" +import { isNullish } from "~/lib/is-nullish" +import { HTTPError } from "~/lib/http-error" + +import type { + AnthropicMessagesRequest, + AnthropicMessagesResponse, + AnthropicTokenCountRequest, + AnthropicTokenCountResponse, + AnthropicErrorResponse +} from "~/types/anthropic" + +import { + convertAnthropicToOpenAIMessages, + convertAnthropicToolsToOpenAI, + convertAnthropicToolChoiceToOpenAI, + convertOpenAIToAnthropicResponse +} from "~/services/anthropic/converters" + +import { convertOpenAIStreamToAnthropic } from "~/services/anthropic/streaming" + +import { + createChatCompletions, + type ChatCompletionsPayload +} from "~/services/copilot/create-chat-completions" + +export async function handleAnthropicMessages(c: Context) { + await checkRateLimit(state) + + const requestId = randomUUID() + + try { + const anthropicRequest = await c.req.json() + + consola.info("Received Anthropic messages request, requestModel:", anthropicRequest.model) + + if (anthropicRequest.messages) { + const tokenCount = getTokenCount( + convertAnthropicToOpenAIMessages(anthropicRequest.messages, anthropicRequest.system) + ) + consola.info("Estimated token count:", tokenCount) + } + + if (state.manualApprove) { + await awaitApproval() + } + + // Convert Anthropic request to OpenAI format + const openaiMessages = convertAnthropicToOpenAIMessages( + anthropicRequest.messages, + anthropicRequest.system + ) + + const openaiTools = convertAnthropicToolsToOpenAI(anthropicRequest.tools) + const openaiToolChoice = convertAnthropicToolChoiceToOpenAI(anthropicRequest.tool_choice) + + // Build OpenAI payload + let openaiPayload: ChatCompletionsPayload = { + model: selectCopilotModel(anthropicRequest.model), + messages: openaiMessages, + stream: anthropicRequest.stream || false + } + + // Set max_tokens + if (isNullish(anthropicRequest.max_tokens)) { + const selectedModel = state.models?.data.find( + (model) => model.id === openaiPayload.model + ) + openaiPayload.max_tokens = selectedModel?.capabilities.limits.max_output_tokens + } else { + openaiPayload.max_tokens = anthropicRequest.max_tokens + } + + // Add optional parameters + if (anthropicRequest.temperature !== undefined) { + openaiPayload.temperature = anthropicRequest.temperature + } + if (anthropicRequest.top_p !== undefined) { + openaiPayload.top_p = anthropicRequest.top_p + } + if (anthropicRequest.stop_sequences) { + openaiPayload.stop = anthropicRequest.stop_sequences + } + if (openaiTools) { + openaiPayload.tools = openaiTools + } + if (openaiToolChoice) { + openaiPayload.tool_choice = openaiToolChoice + } + + consola.debug("Converted to OpenAI payload", { + model: openaiPayload.model, + messageCount: openaiPayload.messages.length, + hasTools: Boolean(openaiPayload.tools), + requestId + }) + + const response = await createChatCompletions(openaiPayload) + + // Handle streaming response + if (anthropicRequest.stream && isAsyncIterable(response)) { + return streamSSE(c, async (stream) => { + const estimatedInputTokens = getTokenCount(openaiMessages) + + for await (const sseEvent of convertOpenAIStreamToAnthropic( + response, + anthropicRequest.model, + estimatedInputTokens, + requestId + )) { + await stream.write(sseEvent) + } + }) + } + + // Handle non-streaming response + if (isNonStreamingResponse(response)) { + const anthropicResponse = convertOpenAIToAnthropicResponse( + response, + anthropicRequest.model, + requestId + ) + + consola.info("Anthropic messages request completed", { + model: anthropicResponse.model, + stopReason: anthropicResponse.stop_reason, + inputTokens: anthropicResponse.usage.input_tokens, + outputTokens: anthropicResponse.usage.output_tokens, + requestId + }) + + return c.json(anthropicResponse) + } + + throw new Error("Unexpected response type from OpenAI") + + } catch (error) { + consola.error("Error handling Anthropic messages request:", error) + + if (error instanceof HTTPError) { + const errorResponse: AnthropicErrorResponse = { + type: "error", + error: { + type: error.response.status >= 400 && error.response.status < 500 + ? "invalid_request_error" + : "api_error", + message: error.message + } + } + return c.json(errorResponse, error.response.status) + } + + const errorResponse: AnthropicErrorResponse = { + type: "error", + error: { + type: "api_error", + message: error instanceof Error ? error.message : "An unexpected error occurred" + } + } + return c.json(errorResponse, 500) + } +} + +export async function handleAnthropicTokenCount(c: Context) { + try { + const request = await c.req.json() + + consola.info("Received Anthropic token count request", { + model: request.model, + messageCount: request.messages.length + }) + + const openaiMessages = convertAnthropicToOpenAIMessages( + request.messages, + request.system + ) + + const tokenCount = getTokenCount(openaiMessages) + + const response: AnthropicTokenCountResponse = { + input_tokens: tokenCount + } + + consola.info("Token count completed", { + tokens: tokenCount, + model: request.model + }) + + return c.json(response) + + } catch (error) { + consola.error("Error counting tokens:", error) + + const errorResponse: AnthropicErrorResponse = { + type: "error", + error: { + type: "invalid_request_error", + message: error instanceof Error ? error.message : "Failed to count tokens" + } + } + return c.json(errorResponse, 400) + } +} + +function selectCopilotModel(anthropicModel: string): string { + // Map Anthropic model names to available Copilot models + const modelName = anthropicModel.toLowerCase() + + if (!state.models?.data) { + // Fallback to a default model name if models aren't cached + return "claude-3-5-sonnet-20241022" + } + + // First try exact match (case-insensitive) + const exactMatch = state.models.data.find(model => + model.id.toLowerCase() === modelName + ) + + if (exactMatch) { + consola.debug(`Found exact model match: ${exactMatch.id}`) + return exactMatch.id + } + + // Then try to find claude-3.7-sonnet specifically + const preferredModel = state.models.data.find(model => + model.id.toLowerCase() === "claude-3.7-sonnet" + ) + + if (preferredModel) { + consola.debug(`Using preferred model: ${preferredModel.id}`) + return preferredModel.id + } + + // Then try to find any Claude model + const claudeModel = state.models.data.find(model => + model.id.toLowerCase().includes("claude") + ) + + if (claudeModel) { + consola.debug(`Using claude model: ${claudeModel.id}`) + return claudeModel.id + } + + // Fallback to first available model + const fallbackModel = state.models.data[0]?.id || "claude-3-5-sonnet-20241022" + consola.debug(`Using fallback model: ${fallbackModel}`) + return fallbackModel +} + +function isAsyncIterable(obj: any): obj is AsyncIterable { + return obj != null && typeof obj[Symbol.asyncIterator] === "function" +} + +function isNonStreamingResponse(response: any): response is import("~/services/copilot/create-chat-completions").ChatCompletionResponse { + return response && typeof response === "object" && "choices" in response +} \ No newline at end of file diff --git a/src/routes/anthropic/route.ts b/src/routes/anthropic/route.ts new file mode 100644 index 000000000..5789a3ed3 --- /dev/null +++ b/src/routes/anthropic/route.ts @@ -0,0 +1,10 @@ +import { Hono } from "hono" +import { handleAnthropicMessages, handleAnthropicTokenCount } from "./handler" + +export const anthropicRoutes = new Hono() + +// Main Anthropic messages endpoint +anthropicRoutes.post("/", handleAnthropicMessages) + +// Token counting endpoint +anthropicRoutes.post("/count_tokens", handleAnthropicTokenCount) \ No newline at end of file diff --git a/src/routes/chat-completions/handler.ts b/src/routes/chat-completions/handler.ts index 9755ecd29..ce113c723 100644 --- a/src/routes/chat-completions/handler.ts +++ b/src/routes/chat-completions/handler.ts @@ -19,7 +19,10 @@ export async function handleCompletion(c: Context) { let payload = await c.req.json() - consola.info("Current token count:", getTokenCount(payload.messages)) + if(payload.messages) { + consola.info("Current token count:", getTokenCount(payload.messages)) + } + if (state.manualApprove) await awaitApproval() diff --git a/src/server.ts b/src/server.ts index eb65371bf..ff50e71b2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,6 +5,7 @@ import { logger } from "hono/logger" import { completionRoutes } from "./routes/chat-completions/route" import { embeddingRoutes } from "./routes/embeddings/route" import { modelRoutes } from "./routes/models/route" +import { anthropicRoutes } from "./routes/anthropic/route" export const server = new Hono() @@ -13,6 +14,7 @@ server.use(cors()) server.get("/", (c) => c.text("Server running")) +// OpenAI-compatible endpoints server.route("/chat/completions", completionRoutes) server.route("/models", modelRoutes) server.route("/embeddings", embeddingRoutes) @@ -21,3 +23,6 @@ server.route("/embeddings", embeddingRoutes) server.route("/v1/chat/completions", completionRoutes) server.route("/v1/models", modelRoutes) server.route("/v1/embeddings", embeddingRoutes) + +// Anthropic-compatible endpoints +server.route("/v1/messages", anthropicRoutes) diff --git a/src/services/anthropic/converters.ts b/src/services/anthropic/converters.ts new file mode 100644 index 000000000..ce23ee322 --- /dev/null +++ b/src/services/anthropic/converters.ts @@ -0,0 +1,356 @@ +import { randomUUID } from "node:crypto" +import type { + AnthropicMessage, + AnthropicTool, + AnthropicToolChoice, + ContentBlock, + ContentBlockText, + ContentBlockImage, + ContentBlockToolUse, + ContentBlockToolResult, + SystemContent, + AnthropicMessagesResponse, + AnthropicUsage +} from "~/types/anthropic" +import type { + Message, + Tool, + ToolChoice, + ChatCompletionResponse, + ContentPart, + ToolCall +} from "~/services/copilot/create-chat-completions" + +export function convertAnthropicToOpenAIMessages( + anthropicMessages: Array, + anthropicSystem?: string | Array +): Array { + const openaiMessages: Array = [] + + // Handle system message + let systemTextContent = "" + if (typeof anthropicSystem === "string") { + systemTextContent = anthropicSystem + } else if (Array.isArray(anthropicSystem)) { + const systemTexts = anthropicSystem + .filter((block): block is SystemContent => block.type === "text") + .map(block => block.text) + systemTextContent = systemTexts.join("\n") + } + + if (systemTextContent) { + openaiMessages.push({ + role: "system", + content: systemTextContent + }) + } + + // Convert messages + for (const msg of anthropicMessages) { + const role = msg.role + const content = msg.content + + if (typeof content === "string") { + openaiMessages.push({ + role, + content + }) + continue + } + + if (Array.isArray(content)) { + const openaiPartsForUserMessage: Array = [] + const assistantToolCalls: Array = [] + const textContentForAssistant: Array = [] + + if (content.length === 0) { + openaiMessages.push({ role, content: "" }) + continue + } + + for (const block of content) { + if (isContentBlockText(block)) { + if (role === "user") { + openaiPartsForUserMessage.push({ + type: "text", + text: block.text + }) + } else if (role === "assistant") { + textContentForAssistant.push(block.text) + } + } else if (isContentBlockImage(block) && role === "user") { + if (block.source.type === "base64") { + openaiPartsForUserMessage.push({ + type: "image_url", + image_url: `data:${block.source.media_type};base64,${block.source.data}` + }) + } + } else if (isContentBlockToolUse(block) && role === "assistant") { + try { + const argsStr = JSON.stringify(block.input) + assistantToolCalls.push({ + id: block.id, + type: "function", + function: { + name: block.name, + arguments: argsStr + } + }) + } catch (e) { + console.warn(`Failed to serialize tool input for ${block.name}:`, e) + assistantToolCalls.push({ + id: block.id, + type: "function", + function: { + name: block.name, + arguments: "{}" + } + }) + } + } else if (isContentBlockToolResult(block) && role === "user") { + const serializedContent = serializeToolResultContent(block.content) + openaiMessages.push({ + role: "tool", + content: serializedContent, + tool_call_id: block.tool_use_id + }) + } + } + + // Handle user message with multimodal content + if (role === "user" && openaiPartsForUserMessage.length > 0) { + const isMultimodal = openaiPartsForUserMessage.some(part => part.type === "image_url") + if (isMultimodal || openaiPartsForUserMessage.length > 1) { + openaiMessages.push({ + role: "user", + content: openaiPartsForUserMessage + }) + } else if (openaiPartsForUserMessage.length === 1 && openaiPartsForUserMessage[0].type === "text") { + openaiMessages.push({ + role: "user", + content: openaiPartsForUserMessage[0].text || "" + }) + } + } + + // Handle assistant message with text and/or tool calls + if (role === "assistant") { + const assistantText = textContentForAssistant.filter(Boolean).join("\n") + + if (assistantText && assistantToolCalls.length > 0) { + // Text and tool calls - need separate messages + openaiMessages.push({ + role: "assistant", + content: assistantText + }) + openaiMessages.push({ + role: "assistant", + content: null, + tool_calls: assistantToolCalls + }) + } else if (assistantText) { + // Just text + openaiMessages.push({ + role: "assistant", + content: assistantText + }) + } else if (assistantToolCalls.length > 0) { + // Just tool calls + openaiMessages.push({ + role: "assistant", + content: null, + tool_calls: assistantToolCalls + }) + } else { + // Empty message + openaiMessages.push({ + role: "assistant", + content: "" + }) + } + } + } + } + + return openaiMessages +} + +export function convertAnthropicToolsToOpenAI( + anthropicTools?: Array +): Array | undefined { + if (!anthropicTools || anthropicTools.length === 0) { + return undefined + } + + return anthropicTools.map(tool => ({ + type: "function" as const, + function: { + name: tool.name, + description: tool.description || "", + parameters: tool.input_schema + } + })) +} + +export function convertAnthropicToolChoiceToOpenAI( + anthropicChoice?: AnthropicToolChoice +): "auto" | "none" | ToolChoice | undefined { + if (!anthropicChoice) { + return undefined + } + + if (anthropicChoice.type === "auto") { + return "auto" + } + if (anthropicChoice.type === "any") { + // Map 'any' to 'auto' as closest equivalent + return "auto" + } + if (anthropicChoice.type === "tool" && anthropicChoice.name) { + return { + type: "function", + function: { + name: anthropicChoice.name + } + } + } + + return "auto" +} + +export function convertOpenAIToAnthropicResponse( + openaiResponse: ChatCompletionResponse, + originalAnthropicModel: string, + requestId?: string +): AnthropicMessagesResponse { + const anthropicContent: Array = [] + let anthropicStopReason: AnthropicMessagesResponse["stop_reason"] = "end_turn" + + const stopReasonMap: Record = { + "stop": "end_turn", + "length": "max_tokens", + "tool_calls": "tool_use", + "function_call": "tool_use", + "content_filter": "stop_sequence" + } + + if (openaiResponse.choices && openaiResponse.choices.length > 0) { + const choice = openaiResponse.choices[0] + const message = choice.message + const finishReason = choice.finish_reason + + anthropicStopReason = stopReasonMap[finishReason] || "end_turn" + + if (message.content) { + anthropicContent.push({ + type: "text", + text: message.content + }) + } + + if (message.tool_calls) { + for (const call of message.tool_calls) { + if (call.type === "function") { + let toolInputDict: Record = {} + try { + const parsedInput = JSON.parse(call.function.arguments) + if (typeof parsedInput === "object" && parsedInput !== null) { + toolInputDict = parsedInput + } else { + toolInputDict = { value: parsedInput } + } + } catch (e) { + console.warn(`Failed to parse tool arguments for ${call.function.name}:`, e) + toolInputDict = { error_parsing_arguments: call.function.arguments } + } + + anthropicContent.push({ + type: "tool_use", + id: call.id, + name: call.function.name, + input: toolInputDict + }) + } + } + if (finishReason === "tool_calls") { + anthropicStopReason = "tool_use" + } + } + } + + if (anthropicContent.length === 0) { + anthropicContent.push({ + type: "text", + text: "" + }) + } + + const usage: AnthropicUsage = { + input_tokens: 0, + output_tokens: 0 + } + + const responseId = openaiResponse.id ? + `msg_${openaiResponse.id}` : + `msg_${requestId || randomUUID()}_completed` + + return { + id: responseId, + type: "message", + role: "assistant", + model: originalAnthropicModel, + content: anthropicContent, + stop_reason: anthropicStopReason, + usage + } +} + +function serializeToolResultContent( + content: string | Array> | Array +): string { + if (typeof content === "string") { + return content + } + + if (Array.isArray(content)) { + const processedParts: Array = [] + for (const item of content) { + if (typeof item === "object" && item !== null && item.type === "text" && "text" in item) { + processedParts.push(String(item.text)) + } else { + try { + processedParts.push(JSON.stringify(item)) + } catch { + processedParts.push(``) + } + } + } + return processedParts.join("\n") + } + + try { + return JSON.stringify(content) + } catch { + return JSON.stringify({ + error: "Serialization failed", + original_type: typeof content + }) + } +} + +// Type guards +function isContentBlockText(block: ContentBlock): block is ContentBlockText { + return block.type === "text" +} + +function isContentBlockImage(block: ContentBlock): block is ContentBlockImage { + return block.type === "image" +} + +function isContentBlockToolUse(block: ContentBlock): block is ContentBlockToolUse { + return block.type === "tool_use" +} + +function isContentBlockToolResult(block: ContentBlock): block is ContentBlockToolResult { + return block.type === "tool_result" +} \ No newline at end of file diff --git a/src/services/anthropic/streaming.ts b/src/services/anthropic/streaming.ts new file mode 100644 index 000000000..2109e0da6 --- /dev/null +++ b/src/services/anthropic/streaming.ts @@ -0,0 +1,269 @@ +import { randomUUID } from "node:crypto" +import type { + AnthropicStreamEvent, + AnthropicMessageStartEvent, + AnthropicContentBlockStartEvent, + AnthropicContentBlockDeltaEvent, + AnthropicContentBlockStopEvent, + AnthropicMessageDeltaEvent, + AnthropicMessageStopEvent, + AnthropicPingEvent, + AnthropicErrorEvent +} from "~/types/anthropic" +import type { ChatCompletionChunk } from "~/services/copilot/create-chat-completions" +import { getTokenCount } from "~/lib/tokenizer" + +export async function* convertOpenAIStreamToAnthropic( + openaiStream: AsyncIterable, + originalAnthropicModel: string, + estimatedInputTokens: number, + requestId: string +): AsyncGenerator { + const anthropicMessageId = `msg_stream_${requestId}_${randomUUID().slice(0, 8)}` + + let nextAnthropicBlockIdx = 0 + let textBlockAnthropicIdx: number | null = null + const openaiToolIdxToAnthropicBlockIdx: Map = new Map() + const toolStates: Map = new Map() + const sentToolBlockStarts = new Set() + + let outputTokenCount = 0 + let finalAnthropicStopReason: "end_turn" | "max_tokens" | "stop_sequence" | "tool_use" | "error" = "end_turn" + + const openaiToAnthropicStopReasonMap: Record = { + "stop": "end_turn", + "length": "max_tokens", + "tool_calls": "tool_use", + "function_call": "tool_use", + "content_filter": "stop_sequence" + } + + try { + // Send message_start event + const messageStartEvent: AnthropicMessageStartEvent = { + type: "message_start", + message: { + id: anthropicMessageId, + type: "message", + role: "assistant", + model: originalAnthropicModel, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: estimatedInputTokens, output_tokens: 0 } + } + } + yield `event: message_start\ndata: ${JSON.stringify(messageStartEvent)}\n\n` + + // Send ping event + const pingEvent: AnthropicPingEvent = { type: "ping" } + yield `event: ping\ndata: ${JSON.stringify(pingEvent)}\n\n` + + for await (const chunk of openaiStream) { + // Parse the chunk - events() from fetch-event-stream returns parsed objects + let parsedChunk: ChatCompletionChunk + if (typeof chunk === "string") { + try { + // Handle SSE format: "data: {...}" + const dataMatch = chunk.match(/^data: (.+)$/) + if (dataMatch) { + parsedChunk = JSON.parse(dataMatch[1]) + } else { + parsedChunk = JSON.parse(chunk) + } + } catch (e) { + console.warn("Failed to parse chunk:", chunk) + continue + } + } else if (chunk && typeof chunk === "object" && chunk.data) { + // Handle fetch-event-stream format where chunk.data contains the actual data + try { + parsedChunk = typeof chunk.data === "string" ? JSON.parse(chunk.data) : chunk.data + } catch (e) { + console.warn("Failed to parse chunk.data:", chunk.data) + continue + } + } else { + parsedChunk = chunk as ChatCompletionChunk + } + + if (!parsedChunk.choices || parsedChunk.choices.length === 0) { + continue + } + + const delta = parsedChunk.choices[0].delta + const openaiFinishReason = parsedChunk.choices[0].finish_reason + + // Handle text content + if (delta.content) { + outputTokenCount += estimateTokenCount(delta.content) + + if (textBlockAnthropicIdx === null) { + textBlockAnthropicIdx = nextAnthropicBlockIdx + nextAnthropicBlockIdx += 1 + + const startTextEvent: AnthropicContentBlockStartEvent = { + type: "content_block_start", + index: textBlockAnthropicIdx, + content_block: { type: "text", text: "" } + } + yield `event: content_block_start\ndata: ${JSON.stringify(startTextEvent)}\n\n` + } + + const textDeltaEvent: AnthropicContentBlockDeltaEvent = { + type: "content_block_delta", + index: textBlockAnthropicIdx, + delta: { type: "text_delta", text: delta.content } + } + yield `event: content_block_delta\ndata: ${JSON.stringify(textDeltaEvent)}\n\n` + } + + // Handle tool calls + if (delta.tool_calls) { + for (const toolDelta of delta.tool_calls) { + const openaiTcIdx = toolDelta.index + + if (!openaiToolIdxToAnthropicBlockIdx.has(openaiTcIdx)) { + const currentAnthropicToolBlockIdx = nextAnthropicBlockIdx + nextAnthropicBlockIdx += 1 + openaiToolIdxToAnthropicBlockIdx.set(openaiTcIdx, currentAnthropicToolBlockIdx) + + toolStates.set(currentAnthropicToolBlockIdx, { + id: toolDelta.id || `tool_ph_${requestId}_${currentAnthropicToolBlockIdx}`, + name: "", + argumentsBuffer: "" + }) + } + + const currentAnthropicToolBlockIdx = openaiToolIdxToAnthropicBlockIdx.get(openaiTcIdx)! + const toolState = toolStates.get(currentAnthropicToolBlockIdx)! + + // Update tool state + if (toolDelta.id && toolState.id.startsWith("tool_ph_")) { + toolState.id = toolDelta.id + } + + if (toolDelta.function) { + if (toolDelta.function.name) { + toolState.name = toolDelta.function.name + } + if (toolDelta.function.arguments) { + toolState.argumentsBuffer += toolDelta.function.arguments + outputTokenCount += estimateTokenCount(toolDelta.function.arguments) + } + } + + // Send content_block_start if we have enough info and haven't sent it yet + if ( + !sentToolBlockStarts.has(currentAnthropicToolBlockIdx) && + toolState.id && + !toolState.id.startsWith("tool_ph_") && + toolState.name + ) { + const startToolEvent: AnthropicContentBlockStartEvent = { + type: "content_block_start", + index: currentAnthropicToolBlockIdx, + content_block: { + type: "tool_use", + id: toolState.id, + name: toolState.name, + input: {} + } + } + yield `event: content_block_start\ndata: ${JSON.stringify(startToolEvent)}\n\n` + sentToolBlockStarts.add(currentAnthropicToolBlockIdx) + } + + // Send delta if we have arguments and have started the block + if ( + toolDelta.function?.arguments && + sentToolBlockStarts.has(currentAnthropicToolBlockIdx) + ) { + const argsDeltaEvent: AnthropicContentBlockDeltaEvent = { + type: "content_block_delta", + index: currentAnthropicToolBlockIdx, + delta: { + type: "input_json_delta", + partial_json: toolDelta.function.arguments + } + } + yield `event: content_block_delta\ndata: ${JSON.stringify(argsDeltaEvent)}\n\n` + } + } + } + + // Handle finish reason + if (openaiFinishReason) { + finalAnthropicStopReason = openaiToAnthropicStopReasonMap[openaiFinishReason] || "end_turn" + if (openaiFinishReason === "tool_calls") { + finalAnthropicStopReason = "tool_use" + } + break + } + } + + // Send content_block_stop events + if (textBlockAnthropicIdx !== null) { + const stopEvent: AnthropicContentBlockStopEvent = { + type: "content_block_stop", + index: textBlockAnthropicIdx + } + yield `event: content_block_stop\ndata: ${JSON.stringify(stopEvent)}\n\n` + } + + for (const anthropicToolIdx of sentToolBlockStarts) { + const toolState = toolStates.get(anthropicToolIdx) + if (toolState) { + try { + JSON.parse(toolState.argumentsBuffer) + } catch { + console.warn(`Invalid JSON in tool arguments for ${toolState.name}`) + } + } + + const stopEvent: AnthropicContentBlockStopEvent = { + type: "content_block_stop", + index: anthropicToolIdx + } + yield `event: content_block_stop\ndata: ${JSON.stringify(stopEvent)}\n\n` + } + + // Send message_delta event + const messageDeltaEvent: AnthropicMessageDeltaEvent = { + type: "message_delta", + delta: { + stop_reason: finalAnthropicStopReason, + stop_sequence: undefined + }, + usage: { output_tokens: outputTokenCount } + } + yield `event: message_delta\ndata: ${JSON.stringify(messageDeltaEvent)}\n\n` + + // Send message_stop event + const messageStopEvent: AnthropicMessageStopEvent = { + type: "message_stop" + } + yield `event: message_stop\ndata: ${JSON.stringify(messageStopEvent)}\n\n` + + } catch (error) { + console.error("Error in stream conversion:", error) + + const errorEvent: AnthropicErrorEvent = { + type: "error", + error: { + type: "api_error", + message: error instanceof Error ? error.message : "An unexpected error occurred" + } + } + yield `event: error\ndata: ${JSON.stringify(errorEvent)}\n\n` + } +} + +function estimateTokenCount(text: string): number { + // Simple token estimation - roughly 4 characters per token + return Math.ceil(text.length / 4) +} \ No newline at end of file diff --git a/src/services/copilot/create-chat-completions.ts b/src/services/copilot/create-chat-completions.ts index 7d54d11f0..a6b8a4d24 100644 --- a/src/services/copilot/create-chat-completions.ts +++ b/src/services/copilot/create-chat-completions.ts @@ -15,18 +15,32 @@ export const createChatCompletions = async ( const visionEnable = payload.messages.some( (x) => - typeof x.content !== "string" + (x.content && typeof x.content !== "string") && x.content.some((x) => x.type === "image_url"), ) + // Check if tools are being used + const toolsEnable = Boolean(payload.tools && payload.tools.length > 0) + const response = await fetch(`${copilotBaseUrl(state)}/chat/completions`, { method: "POST", headers: copilotHeaders(state, visionEnable), body: JSON.stringify(payload), }) - if (!response.ok) + if (!response.ok) { + const errorText = await response.text() + + // If tools are not supported, provide a helpful error message + if (toolsEnable && response.status === 400) { + throw new HTTPError( + `Failed to create chat completions. GitHub Copilot may not support tool calls. Error: ${errorText}`, + response + ) + } + throw new HTTPError("Failed to create chat completions", response) + } if (payload.stream) { return events(response) @@ -36,8 +50,19 @@ export const createChatCompletions = async ( } const intoCopilotMessage = (message: Message) => { + // Skip processing for assistant messages (they may have tool_calls) + if (message.role === "assistant") return false + + // Skip processing for tool messages (they have specific format) + if (message.role === "tool") return false + + // Skip processing for string content if (typeof message.content === "string") return false + // Skip processing for null content + if (message.content === null) return false + + // Transform content parts for vision support for (const part of message.content) { if (part.type === "input_image") part.type = "image_url" } @@ -56,12 +81,23 @@ export interface ChatCompletionChunk { interface Delta { content?: string role?: string + tool_calls?: Array +} + +interface DeltaToolCall { + index: number + id?: string + type?: "function" + function?: { + name?: string + arguments?: string + } } interface Choice { index: number delta: Delta - finish_reason: "stop" | null + finish_reason: "stop" | "tool_calls" | "length" | "content_filter" | null logprobs: null } @@ -79,7 +115,7 @@ interface ChoiceNonStreaming { index: number message: Message logprobs: null - finish_reason: "stop" + finish_reason: "stop" | "tool_calls" | "length" | "content_filter" } // Payload types @@ -93,19 +129,50 @@ export interface ChatCompletionsPayload { stop?: Array n?: number stream?: boolean + tools?: Array + tool_choice?: "none" | "auto" | ToolChoice +} + +export interface Tool { + type: "function" + function: { + name: string + description?: string + parameters?: Record + } +} + +export interface ToolChoice { + type: "function" + function: { + name: string + } } export interface Message { - role: "user" | "assistant" | "system" - content: string | Array + role: "user" | "assistant" | "system" | "tool" + content: string | Array | null + tool_calls?: Array + tool_call_id?: string + name?: string } // https://platform.openai.com/docs/api-reference export interface ContentPart { - type: "input_image" | "input_text" | "image_url" + type: "input_image" | "text" | "image_url" text?: string image_url?: string } + +export interface ToolCall { + id: string + type: "function" + function: { + name: string + arguments: string + } +} + // https://platform.openai.com/docs/guides/images-vision#giving-a-model-images-as-input // Note: copilot use "image_url", but openai use "input_image" diff --git a/src/types/anthropic.ts b/src/types/anthropic.ts new file mode 100644 index 000000000..9485e5c43 --- /dev/null +++ b/src/types/anthropic.ts @@ -0,0 +1,184 @@ +// Anthropic API types based on the Python implementation + +export interface ContentBlockText { + type: "text" + text: string +} + +export interface ContentBlockImageSource { + type: string + media_type: string + data: string +} + +export interface ContentBlockImage { + type: "image" + source: ContentBlockImageSource +} + +export interface ContentBlockToolUse { + type: "tool_use" + id: string + name: string + input: Record +} + +export interface ContentBlockToolResult { + type: "tool_result" + tool_use_id: string + content: string | Array> | Array + is_error?: boolean +} + +export type ContentBlock = + | ContentBlockText + | ContentBlockImage + | ContentBlockToolUse + | ContentBlockToolResult + +export interface SystemContent { + type: "text" + text: string +} + +export interface AnthropicMessage { + role: "user" | "assistant" + content: string | Array +} + +export interface AnthropicTool { + name: string + description?: string + input_schema: Record +} + +export interface AnthropicToolChoice { + type: "auto" | "any" | "tool" + name?: string +} + +export interface AnthropicMessagesRequest { + model: string + max_tokens: number + messages: Array + system?: string | Array + stop_sequences?: Array + stream?: boolean + temperature?: number + top_p?: number + top_k?: number + metadata?: Record + tools?: Array + tool_choice?: AnthropicToolChoice +} + +export interface AnthropicUsage { + input_tokens: number + output_tokens: number +} + +export interface AnthropicMessagesResponse { + id: string + type: "message" + role: "assistant" + model: string + content: Array + stop_reason?: "end_turn" | "max_tokens" | "stop_sequence" | "tool_use" | "error" + stop_sequence?: string + usage: AnthropicUsage +} + +export interface AnthropicTokenCountRequest { + model: string + messages: Array + system?: string | Array + tools?: Array +} + +export interface AnthropicTokenCountResponse { + input_tokens: number +} + +export interface AnthropicErrorDetail { + type: "invalid_request_error" | "authentication_error" | "permission_error" | + "not_found_error" | "rate_limit_error" | "api_error" | "overloaded_error" | + "request_too_large_error" + message: string + provider?: string + provider_message?: string + provider_code?: string | number +} + +export interface AnthropicErrorResponse { + type: "error" + error: AnthropicErrorDetail +} + +// Streaming event types +export interface AnthropicStreamEvent { + type: string + [key: string]: any +} + +export interface AnthropicMessageStartEvent extends AnthropicStreamEvent { + type: "message_start" + message: { + id: string + type: "message" + role: "assistant" + model: string + content: Array + stop_reason: null + stop_sequence: null + usage: { input_tokens: number; output_tokens: number } + } +} + +export interface AnthropicContentBlockStartEvent extends AnthropicStreamEvent { + type: "content_block_start" + index: number + content_block: { + type: "text" | "tool_use" + text?: string + id?: string + name?: string + input?: Record + } +} + +export interface AnthropicContentBlockDeltaEvent extends AnthropicStreamEvent { + type: "content_block_delta" + index: number + delta: { + type: "text_delta" | "input_json_delta" + text?: string + partial_json?: string + } +} + +export interface AnthropicContentBlockStopEvent extends AnthropicStreamEvent { + type: "content_block_stop" + index: number +} + +export interface AnthropicMessageDeltaEvent extends AnthropicStreamEvent { + type: "message_delta" + delta: { + stop_reason: "end_turn" | "max_tokens" | "stop_sequence" | "tool_use" | "error" + stop_sequence?: string + } + usage: { output_tokens: number } +} + +export interface AnthropicMessageStopEvent extends AnthropicStreamEvent { + type: "message_stop" +} + +export interface AnthropicPingEvent extends AnthropicStreamEvent { + type: "ping" +} + +export interface AnthropicErrorEvent extends AnthropicStreamEvent { + type: "error" + error: AnthropicErrorDetail +} \ No newline at end of file