From 6e283848b235f1ca1a76e429555c6b490feca9f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cnghyane=E2=80=9D?= <“hoangvananhnghia99@gmail.com”> Date: Tue, 10 Jun 2025 19:51:40 +0700 Subject: [PATCH 01/15] fix: enhance tokenizer to handle complex message types - Fix null content handling in getTokenCount (fixes #21) - Add support for tool calls and content parts in token counting - Improve token counting accuracy for different message roles - Add null safety checks for message processing - Handle ContentPart arrays and extract text content properly --- src/lib/tokenizer.ts | 82 ++++++++++++++++++++++++-- src/routes/chat-completions/handler.ts | 5 +- 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/src/lib/tokenizer.ts b/src/lib/tokenizer.ts index 98797c6..b6a4ec8 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/chat-completions/handler.ts b/src/routes/chat-completions/handler.ts index 9755ecd..ce113c7 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() From 8e106abec4909697b382dc15da03a11d492ac426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cnghyane=E2=80=9D?= <“hoangvananhnghia99@gmail.com”> Date: Tue, 10 Jun 2025 19:52:05 +0700 Subject: [PATCH 02/15] feat: add comprehensive tool support - Add tool/function calling support to chat completions (addresses #30) - Enhanced error handling for unsupported tool features - Support for tool choice and tool responses in message types - Better message processing for tool-related content - Add proper TypeScript types for Tool, ToolCall, and ToolChoice - Improve vision capability detection logic - Simplify default parameter syntax in copilotHeaders --- src/lib/api-config.ts | 2 +- .../copilot/create-chat-completions.ts | 79 +++++++++++++++++-- 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/src/lib/api-config.ts b/src/lib/api-config.ts index 8075145..13e1305 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/services/copilot/create-chat-completions.ts b/src/services/copilot/create-chat-completions.ts index 7d54d11..983a7ae 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,11 +129,32 @@ 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 @@ -107,5 +164,15 @@ export interface ContentPart { 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" From 0be8394d849edacee7d4209fe2f0129bd0ea954b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cnghyane=E2=80=9D?= <“hoangvananhnghia99@gmail.com”> Date: Tue, 10 Jun 2025 19:56:27 +0700 Subject: [PATCH 03/15] fix: add safe navigation for model capabilities access - Fix undefined error when accessing selectedModel.capabilities.limits.max_output_tokens - Add proper optional chaining to prevent crashes when model is not found - Ensures graceful handling when state.models or selectedModel is undefined --- src/routes/chat-completions/handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/chat-completions/handler.ts b/src/routes/chat-completions/handler.ts index ce113c7..2fadad3 100644 --- a/src/routes/chat-completions/handler.ts +++ b/src/routes/chat-completions/handler.ts @@ -33,7 +33,7 @@ export async function handleCompletion(c: Context) { payload = { ...payload, - max_tokens: selectedModel?.capabilities.limits.max_output_tokens, + max_tokens: selectedModel?.capabilities?.limits?.max_output_tokens, } } From 0867a5fa592851c13b0e736ddc41a784756ad62f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cnghyane=E2=80=9D?= <“hoangvananhnghia99@gmail.com”> Date: Thu, 12 Jun 2025 19:07:11 +0700 Subject: [PATCH 04/15] Update tokenizer and tool support fixes --- src/auth.ts | 4 ++-- src/lib/forward-error.ts | 9 ++++++++- src/services/copilot/create-chat-completions.ts | 2 +- src/services/copilot/create-embeddings.ts | 8 +++++++- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/auth.ts b/src/auth.ts index 85b5a13..9dfbef8 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -16,8 +16,8 @@ export async function runAuth(options: RunAuthOptions): Promise { consola.info("Verbose logging enabled") } - await ensurePaths() - await setupGitHubToken({ force: true }) + // await ensurePaths() + // await setupGitHubToken({ force: true }) consola.success("GitHub token written to", PATHS.GITHUB_TOKEN_PATH) } diff --git a/src/lib/forward-error.ts b/src/lib/forward-error.ts index c0a1e02..a1887c6 100644 --- a/src/lib/forward-error.ts +++ b/src/lib/forward-error.ts @@ -9,7 +9,14 @@ export async function forwardError(c: Context, error: unknown) { consola.error("Error occurred:", error) if (error instanceof HTTPError) { - const errorText = await error.response.text() + let errorText: string + try { + errorText = await error.response.text() + } catch { + // If body is already used, fall back to the error message + errorText = error.message + } + return c.json( { error: { diff --git a/src/services/copilot/create-chat-completions.ts b/src/services/copilot/create-chat-completions.ts index 983a7ae..e4be0fe 100644 --- a/src/services/copilot/create-chat-completions.ts +++ b/src/services/copilot/create-chat-completions.ts @@ -39,7 +39,7 @@ export const createChatCompletions = async ( ) } - throw new HTTPError("Failed to create chat completions", response) + throw new HTTPError(`Failed to create chat completions: ${errorText}`, response) } if (payload.stream) { diff --git a/src/services/copilot/create-embeddings.ts b/src/services/copilot/create-embeddings.ts index 7b43a19..586c6bf 100644 --- a/src/services/copilot/create-embeddings.ts +++ b/src/services/copilot/create-embeddings.ts @@ -1,6 +1,7 @@ import { copilotHeaders, copilotBaseUrl } from "~/lib/api-config" import { HTTPError } from "~/lib/http-error" import { state } from "~/lib/state" +import consola from "consola" export const createEmbeddings = async (payload: EmbeddingRequest) => { if (!state.copilotToken) throw new Error("Copilot token not found") @@ -13,7 +14,12 @@ export const createEmbeddings = async (payload: EmbeddingRequest) => { if (!response.ok) throw new HTTPError("Failed to create embeddings", response) - return (await response.json()) as EmbeddingResponse + + // return (await response.json()) as EmbeddingResponse + + const json = await response.json() + + return json as EmbeddingResponse } export interface EmbeddingRequest { From de0bc916e50555e68778949779c9606b47615b80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cnghyane=E2=80=9D?= <“hoangvananhnghia99@gmail.com”> Date: Sun, 15 Jun 2025 15:56:04 +0700 Subject: [PATCH 05/15] feat: improve tokenizer support and tool compatibility - Enhanced tokenizer with better model support and fallback mechanisms - Added comprehensive logging system with global logger - Improved model utilities and streaming capabilities - Added format converter for better data handling - Enhanced error handling and debugging capabilities - Updated dependencies and configurations for better compatibility --- package.json | 8 +- src/auth.ts | 8 +- src/lib/format-converter.ts | 239 ++++++++++++++++++ src/lib/logger.ts | 9 + src/lib/model-utils.ts | 39 +++ src/lib/models.ts | 39 ++- src/lib/streaming-utils.ts | 47 ++++ src/lib/token.ts | 10 +- src/lib/tokenizer.ts | 30 ++- src/lib/vscode-version.ts | 4 - src/main.ts | 4 - src/routes/chat-completions/handler.ts | 86 +++++-- src/routes/models/route.ts | 13 +- .../copilot/create-chat-completions.ts | 196 ++++---------- src/services/copilot/create-embeddings.ts | 4 +- src/services/github/poll-access-token.ts | 2 - tsconfig.json | 2 + 17 files changed, 526 insertions(+), 214 deletions(-) create mode 100644 src/lib/format-converter.ts create mode 100644 src/lib/logger.ts create mode 100644 src/lib/model-utils.ts create mode 100644 src/lib/streaming-utils.ts diff --git a/package.json b/package.json index 3e1a495..9349196 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,13 @@ "prepack": "bun run build", "prepare": "simple-git-hooks", "release": "bumpp && bun publish --access public", - "start": "NODE_ENV=production bun run ./src/main.ts" + "start": "NODE_ENV=production bun run ./src/main.ts", + "test": "bun test", + "test:coverage": "bun test --coverage", + "test:integration": "bun test src/tests/integration.test.ts", + "test:performance": "bun test src/tests/performance.test.ts", + "test:unit": "bun test src/tests/*.test.ts", + "test:watch": "bun test --watch" }, "simple-git-hooks": { "pre-commit": "bunx lint-staged" diff --git a/src/auth.ts b/src/auth.ts index 9dfbef8..0843ecd 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -3,7 +3,8 @@ import { defineCommand } from "citty" import consola from "consola" -import { PATHS, ensurePaths } from "./lib/paths" +import { ensurePaths } from "./lib/paths" +import { PATHS } from "./lib/paths" import { setupGitHubToken } from "./lib/token" interface RunAuthOptions { @@ -13,11 +14,10 @@ interface RunAuthOptions { export async function runAuth(options: RunAuthOptions): Promise { if (options.verbose) { consola.level = 5 - consola.info("Verbose logging enabled") } - // await ensurePaths() - // await setupGitHubToken({ force: true }) + await ensurePaths() + await setupGitHubToken({ force: true }) consola.success("GitHub token written to", PATHS.GITHUB_TOKEN_PATH) } diff --git a/src/lib/format-converter.ts b/src/lib/format-converter.ts new file mode 100644 index 0000000..0ae46b5 --- /dev/null +++ b/src/lib/format-converter.ts @@ -0,0 +1,239 @@ +/** + * Lightweight format converter for Anthropic ↔ OpenAI compatibility + */ + +export interface FormatDetectionResult { + isAnthropic: boolean + originalFormat: 'anthropic' | 'openai' +} + +/** + * Detect if request is in Anthropic format + */ +export function detectFormat(payload: any): FormatDetectionResult { + const isAnthropic = !!( + (Array.isArray(payload.system)) || + (payload.metadata) || + (payload.messages?.some((msg: any) => + msg.content?.some?.((part: any) => + part.cache_control || + part.type === 'tool_use' || + part.type === 'tool_result' + ) + )) || + (payload.tools?.some((tool: any) => tool.input_schema && !tool.function)) || + (typeof payload.tool_choice === 'object' && payload.tool_choice?.type) + ) + + return { + isAnthropic, + originalFormat: isAnthropic ? 'anthropic' : 'openai' + } +} + +/** + * Convert Anthropic format to OpenAI format + */ +export function anthropicToOpenAI(payload: any): any { + const converted = { ...payload } + + // Convert system array to string + if (Array.isArray(converted.system)) { + converted.system = converted.system + .map((s: any) => typeof s === 'string' ? s : s.text || '') + .join('\n') + } + + // Remove Anthropic-specific fields + delete converted.metadata + + // Convert messages from Anthropic to OpenAI format + if (converted.messages) { + const convertedMessages: any[] = [] + + converted.messages.forEach((msg: any) => { + if (Array.isArray(msg.content)) { + // Separate tool_use and tool_result content from other content + const toolUseContent = msg.content.filter((part: any) => part.type === 'tool_use') + const toolResultContent = msg.content.filter((part: any) => part.type === 'tool_result') + const otherContent = msg.content.filter((part: any) => + part.type !== 'tool_use' && part.type !== 'tool_result' + ) + + // Clean cache_control from non-tool content + const cleanedContent = otherContent.map((part: any) => { + const { cache_control, ...cleanPart } = part + return cleanPart + }) + + // Handle tool_result content - convert to separate tool messages + if (toolResultContent.length > 0) { + // Add tool result messages separately + toolResultContent.forEach((toolResult: any) => { + const toolContent = Array.isArray(toolResult.content) + ? toolResult.content.map((c: any) => c.text || JSON.stringify(c)).join('\n') + : typeof toolResult.content === 'string' + ? toolResult.content + : JSON.stringify(toolResult.content) + + convertedMessages.push({ + role: "tool", + name: "tool_result", // or extract from tool_use_id if needed + tool_call_id: toolResult.tool_use_id, + content: toolContent + }) + }) + + // If this message only contains tool_result content, don't add the original message + if (otherContent.length === 0) { + return + } + } + + // Combine remaining content (excluding tool results) + const allContent = [...cleanedContent] + + // Convert tool_use to GitHub Copilot API tool_calls format + if (toolUseContent.length > 0) { + const tool_calls = toolUseContent.map((toolUse: any, index: number) => ({ + type: "function", + id: toolUse.id || `call_${Math.random().toString(36).substring(2, 15)}`, + index: index, + function: { + name: toolUse.name, + arguments: JSON.stringify(toolUse.input || {}) + } + })) + + // For assistant messages with tool calls, content should be empty string + // and tool_calls should be at message level (GitHub Copilot format) + if (msg.role === 'assistant') { + convertedMessages.push({ + ...msg, + content: allContent.length > 0 ? allContent : "", + tool_calls + }) + return + } + } + + // Add regular message + convertedMessages.push({ + ...msg, + content: allContent.length > 0 ? allContent : msg.content + }) + } else { + // Non-array content, add as-is + convertedMessages.push(msg) + } + }) + + converted.messages = convertedMessages + } + + // Convert tools from Anthropic to OpenAI format + if (converted.tools) { + converted.tools = converted.tools.map((tool: any) => ({ + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters: tool.input_schema + } + })) + } + + // Convert tool_choice from Anthropic to OpenAI format + if (converted.tool_choice) { + if (typeof converted.tool_choice === 'object' && converted.tool_choice.type === 'auto') { + converted.tool_choice = 'auto' + } else if (typeof converted.tool_choice === 'object' && converted.tool_choice.type === 'tool') { + converted.tool_choice = { + type: 'function', + function: { name: converted.tool_choice.name } + } + } + } + + return converted +} + +/** + * Convert OpenAI response back to Anthropic format if needed + */ +export function openAIToAnthropic(response: any, originalFormat: string): any { + if (originalFormat !== 'anthropic') { + return response + } + + // Handle streaming chunks + if (response.choices && response.choices[0]?.delta) { + const choice = response.choices[0] + const delta = choice.delta + + // Convert tool_calls in streaming delta to Anthropic format + if (delta.tool_calls) { + const convertedContent = delta.tool_calls.map((toolCall: any) => ({ + type: "tool_use", + id: toolCall.id || `toolu_${Math.random().toString(36).substring(2, 11)}`, + name: toolCall.function?.name, + input: toolCall.function?.arguments ? JSON.parse(toolCall.function.arguments) : {} + })) + + return { + ...response, + content: convertedContent, + stop_reason: choice.finish_reason === 'tool_calls' ? 'tool_use' : choice.finish_reason + } + } + + // Convert regular content + if (delta.content) { + return { + ...response, + content: [{ type: "text", text: delta.content }] + } + } + + return response + } + + // Handle non-streaming response + if (response.choices && response.choices[0]?.message) { + const message = response.choices[0].message + const content = [] + + // Add text content + if (message.content) { + content.push({ + type: "text", + text: message.content + }) + } + + // Convert tool_calls to Anthropic format + if (message.tool_calls) { + message.tool_calls.forEach((toolCall: any) => { + content.push({ + type: "tool_use", + id: toolCall.id || `toolu_${Math.random().toString(36).substring(2, 11)}`, + name: toolCall.function.name, + input: JSON.parse(toolCall.function.arguments || '{}') + }) + }) + } + + return { + id: response.id, + type: "message", + role: "assistant", + content, + model: response.model, + stop_reason: response.choices[0].finish_reason === 'tool_calls' ? 'tool_use' : response.choices[0].finish_reason, + usage: response.usage + } + } + + // Fallback: return as-is + return response +} diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 0000000..d17aedc --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,9 @@ +import consola from "consola" + +// Simple logger wrapper around consola for basic logging needs +export const globalLogger = { + debug: (message: string, data?: any) => consola.debug(message, data), + info: (message: string, data?: any) => consola.info(message, data), + warn: (message: string, data?: any) => consola.warn(message, data), + error: (message: string, data?: any) => consola.error(message, data), +} diff --git a/src/lib/model-utils.ts b/src/lib/model-utils.ts new file mode 100644 index 0000000..0015c67 --- /dev/null +++ b/src/lib/model-utils.ts @@ -0,0 +1,39 @@ +import { state } from "./state" + +/** + * Check if the model vendor is Anthropic (Claude models) + */ +export const isAnthropicVendor = (modelName: string): boolean => { + if (!state.models?.data) return false + + const model = state.models.data.find((m) => m.id === modelName) + return model?.vendor === "Anthropic" +} + +/** + * Get model information by name + */ +export const getModelInfo = (modelName: string) => { + if (!state.models?.data) return null + + return state.models.data.find((m) => m.id === modelName) +} + +/** + * Check if model supports vision + * Note: Vision support is not explicitly defined in the API response, + * so we check based on model name patterns + */ +export const supportsVision = (modelName: string): boolean => { + // For now, assume vision support based on model name patterns + // This can be updated when the API provides explicit vision support info + return modelName.includes("gpt-4") || modelName.includes("claude") +} + +/** + * Check if model supports tool calls + */ +export const supportsToolCalls = (modelName: string): boolean => { + const model = getModelInfo(modelName) + return model?.capabilities?.supports?.tool_calls === true +} diff --git a/src/lib/models.ts b/src/lib/models.ts index d6a3516..80f7f69 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -1,14 +1,41 @@ -import consola from "consola" - import { getModels } from "~/services/copilot/get-models" import { state } from "./state" +/** + * Transform model name from client-facing format to internal format + * Example: "claude-4-sonnet" -> "claude-sonnet-4" + */ +export function transformModelName(modelName: string): string { + if (modelName === "claude-4-sonnet") { + return "claude-sonnet-4" + } + + // claude-4-sonnet-20250514 + if (modelName.startsWith("claude-sonnet-4-")) { + return "claude-sonnet-4" + } + + if (modelName.startsWith("claude-3-7-sonnet-")) { + return "claude-sonnet-3.7" + } + + return modelName +} + +/** + * Transform model name from internal format to client-facing format + * Example: "claude-sonnet-4" -> "claude-4-sonnet" + */ +export function reverseTransformModelName(modelName: string): string { + if (modelName === "claude-sonnet-4") { + return "claude-4-sonnet" + } + + return modelName +} + export async function cacheModels(): Promise { const models = await getModels() state.models = models - - consola.info( - `Available models: \n${models.data.map((model) => `- ${model.id}`).join("\n")}`, - ) } diff --git a/src/lib/streaming-utils.ts b/src/lib/streaming-utils.ts new file mode 100644 index 0000000..1f8a897 --- /dev/null +++ b/src/lib/streaming-utils.ts @@ -0,0 +1,47 @@ +import { events } from "fetch-event-stream" +import { openAIToAnthropic } from "./format-converter" + +/** + * Create streaming response for all models + * Simple pass-through implementation + */ +export async function* createStreamingResponse( + response: Response, +): AsyncIterable { + // Handle all models (including Anthropic models) + + const eventStream = events(response) + + for await (const event of eventStream) { + yield event + } +} + +/** + * Create streaming response with format conversion + */ +export async function* createStreamingResponseWithFormat( + response: Response, + originalFormat: string, +): AsyncIterable { + const eventStream = events(response) + + for await (const event of eventStream) { + // Convert streaming chunks if needed + if (event.data && event.data !== '[DONE]') { + try { + const chunk = JSON.parse(event.data) + const convertedChunk = openAIToAnthropic(chunk, originalFormat) + yield { + ...event, + data: JSON.stringify(convertedChunk) + } + } catch { + // If parsing fails, pass through as-is + yield event + } + } else { + yield event + } + } +} diff --git a/src/lib/token.ts b/src/lib/token.ts index aa66967..86a0b8a 100644 --- a/src/lib/token.ts +++ b/src/lib/token.ts @@ -1,5 +1,5 @@ -import consola from "consola" import fs from "node:fs/promises" +import consola from "consola" import { PATHS } from "~/lib/paths" import { getCopilotToken } from "~/services/github/get-copilot-token" @@ -22,7 +22,6 @@ export const setupCopilotToken = async () => { const refreshInterval = (refresh_in - 60) * 1000 setInterval(async () => { - consola.start("Refreshing Copilot token") try { const { token } = await getCopilotToken() state.copilotToken = token @@ -50,13 +49,10 @@ export async function setupGitHubToken( return } - consola.info("Not logged in, getting new access token") + consola.info("Getting new GitHub access token") const response = await getDeviceCode() - consola.debug("Device code response:", response) - consola.info( - `Please enter the code "${response.user_code}" in ${response.verification_uri}`, - ) + consola.info(`Please enter the code "${response.user_code}" in ${response.verification_uri}`) const token = await pollAccessToken(response) await writeGithubToken(token) diff --git a/src/lib/tokenizer.ts b/src/lib/tokenizer.ts index b6a4ec8..0c1298b 100644 --- a/src/lib/tokenizer.ts +++ b/src/lib/tokenizer.ts @@ -1,6 +1,6 @@ import { countTokens } from "gpt-tokenizer/model/gpt-4o" -import type { Message, ContentPart, ToolCall } from "~/services/copilot/create-chat-completions" +import type { MessageRole } from "~/services/copilot/create-chat-completions" // Convert Message to gpt-tokenizer compatible format interface ChatMessage { @@ -8,14 +8,26 @@ interface ChatMessage { content: string } -const convertToTokenizerFormat = (message: Message): ChatMessage | null => { +// Generic message type for tokenizer +interface TokenizerMessage { + role: MessageRole + content: string | Array | null + tool_calls?: Array + tool_call_id?: string + name?: string + [key: string]: any +} + +const convertToTokenizerFormat = ( + message: TokenizerMessage, +): 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", + role: role, content: message.content, } } @@ -25,13 +37,13 @@ const convertToTokenizerFormat = (message: Message): ChatMessage | 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})` + .map((toolCall: any) => { + return `Function call: ${toolCall.function?.name}(${toolCall.function?.arguments})` }) .join(" ") return { - role: role as "user" | "assistant" | "system", + role: role, content: toolCallsText, } } @@ -49,7 +61,7 @@ const convertToTokenizerFormat = (message: Message): ChatMessage | null => { // Handle ContentPart array - extract text content const textContent = message.content - .map((part: ContentPart) => { + .map((part: any) => { if (part.type === "input_text" && part.text) { return part.text } @@ -63,7 +75,7 @@ const convertToTokenizerFormat = (message: Message): ChatMessage | null => { // Only return a message if we have actual text content if (textContent.trim()) { return { - role: role as "user" | "assistant" | "system", + role: role, content: textContent, } } @@ -71,7 +83,7 @@ const convertToTokenizerFormat = (message: Message): ChatMessage | null => { return null } -export const getTokenCount = (messages: Array) => { +export const getTokenCount = (messages: Array) => { // Convert messages to tokenizer-compatible format const convertedMessages = messages .map(convertToTokenizerFormat) diff --git a/src/lib/vscode-version.ts b/src/lib/vscode-version.ts index 5b33011..6ae9aa0 100644 --- a/src/lib/vscode-version.ts +++ b/src/lib/vscode-version.ts @@ -1,5 +1,3 @@ -import consola from "consola" - import { getVSCodeVersion } from "~/services/get-vscode-version" import { state } from "./state" @@ -7,6 +5,4 @@ import { state } from "./state" export const cacheVSCodeVersion = async () => { const response = await getVSCodeVersion() state.vsCodeVersion = response - - consola.info(`Using VSCode version: ${response}`) } diff --git a/src/main.ts b/src/main.ts index b028364..b4c4cf7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -26,15 +26,12 @@ interface RunServerOptions { export async function runServer(options: RunServerOptions): Promise { if (options.verbose) { consola.level = 5 - consola.info("Verbose logging enabled") } if (options.business) { state.accountType = "business" - consola.info("Using business plan GitHub account") } else if (options.enterprise) { state.accountType = "enterprise" - consola.info("Using enterprise plan GitHub account") } state.manualApprove = options.manual @@ -46,7 +43,6 @@ export async function runServer(options: RunServerOptions): Promise { if (options.githubToken) { state.githubToken = options.githubToken - consola.info("Using provided GitHub token") } else { await setupGitHubToken() } diff --git a/src/routes/chat-completions/handler.ts b/src/routes/chat-completions/handler.ts index 2fadad3..ade311b 100644 --- a/src/routes/chat-completions/handler.ts +++ b/src/routes/chat-completions/handler.ts @@ -1,55 +1,99 @@ import type { Context } from "hono" -import consola from "consola" import { streamSSE, type SSEMessage } from "hono/streaming" import { awaitApproval } from "~/lib/approval" +import { detectFormat, anthropicToOpenAI, openAIToAnthropic } from "~/lib/format-converter" import { isNullish } from "~/lib/is-nullish" +import { transformModelName } from "~/lib/models" import { checkRateLimit } from "~/lib/rate-limit" import { state } from "~/lib/state" -import { getTokenCount } from "~/lib/tokenizer" -import { - createChatCompletions, - type ChatCompletionResponse, - type ChatCompletionsPayload, -} from "~/services/copilot/create-chat-completions" +import { createChatCompletions } from "~/services/copilot/create-chat-completions" export async function handleCompletion(c: Context) { await checkRateLimit(state) - let payload = await c.req.json() + const originalPayload = await c.req.json() + + + + // Detect and convert format if needed + const formatInfo = detectFormat(originalPayload) + const payload = formatInfo.isAnthropic + ? anthropicToOpenAI(originalPayload) + : originalPayload + + + + - if(payload.messages) { - consola.info("Current token count:", getTokenCount(payload.messages)) - } if (state.manualApprove) await awaitApproval() - if (isNullish(payload.max_tokens)) { + if (isNullish(payload.max_tokens) && payload.model) { + // Transform model name to internal format for lookup + const internalModelName = transformModelName(payload.model) const selectedModel = state.models?.data.find( - (model) => model.id === payload.model, + (model) => model.id === internalModelName, ) - payload = { - ...payload, - max_tokens: selectedModel?.capabilities?.limits?.max_output_tokens, + if (selectedModel?.capabilities?.limits?.max_output_tokens) { + payload.max_tokens = selectedModel.capabilities.limits.max_output_tokens } } - const response = await createChatCompletions(payload) + const response = await createChatCompletions(payload, formatInfo.originalFormat) if (isNonStreaming(response)) { - return c.json(response) + // Convert response back to original format if needed + const finalResponse = openAIToAnthropic(response, formatInfo.originalFormat) + return c.json(finalResponse) } return streamSSE(c, async (stream) => { - for await (const chunk of response) { - await stream.writeSSE(chunk as SSEMessage) + try { + for await (const chunk of response as AsyncIterable) { + // Check if connection is still alive + if (stream.closed) { + break + } + + await stream.writeSSE(chunk as SSEMessage) + } + } catch (error) { + // Send error event to client if connection is still open + if (!stream.closed) { + try { + await stream.writeSSE({ + event: "error", + data: JSON.stringify({ error: "Stream interrupted" }), + }) + } catch { + // Ignore write errors to closed streams + } + } + } finally { + // Ensure stream is properly closed + if (!stream.closed) { + try { + await stream.writeSSE({ event: "done", data: "[DONE]" }) + } catch { + // Ignore close errors + } + } } }) } const isNonStreaming = ( response: Awaited>, -): response is ChatCompletionResponse => Object.hasOwn(response, "choices") +): response is Record => { + return ( + response + && typeof response === "object" + && (Object.hasOwn(response, "choices") + || Object.hasOwn(response, "content")) // Support both OpenAI and Anthropic formats + && !response[Symbol.asyncIterator] // Not an async iterable + ) +} diff --git a/src/routes/models/route.ts b/src/routes/models/route.ts index 8e282a3..cd9a889 100644 --- a/src/routes/models/route.ts +++ b/src/routes/models/route.ts @@ -1,6 +1,7 @@ import { Hono } from "hono" import { forwardError } from "~/lib/forward-error" +import { reverseTransformModelName } from "~/lib/models" import { getModels } from "~/services/copilot/get-models" export const modelRoutes = new Hono() @@ -8,7 +9,17 @@ export const modelRoutes = new Hono() modelRoutes.get("/", async (c) => { try { const models = await getModels() - return c.json(models) + + // Transform model names to client-facing format + const transformedModels = { + ...models, + data: models.data.map((model) => ({ + ...model, + id: reverseTransformModelName(model.id), + })), + } + + return c.json(transformedModels) } catch (error) { return await forwardError(c, error) } diff --git a/src/services/copilot/create-chat-completions.ts b/src/services/copilot/create-chat-completions.ts index e4be0fe..5f2b430 100644 --- a/src/services/copilot/create-chat-completions.ts +++ b/src/services/copilot/create-chat-completions.ts @@ -1,178 +1,70 @@ -import { events } from "fetch-event-stream" - import { copilotHeaders, copilotBaseUrl } from "~/lib/api-config" import { HTTPError } from "~/lib/http-error" +import { transformModelName } from "~/lib/models" import { state } from "~/lib/state" +import { createStreamingResponse, createStreamingResponseWithFormat } from "~/lib/streaming-utils" export const createChatCompletions = async ( - payload: ChatCompletionsPayload, -) => { + payload: any, + originalFormat?: string, +): Promise> => { if (!state.copilotToken) throw new Error("Copilot token not found") - for (const message of payload.messages) { - intoCopilotMessage(message) + // Transform model name + if (payload.model) { + payload.model = transformModelName(payload.model) } - const visionEnable = payload.messages.some( - (x) => - (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) { - 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 - ) - } + // Process all models (including Anthropic models) + const processedPayload = { ...payload } - throw new HTTPError(`Failed to create chat completions: ${errorText}`, response) - } - if (payload.stream) { - return events(response) - } - return (await response.json()) as ChatCompletionResponse -} + try { + // Detect vision usage for headers + const visionEnable = processedPayload.messages?.some( + (message: any) => + Array.isArray(message.content) + && message.content.some( + (part: any) => part.type === "image_url" || part.type === "image", + ), + ) -const intoCopilotMessage = (message: Message) => { - // Skip processing for assistant messages (they may have tool_calls) - if (message.role === "assistant") return false + const response = await fetch(`${copilotBaseUrl(state)}/chat/completions`, { + method: "POST", + headers: copilotHeaders(state, visionEnable), + body: JSON.stringify(processedPayload), + }) - // Skip processing for tool messages (they have specific format) - if (message.role === "tool") return false + if (!response.ok) { + const errorText = await response.text() - // 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" - } -} - -// Streaming types - -export interface ChatCompletionChunk { - choices: [Choice] - created: number - object: "chat.completion.chunk" - id: string - model: string -} + throw new HTTPError( + `Failed to create chat completions: ${errorText}`, + response, + ) + } -interface Delta { - content?: string - role?: string - tool_calls?: Array -} + if (processedPayload.stream) { + return originalFormat === 'anthropic' + ? createStreamingResponseWithFormat(response, originalFormat) + : createStreamingResponse(response) + } -interface DeltaToolCall { - index: number - id?: string - type?: "function" - function?: { - name?: string - arguments?: string + const responseData = (await response.json()) as any + return responseData + } catch (error) { + throw error } } -interface Choice { - index: number - delta: Delta - finish_reason: "stop" | "tool_calls" | "length" | "content_filter" | null - logprobs: null -} - -// Non-streaming types - -export interface ChatCompletionResponse { - id: string - object: string - created: number - model: string - choices: [ChoiceNonStreaming] -} - -interface ChoiceNonStreaming { - index: number - message: Message - logprobs: null - finish_reason: "stop" | "tool_calls" | "length" | "content_filter" -} - -// Payload types +// Flexible types for maximum compatibility +export type MessageRole = "user" | "assistant" | "system" | "tool" export interface ChatCompletionsPayload { - messages: Array + messages: Array model: string - temperature?: number - top_p?: number - max_tokens?: number - 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" | "tool" - content: string | Array | null - tool_calls?: Array - tool_call_id?: string - name?: string + [key: string]: any // Allow any additional fields } - -// https://platform.openai.com/docs/api-reference - -export interface ContentPart { - type: "input_image" | "input_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/services/copilot/create-embeddings.ts b/src/services/copilot/create-embeddings.ts index 586c6bf..07571c6 100644 --- a/src/services/copilot/create-embeddings.ts +++ b/src/services/copilot/create-embeddings.ts @@ -1,7 +1,6 @@ import { copilotHeaders, copilotBaseUrl } from "~/lib/api-config" import { HTTPError } from "~/lib/http-error" import { state } from "~/lib/state" -import consola from "consola" export const createEmbeddings = async (payload: EmbeddingRequest) => { if (!state.copilotToken) throw new Error("Copilot token not found") @@ -14,9 +13,8 @@ export const createEmbeddings = async (payload: EmbeddingRequest) => { if (!response.ok) throw new HTTPError("Failed to create embeddings", response) - // return (await response.json()) as EmbeddingResponse - + const json = await response.json() return json as EmbeddingResponse diff --git a/src/services/github/poll-access-token.ts b/src/services/github/poll-access-token.ts index 938ff70..674f5e6 100644 --- a/src/services/github/poll-access-token.ts +++ b/src/services/github/poll-access-token.ts @@ -15,7 +15,6 @@ export async function pollAccessToken( // Interval is in seconds, we need to multiply by 1000 to get milliseconds // I'm also adding another second, just to be safe const sleepDuration = (deviceCode.interval + 1) * 1000 - consola.debug(`Polling access token with interval of ${sleepDuration}ms`) while (true) { const response = await fetch( @@ -39,7 +38,6 @@ export async function pollAccessToken( } const json = await response.json() - consola.debug("Polling access token response:", json) const { access_token } = json as AccessTokenResponse diff --git a/tsconfig.json b/tsconfig.json index bfff5e6..be86ccb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,8 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true, + "noImplicitAny": true, + "baseUrl": ".", "paths": { From d3acb40a35587a3fd8ecaf18ca5a33adfa3bc194 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cnghyane=E2=80=9D?= <“hoangvananhnghia99@gmail.com”> Date: Sun, 15 Jun 2025 19:37:27 +0700 Subject: [PATCH 06/15] chore: release v0.3.1 From acf673b034fb82af4194e2793c9fcb0fa28c2d5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cnghyane=E2=80=9D?= <“hoangvananhnghia99@gmail.com”> Date: Sun, 15 Jun 2025 19:37:49 +0700 Subject: [PATCH 07/15] chore: release v0.3.2 --- package.json | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 9349196..4c42c3c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "copilot-api", - "version": "0.3.1", + "version": "0.3.2", "description": "A wrapper around GitHub Copilot API to make it OpenAI compatible, making it usable for other tools.", "keywords": [ "proxy", @@ -29,13 +29,7 @@ "prepack": "bun run build", "prepare": "simple-git-hooks", "release": "bumpp && bun publish --access public", - "start": "NODE_ENV=production bun run ./src/main.ts", - "test": "bun test", - "test:coverage": "bun test --coverage", - "test:integration": "bun test src/tests/integration.test.ts", - "test:performance": "bun test src/tests/performance.test.ts", - "test:unit": "bun test src/tests/*.test.ts", - "test:watch": "bun test --watch" + "start": "NODE_ENV=production bun run ./src/main.ts" }, "simple-git-hooks": { "pre-commit": "bunx lint-staged" From 680ac65f840a89d6b929bb7d1bf82f255f3f694e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cnghyane=E2=80=9D?= <“hoangvananhnghia99@gmail.com”> Date: Sun, 15 Jun 2025 19:39:17 +0700 Subject: [PATCH 08/15] chore: release v0.3.2 From 1194766b7f8a63beb7f7a590f3da319ee4baa290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cnghyane=E2=80=9D?= <“hoangvananhnghia99@gmail.com”> Date: Sun, 15 Jun 2025 19:39:40 +0700 Subject: [PATCH 09/15] chore: release v0.3.2 From e0d7bd6dfa13d64f793a3053e0b5edc96898c64c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cnghyane=E2=80=9D?= <“hoangvananhnghia99@gmail.com”> Date: Sun, 15 Jun 2025 19:39:45 +0700 Subject: [PATCH 10/15] chore: release v0.3.3-beta.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4c42c3c..44ce1df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "copilot-api", - "version": "0.3.2", + "version": "0.3.3-beta.1", "description": "A wrapper around GitHub Copilot API to make it OpenAI compatible, making it usable for other tools.", "keywords": [ "proxy", From 423ede541f6b75af5994bc3428acf5730f716dd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cnghyane=E2=80=9D?= <“hoangvananhnghia99@gmail.com”> Date: Sun, 15 Jun 2025 19:40:44 +0700 Subject: [PATCH 11/15] chore: release v0.3.3-beta.2 --- package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 44ce1df..d02a6a6 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,17 @@ { "name": "copilot-api", - "version": "0.3.3-beta.1", + "version": "0.3.3-beta.2", "description": "A wrapper around GitHub Copilot API to make it OpenAI compatible, making it usable for other tools.", "keywords": [ "proxy", "github-copilot", "openai-compatible" ], - "homepage": "https://github.com/ericc-ch/copilot-api", - "bugs": "https://github.com/ericc-ch/copilot-api/issues", + "homepage": "https://github.com/nghyane/copilot-api", + "bugs": "https://github.com/nghyane/copilot-api/issues", "repository": { "type": "git", - "url": "git+https://github.com/ericc-ch/copilot-api.git" + "url": "git+https://github.com/nghyane/copilot-api.git" }, "author": "Erick Christian ", "type": "module", From fd1e2bb89dbd09d41c5dc23f92bfb5e7e1b957fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cnghyane=E2=80=9D?= <“hoangvananhnghia99@gmail.com”> Date: Sun, 15 Jun 2025 19:41:20 +0700 Subject: [PATCH 12/15] chore: release v1.0.0 --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index d02a6a6..c47553b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "copilot-api", - "version": "0.3.3-beta.2", + "name": "@nghyane/copilot-api", + "version": "1.0.0", "description": "A wrapper around GitHub Copilot API to make it OpenAI compatible, making it usable for other tools.", "keywords": [ "proxy", From 4cd37347e7636c126d278629446889588276e954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cnghyane=E2=80=9D?= <“hoangvananhnghia99@gmail.com”> Date: Sun, 15 Jun 2025 19:44:39 +0700 Subject: [PATCH 13/15] chore: update author information and clean up code formatting --- package.json | 2 +- src/lib/http-error.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index c47553b..a1e161d 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "type": "git", "url": "git+https://github.com/nghyane/copilot-api.git" }, - "author": "Erick Christian ", + "author": "Nghyane ", "type": "module", "bin": { "copilot-api": "./dist/main.js" diff --git a/src/lib/http-error.ts b/src/lib/http-error.ts index 352d3c6..c13d76e 100644 --- a/src/lib/http-error.ts +++ b/src/lib/http-error.ts @@ -4,5 +4,7 @@ export class HTTPError extends Error { constructor(message: string, response: Response) { super(message) this.response = response + + console.error(message, response) } } From c458f15ff332c1d3e33472390db128b50e31918a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cnghyane=E2=80=9D?= <“hoangvananhnghia99@gmail.com”> Date: Mon, 16 Jun 2025 10:53:54 +0700 Subject: [PATCH 14/15] chore: release v1.0.1-beta.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a1e161d..0eee1f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nghyane/copilot-api", - "version": "1.0.0", + "version": "1.0.1-beta.1", "description": "A wrapper around GitHub Copilot API to make it OpenAI compatible, making it usable for other tools.", "keywords": [ "proxy", From 03aa1bc2f620c28aad4f1d88fa520d69d2abb61a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cnghyane=E2=80=9D?= <“hoangvananhnghia99@gmail.com”> Date: Mon, 16 Jun 2025 10:55:44 +0700 Subject: [PATCH 15/15] feat: improve format detection and remove format converter - Remove format-converter.ts and replace with format-detector.ts - Enhance format detection logic for better compatibility - Update streaming utilities and chat completion handlers - Improve model handling and route processing - Build new Docker images with multi-architecture support --- src/lib/format-converter.ts | 239 ------------------ src/lib/format-detector.ts | 43 ++++ src/lib/models.ts | 17 +- src/lib/streaming-utils.ts | 36 +-- src/routes/chat-completions/handler.ts | 20 +- src/routes/models/route.ts | 36 ++- .../copilot/create-chat-completions.ts | 7 +- 7 files changed, 95 insertions(+), 303 deletions(-) delete mode 100644 src/lib/format-converter.ts create mode 100644 src/lib/format-detector.ts diff --git a/src/lib/format-converter.ts b/src/lib/format-converter.ts deleted file mode 100644 index 0ae46b5..0000000 --- a/src/lib/format-converter.ts +++ /dev/null @@ -1,239 +0,0 @@ -/** - * Lightweight format converter for Anthropic ↔ OpenAI compatibility - */ - -export interface FormatDetectionResult { - isAnthropic: boolean - originalFormat: 'anthropic' | 'openai' -} - -/** - * Detect if request is in Anthropic format - */ -export function detectFormat(payload: any): FormatDetectionResult { - const isAnthropic = !!( - (Array.isArray(payload.system)) || - (payload.metadata) || - (payload.messages?.some((msg: any) => - msg.content?.some?.((part: any) => - part.cache_control || - part.type === 'tool_use' || - part.type === 'tool_result' - ) - )) || - (payload.tools?.some((tool: any) => tool.input_schema && !tool.function)) || - (typeof payload.tool_choice === 'object' && payload.tool_choice?.type) - ) - - return { - isAnthropic, - originalFormat: isAnthropic ? 'anthropic' : 'openai' - } -} - -/** - * Convert Anthropic format to OpenAI format - */ -export function anthropicToOpenAI(payload: any): any { - const converted = { ...payload } - - // Convert system array to string - if (Array.isArray(converted.system)) { - converted.system = converted.system - .map((s: any) => typeof s === 'string' ? s : s.text || '') - .join('\n') - } - - // Remove Anthropic-specific fields - delete converted.metadata - - // Convert messages from Anthropic to OpenAI format - if (converted.messages) { - const convertedMessages: any[] = [] - - converted.messages.forEach((msg: any) => { - if (Array.isArray(msg.content)) { - // Separate tool_use and tool_result content from other content - const toolUseContent = msg.content.filter((part: any) => part.type === 'tool_use') - const toolResultContent = msg.content.filter((part: any) => part.type === 'tool_result') - const otherContent = msg.content.filter((part: any) => - part.type !== 'tool_use' && part.type !== 'tool_result' - ) - - // Clean cache_control from non-tool content - const cleanedContent = otherContent.map((part: any) => { - const { cache_control, ...cleanPart } = part - return cleanPart - }) - - // Handle tool_result content - convert to separate tool messages - if (toolResultContent.length > 0) { - // Add tool result messages separately - toolResultContent.forEach((toolResult: any) => { - const toolContent = Array.isArray(toolResult.content) - ? toolResult.content.map((c: any) => c.text || JSON.stringify(c)).join('\n') - : typeof toolResult.content === 'string' - ? toolResult.content - : JSON.stringify(toolResult.content) - - convertedMessages.push({ - role: "tool", - name: "tool_result", // or extract from tool_use_id if needed - tool_call_id: toolResult.tool_use_id, - content: toolContent - }) - }) - - // If this message only contains tool_result content, don't add the original message - if (otherContent.length === 0) { - return - } - } - - // Combine remaining content (excluding tool results) - const allContent = [...cleanedContent] - - // Convert tool_use to GitHub Copilot API tool_calls format - if (toolUseContent.length > 0) { - const tool_calls = toolUseContent.map((toolUse: any, index: number) => ({ - type: "function", - id: toolUse.id || `call_${Math.random().toString(36).substring(2, 15)}`, - index: index, - function: { - name: toolUse.name, - arguments: JSON.stringify(toolUse.input || {}) - } - })) - - // For assistant messages with tool calls, content should be empty string - // and tool_calls should be at message level (GitHub Copilot format) - if (msg.role === 'assistant') { - convertedMessages.push({ - ...msg, - content: allContent.length > 0 ? allContent : "", - tool_calls - }) - return - } - } - - // Add regular message - convertedMessages.push({ - ...msg, - content: allContent.length > 0 ? allContent : msg.content - }) - } else { - // Non-array content, add as-is - convertedMessages.push(msg) - } - }) - - converted.messages = convertedMessages - } - - // Convert tools from Anthropic to OpenAI format - if (converted.tools) { - converted.tools = converted.tools.map((tool: any) => ({ - type: "function", - function: { - name: tool.name, - description: tool.description, - parameters: tool.input_schema - } - })) - } - - // Convert tool_choice from Anthropic to OpenAI format - if (converted.tool_choice) { - if (typeof converted.tool_choice === 'object' && converted.tool_choice.type === 'auto') { - converted.tool_choice = 'auto' - } else if (typeof converted.tool_choice === 'object' && converted.tool_choice.type === 'tool') { - converted.tool_choice = { - type: 'function', - function: { name: converted.tool_choice.name } - } - } - } - - return converted -} - -/** - * Convert OpenAI response back to Anthropic format if needed - */ -export function openAIToAnthropic(response: any, originalFormat: string): any { - if (originalFormat !== 'anthropic') { - return response - } - - // Handle streaming chunks - if (response.choices && response.choices[0]?.delta) { - const choice = response.choices[0] - const delta = choice.delta - - // Convert tool_calls in streaming delta to Anthropic format - if (delta.tool_calls) { - const convertedContent = delta.tool_calls.map((toolCall: any) => ({ - type: "tool_use", - id: toolCall.id || `toolu_${Math.random().toString(36).substring(2, 11)}`, - name: toolCall.function?.name, - input: toolCall.function?.arguments ? JSON.parse(toolCall.function.arguments) : {} - })) - - return { - ...response, - content: convertedContent, - stop_reason: choice.finish_reason === 'tool_calls' ? 'tool_use' : choice.finish_reason - } - } - - // Convert regular content - if (delta.content) { - return { - ...response, - content: [{ type: "text", text: delta.content }] - } - } - - return response - } - - // Handle non-streaming response - if (response.choices && response.choices[0]?.message) { - const message = response.choices[0].message - const content = [] - - // Add text content - if (message.content) { - content.push({ - type: "text", - text: message.content - }) - } - - // Convert tool_calls to Anthropic format - if (message.tool_calls) { - message.tool_calls.forEach((toolCall: any) => { - content.push({ - type: "tool_use", - id: toolCall.id || `toolu_${Math.random().toString(36).substring(2, 11)}`, - name: toolCall.function.name, - input: JSON.parse(toolCall.function.arguments || '{}') - }) - }) - } - - return { - id: response.id, - type: "message", - role: "assistant", - content, - model: response.model, - stop_reason: response.choices[0].finish_reason === 'tool_calls' ? 'tool_use' : response.choices[0].finish_reason, - usage: response.usage - } - } - - // Fallback: return as-is - return response -} diff --git a/src/lib/format-detector.ts b/src/lib/format-detector.ts new file mode 100644 index 0000000..dfdf542 --- /dev/null +++ b/src/lib/format-detector.ts @@ -0,0 +1,43 @@ +/** + * Simple format detection for Anthropic vs OpenAI requests + * Only used to detect incoming format - no response conversion + */ + +export interface FormatDetectionResult { + isAnthropic: boolean + originalFormat: 'anthropic' | 'openai' +} + +/** + * Detect if request is in Anthropic format + * Based on Anthropic-specific fields and structures + */ +export function detectFormat(payload: any): FormatDetectionResult { + const isAnthropic = !!( + // Anthropic system format (array instead of string) + (Array.isArray(payload.system)) || + + // Anthropic metadata field + (payload.metadata) || + + // Anthropic message content structures + (payload.messages?.some((msg: any) => + msg.content?.some?.((part: any) => + part.cache_control || + part.type === 'tool_use' || + part.type === 'tool_result' + ) + )) || + + // Anthropic tool format (input_schema instead of function) + (payload.tools?.some((tool: any) => tool.input_schema && !tool.function)) || + + // Anthropic tool_choice format (object with type) + (typeof payload.tool_choice === 'object' && payload.tool_choice?.type) + ) + + return { + isAnthropic, + originalFormat: isAnthropic ? 'anthropic' : 'openai' + } +} diff --git a/src/lib/models.ts b/src/lib/models.ts index 80f7f69..9803ce6 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -3,22 +3,21 @@ import { getModels } from "~/services/copilot/get-models" import { state } from "./state" /** - * Transform model name from client-facing format to internal format - * Example: "claude-4-sonnet" -> "claude-sonnet-4" + * Transform disguised model names back to real Claude model names + * + * STRATEGY: Cursor sees "gpt-4-claude-sonnet-4" and sends OpenAI format, + * but we need to map it back to real Claude model for GitHub Copilot API. */ export function transformModelName(modelName: string): string { - if (modelName === "claude-4-sonnet") { + // Handle disguised Claude models - map back to real Claude models + if (modelName === "gpt-4.1") { return "claude-sonnet-4" } - // claude-4-sonnet-20250514 - if (modelName.startsWith("claude-sonnet-4-")) { - return "claude-sonnet-4" + if (modelName.startsWith("gpt-4.1-")) { + return "gpt-4.1" } - if (modelName.startsWith("claude-3-7-sonnet-")) { - return "claude-sonnet-3.7" - } return modelName } diff --git a/src/lib/streaming-utils.ts b/src/lib/streaming-utils.ts index 1f8a897..1f2afbd 100644 --- a/src/lib/streaming-utils.ts +++ b/src/lib/streaming-utils.ts @@ -1,47 +1,15 @@ import { events } from "fetch-event-stream" -import { openAIToAnthropic } from "./format-converter" /** - * Create streaming response for all models - * Simple pass-through implementation + * Create streaming response - simple pass-through for all models + * No format conversion needed since all requests are OpenAI format */ export async function* createStreamingResponse( response: Response, ): AsyncIterable { - // Handle all models (including Anthropic models) - const eventStream = events(response) for await (const event of eventStream) { yield event } } - -/** - * Create streaming response with format conversion - */ -export async function* createStreamingResponseWithFormat( - response: Response, - originalFormat: string, -): AsyncIterable { - const eventStream = events(response) - - for await (const event of eventStream) { - // Convert streaming chunks if needed - if (event.data && event.data !== '[DONE]') { - try { - const chunk = JSON.parse(event.data) - const convertedChunk = openAIToAnthropic(chunk, originalFormat) - yield { - ...event, - data: JSON.stringify(convertedChunk) - } - } catch { - // If parsing fails, pass through as-is - yield event - } - } else { - yield event - } - } -} diff --git a/src/routes/chat-completions/handler.ts b/src/routes/chat-completions/handler.ts index ade311b..4a35d52 100644 --- a/src/routes/chat-completions/handler.ts +++ b/src/routes/chat-completions/handler.ts @@ -1,9 +1,7 @@ import type { Context } from "hono" import { streamSSE, type SSEMessage } from "hono/streaming" - import { awaitApproval } from "~/lib/approval" -import { detectFormat, anthropicToOpenAI, openAIToAnthropic } from "~/lib/format-converter" import { isNullish } from "~/lib/is-nullish" import { transformModelName } from "~/lib/models" import { checkRateLimit } from "~/lib/rate-limit" @@ -15,13 +13,15 @@ export async function handleCompletion(c: Context) { const originalPayload = await c.req.json() + // No format conversion - pass through all requests as OpenAI format + let payload = originalPayload + // Transform model name if needed + if (payload.model) { + payload.model = transformModelName(payload.model) + } - // Detect and convert format if needed - const formatInfo = detectFormat(originalPayload) - const payload = formatInfo.isAnthropic - ? anthropicToOpenAI(originalPayload) - : originalPayload + // All requests are passed through as OpenAI format @@ -43,12 +43,10 @@ export async function handleCompletion(c: Context) { } } - const response = await createChatCompletions(payload, formatInfo.originalFormat) + const response = await createChatCompletions(payload) if (isNonStreaming(response)) { - // Convert response back to original format if needed - const finalResponse = openAIToAnthropic(response, formatInfo.originalFormat) - return c.json(finalResponse) + return c.json(response) } return streamSSE(c, async (stream) => { diff --git a/src/routes/models/route.ts b/src/routes/models/route.ts index cd9a889..0f2172a 100644 --- a/src/routes/models/route.ts +++ b/src/routes/models/route.ts @@ -10,13 +10,39 @@ modelRoutes.get("/", async (c) => { try { const models = await getModels() - // Transform model names to client-facing format + // Transform model names and disguise Claude models as GPT models + // This prevents Cursor from detecting Claude models and sending Anthropic format const transformedModels = { ...models, - data: models.data.map((model) => ({ - ...model, - id: reverseTransformModelName(model.id), - })), + data: models.data.map((model) => { + const transformedModel = { ...model } + + // Disguise Claude models as GPT models to force OpenAI format + if (model.id.includes('claude-sonnet-4')) { + transformedModel.id = 'gpt-4-claude-sonnet-4' + transformedModel.name = 'GPT-4 (Claude Sonnet 4)' + transformedModel.vendor = 'OpenAI' + } else if (model.id.includes('claude-sonnet-3.7')) { + transformedModel.id = 'gpt-4-claude-sonnet-37' + transformedModel.name = 'GPT-4 (Claude Sonnet 3.7)' + transformedModel.vendor = 'OpenAI' + } else if (model.id.includes('claude-sonnet-3.5')) { + transformedModel.id = 'gpt-35-turbo-claude-sonnet-35' + transformedModel.name = 'GPT-3.5 Turbo (Claude Sonnet 3.5)' + transformedModel.vendor = 'OpenAI' + } else if (model.id.includes('claude')) { + // Handle other Claude models + const claudeId = model.id.replace(/[^a-z0-9]/gi, '') + transformedModel.id = `gpt-4-${claudeId}` + transformedModel.name = `GPT-4 (${model.name || model.id})` + transformedModel.vendor = 'OpenAI' + } else { + // Non-Claude models pass through with normal transformation + transformedModel.id = reverseTransformModelName(model.id) + } + + return transformedModel + }), } return c.json(transformedModels) diff --git a/src/services/copilot/create-chat-completions.ts b/src/services/copilot/create-chat-completions.ts index 5f2b430..993d40b 100644 --- a/src/services/copilot/create-chat-completions.ts +++ b/src/services/copilot/create-chat-completions.ts @@ -2,11 +2,10 @@ import { copilotHeaders, copilotBaseUrl } from "~/lib/api-config" import { HTTPError } from "~/lib/http-error" import { transformModelName } from "~/lib/models" import { state } from "~/lib/state" -import { createStreamingResponse, createStreamingResponseWithFormat } from "~/lib/streaming-utils" +import { createStreamingResponse } from "~/lib/streaming-utils" export const createChatCompletions = async ( payload: any, - originalFormat?: string, ): Promise> => { if (!state.copilotToken) throw new Error("Copilot token not found") @@ -48,9 +47,7 @@ export const createChatCompletions = async ( } if (processedPayload.stream) { - return originalFormat === 'anthropic' - ? createStreamingResponseWithFormat(response, originalFormat) - : createStreamingResponse(response) + return createStreamingResponse(response) } const responseData = (await response.json()) as any